chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
+600
View File
@@ -0,0 +1,600 @@
import json
import os
import pprint
import shlex
import sys
import time
from subprocess import list2cmdline
from typing import Any, Dict, Optional, Tuple, Union
import click
import ray._private.ray_constants as ray_constants
from ray._common.utils import (
get_or_create_event_loop,
load_class,
)
from ray._private.utils import (
parse_metadata_json,
parse_resources_json,
)
from ray.autoscaler._private.cli_logger import add_click_logging_options, cf, cli_logger
from ray.dashboard.modules.dashboard_sdk import parse_runtime_env_args
from ray.dashboard.modules.job.cli_utils import add_common_job_options
from ray.dashboard.modules.job.utils import redact_url_password
from ray.job_submission import JobStatus, JobSubmissionClient
from ray.util.annotations import PublicAPI
def _get_sdk_client(
address: Optional[str],
create_cluster_if_needed: bool = False,
headers: Optional[str] = None,
verify: Union[bool, str] = True,
) -> JobSubmissionClient:
client = JobSubmissionClient(
address,
create_cluster_if_needed,
headers=_handle_headers(headers),
verify=verify,
)
client_address = client.get_address()
cli_logger.labeled_value(
"Job submission server address", redact_url_password(client_address)
)
return client
def _handle_headers(headers: Optional[str]) -> Optional[Dict[str, Any]]:
if headers is None and "RAY_JOB_HEADERS" in os.environ:
headers = os.environ["RAY_JOB_HEADERS"]
if headers is not None:
try:
return json.loads(headers)
except Exception as exc:
raise ValueError(
"""Failed to parse headers into JSON.
Expected format: {{"KEY": "VALUE"}}, got {}, {}""".format(
headers, exc
)
)
return None
def _log_big_success_msg(success_msg):
cli_logger.newline()
cli_logger.success("-" * len(success_msg))
cli_logger.success(success_msg)
cli_logger.success("-" * len(success_msg))
cli_logger.newline()
def _log_big_error_msg(success_msg):
cli_logger.newline()
cli_logger.error("-" * len(success_msg))
cli_logger.error(success_msg)
cli_logger.error("-" * len(success_msg))
cli_logger.newline()
def _log_job_status(client: JobSubmissionClient, job_id: str) -> JobStatus:
info = client.get_job_info(job_id)
if info.status == JobStatus.SUCCEEDED:
_log_big_success_msg(f"Job '{job_id}' succeeded")
elif info.status == JobStatus.STOPPED:
cli_logger.warning(f"Job '{job_id}' was stopped")
elif info.status == JobStatus.FAILED:
_log_big_error_msg(f"Job '{job_id}' failed")
if info.message is not None:
cli_logger.print(f"Status message: {info.message}", no_format=True)
else:
# Catch-all.
cli_logger.print(f"Status for job '{job_id}': {info.status}")
if info.message is not None:
cli_logger.print(f"Status message: {info.message}", no_format=True)
return info.status
async def _tail_logs(client: JobSubmissionClient, job_id: str) -> JobStatus:
async for lines in client.tail_job_logs(job_id):
print(lines, end="")
return _log_job_status(client, job_id)
@click.group("job")
def job_cli_group():
"""Submit, stop, delete, or list Ray jobs."""
pass
@job_cli_group.command()
@click.option(
"--address",
type=str,
default=None,
required=False,
help=(
"Address of the Ray cluster to connect to. Can also be specified "
"using the RAY_API_SERVER_ADDRESS environment variable (falls back to RAY_ADDRESS)."
),
)
@click.option(
"--job-id",
type=str,
default=None,
required=False,
help=("DEPRECATED: Use `--submission-id` instead."),
)
@click.option(
"--submission-id",
type=str,
default=None,
required=False,
help=(
"Submission ID to specify for the job. If not provided, one will be generated."
),
)
@click.option(
"--runtime-env",
type=str,
default=None,
required=False,
help="Path to a local YAML file containing a runtime_env definition.",
)
@click.option(
"--runtime-env-json",
type=str,
default=None,
required=False,
help="JSON-serialized runtime_env dictionary.",
)
@click.option(
"--working-dir",
type=str,
default=None,
required=False,
help=(
"Directory containing files that your job will run in. Can be a "
"local directory or a remote URI to a .zip file (S3, GS, HTTP). "
"If specified, this overrides the option in `--runtime-env`."
),
)
@click.option(
"--metadata-json",
type=str,
default=None,
required=False,
help="JSON-serialized dictionary of metadata to attach to the job.",
)
@click.option(
"--entrypoint-num-cpus",
required=False,
type=float,
help="the quantity of CPU cores to reserve for the entrypoint command, "
"separately from any tasks or actors that are launched by it",
)
@click.option(
"--entrypoint-num-gpus",
required=False,
type=float,
help="the quantity of GPUs to reserve for the entrypoint command, "
"separately from any tasks or actors that are launched by it",
)
@click.option(
"--entrypoint-memory",
required=False,
type=int,
help="the amount of memory to reserve "
"for the entrypoint command, separately from any tasks or actors that are "
"launched by it",
)
@click.option(
"--entrypoint-resources",
required=False,
type=str,
help="a JSON-serialized dictionary mapping resource name to resource quantity "
"describing resources to reserve for the entrypoint command, "
"separately from any tasks or actors that are launched by it",
)
@click.option(
"--entrypoint-label-selector",
required=False,
type=str,
help="a JSON-serialized dictionary mapping label keys to selector strings "
"describing placement constraints for the entrypoint command",
)
@click.option(
"--no-wait",
is_flag=True,
type=bool,
default=False,
help="If set, will not stream logs and wait for the job to exit.",
)
@add_common_job_options
@add_click_logging_options
@click.argument("entrypoint", nargs=-1, required=True, type=click.UNPROCESSED)
@PublicAPI
def submit(
address: Optional[str],
job_id: Optional[str],
submission_id: Optional[str],
runtime_env: Optional[str],
runtime_env_json: Optional[str],
metadata_json: Optional[str],
working_dir: Optional[str],
entrypoint: Tuple[str],
entrypoint_num_cpus: Optional[Union[int, float]],
entrypoint_num_gpus: Optional[Union[int, float]],
entrypoint_memory: Optional[int],
entrypoint_resources: Optional[str],
entrypoint_label_selector: Optional[str],
no_wait: bool,
verify: Union[bool, str],
headers: Optional[str],
):
"""Submits a job to be run on the cluster.
By default (if --no-wait is not set), streams logs to stdout until the job finishes.
If the job succeeded, exits with 0. If it failed, exits with 1.
Example:
`ray job submit -- python my_script.py --arg=val`
Args:
address: Job submission server address.
job_id: DEPRECATED. Use submission_id instead.
submission_id: Submission ID for the job.
runtime_env: Path to a runtime_env YAML file.
runtime_env_json: JSON-serialized runtime_env dictionary.
metadata_json: JSON-serialized metadata dictionary.
working_dir: Working directory for the job.
entrypoint: Entrypoint command.
entrypoint_num_cpus: CPU cores to reserve.
entrypoint_num_gpus: GPUs to reserve.
entrypoint_memory: Memory to reserve.
entrypoint_resources: JSON-serialized custom resources dict.
entrypoint_label_selector: JSON-serialized label selector dict.
no_wait: Do not wait for job completion.
verify: TLS verification flag or path.
headers: JSON-serialized headers.
"""
if job_id:
cli_logger.warning(
"--job-id option is deprecated. Please use --submission-id instead."
)
if entrypoint_resources is not None:
entrypoint_resources = parse_resources_json(
entrypoint_resources, cli_logger, cf, command_arg="entrypoint-resources"
)
if entrypoint_label_selector is not None:
entrypoint_label_selector = parse_resources_json(
entrypoint_label_selector,
cli_logger,
cf,
command_arg="entrypoint-label-selector",
)
if metadata_json is not None:
metadata_json = parse_metadata_json(
metadata_json, cli_logger, cf, command_arg="metadata-json"
)
submission_id = submission_id or job_id
if ray_constants.RAY_JOB_SUBMIT_HOOK in os.environ:
# Submit all args as **kwargs per the JOB_SUBMIT_HOOK contract.
load_class(os.environ[ray_constants.RAY_JOB_SUBMIT_HOOK])(
address=address,
job_id=submission_id,
submission_id=submission_id,
runtime_env=runtime_env,
runtime_env_json=runtime_env_json,
metadata_json=metadata_json,
working_dir=working_dir,
entrypoint=entrypoint,
entrypoint_num_cpus=entrypoint_num_cpus,
entrypoint_num_gpus=entrypoint_num_gpus,
entrypoint_memory=entrypoint_memory,
entrypoint_resources=entrypoint_resources,
entrypoint_label_selector=entrypoint_label_selector,
no_wait=no_wait,
)
client = _get_sdk_client(
address, create_cluster_if_needed=True, headers=headers, verify=verify
)
final_runtime_env = parse_runtime_env_args(
runtime_env=runtime_env,
runtime_env_json=runtime_env_json,
working_dir=working_dir,
)
job_id = client.submit_job(
entrypoint=(
list2cmdline(entrypoint)
if sys.platform == "win32"
else shlex.join(entrypoint)
),
submission_id=submission_id,
runtime_env=final_runtime_env,
metadata=metadata_json,
entrypoint_num_cpus=entrypoint_num_cpus,
entrypoint_num_gpus=entrypoint_num_gpus,
entrypoint_memory=entrypoint_memory,
entrypoint_resources=entrypoint_resources,
entrypoint_label_selector=entrypoint_label_selector,
)
_log_big_success_msg(f"Job '{job_id}' submitted successfully")
with cli_logger.group("Next steps"):
cli_logger.print("Query the logs of the job:")
with cli_logger.indented():
cli_logger.print(cf.bold(f"ray job logs {job_id}"))
cli_logger.print("Query the status of the job:")
with cli_logger.indented():
cli_logger.print(cf.bold(f"ray job status {job_id}"))
cli_logger.print("Request the job to be stopped:")
with cli_logger.indented():
cli_logger.print(cf.bold(f"ray job stop {job_id}"))
cli_logger.newline()
# Flush stdout to ensure the Ray job ID is output immediately
# for the kubectl plugin, ref PR #52780, Issue kuberay/#3508.
cli_logger.flush()
sdk_version = client.get_version()
# sdk version 0 does not have log streaming
if not no_wait:
if int(sdk_version) > 0:
cli_logger.print(
"Tailing logs until the job exits (disable with --no-wait):"
)
job_status = get_or_create_event_loop().run_until_complete(
_tail_logs(client, job_id)
)
if job_status == JobStatus.FAILED:
sys.exit(1)
else:
cli_logger.warning(
"Tailing logs is not enabled for job sdk client version "
f"{sdk_version}. Please upgrade Ray to the latest version "
"for this feature."
)
@job_cli_group.command()
@click.option(
"--address",
type=str,
default=None,
required=False,
help=(
"Address of the Ray cluster to connect to. Can also be specified "
"using the RAY_API_SERVER_ADDRESS environment variable (falls back to RAY_ADDRESS)."
),
)
@click.argument("job-id", type=str)
@add_common_job_options
@add_click_logging_options
@PublicAPI(stability="stable")
def status(
address: Optional[str],
job_id: str,
headers: Optional[str],
verify: Union[bool, str],
):
"""Queries for the current status of a job.
Example:
`ray job status <my_job_id>`
Args:
address: Address of the Ray cluster to connect to.
job_id: The submission ID of the job to query.
headers: JSON string of headers to attach to requests.
verify: Path to a CA bundle, or boolean toggling TLS verification.
"""
client = _get_sdk_client(address, headers=headers, verify=verify)
_log_job_status(client, job_id)
@job_cli_group.command()
@click.option(
"--address",
type=str,
default=None,
required=False,
help=(
"Address of the Ray cluster to connect to. Can also be specified "
"using the RAY_API_SERVER_ADDRESS environment variable (falls back to RAY_ADDRESS)."
),
)
@click.option(
"--no-wait",
is_flag=True,
type=bool,
default=False,
help="If set, will not wait for the job to exit.",
)
@click.argument("job-id", type=str)
@add_common_job_options
@add_click_logging_options
@PublicAPI(stability="stable")
def stop(
address: Optional[str],
no_wait: bool,
job_id: str,
headers: Optional[str],
verify: Union[bool, str],
):
"""Attempts to stop a job.
Example:
`ray job stop <my_job_id>`
Args:
address: Address of the Ray cluster to connect to.
no_wait: If True, return immediately instead of waiting for the job to reach a terminal state.
job_id: The submission ID of the job to stop.
headers: JSON string of headers to attach to requests.
verify: Path to a CA bundle, or boolean toggling TLS verification.
Returns:
None. The function returns early when ``no_wait`` is True; otherwise it
polls until the job reaches a terminal state.
"""
client = _get_sdk_client(address, headers=headers, verify=verify)
cli_logger.print(f"Attempting to stop job '{job_id}'")
client.stop_job(job_id)
if no_wait:
return
else:
cli_logger.print(
f"Waiting for job '{job_id}' to exit (disable with --no-wait):"
)
while True:
status = client.get_job_status(job_id)
if status in {JobStatus.STOPPED, JobStatus.SUCCEEDED, JobStatus.FAILED}:
_log_job_status(client, job_id)
break
else:
cli_logger.print(f"Job has not exited yet. Status: {status}")
time.sleep(1)
@job_cli_group.command()
@click.option(
"--address",
type=str,
default=None,
required=False,
help=(
"Address of the Ray cluster to connect to. Can also be specified "
"using the RAY_API_SERVER_ADDRESS environment variable (falls back to RAY_ADDRESS)."
),
)
@click.argument("job-id", type=str)
@add_common_job_options
@add_click_logging_options
@PublicAPI(stability="stable")
def delete(
address: Optional[str],
job_id: str,
headers: Optional[str],
verify: Union[bool, str],
):
"""Deletes a stopped job and its associated data from memory.
Only supported for jobs that are already in a terminal state.
Fails with exit code 1 if the job is not already stopped.
Does not delete job logs from disk.
Submitting a job with the same submission ID as a previously
deleted job is not supported and may lead to unexpected behavior.
Example:
ray job delete <my_job_id>
Args:
address: Address of the Ray cluster to connect to.
job_id: The submission ID of the job to delete.
headers: JSON string of headers to attach to requests.
verify: Path to a CA bundle, or boolean toggling TLS verification.
"""
client = _get_sdk_client(address, headers=headers, verify=verify)
client.delete_job(job_id)
cli_logger.print(f"Job '{job_id}' deleted successfully")
@job_cli_group.command()
@click.option(
"--address",
type=str,
default=None,
required=False,
help=(
"Address of the Ray cluster to connect to. Can also be specified "
"using the RAY_API_SERVER_ADDRESS environment variable (falls back to RAY_ADDRESS)."
),
)
@click.argument("job-id", type=str)
@click.option(
"-f",
"--follow",
is_flag=True,
type=bool,
default=False,
help="If set, follow the logs (like `tail -f`).",
)
@add_common_job_options
@add_click_logging_options
@PublicAPI(stability="stable")
def logs(
address: Optional[str],
job_id: str,
follow: bool,
headers: Optional[str],
verify: Union[bool, str],
):
"""Gets the logs of a job.
Example:
`ray job logs <my_job_id>`
Args:
address: Address of the Ray cluster to connect to.
job_id: The submission ID of the job whose logs to fetch.
follow: If True, stream the logs (``tail -f`` style) instead of printing them once.
headers: JSON string of headers to attach to requests.
verify: Path to a CA bundle, or boolean toggling TLS verification.
"""
client = _get_sdk_client(address, headers=headers, verify=verify)
sdk_version = client.get_version()
# sdk version 0 did not have log streaming
if follow:
if int(sdk_version) > 0:
get_or_create_event_loop().run_until_complete(_tail_logs(client, job_id))
else:
cli_logger.warning(
"Tailing logs is not enabled for the Jobs SDK client version "
f"{sdk_version}. Please upgrade Ray to latest version "
"for this feature."
)
else:
# Set no_format to True because the logs may have unescaped "{" and "}"
# and the CLILogger calls str.format().
cli_logger.print(client.get_job_logs(job_id), end="", no_format=True)
@job_cli_group.command()
@click.option(
"--address",
type=str,
default=None,
required=False,
help=(
"Address of the Ray cluster to connect to. Can also be specified "
"using the RAY_API_SERVER_ADDRESS environment variable (falls back to RAY_ADDRESS)."
),
)
@add_common_job_options
@add_click_logging_options
@PublicAPI(stability="stable")
def list(address: Optional[str], headers: Optional[str], verify: Union[bool, str]):
"""Lists all running jobs and their information.
Example:
`ray job list`
Args:
address: Address of the Ray cluster to connect to.
headers: JSON string of headers to attach to requests.
verify: Path to a CA bundle, or boolean toggling TLS verification.
"""
client = _get_sdk_client(address, headers=headers, verify=verify)
# Set no_format to True because the logs may have unescaped "{" and "}"
# and the CLILogger calls str.format().
cli_logger.print(pprint.pformat(client.list_jobs()), no_format=True)
@@ -0,0 +1,56 @@
import functools
from typing import Union
import click
def bool_cast(string: str) -> Union[bool, str]:
"""Cast a string to a boolean if possible, otherwise return the string."""
if string.lower() == "true" or string == "1":
return True
elif string.lower() == "false" or string == "0":
return False
else:
return string
class BoolOrStringParam(click.ParamType):
"""A click parameter that can be either a boolean or a string."""
name = "BOOL | TEXT"
def convert(self, value, param, ctx):
if isinstance(value, bool):
return value
else:
return bool_cast(value)
def add_common_job_options(func):
"""Decorator for adding CLI flags shared by all `ray job` commands."""
@click.option(
"--verify",
default=True,
show_default=True,
type=BoolOrStringParam(),
help=(
"Boolean indication to verify the server's TLS certificate or a path to"
" a file or directory of trusted certificates."
),
)
@click.option(
"--headers",
required=False,
type=str,
default=None,
help=(
"Used to pass headers through http/s to the Ray Cluster."
'please follow JSON formatting formatting {"key": "value"}'
),
)
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
+599
View File
@@ -0,0 +1,599 @@
import asyncio
import json
import logging
import time
from dataclasses import asdict, dataclass, replace
from enum import Enum
from pathlib import Path
from typing import Any, Dict, Optional, Tuple, Union
from ray._private import ray_constants
from ray._private.event.export_event_logger import (
EventLogType,
check_export_api_enabled,
get_export_event_logger,
)
from ray._private.runtime_env.packaging import parse_uri
from ray._raylet import RAY_INTERNAL_NAMESPACE_PREFIX, GcsClient
from ray.core.generated.export_event_pb2 import ExportEvent
from ray.core.generated.export_submission_job_event_pb2 import (
ExportSubmissionJobEventData,
)
from ray.util.annotations import PublicAPI
# NOTE(edoakes): these constants should be considered a public API because
# they're exposed in the snapshot API.
JOB_ID_METADATA_KEY = "job_submission_id"
JOB_NAME_METADATA_KEY = "job_name"
JOB_ACTOR_NAME_TEMPLATE = f"{RAY_INTERNAL_NAMESPACE_PREFIX}job_actor_" + "{job_id}"
# In order to get information about SupervisorActors launched by different jobs,
# they must be set to the same namespace.
SUPERVISOR_ACTOR_RAY_NAMESPACE = "SUPERVISOR_ACTOR_RAY_NAMESPACE"
JOB_LOGS_PATH_TEMPLATE = "job-driver-{submission_id}.log"
logger = logging.getLogger(__name__)
@PublicAPI(stability="stable")
class JobStatus(str, Enum):
"""An enumeration for describing the status of a job."""
#: The job has not started yet, likely waiting for the runtime_env to be set up.
PENDING = "PENDING"
#: The job is currently running.
RUNNING = "RUNNING"
#: The job was intentionally stopped by the user.
STOPPED = "STOPPED"
#: The job finished successfully.
SUCCEEDED = "SUCCEEDED"
#: The job failed.
FAILED = "FAILED"
def __str__(self) -> str:
return f"{self.value}"
def is_terminal(self) -> bool:
"""Return whether or not this status is terminal.
A terminal status is one that cannot transition to any other status.
The terminal statuses are "STOPPED", "SUCCEEDED", and "FAILED".
Returns:
True if this status is terminal, otherwise False.
"""
return self.value in {"STOPPED", "SUCCEEDED", "FAILED"}
@PublicAPI(stability="stable")
class JobErrorType(str, Enum):
"""An enumeration for describing the error type of a job."""
# Runtime environment failed to be set up
RUNTIME_ENV_SETUP_FAILURE = "RUNTIME_ENV_SETUP_FAILURE"
# Job supervisor actor launched, but job failed to start within timeout
JOB_SUPERVISOR_ACTOR_START_TIMEOUT = "JOB_SUPERVISOR_ACTOR_START_TIMEOUT"
# Job supervisor actor failed to start
JOB_SUPERVISOR_ACTOR_START_FAILURE = "JOB_SUPERVISOR_ACTOR_START_FAILURE"
# Job supervisor actor failed to be scheduled
JOB_SUPERVISOR_ACTOR_UNSCHEDULABLE = "JOB_SUPERVISOR_ACTOR_UNSCHEDULABLE"
# Job supervisor actor failed for unknown exception
JOB_SUPERVISOR_ACTOR_UNKNOWN_FAILURE = "JOB_SUPERVISOR_ACTOR_UNKNOWN_FAILURE"
# Job supervisor actor died
JOB_SUPERVISOR_ACTOR_DIED = "JOB_SUPERVISOR_ACTOR_DIED"
# Job driver script failed to start due to exception
JOB_ENTRYPOINT_COMMAND_START_ERROR = "JOB_ENTRYPOINT_COMMAND_START_ERROR"
# Job driver script failed due to non-zero exit code
JOB_ENTRYPOINT_COMMAND_ERROR = "JOB_ENTRYPOINT_COMMAND_ERROR"
# TODO(aguo): Convert to pydantic model
@PublicAPI(stability="stable")
@dataclass
class JobInfo:
"""A class for recording information associated with a job and its execution.
Please keep this in sync with the JobsAPIInfo proto in src/ray/protobuf/gcs.proto.
"""
#: The status of the job.
status: JobStatus
#: The entrypoint command for this job.
entrypoint: str
#: A message describing the status in more detail.
message: Optional[str] = None
#: Internal error, user script error
error_type: Optional[JobErrorType] = None
#: The time when the job was started. A Unix timestamp in ms.
start_time: Optional[int] = None
#: The time when the job moved into a terminal state. A Unix timestamp in ms.
end_time: Optional[int] = None
#: Arbitrary user-provided metadata for the job.
metadata: Optional[Dict[str, str]] = None
#: The runtime environment for the job.
runtime_env: Optional[Dict[str, Any]] = None
#: The quantity of CPU cores to reserve for the entrypoint command.
entrypoint_num_cpus: Optional[Union[int, float]] = None
#: The number of GPUs to reserve for the entrypoint command.
entrypoint_num_gpus: Optional[Union[int, float]] = None
#: The amount of memory for workers requesting memory for the entrypoint command.
entrypoint_memory: Optional[int] = None
#: The quantity of various custom resources to reserve for the entrypoint command.
entrypoint_resources: Optional[Dict[str, float]] = None
#: Driver agent http address
driver_agent_http_address: Optional[str] = None
#: The node id that driver running on. It will be None only when the job status
# is PENDING, and this field will not be deleted or modified even if the driver dies
driver_node_id: Optional[str] = None
#: The driver process exit code after the driver executed. Return None if driver
#: doesn't finish executing
driver_exit_code: Optional[int] = None
def __post_init__(self):
if isinstance(self.status, str):
self.status = JobStatus(self.status)
if self.message is None:
if self.status == JobStatus.PENDING:
self.message = "Job has not started yet."
if any(
[
self.entrypoint_num_cpus is not None
and self.entrypoint_num_cpus > 0,
self.entrypoint_num_gpus is not None
and self.entrypoint_num_gpus > 0,
self.entrypoint_memory is not None
and self.entrypoint_memory > 0,
self.entrypoint_resources not in [None, {}],
]
):
self.message += (
" It may be waiting for resources "
"(CPUs, GPUs, memory, custom resources) to become available."
)
if self.runtime_env not in [None, {}]:
self.message += (
" It may be waiting for the runtime environment to be set up."
)
elif self.status == JobStatus.RUNNING:
self.message = "Job is currently running."
elif self.status == JobStatus.STOPPED:
self.message = "Job was intentionally stopped."
elif self.status == JobStatus.SUCCEEDED:
self.message = "Job finished successfully."
elif self.status == JobStatus.FAILED:
self.message = "Job failed."
def to_json(self) -> Dict[str, Any]:
"""Convert this object to a JSON-serializable dictionary.
Note that the runtime_env field is converted to a JSON-serialized string
and the field is renamed to runtime_env_json.
Returns:
A JSON-serializable dictionary representing the JobInfo object.
"""
json_dict = asdict(self)
# Convert enum values to strings.
json_dict["status"] = str(json_dict["status"])
json_dict["error_type"] = (
json_dict["error_type"].value if json_dict.get("error_type") else None
)
# Convert runtime_env to a JSON-serialized string.
if "runtime_env" in json_dict:
if json_dict["runtime_env"] is not None:
json_dict["runtime_env_json"] = json.dumps(json_dict["runtime_env"])
del json_dict["runtime_env"]
# Assert that the dictionary is JSON-serializable.
json.dumps(json_dict)
return json_dict
@classmethod
def from_json(cls, json_dict: Dict[str, Any]) -> None:
"""Initialize this object from a JSON dictionary.
Note that the runtime_env_json field is converted to a dictionary and
the field is renamed to runtime_env.
Args:
json_dict: A JSON dictionary to use to initialize the JobInfo object.
"""
# Convert enum values to enum objects.
json_dict["status"] = JobStatus(json_dict["status"])
json_dict["error_type"] = (
JobErrorType(json_dict["error_type"])
if json_dict.get("error_type")
else None
)
# Convert runtime_env from a JSON-serialized string to a dictionary.
if "runtime_env_json" in json_dict:
if json_dict["runtime_env_json"] is not None:
json_dict["runtime_env"] = json.loads(json_dict["runtime_env_json"])
del json_dict["runtime_env_json"]
return cls(**json_dict)
class JobInfoStorageClient:
"""
Interface to put and get job data from the Internal KV store.
"""
# Please keep this format in sync with JobDataKey()
# in src/ray/gcs/gcs_server/gcs_job_manager.h.
JOB_DATA_KEY_PREFIX = f"{RAY_INTERNAL_NAMESPACE_PREFIX}job_info_"
JOB_DATA_KEY = f"{JOB_DATA_KEY_PREFIX}{{job_id}}"
def __init__(
self,
gcs_client: GcsClient,
export_event_log_dir_root: Optional[str] = None,
):
"""
Initialize the JobInfoStorageClient which manages data in the internal KV store.
Export Submission Job events are written when the KV store is updated if
the feature flag is on and a export_event_log_dir_root is passed.
export_event_log_dir_root doesn't need to be passed if the caller
is not modifying data in the KV store.
"""
self._gcs_client = gcs_client
self._export_submission_job_event_logger: logging.Logger = None
try:
if (
check_export_api_enabled(ExportEvent.SourceType.EXPORT_SUBMISSION_JOB)
and export_event_log_dir_root is not None
):
self._export_submission_job_event_logger = get_export_event_logger(
EventLogType.SUBMISSION_JOB,
export_event_log_dir_root,
)
except Exception:
logger.exception(
"Unable to initialize export event logger so no export "
"events will be written."
)
async def put_info(
self,
job_id: str,
job_info: JobInfo,
overwrite: bool = True,
timeout: Optional[int] = 30,
) -> bool:
"""Put job info to the internal kv store.
Args:
job_id: The job id.
job_info: The job info.
overwrite: Whether to overwrite the existing job info.
timeout: The timeout in seconds for the GCS operation.
Returns:
True if a new key is added.
"""
added_num = await self._gcs_client.async_internal_kv_put(
self.JOB_DATA_KEY.format(job_id=job_id).encode(),
json.dumps(job_info.to_json()).encode(),
overwrite,
namespace=ray_constants.KV_NAMESPACE_JOB,
timeout=timeout,
)
if added_num == 1 or overwrite:
# Write export event if data was updated in the KV store
try:
self._write_submission_job_export_event(job_id, job_info)
except Exception:
logger.exception("Error while writing job submission export event.")
return added_num == 1
def _write_submission_job_export_event(
self, job_id: str, job_info: JobInfo
) -> None:
"""
Write Submission Job export event if _export_submission_job_event_logger
exists. The logger will exist if the export API feature flag is enabled
and a log directory was passed to JobInfoStorageClient.
"""
if not self._export_submission_job_event_logger:
return
status_value_descriptor = (
ExportSubmissionJobEventData.JobStatus.DESCRIPTOR.values_by_name.get(
job_info.status.name
)
)
if status_value_descriptor is None:
logger.error(
f"{job_info.status.name} is not a valid "
"ExportSubmissionJobEventData.JobStatus enum value. This event "
"will not be written."
)
return
job_status = status_value_descriptor.number
submission_event_data = ExportSubmissionJobEventData(
submission_job_id=job_id,
status=job_status,
entrypoint=job_info.entrypoint,
message=job_info.message,
metadata=job_info.metadata,
error_type=job_info.error_type,
start_time=job_info.start_time,
end_time=job_info.end_time,
runtime_env_json=json.dumps(job_info.runtime_env),
driver_agent_http_address=job_info.driver_agent_http_address,
driver_node_id=job_info.driver_node_id,
driver_exit_code=job_info.driver_exit_code,
)
self._export_submission_job_event_logger.send_event(submission_event_data)
async def get_info(self, job_id: str, timeout: int = 30) -> Optional[JobInfo]:
serialized_info = await self._gcs_client.async_internal_kv_get(
self.JOB_DATA_KEY.format(job_id=job_id).encode(),
namespace=ray_constants.KV_NAMESPACE_JOB,
timeout=timeout,
)
if serialized_info is None:
return None
else:
return JobInfo.from_json(json.loads(serialized_info))
async def delete_info(self, job_id: str, timeout: int = 30):
await self._gcs_client.async_internal_kv_del(
self.JOB_DATA_KEY.format(job_id=job_id).encode(),
False,
namespace=ray_constants.KV_NAMESPACE_JOB,
timeout=timeout,
)
async def put_status(
self,
job_id: str,
status: JobStatus,
message: Optional[str] = None,
driver_exit_code: Optional[int] = None,
error_type: Optional[JobErrorType] = None,
jobinfo_replace_kwargs: Optional[Dict[str, Any]] = None,
timeout: Optional[int] = 30,
):
"""Puts or updates job status. Sets end_time if status is terminal."""
old_info = await self.get_info(job_id, timeout=timeout)
if jobinfo_replace_kwargs is None:
jobinfo_replace_kwargs = dict()
jobinfo_replace_kwargs.update(
status=status,
message=message,
driver_exit_code=driver_exit_code,
error_type=error_type,
)
if old_info is not None:
if status != old_info.status and old_info.status.is_terminal():
raise RuntimeError(
f"Attempted to change job status from a terminal state: "
f"{old_info.status} -> {status}"
)
new_info = replace(old_info, **jobinfo_replace_kwargs)
else:
new_info = JobInfo(
entrypoint="Entrypoint not found.", **jobinfo_replace_kwargs
)
if status.is_terminal():
new_info.end_time = int(time.time() * 1000)
await self.put_info(job_id, new_info, timeout=timeout)
async def get_status(self, job_id: str, timeout: int = 30) -> Optional[JobStatus]:
job_info = await self.get_info(job_id, timeout)
if job_info is None:
return None
else:
return job_info.status
async def get_all_jobs(self, timeout: int = 30) -> Dict[str, JobInfo]:
raw_job_ids_with_prefixes = await self._gcs_client.async_internal_kv_keys(
self.JOB_DATA_KEY_PREFIX.encode(),
namespace=ray_constants.KV_NAMESPACE_JOB,
timeout=timeout,
)
job_ids_with_prefixes = [
job_id.decode() for job_id in raw_job_ids_with_prefixes
]
job_ids = []
for job_id_with_prefix in job_ids_with_prefixes:
assert job_id_with_prefix.startswith(
self.JOB_DATA_KEY_PREFIX
), "Unexpected format for internal_kv key for Job submission"
job_ids.append(job_id_with_prefix[len(self.JOB_DATA_KEY_PREFIX) :])
async def get_job_info(job_id: str):
job_info = await self.get_info(job_id, timeout)
return job_id, job_info
results = await asyncio.gather(*[get_job_info(job_id) for job_id in job_ids])
return {
job_id: job_info for job_id, job_info in results if job_info is not None
}
def uri_to_http_components(package_uri: str) -> Tuple[str, str]:
suffix = Path(package_uri).suffix
if suffix not in {".zip", ".whl"}:
raise ValueError(f"package_uri ({package_uri}) does not end in .zip or .whl")
# We need to strip the <protocol>:// prefix to make it possible to pass
# the package_uri over HTTP.
protocol, package_name = parse_uri(package_uri)
return protocol.value, package_name
def http_uri_components_to_uri(protocol: str, package_name: str) -> str:
return f"{protocol}://{package_name}"
def validate_request_type(json_data: Dict[str, Any], request_type: dataclass) -> Any:
return request_type(**json_data)
@dataclass
class JobSubmitRequest:
# Command to start execution, ex: "python script.py"
entrypoint: str
# Optional submission_id to specify for the job. If the submission_id
# is not specified, one will be generated. If a job with the same
# submission_id already exists, it will be rejected.
submission_id: Optional[str] = None
# DEPRECATED. Use submission_id instead
job_id: Optional[str] = None
# Dict to setup execution environment.
runtime_env: Optional[Dict[str, Any]] = None
# Metadata to pass in to the JobConfig.
metadata: Optional[Dict[str, str]] = None
# The quantity of CPU cores to reserve for the execution
# of the entrypoint command, separately from any Ray tasks or actors
# that are created by it.
entrypoint_num_cpus: Optional[Union[int, float]] = None
# The quantity of GPUs to reserve for the execution
# of the entrypoint command, separately from any Ray tasks or actors
# that are created by it.
entrypoint_num_gpus: Optional[Union[int, float]] = None
# The amount of total available memory for workers requesting memory
# for the execution of the entrypoint command, separately from any Ray
# tasks or actors that are created by it.
entrypoint_memory: Optional[int] = None
# The quantity of various custom resources
# to reserve for the entrypoint command, separately from any Ray tasks
# or actors that are created by it.
entrypoint_resources: Optional[Dict[str, float]] = None
# Label selector for the entrypoint command.
entrypoint_label_selector: Optional[Dict[str, str]] = None
def __post_init__(self):
if not isinstance(self.entrypoint, str):
raise TypeError(f"entrypoint must be a string, got {type(self.entrypoint)}")
if self.submission_id is not None and not isinstance(self.submission_id, str):
raise TypeError(
"submission_id must be a string if provided, "
f"got {type(self.submission_id)}"
)
if self.job_id is not None and not isinstance(self.job_id, str):
raise TypeError(
"job_id must be a string if provided, " f"got {type(self.job_id)}"
)
if self.runtime_env is not None:
if not isinstance(self.runtime_env, dict):
raise TypeError(
f"runtime_env must be a dict, got {type(self.runtime_env)}"
)
else:
for k in self.runtime_env.keys():
if not isinstance(k, str):
raise TypeError(
f"runtime_env keys must be strings, got {type(k)}"
)
if self.metadata is not None:
if not isinstance(self.metadata, dict):
raise TypeError(f"metadata must be a dict, got {type(self.metadata)}")
else:
for k in self.metadata.keys():
if not isinstance(k, str):
raise TypeError(f"metadata keys must be strings, got {type(k)}")
for v in self.metadata.values():
if not isinstance(v, str):
raise TypeError(
f"metadata values must be strings, got {type(v)}"
)
if self.entrypoint_num_cpus is not None and not isinstance(
self.entrypoint_num_cpus, (int, float)
):
raise TypeError(
"entrypoint_num_cpus must be a number, "
f"got {type(self.entrypoint_num_cpus)}"
)
if self.entrypoint_num_gpus is not None and not isinstance(
self.entrypoint_num_gpus, (int, float)
):
raise TypeError(
"entrypoint_num_gpus must be a number, "
f"got {type(self.entrypoint_num_gpus)}"
)
if self.entrypoint_memory is not None and not isinstance(
self.entrypoint_memory, int
):
raise TypeError(
"entrypoint_memory must be an integer, "
f"got {type(self.entrypoint_memory)}"
)
if self.entrypoint_resources is not None:
if not isinstance(self.entrypoint_resources, dict):
raise TypeError(
"entrypoint_resources must be a dict, "
f"got {type(self.entrypoint_resources)}"
)
else:
for k in self.entrypoint_resources.keys():
if not isinstance(k, str):
raise TypeError(
"entrypoint_resources keys must be strings, "
f"got {type(k)}"
)
for v in self.entrypoint_resources.values():
if not isinstance(v, (int, float)):
raise TypeError(
"entrypoint_resources values must be numbers, "
f"got {type(v)}"
)
if self.entrypoint_label_selector is not None:
if not isinstance(self.entrypoint_label_selector, dict):
raise TypeError(
"entrypoint_label_selector must be a dict, "
f"got {type(self.entrypoint_label_selector)}"
)
else:
for k, v in self.entrypoint_label_selector.items():
if not isinstance(k, str):
raise TypeError(
"entrypoint_label_selector keys must be strings, "
f"got {type(k)}"
)
if not isinstance(v, str):
raise TypeError(
"entrypoint_label_selector values must be strings, "
f"got {type(v)}"
)
@dataclass
class JobSubmitResponse:
# DEPRECATED: Use submission_id instead.
job_id: str
submission_id: str
@dataclass
class JobStopResponse:
stopped: bool
@dataclass
class JobDeleteResponse:
deleted: bool
# TODO(jiaodong): Support log streaming #19415
@dataclass
class JobLogsResponse:
logs: str
@@ -0,0 +1,26 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "http://github.com/ray-project/ray/dashboard/modules/job/component_activities_schema.json",
"type": "object",
"patternProperties": {
"[0-9a-f]*": {
"type": "object",
"properties": {
"is_active": {
"type": "string",
"enum": ["ACTIVE", "INACTIVE", "ERROR"]
},
"reason": {
"type": ["string", "null"]
},
"timestamp": {
"type": ["number"]
},
"last_activity_at": {
"type": ["number", "null"]
}
},
"required": ["is_active"]
}
}
}
@@ -0,0 +1,210 @@
import dataclasses
import json
import logging
import traceback
import aiohttp
from aiohttp.web import Request, Response
import ray
import ray.dashboard.optional_utils as optional_utils
import ray.dashboard.utils as dashboard_utils
from ray.dashboard.modules.job.common import (
JobDeleteResponse,
JobLogsResponse,
JobStopResponse,
JobSubmitRequest,
JobSubmitResponse,
)
from ray.dashboard.modules.job.job_manager import JobManager
from ray.dashboard.modules.job.pydantic_models import JobType
from ray.dashboard.modules.job.utils import find_job_by_ids, parse_and_validate_request
routes = optional_utils.DashboardAgentRouteTable
logger = logging.getLogger(__name__)
class JobAgent(dashboard_utils.DashboardAgentModule):
def __init__(self, dashboard_agent):
super().__init__(dashboard_agent)
self._job_manager = None
@routes.post("/api/job_agent/jobs/")
@optional_utils.init_ray_and_catch_exceptions()
async def submit_job(self, req: Request) -> Response:
result = await parse_and_validate_request(req, JobSubmitRequest)
# Request parsing failed, returned with Response object.
if isinstance(result, Response):
return result
else:
submit_request = result
request_submission_id = submit_request.submission_id or submit_request.job_id
try:
ray._common.usage.usage_lib.record_library_usage("job_submission")
submission_id = await self.get_job_manager().submit_job(
entrypoint=submit_request.entrypoint,
submission_id=request_submission_id,
runtime_env=submit_request.runtime_env,
metadata=submit_request.metadata,
entrypoint_num_cpus=submit_request.entrypoint_num_cpus,
entrypoint_num_gpus=submit_request.entrypoint_num_gpus,
entrypoint_memory=submit_request.entrypoint_memory,
entrypoint_resources=submit_request.entrypoint_resources,
entrypoint_label_selector=submit_request.entrypoint_label_selector,
)
resp = JobSubmitResponse(job_id=submission_id, submission_id=submission_id)
except (TypeError, ValueError):
return Response(
text=traceback.format_exc(),
status=aiohttp.web.HTTPBadRequest.status_code,
)
except Exception:
return Response(
text=traceback.format_exc(),
status=aiohttp.web.HTTPInternalServerError.status_code,
)
return Response(
text=json.dumps(dataclasses.asdict(resp)),
content_type="application/json",
status=aiohttp.web.HTTPOk.status_code,
)
@routes.post("/api/job_agent/jobs/{job_or_submission_id}/stop")
@optional_utils.init_ray_and_catch_exceptions()
async def stop_job(self, req: Request) -> Response:
job_or_submission_id = req.match_info["job_or_submission_id"]
job = await find_job_by_ids(
self._dashboard_agent.gcs_client,
self.get_job_manager().job_info_client(),
job_or_submission_id,
)
if not job:
return Response(
text=f"Job {job_or_submission_id} does not exist",
status=aiohttp.web.HTTPNotFound.status_code,
)
if job.type is not JobType.SUBMISSION:
return Response(
text="Can only stop submission type jobs",
status=aiohttp.web.HTTPBadRequest.status_code,
)
try:
stopped = self.get_job_manager().stop_job(job.submission_id)
resp = JobStopResponse(stopped=stopped)
except Exception:
return Response(
text=traceback.format_exc(),
status=aiohttp.web.HTTPInternalServerError.status_code,
)
return Response(
text=json.dumps(dataclasses.asdict(resp)), content_type="application/json"
)
@routes.delete("/api/job_agent/jobs/{job_or_submission_id}")
@optional_utils.init_ray_and_catch_exceptions()
async def delete_job(self, req: Request) -> Response:
job_or_submission_id = req.match_info["job_or_submission_id"]
job = await find_job_by_ids(
self._dashboard_agent.gcs_client,
self.get_job_manager().job_info_client(),
job_or_submission_id,
)
if not job:
return Response(
text=f"Job {job_or_submission_id} does not exist",
status=aiohttp.web.HTTPNotFound.status_code,
)
if job.type is not JobType.SUBMISSION:
return Response(
text="Can only delete submission type jobs",
status=aiohttp.web.HTTPBadRequest.status_code,
)
try:
deleted = await self.get_job_manager().delete_job(job.submission_id)
resp = JobDeleteResponse(deleted=deleted)
except Exception:
return Response(
text=traceback.format_exc(),
status=aiohttp.web.HTTPInternalServerError.status_code,
)
return Response(
text=json.dumps(dataclasses.asdict(resp)), content_type="application/json"
)
@routes.get("/api/job_agent/jobs/{job_or_submission_id}/logs")
@optional_utils.init_ray_and_catch_exceptions()
async def get_job_logs(self, req: Request) -> Response:
job_or_submission_id = req.match_info["job_or_submission_id"]
job = await find_job_by_ids(
self._dashboard_agent.gcs_client,
self.get_job_manager().job_info_client(),
job_or_submission_id,
)
if not job:
return Response(
text=f"Job {job_or_submission_id} does not exist",
status=aiohttp.web.HTTPNotFound.status_code,
)
if job.type is not JobType.SUBMISSION:
return Response(
text="Can only get logs of submission type jobs",
status=aiohttp.web.HTTPBadRequest.status_code,
)
resp = JobLogsResponse(
logs=self.get_job_manager().get_job_logs(job.submission_id)
)
return Response(
text=json.dumps(dataclasses.asdict(resp)), content_type="application/json"
)
@routes.get("/api/job_agent/jobs/{job_or_submission_id}/logs/tail")
@optional_utils.init_ray_and_catch_exceptions()
async def tail_job_logs(self, req: Request) -> Response:
job_or_submission_id = req.match_info["job_or_submission_id"]
job = await find_job_by_ids(
self._dashboard_agent.gcs_client,
self.get_job_manager().job_info_client(),
job_or_submission_id,
)
if not job:
return Response(
text=f"Job {job_or_submission_id} does not exist",
status=aiohttp.web.HTTPNotFound.status_code,
)
if job.type is not JobType.SUBMISSION:
return Response(
text="Can only get logs of submission type jobs",
status=aiohttp.web.HTTPBadRequest.status_code,
)
ws = aiohttp.web.WebSocketResponse()
await ws.prepare(req)
async for lines in self._job_manager.tail_job_logs(job.submission_id):
await ws.send_str(lines)
return ws
def get_job_manager(self):
if not self._job_manager:
self._job_manager = JobManager(
self._dashboard_agent.gcs_client, self._dashboard_agent.log_dir
)
return self._job_manager
async def run(self, server):
pass
@staticmethod
def is_minimal_module():
return False
@@ -0,0 +1,774 @@
import asyncio
import dataclasses
import enum
import json
import logging
import os
import time
import traceback
from datetime import datetime
from typing import AsyncIterator, Dict, Optional, Tuple
import aiohttp.web
from aiohttp.client import ClientResponse
from aiohttp.web import Request, Response, StreamResponse
import ray
from ray import NodeID
from ray._common.network_utils import build_address
from ray._common.pydantic_compat import BaseModel, Extra, Field, validator
from ray._common.utils import get_or_create_event_loop, load_class
from ray._private.authentication.http_token_authentication import (
get_auth_headers_if_auth_enabled,
)
from ray._private.ray_constants import KV_NAMESPACE_DASHBOARD
from ray._private.runtime_env.packaging import (
package_exists,
pin_runtime_env_uri,
upload_package_to_gcs,
)
from ray.dashboard.consts import (
DASHBOARD_AGENT_ADDR_NODE_ID_PREFIX,
GCS_RPC_TIMEOUT_SECONDS,
RAY_CLUSTER_ACTIVITY_HOOK,
TRY_TO_GET_AGENT_INFO_INTERVAL_SECONDS,
WAIT_AVAILABLE_AGENT_TIMEOUT,
)
from ray.dashboard.modules.job.common import (
JobDeleteResponse,
JobInfoStorageClient,
JobLogsResponse,
JobStopResponse,
JobSubmitRequest,
JobSubmitResponse,
http_uri_components_to_uri,
)
from ray.dashboard.modules.job.pydantic_models import JobDetails, JobType
from ray.dashboard.modules.job.utils import (
find_job_by_ids,
get_driver_jobs,
parse_and_validate_request,
)
from ray.dashboard.modules.version import CURRENT_VERSION, VersionResponse
from ray.dashboard.subprocesses.module import SubprocessModule
from ray.dashboard.subprocesses.routes import SubprocessRouteTable as routes
from ray.dashboard.subprocesses.utils import ResponseType
from ray.dashboard.utils import get_head_node_id
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
class RayActivityStatus(str, enum.Enum):
ACTIVE = "ACTIVE"
INACTIVE = "INACTIVE"
ERROR = "ERROR"
class RayActivityResponse(BaseModel, extra=Extra.allow):
"""
Pydantic model used to inform if a particular Ray component can be considered
active, and metadata about observation.
"""
is_active: RayActivityStatus = Field(
...,
description=(
"Whether the corresponding Ray component is considered active or inactive, "
"or if there was an error while collecting this observation."
),
)
reason: Optional[str] = Field(
None, description="Reason if Ray component is considered active or errored."
)
timestamp: float = Field(
...,
description=(
"Timestamp of when this observation about the Ray component was made. "
"This is in the format of seconds since unix epoch."
),
)
last_activity_at: Optional[float] = Field(
None,
description=(
"Timestamp when last actvity of this Ray component finished in format of "
"seconds since unix epoch. This field does not need to be populated "
"for Ray components where it is not meaningful."
),
)
@validator("reason", always=True)
def reason_required(cls, v, values, **kwargs):
if "is_active" in values and values["is_active"] != RayActivityStatus.INACTIVE:
if v is None:
raise ValueError(
'Reason is required if is_active is "active" or "error"'
)
return v
class JobAgentSubmissionClient:
"""A local client for submitting and interacting with jobs on a specific node
in the remote cluster.
Submits requests over HTTP to the job agent on the specific node using the REST API.
"""
def __init__(
self,
dashboard_agent_address: str,
):
self._agent_address = dashboard_agent_address
self._session = aiohttp.ClientSession()
def _get_headers(self):
"""Get auth headers if token authentication is enabled."""
return get_auth_headers_if_auth_enabled({})
async def _raise_error(self, resp: ClientResponse):
status = resp.status
error_text = await resp.text()
raise RuntimeError(f"Request failed with status code {status}: {error_text}.")
async def submit_job_internal(self, req: JobSubmitRequest) -> JobSubmitResponse:
logger.debug(f"Submitting job with submission_id={req.submission_id}.")
async with self._session.post(
f"{self._agent_address}/api/job_agent/jobs/",
json=dataclasses.asdict(req),
headers=self._get_headers(),
) as resp:
if resp.status == 200:
result_json = await resp.json()
return JobSubmitResponse(**result_json)
else:
await self._raise_error(resp)
async def stop_job_internal(self, job_id: str) -> JobStopResponse:
logger.debug(f"Stopping job with job_id={job_id}.")
async with self._session.post(
f"{self._agent_address}/api/job_agent/jobs/{job_id}/stop",
headers=self._get_headers(),
) as resp:
if resp.status == 200:
result_json = await resp.json()
return JobStopResponse(**result_json)
else:
await self._raise_error(resp)
async def delete_job_internal(self, job_id: str) -> JobDeleteResponse:
logger.debug(f"Deleting job with job_id={job_id}.")
async with self._session.delete(
f"{self._agent_address}/api/job_agent/jobs/{job_id}",
headers=self._get_headers(),
) as resp:
if resp.status == 200:
result_json = await resp.json()
return JobDeleteResponse(**result_json)
else:
await self._raise_error(resp)
async def get_job_logs_internal(self, job_id: str) -> JobLogsResponse:
async with self._session.get(
f"{self._agent_address}/api/job_agent/jobs/{job_id}/logs",
headers=self._get_headers(),
) as resp:
if resp.status == 200:
result_json = await resp.json()
return JobLogsResponse(**result_json)
else:
await self._raise_error(resp)
async def tail_job_logs(self, job_id: str) -> AsyncIterator[str]:
"""Get an iterator that follows the logs of a job."""
ws = await self._session.ws_connect(
f"{self._agent_address}/api/job_agent/jobs/{job_id}/logs/tail",
headers=self._get_headers(),
)
while True:
msg = await ws.receive()
if msg.type == aiohttp.WSMsgType.TEXT:
yield msg.data
elif msg.type == aiohttp.WSMsgType.CLOSED:
logger.info(
f"WebSocket to job agent closed for job {job_id} "
f"with close code {ws.close_code}"
)
break
elif msg.type == aiohttp.WSMsgType.ERROR:
logger.warning(
f"WebSocket to job agent received an error message "
f"while tailing logs for job {job_id}: {ws.exception()!r}. "
)
pass
async def close(self, ignore_error=True):
try:
await self._session.close()
except Exception:
if not ignore_error:
raise
class JobHead(SubprocessModule):
"""Runs on the head node of a Ray cluster and handles Ray Jobs APIs.
NOTE(architkulkarni): Please keep this class in sync with the OpenAPI spec at
`doc/source/cluster/running-applications/job-submission/openapi.yml`.
We currently do not automatically check that the OpenAPI
spec is in sync with the implementation. If any changes are made to the
paths in the @route decorators or in the Responses returned by the
methods (or any nested fields in the Responses), you will need to find the
corresponding field of the OpenAPI yaml file and update it manually. Also,
bump the version number in the yaml file and in this class's `get_version`.
"""
# Time that we sleep while tailing logs while waiting for
# the supervisor actor to start. We don't know which node
# to read the logs from until then.
WAIT_FOR_SUPERVISOR_ACTOR_INTERVAL_S = 1
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._job_info_client = None
# To make sure that the internal KV is initialized by getting the lazy property
assert self.gcs_client is not None
assert ray.experimental.internal_kv._internal_kv_initialized()
# It contains all `JobAgentSubmissionClient` that
# `JobHead` has ever used, and will not be deleted
# from it unless `JobAgentSubmissionClient` is no
# longer available (the corresponding agent process is dead)
# {node_id: JobAgentSubmissionClient}
self._agents: Dict[NodeID, JobAgentSubmissionClient] = dict()
async def get_target_agent(
self, timeout_s: float = WAIT_AVAILABLE_AGENT_TIMEOUT
) -> JobAgentSubmissionClient:
"""
Get a `JobAgentSubmissionClient`, which is a client for interacting with jobs
via an agent process.
Args:
timeout_s: The timeout for the operation.
Returns:
A `JobAgentSubmissionClient` for interacting with jobs via an agent process.
Raises:
TimeoutError: If the operation times out.
"""
return await self._get_head_node_agent(timeout_s)
async def _get_head_node_agent_once(self) -> JobAgentSubmissionClient:
head_node_id_hex = await get_head_node_id(self.gcs_client)
if not head_node_id_hex:
raise Exception("Head node id has not yet been persisted in GCS")
head_node_id = NodeID.from_hex(head_node_id_hex)
if head_node_id not in self._agents:
ip, http_port, _ = await self._fetch_agent_info(head_node_id)
agent_http_address = f"http://{build_address(ip, http_port)}"
self._agents[head_node_id] = JobAgentSubmissionClient(agent_http_address)
return self._agents[head_node_id]
async def _get_head_node_agent(self, timeout_s: float) -> JobAgentSubmissionClient:
"""Retrieves HTTP client for `JobAgent` running on the Head node. If the head
node does not have an agent, it will retry every
`TRY_TO_GET_AGENT_INFO_INTERVAL_SECONDS` seconds indefinitely.
Args:
timeout_s: The timeout for the operation.
Returns:
A `JobAgentSubmissionClient` for interacting with jobs via the head node's agent process.
Raises:
TimeoutError: If the operation times out.
"""
timeout_point = time.time() + timeout_s
exception = None
while time.time() < timeout_point:
try:
return await self._get_head_node_agent_once()
except Exception as e:
exception = e
logger.exception(
f"Failed to get head node agent, retrying in {TRY_TO_GET_AGENT_INFO_INTERVAL_SECONDS} seconds..."
)
await asyncio.sleep(TRY_TO_GET_AGENT_INFO_INTERVAL_SECONDS)
raise TimeoutError(
f"Failed to get head node agent within {timeout_s} seconds. The last exception is {exception}"
)
async def _fetch_agent_info(self, target_node_id: NodeID) -> Tuple[str, int, int]:
"""
Fetches agent info by the Node ID. May raise exception if there's network error or the
agent info is not found.
Returns: (ip, http_port, grpc_port)
"""
key = f"{DASHBOARD_AGENT_ADDR_NODE_ID_PREFIX}{target_node_id.hex()}"
value = await self.gcs_client.async_internal_kv_get(
key,
namespace=KV_NAMESPACE_DASHBOARD,
timeout=GCS_RPC_TIMEOUT_SECONDS,
)
if not value:
raise KeyError(
f"Agent info not found in internal KV for node {target_node_id}. "
"It's possible that the agent didn't launch successfully due to "
"port conflicts or other issues. Please check `dashboard_agent.log` "
"for more details."
)
return json.loads(value.decode())
@routes.get("/api/version")
async def get_version(self, req: Request) -> Response:
# NOTE(edoakes): CURRENT_VERSION should be bumped and checked on the
# client when we have backwards-incompatible changes.
resp = VersionResponse(
version=CURRENT_VERSION,
ray_version=ray.__version__,
ray_commit=ray.__commit__,
session_name=self.session_name,
)
return Response(
text=json.dumps(dataclasses.asdict(resp)),
content_type="application/json",
status=aiohttp.web.HTTPOk.status_code,
)
@routes.get("/api/packages/{protocol}/{package_name}")
async def get_package(self, req: Request) -> Response:
package_uri = http_uri_components_to_uri(
protocol=req.match_info["protocol"],
package_name=req.match_info["package_name"],
)
logger.debug(f"Adding temporary reference to package {package_uri}.")
try:
pin_runtime_env_uri(package_uri)
except Exception:
return Response(
text=traceback.format_exc(),
status=aiohttp.web.HTTPInternalServerError.status_code,
)
if not package_exists(package_uri):
return Response(
text=f"Package {package_uri} does not exist",
status=aiohttp.web.HTTPNotFound.status_code,
)
return Response()
@routes.put("/api/packages/{protocol}/{package_name}")
async def upload_package(self, req: Request):
package_uri = http_uri_components_to_uri(
protocol=req.match_info["protocol"],
package_name=req.match_info["package_name"],
)
logger.info(f"Uploading package {package_uri} to the GCS.")
try:
data = await req.read()
await get_or_create_event_loop().run_in_executor(
None,
upload_package_to_gcs,
package_uri,
data,
)
except Exception:
return Response(
text=traceback.format_exc(),
status=aiohttp.web.HTTPInternalServerError.status_code,
)
return Response(status=aiohttp.web.HTTPOk.status_code)
@routes.post("/api/jobs/")
async def submit_job(self, req: Request) -> Response:
result = await parse_and_validate_request(req, JobSubmitRequest)
# Request parsing failed, returned with Response object.
if isinstance(result, Response):
return result
else:
submit_request: JobSubmitRequest = result
try:
job_agent_client = await self.get_target_agent()
resp = await job_agent_client.submit_job_internal(submit_request)
except asyncio.TimeoutError:
logger.warning(
"Timed out waiting for an available job agent to submit the job."
)
return Response(
text="No available agent to submit job, please try again later.",
status=aiohttp.web.HTTPInternalServerError.status_code,
)
except (TypeError, ValueError):
logger.warning("Failed to submit job due to an invalid request.")
return Response(
text=traceback.format_exc(),
status=aiohttp.web.HTTPBadRequest.status_code,
)
except Exception:
logger.exception("Failed to submit job due to unexpected exception.")
return Response(
text=traceback.format_exc(),
status=aiohttp.web.HTTPInternalServerError.status_code,
)
return Response(
text=json.dumps(dataclasses.asdict(resp)),
content_type="application/json",
status=aiohttp.web.HTTPOk.status_code,
)
@routes.post("/api/jobs/{job_or_submission_id}/stop")
async def stop_job(self, req: Request) -> Response:
job_or_submission_id = req.match_info["job_or_submission_id"]
job = await find_job_by_ids(
self.gcs_client,
self._job_info_client,
job_or_submission_id,
)
if not job:
return Response(
text=f"Job {job_or_submission_id} does not exist",
status=aiohttp.web.HTTPNotFound.status_code,
)
if job.type is not JobType.SUBMISSION:
return Response(
text="Can only stop submission type jobs",
status=aiohttp.web.HTTPBadRequest.status_code,
)
try:
job_agent_client = await self.get_target_agent()
resp = await job_agent_client.stop_job_internal(job.submission_id)
except Exception:
return Response(
text=traceback.format_exc(),
status=aiohttp.web.HTTPInternalServerError.status_code,
)
return Response(
text=json.dumps(dataclasses.asdict(resp)), content_type="application/json"
)
@routes.delete("/api/jobs/{job_or_submission_id}")
async def delete_job(self, req: Request) -> Response:
job_or_submission_id = req.match_info["job_or_submission_id"]
job = await find_job_by_ids(
self.gcs_client,
self._job_info_client,
job_or_submission_id,
)
if not job:
return Response(
text=f"Job {job_or_submission_id} does not exist",
status=aiohttp.web.HTTPNotFound.status_code,
)
if job.type is not JobType.SUBMISSION:
return Response(
text="Can only delete submission type jobs",
status=aiohttp.web.HTTPBadRequest.status_code,
)
try:
job_agent_client = await self.get_target_agent()
resp = await job_agent_client.delete_job_internal(job.submission_id)
except Exception:
return Response(
text=traceback.format_exc(),
status=aiohttp.web.HTTPInternalServerError.status_code,
)
return Response(
text=json.dumps(dataclasses.asdict(resp)), content_type="application/json"
)
@routes.get("/api/jobs/{job_or_submission_id}")
async def get_job_info(self, req: Request) -> Response:
job_or_submission_id = req.match_info["job_or_submission_id"]
job = await find_job_by_ids(
self.gcs_client,
self._job_info_client,
job_or_submission_id,
)
if not job:
return Response(
text=f"Job {job_or_submission_id} does not exist",
status=aiohttp.web.HTTPNotFound.status_code,
)
return Response(
text=json.dumps(job.dict()),
content_type="application/json",
)
# TODO(rickyx): This endpoint's logic is also mirrored in state API's endpoint.
# We should eventually unify the backend logic (and keep the logic in sync before
# that).
@routes.get("/api/jobs/")
async def list_jobs(self, req: Request) -> Response:
(driver_jobs, submission_job_drivers), submission_jobs = await asyncio.gather(
get_driver_jobs(self.gcs_client), self._job_info_client.get_all_jobs()
)
submission_jobs = [
JobDetails(
**dataclasses.asdict(job),
submission_id=submission_id,
job_id=submission_job_drivers.get(submission_id).id
if submission_id in submission_job_drivers
else None,
driver_info=submission_job_drivers.get(submission_id),
type=JobType.SUBMISSION,
)
for submission_id, job in submission_jobs.items()
]
return Response(
text=json.dumps(
[
*[submission_job.dict() for submission_job in submission_jobs],
*[job_info.dict() for job_info in driver_jobs.values()],
]
),
content_type="application/json",
)
@routes.get("/api/jobs/{job_or_submission_id}/logs")
async def get_job_logs(self, req: Request) -> Response:
job_or_submission_id = req.match_info["job_or_submission_id"]
job = await find_job_by_ids(
self.gcs_client,
self._job_info_client,
job_or_submission_id,
)
if not job:
return Response(
text=f"Job {job_or_submission_id} does not exist",
status=aiohttp.web.HTTPNotFound.status_code,
)
if job.type is not JobType.SUBMISSION:
return Response(
text="Can only get logs of submission type jobs",
status=aiohttp.web.HTTPBadRequest.status_code,
)
try:
job_agent_client = self.get_job_driver_agent_client(job)
payload = (
await job_agent_client.get_job_logs_internal(job.submission_id)
if job_agent_client
else JobLogsResponse("")
)
return Response(
text=json.dumps(dataclasses.asdict(payload)),
content_type="application/json",
)
except Exception:
return Response(
text=traceback.format_exc(),
status=aiohttp.web.HTTPInternalServerError.status_code,
)
@routes.get(
"/api/jobs/{job_or_submission_id}/logs/tail", resp_type=ResponseType.WEBSOCKET
)
async def tail_job_logs(self, req: Request) -> StreamResponse:
job_or_submission_id = req.match_info["job_or_submission_id"]
job = await find_job_by_ids(
self.gcs_client,
self._job_info_client,
job_or_submission_id,
)
if not job:
return Response(
text=f"Job {job_or_submission_id} does not exist",
status=aiohttp.web.HTTPNotFound.status_code,
)
if job.type is not JobType.SUBMISSION:
return Response(
text="Can only get logs of submission type jobs",
status=aiohttp.web.HTTPBadRequest.status_code,
)
ws = aiohttp.web.WebSocketResponse()
await ws.prepare(req)
driver_agent_http_address = None
while driver_agent_http_address is None:
job = await find_job_by_ids(
self.gcs_client,
self._job_info_client,
job_or_submission_id,
)
driver_agent_http_address = job.driver_agent_http_address
status = job.status
if status.is_terminal() and driver_agent_http_address is None:
# Job exited before supervisor actor started.
return ws
await asyncio.sleep(self.WAIT_FOR_SUPERVISOR_ACTOR_INTERVAL_S)
job_agent_client = self.get_job_driver_agent_client(job)
async for lines in job_agent_client.tail_job_logs(job.submission_id):
await ws.send_str(lines)
return ws
def get_job_driver_agent_client(
self, job: JobDetails
) -> Optional[JobAgentSubmissionClient]:
if job.driver_agent_http_address is None:
return None
driver_node_id = job.driver_node_id
if driver_node_id not in self._agents:
self._agents[driver_node_id] = JobAgentSubmissionClient(
job.driver_agent_http_address
)
return self._agents[driver_node_id]
@routes.get("/api/component_activities")
async def get_component_activities(
self, req: aiohttp.web.Request
) -> aiohttp.web.Response:
timeout = req.query.get("timeout", None)
if timeout and timeout.isdigit():
timeout = int(timeout)
else:
timeout = 30
# Get activity information for driver
driver_activity_info = await self._get_job_activity_info(timeout=timeout)
resp = {"driver": dict(driver_activity_info)}
if RAY_CLUSTER_ACTIVITY_HOOK in os.environ:
try:
cluster_activity_callable = load_class(
os.environ[RAY_CLUSTER_ACTIVITY_HOOK]
)
external_activity_output = cluster_activity_callable()
assert isinstance(external_activity_output, dict), (
f"Output of hook {os.environ[RAY_CLUSTER_ACTIVITY_HOOK]} "
"should be Dict[str, RayActivityResponse]. Got "
f"output: {external_activity_output}"
)
for component_type in external_activity_output:
try:
component_activity_output = external_activity_output[
component_type
]
# Parse and validate output to type RayActivityResponse
component_activity_output = RayActivityResponse(
**dict(component_activity_output)
)
resp[component_type] = dict(component_activity_output)
except Exception as e:
logger.exception(
f"Failed to get activity status of {component_type} "
f"from user hook {os.environ[RAY_CLUSTER_ACTIVITY_HOOK]}."
)
resp[component_type] = {
"is_active": RayActivityStatus.ERROR,
"reason": repr(e),
"timestamp": datetime.now().timestamp(),
}
except Exception as e:
logger.exception(
"Failed to get activity status from user "
f"hook {os.environ[RAY_CLUSTER_ACTIVITY_HOOK]}."
)
resp["external_component"] = {
"is_active": RayActivityStatus.ERROR,
"reason": repr(e),
"timestamp": datetime.now().timestamp(),
}
return aiohttp.web.Response(
text=json.dumps(resp),
content_type="application/json",
status=aiohttp.web.HTTPOk.status_code,
)
async def _get_job_activity_info(self, timeout: int) -> RayActivityResponse:
# Returns if there is Ray activity from drivers (job).
# Drivers in namespaces that start with _ray_internal_ are not
# considered activity.
# This includes the _ray_internal_dashboard job that gets automatically
# created with every cluster
try:
reply = await self.gcs_client.async_get_all_job_info(
skip_submission_job_info_field=True,
skip_is_running_tasks_field=True,
timeout=timeout,
)
num_active_drivers = 0
latest_job_end_time = 0
for job_table_entry in reply.values():
is_dead = bool(job_table_entry.is_dead)
in_internal_namespace = job_table_entry.config.ray_namespace.startswith(
"_ray_internal_"
)
latest_job_end_time = (
max(latest_job_end_time, job_table_entry.end_time)
if job_table_entry.end_time
else latest_job_end_time
)
if not is_dead and not in_internal_namespace:
num_active_drivers += 1
current_timestamp = datetime.now().timestamp()
# Latest job end time must be before or equal to the current timestamp.
# Job end times may be provided in epoch milliseconds. Check if this
# is true, and convert to seconds
if latest_job_end_time > current_timestamp:
latest_job_end_time = latest_job_end_time / 1000
assert current_timestamp >= latest_job_end_time, (
f"Most recent job end time {latest_job_end_time} must be "
f"before or equal to the current timestamp {current_timestamp}"
)
is_active = (
RayActivityStatus.ACTIVE
if num_active_drivers > 0
else RayActivityStatus.INACTIVE
)
return RayActivityResponse(
is_active=is_active,
reason=f"Number of active drivers: {num_active_drivers}"
if num_active_drivers
else None,
timestamp=current_timestamp,
# If latest_job_end_time == 0, no jobs have finished yet so don't
# populate last_activity_at
last_activity_at=latest_job_end_time if latest_job_end_time else None,
)
except Exception as e:
logger.exception("Failed to get activity status of Ray drivers.")
return RayActivityResponse(
is_active=RayActivityStatus.ERROR,
reason=repr(e),
timestamp=datetime.now().timestamp(),
)
async def run(self):
await super().run()
if not self._job_info_client:
self._job_info_client = JobInfoStorageClient(self.gcs_client)
@@ -0,0 +1,57 @@
import os
from typing import AsyncIterator, List, Tuple
import ray
from ray.dashboard.modules.job.common import JOB_LOGS_PATH_TEMPLATE
from ray.dashboard.modules.job.utils import fast_tail_last_n_lines, file_tail_iterator
class JobLogStorageClient:
"""
Disk storage for stdout / stderr of driver script logs.
"""
# Number of last N lines to put in job message upon failure.
NUM_LOG_LINES_ON_ERROR = 10
# Maximum number of characters to print out of the logs to avoid
# HUGE log outputs that bring down the api server
MAX_LOG_SIZE = 20000
def get_logs(self, job_id: str) -> str:
try:
with open(self.get_log_file_path(job_id), "r") as f:
return f.read()
except FileNotFoundError:
return ""
def tail_logs(self, job_id: str) -> AsyncIterator[List[str]]:
return file_tail_iterator(self.get_log_file_path(job_id))
async def get_last_n_log_lines(
self, job_id: str, num_log_lines: int = NUM_LOG_LINES_ON_ERROR
) -> str:
"""Returns the last MAX_LOG_SIZE (20000) characters in the last ``num_log_lines`` lines.
Args:
job_id: The id of the job whose logs we want to return
num_log_lines: The number of lines to return.
Returns:
Up to ``MAX_LOG_SIZE`` characters drawn from the last
``num_log_lines`` lines of the job's log file.
"""
return fast_tail_last_n_lines(
path=self.get_log_file_path(job_id),
num_lines=num_log_lines,
max_chars=self.MAX_LOG_SIZE,
)
def get_log_file_path(self, job_id: str) -> Tuple[str, str]:
"""
Get the file path to the logs of a given job. Example:
/tmp/ray/session_date/logs/job-driver-{job_id}.log
"""
return os.path.join(
ray._private.worker._global_node.get_logs_dir_path(),
JOB_LOGS_PATH_TEMPLATE.format(submission_id=job_id),
)
@@ -0,0 +1,707 @@
import asyncio
import copy
import logging
import os
import random
import string
import time
import traceback
from typing import Any, AsyncIterator, Dict, Optional, Union
import ray
import ray._private.ray_constants as ray_constants
from ray._common.utils import Timer, run_background_task
from ray._private.accelerators.npu import NOSET_ASCEND_RT_VISIBLE_DEVICES_ENV_VAR
from ray._private.accelerators.nvidia_gpu import NOSET_CUDA_VISIBLE_DEVICES_ENV_VAR
from ray._private.event.event_logger import get_event_logger
from ray._private.label_utils import validate_label_selector
from ray._raylet import GcsClient
from ray.actor import ActorHandle
from ray.core.generated.event_pb2 import Event
from ray.dashboard.consts import (
DEFAULT_JOB_START_TIMEOUT_SECONDS,
RAY_JOB_ALLOW_DRIVER_ON_WORKER_NODES_ENV_VAR,
RAY_JOB_START_TIMEOUT_SECONDS_ENV_VAR,
RAY_STREAM_RUNTIME_ENV_LOG_TO_JOB_DRIVER_LOG_ENV_VAR,
)
from ray.dashboard.modules.job.common import (
JOB_ACTOR_NAME_TEMPLATE,
SUPERVISOR_ACTOR_RAY_NAMESPACE,
JobInfo,
JobInfoStorageClient,
)
from ray.dashboard.modules.job.job_log_storage_client import JobLogStorageClient
from ray.dashboard.modules.job.job_supervisor import JobSupervisor
from ray.dashboard.utils import close_logger_file_descriptor, get_head_node_id
from ray.exceptions import ActorDiedError, ActorUnschedulableError, RuntimeEnvSetupError
from ray.job_submission import JobErrorType, JobStatus
from ray.runtime_env import RuntimeEnvConfig
logger = logging.getLogger(__name__)
def generate_job_id() -> str:
"""Returns a job_id of the form 'raysubmit_XYZ'.
Prefixed with 'raysubmit' to avoid confusion with Ray JobID (driver ID).
"""
rand = random.SystemRandom()
possible_characters = list(
set(string.ascii_letters + string.digits)
- {"I", "l", "o", "O", "0"} # No confusing characters
)
id_part = "".join(rand.choices(possible_characters, k=16))
return f"raysubmit_{id_part}"
class JobManager:
"""Provide python APIs for job submission and management.
It does not provide persistence, all info will be lost if the cluster
goes down.
"""
# Time that we will sleep while tailing logs if no new log line is
# available.
LOG_TAIL_SLEEP_S = 1
JOB_MONITOR_LOOP_PERIOD_S = 1
WAIT_FOR_ACTOR_DEATH_TIMEOUT_S = 0.1
def __init__(
self, gcs_client: GcsClient, logs_dir: str, timeout_check_timer: Timer = None
):
self._gcs_client = gcs_client
self._logs_dir = logs_dir
self._job_info_client = JobInfoStorageClient(gcs_client, logs_dir)
self._gcs_address = gcs_client.address
self._cluster_id_hex = gcs_client.cluster_id.hex()
self._log_client = JobLogStorageClient()
self._supervisor_actor_cls = ray.remote(JobSupervisor)
self._timeout_check_timer = timeout_check_timer or Timer()
self.monitored_jobs = set()
try:
self.event_logger = get_event_logger(Event.SourceType.JOBS, logs_dir)
except Exception:
self.event_logger = None
self._recover_running_jobs_event = asyncio.Event()
run_background_task(self._recover_running_jobs())
def _get_job_driver_logger(self, job_id: str) -> logging.Logger:
"""Return job driver logger to log messages to the job driver log file.
If this function is called for the first time, configure the logger.
"""
job_driver_logger = logging.getLogger(f"{__name__}.driver-{job_id}")
# Configure the logger if it's not already configured.
if not job_driver_logger.handlers:
job_driver_log_path = self._log_client.get_log_file_path(job_id)
job_driver_handler = logging.FileHandler(job_driver_log_path)
job_driver_formatter = logging.Formatter(ray_constants.LOGGER_FORMAT)
job_driver_handler.setFormatter(job_driver_formatter)
job_driver_logger.addHandler(job_driver_handler)
return job_driver_logger
async def _recover_running_jobs(self):
"""Recovers all running jobs from the status client.
For each job, we will spawn a coroutine to monitor it.
Each will be added to self._running_jobs and reconciled.
"""
try:
all_jobs = await self._job_info_client.get_all_jobs()
for job_id, job_info in all_jobs.items():
if not job_info.status.is_terminal():
run_background_task(self._monitor_job(job_id))
finally:
# This event is awaited in `submit_job` to avoid race conditions between
# recovery and new job submission, so it must always get set even if there
# are exceptions.
self._recover_running_jobs_event.set()
def _get_actor_for_job(self, job_id: str) -> Optional[ActorHandle]:
try:
return ray.get_actor(
JOB_ACTOR_NAME_TEMPLATE.format(job_id=job_id),
namespace=SUPERVISOR_ACTOR_RAY_NAMESPACE,
)
except ValueError: # Ray returns ValueError for nonexistent actor.
return None
async def _monitor_job(
self, job_id: str, job_supervisor: Optional[ActorHandle] = None
):
"""Monitors the specified job until it enters a terminal state.
This is necessary because we need to handle the case where the
JobSupervisor dies unexpectedly.
"""
if job_id in self.monitored_jobs:
logger.debug(f"Job {job_id} is already being monitored.")
return
self.monitored_jobs.add(job_id)
try:
await self._monitor_job_internal(job_id, job_supervisor)
except Exception as e:
logger.error("Unhandled exception in job monitoring!", exc_info=e)
raise e
finally:
self.monitored_jobs.remove(job_id)
async def _monitor_job_internal(
self, job_id: str, job_supervisor: Optional[ActorHandle] = None
):
timeout = float(
os.environ.get(
RAY_JOB_START_TIMEOUT_SECONDS_ENV_VAR,
DEFAULT_JOB_START_TIMEOUT_SECONDS,
)
)
job_status = None
job_info = None
ping_obj_ref = None
while True:
try:
# NOTE: Job monitoring loop sleeps before proceeding with monitoring
# sequence to consolidate the control-flow of the pacing
# in a single place, rather than having it spread across
# many branches
await asyncio.sleep(self.JOB_MONITOR_LOOP_PERIOD_S)
job_status = await self._job_info_client.get_status(
job_id, timeout=None
)
if job_status == JobStatus.PENDING:
# Compare the current time with the job start time.
# If the job is still pending, we will set the status
# to FAILED.
if job_info is None:
job_info = await self._job_info_client.get_info(
job_id, timeout=None
)
if (
self._timeout_check_timer.time() - job_info.start_time / 1000
> timeout
):
err_msg = (
"Job supervisor actor failed to start within "
f"{timeout} seconds. This timeout can be "
f"configured by setting the environment "
f"variable {RAY_JOB_START_TIMEOUT_SECONDS_ENV_VAR}."
)
resources_specified = (
(
job_info.entrypoint_num_cpus is not None
and job_info.entrypoint_num_cpus > 0
)
or (
job_info.entrypoint_num_gpus is not None
and job_info.entrypoint_num_gpus > 0
)
or (
job_info.entrypoint_memory is not None
and job_info.entrypoint_memory > 0
)
or (
job_info.entrypoint_resources is not None
and len(job_info.entrypoint_resources) > 0
)
)
if resources_specified:
err_msg += (
" This may be because the job entrypoint's specified "
"resources (entrypoint_num_cpus, entrypoint_num_gpus, "
"entrypoint_resources, entrypoint_memory)"
"aren't available on the cluster."
" Try checking the cluster's available resources with "
"`ray status` and specifying fewer resources for the "
"job entrypoint."
)
await self._job_info_client.put_status(
job_id,
JobStatus.FAILED,
message=err_msg,
error_type=JobErrorType.JOB_SUPERVISOR_ACTOR_START_TIMEOUT,
timeout=None,
)
logger.error(err_msg)
break
if job_supervisor is None:
job_supervisor = self._get_actor_for_job(job_id)
if job_supervisor is None:
if job_status == JobStatus.PENDING:
# Maybe the job supervisor actor is not created yet.
# We will wait for the next loop.
continue
else:
# The job supervisor actor is not created, but the job
# status is not PENDING. This means the job supervisor
# actor is not created due to some unexpected errors.
# We will set the job status to FAILED.
logger.error(f"Failed to get job supervisor for job {job_id}.")
await self._job_info_client.put_status(
job_id,
JobStatus.FAILED,
message=(
"Unexpected error occurred: "
"failed to get job supervisor."
),
error_type=JobErrorType.JOB_SUPERVISOR_ACTOR_START_FAILURE,
timeout=None,
)
break
# Check to see if `JobSupervisor` is alive and reachable
if ping_obj_ref is None:
ping_obj_ref = job_supervisor.ping.options(
max_task_retries=-1
).remote()
ready, _ = ray.wait([ping_obj_ref], timeout=0)
if ready:
ray.get(ping_obj_ref)
ping_obj_ref = None
else:
continue
except Exception as e:
job_status = await self._job_info_client.get_status(
job_id, timeout=None
)
target_job_error_message = ""
target_job_error_type: Optional[JobErrorType] = None
if job_status is not None and job_status.is_terminal():
# If the job is already in a terminal state, then the actor
# exiting is expected.
pass
else:
if isinstance(e, RuntimeEnvSetupError):
logger.error(f"Failed to set up runtime_env for job {job_id}.")
target_job_error_message = f"runtime_env setup failed: {e}"
target_job_error_type = JobErrorType.RUNTIME_ENV_SETUP_FAILURE
elif isinstance(e, ActorUnschedulableError):
logger.error(
f"Failed to schedule job {job_id} because the supervisor "
f"actor could not be scheduled: {e}"
)
target_job_error_message = (
f"Job supervisor actor could not be scheduled: {e}"
)
target_job_error_type = (
JobErrorType.JOB_SUPERVISOR_ACTOR_UNSCHEDULABLE
)
elif isinstance(e, ActorDiedError):
logger.error(f"Job supervisor actor for {job_id} died: {e}")
target_job_error_message = f"Job supervisor actor died: {e}"
target_job_error_type = JobErrorType.JOB_SUPERVISOR_ACTOR_DIED
else:
logger.error(
f"Job monitoring for job {job_id} failed "
f"unexpectedly: {e}.",
exc_info=e,
)
target_job_error_message = f"Unexpected error occurred: {e}"
target_job_error_type = (
JobErrorType.JOB_SUPERVISOR_ACTOR_UNKNOWN_FAILURE
)
job_status = JobStatus.FAILED
await self._job_info_client.put_status(
job_id,
job_status,
message=target_job_error_message,
error_type=target_job_error_type
or JobErrorType.JOB_SUPERVISOR_ACTOR_UNKNOWN_FAILURE,
timeout=None,
)
# Log error message to the job driver file for easy access.
if target_job_error_message:
log_path = self._log_client.get_log_file_path(job_id)
os.makedirs(os.path.dirname(log_path), exist_ok=True)
with open(log_path, "a") as log_file:
log_file.write(target_job_error_message)
# Log events
if self.event_logger:
event_log = (
f"Completed a ray job {job_id} with a status {job_status}."
)
if target_job_error_message:
event_log += f" {target_job_error_message}"
self.event_logger.error(event_log, submission_id=job_id)
else:
self.event_logger.info(event_log, submission_id=job_id)
break
# Kill the actor defensively to avoid leaking actors in unexpected error cases.
if job_supervisor is None:
job_supervisor = self._get_actor_for_job(job_id)
if job_supervisor is not None:
ray.kill(job_supervisor, no_restart=True)
def _handle_supervisor_startup(self, job_id: str, result: Optional[Exception]):
"""Handle the result of starting a job supervisor actor.
If started successfully, result should be None. Otherwise it should be
an Exception.
On failure, the job will be marked failed with a relevant error
message.
"""
if result is None:
return
def _get_supervisor_runtime_env(
self,
user_runtime_env: Dict[str, Any],
submission_id: str,
resources_specified: bool = False,
) -> Dict[str, Any]:
"""Configure and return the runtime_env for the supervisor actor.
Args:
user_runtime_env: The runtime_env specified by the user.
submission_id: The submission id of the job; used to derive the log
file path piped into the runtime env config.
resources_specified: Whether the user specified resources in the
submit_job() call. If so, we will skip the workaround introduced
in #24546 for GPU detection and just use the user's resource
requests, so that the behavior matches that of the user specifying
resources for any other actor.
Returns:
The runtime_env for the supervisor actor.
"""
# Make a copy to avoid mutating passed runtime_env.
runtime_env = (
copy.deepcopy(user_runtime_env) if user_runtime_env is not None else {}
)
# NOTE(edoakes): Can't use .get(, {}) here because we need to handle the case
# where env_vars is explicitly set to `None`.
env_vars = runtime_env.get("env_vars")
if env_vars is None:
env_vars = {}
env_vars[ray_constants.RAY_WORKER_NICENESS] = "0"
if not resources_specified:
# Don't set CUDA_VISIBLE_DEVICES for the supervisor actor so the
# driver can use GPUs if it wants to. This will be removed from
# the driver's runtime_env so it isn't inherited by tasks & actors.
env_vars[NOSET_CUDA_VISIBLE_DEVICES_ENV_VAR] = "1"
env_vars[NOSET_ASCEND_RT_VISIBLE_DEVICES_ENV_VAR] = "1"
runtime_env["env_vars"] = env_vars
if os.getenv(RAY_STREAM_RUNTIME_ENV_LOG_TO_JOB_DRIVER_LOG_ENV_VAR, "0") == "1":
config = runtime_env.get("config")
# Empty fields may be set to None, so we need to check for None explicitly.
if config is None:
config = RuntimeEnvConfig()
config["log_files"] = [self._log_client.get_log_file_path(submission_id)]
runtime_env["config"] = config
return runtime_env
async def _get_label_selector(self, resources_specified: bool) -> Dict:
"""Determine the scheduling strategy for the job using a label selector.
If resources_specified is true, or if the environment variable is set to
allow the job to run on worker nodes, we will not use any label constraints.
Otherwise, we will force the job to use the head node via a label selector
specifying the head node id.
Args:
resources_specified: Whether the job specified any resources
(CPUs, GPUs, or custom resources).
Returns:
The label selector to use for the job.
"""
if resources_specified:
return {}
if os.environ.get(RAY_JOB_ALLOW_DRIVER_ON_WORKER_NODES_ENV_VAR, "0") == "1":
logger.info(
f"{RAY_JOB_ALLOW_DRIVER_ON_WORKER_NODES_ENV_VAR} was set to 1. "
"Using Ray's default actor scheduling strategy for the job "
"driver instead of running it on the head node via a label selector."
)
return {}
# If the user did not specify any resources or set the driver on worker nodes
# env var, we will run the driver on the head node.
head_node_id = await get_head_node_id(self._gcs_client)
if head_node_id is None:
logger.info(
"Head node ID not found in GCS. Using Ray's default actor "
"scheduling strategy for the job driver instead of running "
"it on the head node via a label selector."
)
return {}
logger.info(
"Head node ID found in GCS; scheduling job driver on "
f"head node {head_node_id} using a label selector"
)
return {ray._raylet.RAY_NODE_ID_KEY: head_node_id}
async def submit_job(
self,
*,
entrypoint: str,
submission_id: Optional[str] = None,
runtime_env: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, str]] = None,
entrypoint_num_cpus: Optional[Union[int, float]] = None,
entrypoint_num_gpus: Optional[Union[int, float]] = None,
entrypoint_memory: Optional[int] = None,
entrypoint_resources: Optional[Dict[str, float]] = None,
entrypoint_label_selector: Optional[Dict[str, str]] = None,
_start_signal_actor: Optional[ActorHandle] = None,
) -> str:
"""
Job execution happens asynchronously.
1) Generate a new unique id for this job submission, each call of this
method assumes they're independent submission with its own new
ID, job supervisor actor, and child process.
2) Create new detached actor with same runtime_env as job spec
Actual setting up runtime_env, subprocess group, driver command
execution, subprocess cleaning up and running status update to GCS
is all handled by job supervisor actor.
Args:
entrypoint: Driver command to execute in subprocess shell.
Represents the entrypoint to start user application.
submission_id: Optional caller-provided submission id. When None, a
new id is generated via ``generate_job_id()``.
runtime_env: Runtime environment used to execute driver command,
which could contain its own ray.init() to configure runtime
env at ray cluster, task and actor level.
metadata: Support passing arbitrary data to driver command in
case needed.
entrypoint_num_cpus: The quantity of CPU cores to reserve for the execution
of the entrypoint command, separately from any tasks or actors launched
by it. Defaults to 0.
entrypoint_num_gpus: The quantity of GPUs to reserve for
the entrypoint command, separately from any tasks or actors launched
by it. Defaults to 0.
entrypoint_memory: The amount of total available memory for workers
requesting memory the entrypoint command, separately from any tasks
or actors launched by it. Defaults to 0.
entrypoint_resources: The quantity of various custom resources
to reserve for the entrypoint command, separately from any tasks or
actors launched by it.
entrypoint_label_selector: Label selector for the entrypoint command.
_start_signal_actor: Used in testing only to capture state
transitions between PENDING -> RUNNING. Regular user shouldn't
need this.
Returns:
job_id: Generated uuid for further job management. Only valid
within the same ray cluster.
"""
if entrypoint_num_cpus is None:
entrypoint_num_cpus = 0
if entrypoint_num_gpus is None:
entrypoint_num_gpus = 0
if entrypoint_memory is None:
entrypoint_memory = 0
if submission_id is None:
submission_id = generate_job_id()
# Wait for `_recover_running_jobs` to run before accepting submissions to
# avoid duplicate monitoring of the same job.
await self._recover_running_jobs_event.wait()
logger.info(f"Starting job with submission_id: {submission_id}")
if entrypoint_label_selector:
error_message = validate_label_selector(entrypoint_label_selector)
if error_message:
raise ValueError(error_message)
job_info = JobInfo(
entrypoint=entrypoint,
status=JobStatus.PENDING,
start_time=int(time.time() * 1000),
metadata=metadata,
runtime_env=runtime_env,
entrypoint_num_cpus=entrypoint_num_cpus,
entrypoint_num_gpus=entrypoint_num_gpus,
entrypoint_memory=entrypoint_memory,
entrypoint_resources=entrypoint_resources,
)
new_key_added = await self._job_info_client.put_info(
submission_id, job_info, overwrite=False
)
if not new_key_added:
raise ValueError(
f"Job with submission_id {submission_id} already exists. "
"Please use a different submission_id."
)
driver_logger = self._get_job_driver_logger(submission_id)
# Wait for the actor to start up asynchronously so this call always
# returns immediately and we can catch errors with the actor starting
# up.
try:
resources_specified = any(
[
entrypoint_num_cpus is not None and entrypoint_num_cpus > 0,
entrypoint_num_gpus is not None and entrypoint_num_gpus > 0,
entrypoint_memory is not None and entrypoint_memory > 0,
entrypoint_resources not in [None, {}],
entrypoint_label_selector not in [None, {}],
]
)
label_selector = await self._get_label_selector(resources_specified)
if entrypoint_label_selector:
label_selector = {**label_selector, **entrypoint_label_selector}
if self.event_logger:
self.event_logger.info(
f"Started a ray job {submission_id}.", submission_id=submission_id
)
driver_logger.info("Runtime env is setting up.")
supervisor_options = dict(
lifetime="detached",
name=JOB_ACTOR_NAME_TEMPLATE.format(job_id=submission_id),
num_cpus=entrypoint_num_cpus,
num_gpus=entrypoint_num_gpus,
memory=entrypoint_memory,
resources=entrypoint_resources,
label_selector=label_selector,
runtime_env=self._get_supervisor_runtime_env(
runtime_env, submission_id, resources_specified
),
namespace=SUPERVISOR_ACTOR_RAY_NAMESPACE,
# Don't pollute task events with system actor tasks that users don't
# know about.
enable_task_events=False,
)
supervisor = self._supervisor_actor_cls.options(
**supervisor_options
).remote(
submission_id,
entrypoint,
metadata or {},
self._gcs_address,
self._cluster_id_hex,
self._logs_dir,
)
supervisor.run.remote(
_start_signal_actor=_start_signal_actor,
resources_specified=resources_specified,
)
# Monitor the job in the background so we can detect errors without
# requiring a client to poll.
run_background_task(
self._monitor_job(submission_id, job_supervisor=supervisor)
)
except Exception as e:
tb_str = traceback.format_exc()
driver_logger.warning(
f"Failed to start supervisor actor for job {submission_id}: '{e}'"
f". Full traceback:\n{tb_str}"
)
await self._job_info_client.put_status(
submission_id,
JobStatus.FAILED,
message=(
f"Failed to start supervisor actor {submission_id}: '{e}'"
f". Full traceback:\n{tb_str}"
),
error_type=JobErrorType.JOB_SUPERVISOR_ACTOR_START_FAILURE,
)
finally:
close_logger_file_descriptor(driver_logger)
return submission_id
def stop_job(self, job_id) -> bool:
"""Request a job to exit, fire and forget.
Returns whether or not the job was running.
"""
job_supervisor_actor = self._get_actor_for_job(job_id)
if job_supervisor_actor is not None:
# Actor is still alive, signal it to stop the driver, fire and
# forget
job_supervisor_actor.stop.remote()
return True
else:
return False
async def delete_job(self, job_id):
"""Delete a job's info and metadata from the cluster."""
job_status = await self._job_info_client.get_status(job_id)
if job_status is None or not job_status.is_terminal():
raise RuntimeError(
f"Attempted to delete job '{job_id}', "
f"but it is in a non-terminal state {job_status}."
)
await self._job_info_client.delete_info(job_id)
return True
def job_info_client(self) -> JobInfoStorageClient:
return self._job_info_client
async def get_job_status(self, job_id: str) -> Optional[JobStatus]:
"""Get latest status of a job."""
return await self._job_info_client.get_status(job_id)
async def get_job_info(self, job_id: str) -> Optional[JobInfo]:
"""Get latest info of a job."""
return await self._job_info_client.get_info(job_id)
async def list_jobs(self) -> Dict[str, JobInfo]:
"""Get info for all jobs."""
return await self._job_info_client.get_all_jobs()
def get_job_logs(self, job_id: str) -> str:
"""Get all logs produced by a job."""
return self._log_client.get_logs(job_id)
async def tail_job_logs(self, job_id: str) -> AsyncIterator[str]:
"""Return an iterator following the logs of a job."""
if await self.get_job_status(job_id) is None:
raise RuntimeError(f"Job '{job_id}' does not exist.")
job_finished = False
async for lines in self._log_client.tail_logs(job_id):
if lines is None:
if job_finished:
# Job has already finished and we have read EOF afterwards,
# it's guaranteed that we won't get any more logs.
return
else:
status = await self.get_job_status(job_id)
if status.is_terminal():
job_finished = True
# Continue tailing logs generated between the
# last EOF read and the finish of the job.
await asyncio.sleep(self.LOG_TAIL_SLEEP_S)
else:
yield "".join(lines)
@@ -0,0 +1,484 @@
import asyncio
import json
import logging
import os
import signal
import subprocess
import sys
import traceback
from asyncio.tasks import FIRST_COMPLETED
from typing import Any, Dict, List, Optional
import ray
import ray._private.ray_constants as ray_constants
from ray._common.filters import CoreContextFilter
from ray._common.formatters import JSONFormatter, TextFormatter
from ray._common.network_utils import build_address
from ray._private.accelerators.npu import NOSET_ASCEND_RT_VISIBLE_DEVICES_ENV_VAR
from ray._private.accelerators.nvidia_gpu import NOSET_CUDA_VISIBLE_DEVICES_ENV_VAR
from ray._private.runtime_env.constants import RAY_JOB_CONFIG_JSON_ENV_VAR
from ray._private.utils import remove_ray_internal_flags_from_env
from ray._raylet import GcsClient
from ray.actor import ActorHandle
from ray.dashboard.modules.job.common import (
JOB_ID_METADATA_KEY,
JOB_NAME_METADATA_KEY,
JobInfoStorageClient,
)
from ray.dashboard.modules.job.job_log_storage_client import JobLogStorageClient
from ray.job_submission import JobErrorType, JobStatus
import psutil
# asyncio python version compatibility
try:
create_task = asyncio.create_task
except AttributeError:
create_task = asyncio.ensure_future
# Windows requires additional packages for proper process control.
if sys.platform == "win32":
try:
import win32api
import win32con
import win32job
except (ModuleNotFoundError, ImportError) as e:
win32api = None
win32con = None
win32job = None
logger = logging.getLogger(__name__)
logger.warning(
"Failed to Import win32api. For best usage experience run "
f"'conda install pywin32'. Import error: {e}"
)
class JobSupervisor:
"""
Ray actor created by JobManager for each submitted job, responsible to
setup runtime_env, execute given shell command in subprocess, update job
status, persist job logs and manage subprocess group cleaning.
One job supervisor actor maps to one subprocess, for one job_id.
Job supervisor actor should fate share with subprocess it created.
"""
DEFAULT_RAY_JOB_STOP_WAIT_TIME_S = 3
SUBPROCESS_POLL_PERIOD_S = 0.1
VALID_STOP_SIGNALS = ["SIGINT", "SIGTERM"]
def __init__(
self,
job_id: str,
entrypoint: str,
user_metadata: Dict[str, str],
gcs_address: str,
cluster_id_hex: str,
logs_dir: Optional[str] = None,
):
self._job_id = job_id
gcs_client = GcsClient(address=gcs_address, cluster_id=cluster_id_hex)
self._job_info_client = JobInfoStorageClient(gcs_client, logs_dir)
self._log_client = JobLogStorageClient()
self._entrypoint = entrypoint
# Default metadata if not passed by the user.
self._metadata = {JOB_ID_METADATA_KEY: job_id, JOB_NAME_METADATA_KEY: job_id}
self._metadata.update(user_metadata)
# Event used to signal that a job should be stopped.
# Set in the `stop_job` method.
self._stop_event = asyncio.Event()
# Windows Job Object used to handle stopping the child processes.
self._win32_job_object = None
# Logger object to persist JobSupervisor logs in separate file.
self._logger = logging.getLogger(f"{__name__}.supervisor-{job_id}")
self._configure_logger()
def _configure_logger(self) -> None:
"""
Configure self._logger object to write logs to file based on job
submission ID and to console.
"""
supervisor_log_file_name = os.path.join(
ray._private.worker._global_node.get_logs_dir_path(),
f"jobs/supervisor-{self._job_id}.log",
)
os.makedirs(os.path.dirname(supervisor_log_file_name), exist_ok=True)
self._logger.addFilter(CoreContextFilter())
stream_handler = logging.StreamHandler()
file_handler = logging.FileHandler(supervisor_log_file_name)
formatter = TextFormatter()
if ray_constants.env_bool(ray_constants.RAY_BACKEND_LOG_JSON_ENV_VAR, False):
formatter = JSONFormatter()
stream_handler.setFormatter(formatter)
file_handler.setFormatter(formatter)
self._logger.addHandler(stream_handler)
self._logger.addHandler(file_handler)
self._logger.propagate = False
def _get_driver_runtime_env(
self, resources_specified: bool = False
) -> Dict[str, Any]:
"""Get the runtime env that should be set in the job driver.
Args:
resources_specified: Whether the user specified resources (CPUs, GPUs,
custom resources) in the submit_job request. If so, we will skip
the workaround for GPU detection introduced in #24546, so that the
behavior matches that of the user specifying resources for any
other actor.
Returns:
The runtime env that should be set in the job driver.
"""
# Get the runtime_env set for the supervisor actor.
curr_runtime_env = dict(ray.get_runtime_context().runtime_env)
if resources_specified:
return curr_runtime_env
# Allow CUDA_VISIBLE_DEVICES to be set normally for the driver's tasks
# & actors.
env_vars = curr_runtime_env.get("env_vars", {})
env_vars.pop(NOSET_CUDA_VISIBLE_DEVICES_ENV_VAR)
env_vars.pop(NOSET_ASCEND_RT_VISIBLE_DEVICES_ENV_VAR)
env_vars.pop(ray_constants.RAY_WORKER_NICENESS)
curr_runtime_env["env_vars"] = env_vars
return curr_runtime_env
def ping(self):
"""Used to check the health of the actor."""
pass
def _exec_entrypoint(self, env: dict, logs_path: str) -> subprocess.Popen:
"""
Runs the entrypoint command as a child process, streaming stderr &
stdout to given log files.
Unix systems:
Meanwhile we start a demon process and group driver
subprocess in same pgid, such that if job actor dies, entire process
group also fate share with it.
Windows systems:
A jobObject is created to enable fate sharing for the entire process group.
Args:
env: Environment variables passed through to the driver subprocess.
logs_path: File path on head node's local disk to store driver
command's stdout & stderr.
Returns:
child_process: Child process that runs the driver command. Can be
terminated or killed upon user calling stop().
"""
# Open in append mode to avoid overwriting runtime_env setup logs for the
# supervisor actor, which are also written to the same file.
with open(logs_path, "a") as logs_file:
logs_file.write(
f"Running entrypoint for job {self._job_id}: {self._entrypoint}\n"
)
child_process = subprocess.Popen(
self._entrypoint,
shell=True,
start_new_session=True,
stdout=logs_file,
stderr=subprocess.STDOUT,
env=env,
# Ray intentionally blocks SIGINT in all processes, so if the user wants
# to stop job through SIGINT, we need to unblock it in the child process
preexec_fn=(
(
lambda: signal.pthread_sigmask(
signal.SIG_UNBLOCK, {signal.SIGINT}
)
)
if sys.platform != "win32"
and os.environ.get("RAY_JOB_STOP_SIGNAL") == "SIGINT"
else None
),
)
parent_pid = os.getpid()
child_pid = child_process.pid
# Create new pgid with new subprocess to execute driver command
if sys.platform != "win32":
try:
child_pgid = os.getpgid(child_pid)
except ProcessLookupError:
# Process died before we could get its pgid.
return child_process
# Open a new subprocess to kill the child process when the parent
# process dies kill -s 0 parent_pid will succeed if the parent is
# alive. If it fails, SIGKILL the child process group and exit
subprocess.Popen(
f"while kill -s 0 {parent_pid}; do sleep 1; done; kill -9 -{child_pgid}", # noqa: E501
shell=True,
# Suppress output
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
elif sys.platform == "win32" and win32api:
# Create a JobObject to which the child process (and its children)
# will be connected. This job object can be used to kill the child
# processes explicitly or when the jobObject gets deleted during
# garbage collection.
self._win32_job_object = win32job.CreateJobObject(None, "")
win32_job_info = win32job.QueryInformationJobObject(
self._win32_job_object, win32job.JobObjectExtendedLimitInformation
)
win32_job_info["BasicLimitInformation"][
"LimitFlags"
] = win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
win32job.SetInformationJobObject(
self._win32_job_object,
win32job.JobObjectExtendedLimitInformation,
win32_job_info,
)
child_handle = win32api.OpenProcess(
win32con.PROCESS_TERMINATE | win32con.PROCESS_SET_QUOTA,
False,
child_pid,
)
win32job.AssignProcessToJobObject(self._win32_job_object, child_handle)
return child_process
def _get_driver_env_vars(self, resources_specified: bool) -> Dict[str, str]:
"""Returns environment variables that should be set in the driver."""
# RAY_ADDRESS may be the dashboard URL but not the gcs address,
# so when the environment variable is not empty, we force set RAY_ADDRESS
# to "auto" to avoid function `canonicalize_bootstrap_address_or_die` returning
# the wrong GCS address.
# TODO(Jialing He, Archit Kulkarni): Definition of Specification RAY_ADDRESS
if ray_constants.RAY_ADDRESS_ENVIRONMENT_VARIABLE in os.environ:
os.environ[ray_constants.RAY_ADDRESS_ENVIRONMENT_VARIABLE] = "auto"
ray_addr = ray._private.services.canonicalize_bootstrap_address_or_die(
"auto", ray._private.worker._global_node._ray_params.temp_dir
)
assert ray_addr is not None
return {
# Set JobConfig for the child process (runtime_env, metadata).
RAY_JOB_CONFIG_JSON_ENV_VAR: json.dumps(
{
"runtime_env": self._get_driver_runtime_env(resources_specified),
"metadata": self._metadata,
}
),
# Always set RAY_ADDRESS as find_bootstrap_address address for
# job submission. In case of local development, prevent user from
# re-using http://{address}:{dashboard_port} to interact with
# jobs SDK.
# TODO:(mwtian) Check why "auto" does not work in entrypoint script
ray_constants.RAY_ADDRESS_ENVIRONMENT_VARIABLE: ray_addr,
# Set PYTHONUNBUFFERED=1 to stream logs during the job instead of
# only streaming them upon completion of the job.
"PYTHONUNBUFFERED": "1",
}
async def _polling(self, child_process: subprocess.Popen) -> int:
while child_process is not None:
return_code = child_process.poll()
if return_code is not None:
# subprocess finished with return code
return return_code
else:
# still running, yield control, 0.1s by default
await asyncio.sleep(self.SUBPROCESS_POLL_PERIOD_S)
async def _poll_all(self, processes: List[psutil.Process]):
"""Poll processes until all are completed."""
while True:
(_, alive) = psutil.wait_procs(processes, timeout=0)
if len(alive) == 0:
return
else:
await asyncio.sleep(self.SUBPROCESS_POLL_PERIOD_S)
def _kill_processes(self, processes: List[psutil.Process], sig: signal.Signals):
"""Ensure each process is already finished or send a kill signal."""
for proc in processes:
try:
os.kill(proc.pid, sig)
except ProcessLookupError:
# Process is already dead
pass
async def run(
self,
# Signal actor used in testing to capture PENDING -> RUNNING cases
_start_signal_actor: Optional[ActorHandle] = None,
resources_specified: bool = False,
):
"""
Stop and start both happen asynchronously, coordinated by asyncio event
and coroutine, respectively.
1) Sets job status as running
2) Pass runtime env and metadata to subprocess as serialized env
variables.
3) Handle concurrent events of driver execution and
"""
curr_info = await self._job_info_client.get_info(self._job_id)
if curr_info is None:
raise RuntimeError(f"Status could not be retrieved for job {self._job_id}.")
curr_status = curr_info.status
curr_message = curr_info.message
if curr_status == JobStatus.RUNNING:
raise RuntimeError(
f"Job {self._job_id} is already in RUNNING state. "
f"JobSupervisor.run() should only be called once. "
)
if curr_status != JobStatus.PENDING:
raise RuntimeError(
f"Job {self._job_id} is not in PENDING state. "
f"Current status is {curr_status} with message {curr_message}."
)
if _start_signal_actor:
# Block in PENDING state until start signal received.
await _start_signal_actor.wait.remote()
node = ray._private.worker.global_worker.node
driver_agent_http_address = f"http://{build_address(node.node_ip_address, node.dashboard_agent_listen_port)}"
driver_node_id = ray.get_runtime_context().get_node_id()
await self._job_info_client.put_status(
self._job_id,
JobStatus.RUNNING,
jobinfo_replace_kwargs={
"driver_agent_http_address": driver_agent_http_address,
"driver_node_id": driver_node_id,
},
)
try:
# Configure environment variables for the child process.
env = os.environ.copy()
# Remove internal Ray flags. They present because JobSuperVisor itself is
# a Ray worker process but we don't want to pass them to the driver.
remove_ray_internal_flags_from_env(env)
# These will *not* be set in the runtime_env, so they apply to the driver
# only, not its tasks & actors.
env.update(self._get_driver_env_vars(resources_specified))
self._logger.info(
"Submitting job with RAY_ADDRESS = "
f"{env[ray_constants.RAY_ADDRESS_ENVIRONMENT_VARIABLE]}"
)
log_path = self._log_client.get_log_file_path(self._job_id)
child_process = self._exec_entrypoint(env, log_path)
child_pid = child_process.pid
polling_task = create_task(self._polling(child_process))
finished, _ = await asyncio.wait(
[polling_task, create_task(self._stop_event.wait())],
return_when=FIRST_COMPLETED,
)
if self._stop_event.is_set():
polling_task.cancel()
if sys.platform == "win32" and self._win32_job_object:
win32job.TerminateJobObject(self._win32_job_object, -1)
elif sys.platform != "win32":
stop_signal = os.environ.get("RAY_JOB_STOP_SIGNAL", "SIGTERM")
if stop_signal not in self.VALID_STOP_SIGNALS:
self._logger.warning(
f"{stop_signal} not a valid stop signal. Terminating "
"job with SIGTERM."
)
stop_signal = "SIGTERM"
job_process = psutil.Process(child_pid)
proc_to_kill = [job_process] + job_process.children(recursive=True)
# Send stop signal and wait for job to terminate gracefully,
# otherwise SIGKILL job forcefully after timeout.
self._kill_processes(proc_to_kill, getattr(signal, stop_signal))
try:
stop_job_wait_time = int(
os.environ.get(
"RAY_JOB_STOP_WAIT_TIME_S",
self.DEFAULT_RAY_JOB_STOP_WAIT_TIME_S,
)
)
poll_job_stop_task = create_task(self._poll_all(proc_to_kill))
await asyncio.wait_for(poll_job_stop_task, stop_job_wait_time)
self._logger.info(
f"Job {self._job_id} has been terminated gracefully "
f"with {stop_signal}."
)
except asyncio.TimeoutError:
self._logger.warning(
f"Attempt to gracefully terminate job {self._job_id} "
f"through {stop_signal} has timed out after "
f"{stop_job_wait_time} seconds. Job is now being "
"force-killed with SIGKILL."
)
self._kill_processes(proc_to_kill, signal.SIGKILL)
await self._job_info_client.put_status(self._job_id, JobStatus.STOPPED)
else:
# Child process finished execution and no stop event is set
# at the same time
assert len(finished) == 1, "Should have only one coroutine done"
[child_process_task] = finished
return_code = child_process_task.result()
self._logger.info(
f"Job {self._job_id} entrypoint command "
f"exited with code {return_code}"
)
if return_code == 0:
await self._job_info_client.put_status(
self._job_id,
JobStatus.SUCCEEDED,
driver_exit_code=return_code,
)
else:
log_tail = await self._log_client.get_last_n_log_lines(self._job_id)
if log_tail is not None and log_tail != "":
message = (
"Job entrypoint command "
f"failed with exit code {return_code}, "
"last available logs (truncated to 20,000 chars):\n"
+ log_tail
)
else:
message = (
"Job entrypoint command "
f"failed with exit code {return_code}. No logs available."
)
await self._job_info_client.put_status(
self._job_id,
JobStatus.FAILED,
message=message,
driver_exit_code=return_code,
error_type=JobErrorType.JOB_ENTRYPOINT_COMMAND_ERROR,
)
except Exception:
self._logger.error(
"Got unexpected exception while trying to execute driver "
f"command. {traceback.format_exc()}"
)
try:
await self._job_info_client.put_status(
self._job_id,
JobStatus.FAILED,
message=traceback.format_exc(),
error_type=JobErrorType.JOB_ENTRYPOINT_COMMAND_START_ERROR,
)
except Exception:
self._logger.error(
"Failed to update job status to FAILED. "
f"Exception: {traceback.format_exc()}"
)
finally:
# clean up actor after tasks are finished
ray.actor.exit_actor()
def stop(self):
"""Set step_event and let run() handle the rest in its asyncio.wait()."""
self._stop_event.set()
@@ -0,0 +1,110 @@
from enum import Enum
from typing import Any, Dict, Optional
from ray._common.pydantic_compat import PYDANTIC_INSTALLED, BaseModel, Field
from ray.dashboard.modules.job.common import JobStatus
from ray.util.annotations import PublicAPI
# Pydantic is not part of the minimal Ray installation.
if PYDANTIC_INSTALLED:
@PublicAPI(stability="beta")
class DriverInfo(BaseModel):
"""A class for recording information about the driver related to the job."""
id: str = Field(..., description="The id of the driver")
node_ip_address: str = Field(
..., description="The IP address of the node the driver is running on."
)
pid: str = Field(
..., description="The PID of the worker process the driver is using."
)
# TODO(aguo): Add node_id as a field.
@PublicAPI(stability="beta")
class JobType(str, Enum):
"""An enumeration for describing the different job types.
NOTE:
This field is still experimental and may change in the future.
"""
#: A job that was initiated by the Ray Jobs API.
SUBMISSION = "SUBMISSION"
#: A job that was initiated by a driver script.
DRIVER = "DRIVER"
@PublicAPI(stability="beta")
class JobDetails(BaseModel):
"""
Job data with extra details about its driver and its submission.
"""
type: JobType = Field(..., description="The type of job.")
job_id: Optional[str] = Field(
None,
description="The job ID. An ID that is created for every job that is "
"launched in Ray. This can be used to fetch data about jobs using Ray "
"Core APIs.",
)
submission_id: Optional[str] = Field(
None,
description="A submission ID is an ID created for every job submitted via"
"the Ray Jobs API. It can "
"be used to fetch data about jobs using the Ray Jobs API.",
)
driver_info: Optional[DriverInfo] = Field(
None,
description="The driver related to this job. For jobs submitted via "
"the Ray Jobs API, "
"it is the last driver launched by that job submission, "
"or None if there is no driver.",
)
# The following fields are copied from JobInfo.
# TODO(aguo): Inherit from JobInfo once it's migrated to pydantic.
status: JobStatus = Field(..., description="The status of the job.")
entrypoint: str = Field(..., description="The entrypoint command for this job.")
message: Optional[str] = Field(
None, description="A message describing the status in more detail."
)
error_type: Optional[str] = Field(
None, description="Internal error or user script error."
)
start_time: Optional[int] = Field(
None,
description="The time when the job was started. A Unix timestamp in ms.",
)
end_time: Optional[int] = Field(
None,
description="The time when the job moved into a terminal state. "
"A Unix timestamp in ms.",
)
metadata: Optional[Dict[str, str]] = Field(
None, description="Arbitrary user-provided metadata for the job."
)
runtime_env: Optional[Dict[str, Any]] = Field(
None, description="The runtime environment for the job."
)
# the node info where the driver running on.
# - driver_agent_http_address: this node's agent http address
# - driver_node_id: this node's id.
driver_agent_http_address: Optional[str] = Field(
None,
description="The HTTP address of the JobAgent on the node the job "
"entrypoint command is running on.",
)
driver_node_id: Optional[str] = Field(
None,
description="The ID of the node the job entrypoint command is running on.",
)
driver_exit_code: Optional[int] = Field(
None,
description="The driver process exit code after the driver executed. "
"Return None if driver doesn't finish executing.",
)
else:
DriverInfo = None
JobType = None
JobDetails = None
+542
View File
@@ -0,0 +1,542 @@
import copy
import dataclasses
import logging
from typing import Any, AsyncIterator, Dict, List, Optional, Union
import packaging.version
import ray
from ray.dashboard.modules.dashboard_sdk import SubmissionClient
from ray.dashboard.modules.job.common import (
JobDeleteResponse,
JobLogsResponse,
JobStatus,
JobStopResponse,
JobSubmitRequest,
JobSubmitResponse,
)
from ray.dashboard.modules.job.pydantic_models import JobDetails
from ray.dashboard.modules.job.utils import strip_keys_with_value_none
from ray.dashboard.utils import get_address_for_submission_client
from ray.runtime_env import RuntimeEnv
from ray.runtime_env.runtime_env import _validate_no_local_paths
from ray.util.annotations import PublicAPI
try:
import aiohttp
import requests
except ImportError:
aiohttp = None
requests = None
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
class JobSubmissionClient(SubmissionClient):
"""A local client for submitting and interacting with jobs on a remote cluster.
Submits requests over HTTP to the job server on the cluster using the REST API.
Args:
address: Either (1) the address of the Ray cluster, or (2) the HTTP address
of the dashboard server on the head node, e.g. "http://<head-node-ip>:8265".
In case (1) it must be specified as an address that can be passed to
ray.init(), e.g. a Ray Client address (ray://<head_node_host>:10001),
or "auto", or "localhost:<port>". If unspecified, will try to connect to
a running local Ray cluster. This argument is always overridden by the
RAY_API_SERVER_ADDRESS or RAY_ADDRESS environment variable.
create_cluster_if_needed: Indicates whether the cluster at the specified
address needs to already be running. Ray doesn't start a cluster
before interacting with jobs, but third-party job managers may do so.
cookies: Cookies to use when sending requests to the HTTP job server.
metadata: Arbitrary metadata to store along with all jobs. New metadata
specified per job will be merged with the global metadata provided here
via a simple dict update.
headers: Headers to use when sending requests to the HTTP job server, used
for cases like authentication to a remote cluster.
verify: Boolean indication to verify the server's TLS certificate or a path to
a file or directory of trusted certificates. Default: True.
**kwargs: Additional keyword arguments forwarded to the cluster info
resolution function. For external module addresses (e.g.,
``anyscale://``), these are passed through to the module's
``get_job_submission_client_cluster_info()`` implementation.
"""
def __init__(
self,
address: Optional[str] = None,
create_cluster_if_needed: bool = False,
cookies: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, Any]] = None,
verify: Optional[Union[str, bool]] = True,
**kwargs,
):
self._client_ray_version = ray.__version__
"""Initialize a JobSubmissionClient and check the connection to the cluster."""
if requests is None:
raise RuntimeError(
"The Ray jobs CLI & SDK require the ray[default] "
"installation: `pip install 'ray[default]'`"
)
# Check types of arguments
if address is not None and not isinstance(address, str):
raise TypeError(f"address must be a string, got {type(address)}")
if not isinstance(create_cluster_if_needed, bool):
raise TypeError(
f"create_cluster_if_needed must be a bool, got"
f" {type(create_cluster_if_needed)}"
)
if cookies is not None and not isinstance(cookies, dict):
raise TypeError(f"cookies must be a dict, got {type(cookies)}")
if metadata is not None and not isinstance(metadata, dict):
raise TypeError(f"metadata must be a dict, got {type(metadata)}")
if headers is not None and not isinstance(headers, dict):
raise TypeError(f"headers must be a dict, got {type(headers)}")
if not (isinstance(verify, str) or isinstance(verify, bool)):
raise TypeError(f"verify must be a str or bool, got {type(verify)}")
api_server_url = get_address_for_submission_client(address)
super().__init__(
address=api_server_url,
create_cluster_if_needed=create_cluster_if_needed,
cookies=cookies,
metadata=metadata,
headers=headers,
verify=verify,
**kwargs,
)
self._check_connection_and_version(
min_version="1.9",
version_error_message="Jobs API is not supported on the Ray "
"cluster. Please ensure the cluster is "
"running Ray 1.9 or higher.",
)
# In ray>=2.0, the client sends the new kwarg `submission_id` to the server
# upon every job submission, which causes servers with ray<2.0 to error.
if packaging.version.parse(self._client_ray_version) > packaging.version.parse(
"2.0"
):
self._check_connection_and_version(
min_version="2.0",
version_error_message=f"Client Ray version {self._client_ray_version} "
"is not compatible with the Ray cluster. Please ensure the cluster is "
"running Ray 2.0 or higher or downgrade the client Ray version.",
)
@PublicAPI(stability="stable")
def submit_job(
self,
*,
entrypoint: str,
job_id: Optional[str] = None,
runtime_env: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, str]] = None,
submission_id: Optional[str] = None,
entrypoint_num_cpus: Optional[Union[int, float]] = None,
entrypoint_num_gpus: Optional[Union[int, float]] = None,
entrypoint_memory: Optional[int] = None,
entrypoint_resources: Optional[Dict[str, float]] = None,
entrypoint_label_selector: Optional[Dict[str, str]] = None,
) -> str:
"""Submit and execute a job asynchronously.
When a job is submitted, it runs once to completion or failure. Retries or
different runs with different parameters should be handled by the
submitter. Jobs are bound to the lifetime of a Ray cluster, so if the
cluster goes down, all running jobs on that cluster will be terminated.
Example:
>>> from ray.job_submission import JobSubmissionClient
>>> client = JobSubmissionClient("http://127.0.0.1:8265") # doctest: +SKIP
>>> client.submit_job( # doctest: +SKIP
... entrypoint="python script.py",
... runtime_env={
... "working_dir": "./",
... "pip": ["requests==2.26.0"]
... }
... ) # doctest: +SKIP
'raysubmit_4LamXRuQpYdSMg7J'
Args:
entrypoint: The shell command to run for this job.
job_id: DEPRECATED. This has been renamed to submission_id.
runtime_env: The runtime environment to install and run this job in.
metadata: Arbitrary data to store along with this job.
submission_id: A unique ID for this job.
entrypoint_num_cpus: The quantity of CPU cores to reserve for the execution
of the entrypoint command, separately from any tasks or actors launched
by it. Defaults to 0.
entrypoint_num_gpus: The quantity of GPUs to reserve for the execution
of the entrypoint command, separately from any tasks or actors launched
by it. Defaults to 0.
entrypoint_memory: The quantity of memory to reserve for the
execution of the entrypoint command, separately from any tasks or
actors launched by it. Defaults to 0.
entrypoint_resources: The quantity of custom resources to reserve for the
execution of the entrypoint command, separately from any tasks or
actors launched by it.
entrypoint_label_selector: Label selector for the entrypoint command.
Returns:
The submission ID of the submitted job. If not specified,
this is a randomly generated unique ID.
Raises:
RuntimeError: If the request to the job server fails, or if the specified
submission_id has already been used by a job on this cluster.
"""
if job_id:
logger.warning(
"job_id kwarg is deprecated. Please use submission_id instead."
)
if (
entrypoint_num_cpus
or entrypoint_num_gpus
or entrypoint_resources
or entrypoint_label_selector
):
self._check_connection_and_version(
min_version="2.2",
version_error_message="`entrypoint_num_cpus`, `entrypoint_num_gpus`, "
"`entrypoint_resources`, and `entrypoint_label_selector` kwargs "
"are not supported on the Ray cluster. Please ensure the cluster is "
"running Ray 2.2 or higher.",
)
if entrypoint_memory:
self._check_connection_and_version(
min_version="2.8",
version_error_message="`entrypoint_memory` kwarg "
"is not supported on the Ray cluster. Please ensure the cluster is "
"running Ray 2.8 or higher.",
)
runtime_env = copy.deepcopy(runtime_env or {})
metadata = metadata or {}
metadata.update(self._default_metadata)
self._upload_working_dir_if_needed(runtime_env)
self._upload_py_modules_if_needed(runtime_env)
# Verify worker_process_setup_hook type.
setup_hook = runtime_env.get("worker_process_setup_hook")
if setup_hook and not isinstance(setup_hook, str):
raise ValueError(
f"Invalid type {type(setup_hook)} for `worker_process_setup_hook`. "
"When a job submission API is used, `worker_process_setup_hook` "
"only allows a string type (module name). "
"Specify `worker_process_setup_hook` via "
"ray.init within a driver to use a `Callable` type. "
)
# Run the RuntimeEnv constructor to parse local pip/conda requirements files.
runtime_env = RuntimeEnv(**runtime_env)
_validate_no_local_paths(runtime_env)
runtime_env = runtime_env.to_dict()
submission_id = submission_id or job_id
req = JobSubmitRequest(
entrypoint=entrypoint,
submission_id=submission_id,
runtime_env=runtime_env,
metadata=metadata,
entrypoint_num_cpus=entrypoint_num_cpus,
entrypoint_num_gpus=entrypoint_num_gpus,
entrypoint_memory=entrypoint_memory,
entrypoint_resources=entrypoint_resources,
entrypoint_label_selector=entrypoint_label_selector,
)
# Remove keys with value None so that new clients with new optional fields
# are still compatible with older servers. This is also done on the server,
# but we do it here as well to be extra defensive.
json_data = strip_keys_with_value_none(dataclasses.asdict(req))
logger.debug(f"Submitting job with submission_id={submission_id}.")
r = self._do_request("POST", "/api/jobs/", json_data=json_data)
if r.status_code == 200:
return JobSubmitResponse(**r.json()).submission_id
else:
self._raise_error(r)
@PublicAPI(stability="stable")
def stop_job(
self,
job_id: str,
) -> bool:
"""Request a job to exit asynchronously.
Attempts to terminate process first, then kills process after timeout.
Example:
>>> from ray.job_submission import JobSubmissionClient
>>> client = JobSubmissionClient("http://127.0.0.1:8265") # doctest: +SKIP
>>> sub_id = client.submit_job(entrypoint="sleep 10") # doctest: +SKIP
>>> client.stop_job(sub_id) # doctest: +SKIP
True
Args:
job_id: The job ID or submission ID for the job to be stopped.
Returns:
True if the job was running, otherwise False.
Raises:
RuntimeError: If the job does not exist or if the request to the
job server fails.
"""
logger.debug(f"Stopping job with job_id={job_id}.")
r = self._do_request("POST", f"/api/jobs/{job_id}/stop")
if r.status_code == 200:
return JobStopResponse(**r.json()).stopped
else:
self._raise_error(r)
@PublicAPI(stability="stable")
def delete_job(
self,
job_id: str,
) -> bool:
"""Delete a job in a terminal state and all of its associated data.
If the job is not already in a terminal state, raises an error.
This does not delete the job logs from disk.
Submitting a job with the same submission ID as a previously
deleted job is not supported and may lead to unexpected behavior.
Example:
>>> from ray.job_submission import JobSubmissionClient
>>> client = JobSubmissionClient() # doctest: +SKIP
>>> job_id = client.submit_job(entrypoint="echo hello") # doctest: +SKIP
>>> client.delete_job(job_id) # doctest: +SKIP
True
Args:
job_id: submission ID for the job to be deleted.
Returns:
True if the job was deleted, otherwise False.
Raises:
RuntimeError: If the job does not exist, if the request to the
job server fails, or if the job is not in a terminal state.
"""
logger.debug(f"Deleting job with job_id={job_id}.")
r = self._do_request("DELETE", f"/api/jobs/{job_id}")
if r.status_code == 200:
return JobDeleteResponse(**r.json()).deleted
else:
self._raise_error(r)
@PublicAPI(stability="stable")
def get_job_info(
self,
job_id: str,
) -> JobDetails:
"""Get the latest status and other information associated with a job.
Example:
>>> from ray.job_submission import JobSubmissionClient
>>> client = JobSubmissionClient("http://127.0.0.1:8265") # doctest: +SKIP
>>> submission_id = client.submit_job(entrypoint="sleep 1") # doctest: +SKIP
>>> client.get_job_info(submission_id) # doctest: +SKIP
JobDetails(status='SUCCEEDED',
job_id='03000000', type='submission',
submission_id='raysubmit_4LamXRuQpYdSMg7J',
message='Job finished successfully.', error_type=None,
start_time=1647388711, end_time=1647388712, metadata={}, runtime_env={})
Args:
job_id: The job ID or submission ID of the job whose information
is being requested.
Returns:
The JobDetails for the job.
Raises:
RuntimeError: If the job does not exist or if the request to the
job server fails.
"""
r = self._do_request("GET", f"/api/jobs/{job_id}")
if r.status_code == 200:
if JobDetails is None:
raise RuntimeError(
"The Ray jobs CLI & SDK require the ray[default] "
"installation: `pip install 'ray[default]'`"
)
else:
return JobDetails(**r.json())
else:
self._raise_error(r)
@PublicAPI(stability="stable")
def list_jobs(self) -> List[JobDetails]:
"""List all jobs along with their status and other information.
Lists all jobs that have ever run on the cluster, including jobs that are
currently running and jobs that are no longer running.
Example:
>>> from ray.job_submission import JobSubmissionClient
>>> client = JobSubmissionClient("http://127.0.0.1:8265") # doctest: +SKIP
>>> client.submit_job(entrypoint="echo hello") # doctest: +SKIP
>>> client.submit_job(entrypoint="sleep 2") # doctest: +SKIP
>>> client.list_jobs() # doctest: +SKIP
[JobDetails(status='SUCCEEDED',
job_id='03000000', type='submission',
submission_id='raysubmit_4LamXRuQpYdSMg7J',
message='Job finished successfully.', error_type=None,
start_time=1647388711, end_time=1647388712, metadata={}, runtime_env={}),
JobDetails(status='RUNNING',
job_id='04000000', type='submission',
submission_id='raysubmit_1dxCeNvG1fCMVNHG',
message='Job is currently running.', error_type=None,
start_time=1647454832, end_time=None, metadata={}, runtime_env={})]
Returns:
A list of JobDetails containing the job status and other information.
Raises:
RuntimeError: If the request to the job server fails.
"""
r = self._do_request("GET", "/api/jobs/")
if r.status_code == 200:
jobs_info_json = r.json()
jobs_info = [
JobDetails(**job_info_json) for job_info_json in jobs_info_json
]
return jobs_info
else:
self._raise_error(r)
@PublicAPI(stability="stable")
def get_job_status(self, job_id: str) -> JobStatus:
"""Get the most recent status of a job.
Example:
>>> from ray.job_submission import JobSubmissionClient
>>> client = JobSubmissionClient("http://127.0.0.1:8265") # doctest: +SKIP
>>> client.submit_job(entrypoint="echo hello") # doctest: +SKIP
>>> client.get_job_status("raysubmit_4LamXRuQpYdSMg7J") # doctest: +SKIP
'SUCCEEDED'
Args:
job_id: The job ID or submission ID of the job whose status is being
requested.
Returns:
The JobStatus of the job.
Raises:
RuntimeError: If the job does not exist or if the request to the
job server fails.
"""
return self.get_job_info(job_id).status
@PublicAPI(stability="stable")
def get_job_logs(self, job_id: str) -> str:
"""Get all logs produced by a job.
Example:
>>> from ray.job_submission import JobSubmissionClient
>>> client = JobSubmissionClient("http://127.0.0.1:8265") # doctest: +SKIP
>>> sub_id = client.submit_job(entrypoint="echo hello") # doctest: +SKIP
>>> client.get_job_logs(sub_id) # doctest: +SKIP
'hello\\n'
Args:
job_id: The job ID or submission ID of the job whose logs are being
requested.
Returns:
A string containing the full logs of the job.
Raises:
RuntimeError: If the job does not exist or if the request to the
job server fails.
"""
r = self._do_request("GET", f"/api/jobs/{job_id}/logs")
if r.status_code == 200:
return JobLogsResponse(**r.json()).logs
else:
self._raise_error(r)
@PublicAPI(stability="stable")
async def tail_job_logs(self, job_id: str) -> AsyncIterator[str]:
"""Get an iterator that follows the logs of a job.
Example:
>>> from ray.job_submission import JobSubmissionClient
>>> client = JobSubmissionClient("http://127.0.0.1:8265") # doctest: +SKIP
>>> submission_id = client.submit_job( # doctest: +SKIP
... entrypoint="echo hi && sleep 5 && echo hi2")
>>> async for lines in client.tail_job_logs( # doctest: +SKIP
... 'raysubmit_Xe7cvjyGJCyuCvm2'):
... print(lines, end="") # doctest: +SKIP
hi
hi2
Args:
job_id: The job ID or submission ID of the job whose logs are being
requested.
Yields:
str: Successive chunks of the job's stdout/stderr as the driver
process produces them.
Raises:
RuntimeError: If the job does not exist, if the request to the
job server fails, or if the connection closes unexpectedly
before the job reaches a terminal state.
"""
async with aiohttp.ClientSession(
cookies=self._cookies, headers=self._headers
) as session:
ws = await session.ws_connect(
f"{self._address}/api/jobs/{job_id}/logs/tail",
headers=self._headers,
ssl=self._ssl_context,
)
while True:
msg = await ws.receive()
if msg.type == aiohttp.WSMsgType.TEXT:
yield msg.data
elif msg.type == aiohttp.WSMsgType.CLOSED:
logger.info(
f"WebSocket closed for job {job_id} with close code "
f"{ws.close_code}"
)
if ws.close_code == aiohttp.WSCloseCode.ABNORMAL_CLOSURE:
raise RuntimeError(
f"WebSocket connection closed unexpectedly with close code {ws.close_code}"
)
break
elif msg.type == aiohttp.WSMsgType.ERROR:
# Old Ray versions (<=2.0.1) may send ERROR on connection close
if self._server_ray_version is not None and packaging.version.parse(
self._server_ray_version
) > packaging.version.parse("2.0.1"):
raise RuntimeError(
f"WebSocket error for job {job_id}: {ws.exception()}"
)
else:
logger.warning(
f"WebSocket error for job {job_id}, treating as "
f"normal close. Err: {ws.exception()!r}"
)
break
@@ -0,0 +1,26 @@
import requests
import ray
ray.init()
@ray.remote
class Counter:
def __init__(self):
self.counter = 0
def inc(self):
self.counter += 1
def get_counter(self):
return self.counter
counter = Counter.remote()
for _ in range(5):
ray.get(counter.inc.remote())
print(ray.get(counter.get_counter.remote()))
print(requests.__version__)
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
set -ex
unset RAY_ADDRESS
if ! [ -x "$(command -v conda)" ]; then
echo "conda doesn't exist. Please download conda for this machine"
exit 1
else
echo "conda exists"
fi
# This is required to use conda activate
source "$(conda info --base)/etc/profile.d/conda.sh"
PYTHON_VERSION=$(python -c"from platform import python_version; print(python_version())")
RAY_VERSIONS=("2.0.1")
for RAY_VERSION in "${RAY_VERSIONS[@]}"
do
env_name=${JOB_COMPATIBILITY_TEST_TEMP_ENV}
# Check if the conda env exists
if conda env list | grep -q "${env_name}"; then
# Clean up if env name is already taken from previous leaking runs
conda env remove --name="${env_name}"
fi
printf "\n\n\n"
echo "========================================================================================="
printf "Creating new conda environment with python %s for ray %s \n" "${PYTHON_VERSION}" "${RAY_VERSION}"
echo "========================================================================================="
printf "\n\n\n"
# Include `pip` explicitly: conda-forge's `python` package stopped
# bundling pip as a dep, and without it `conda activate` puts us in
# an env with python but no pip, so subsequent `pip install` falls
# back to the base miniforge env's pip. That clobbers the editable
# ray 3.0.0.dev0 in base with ray 2.0.1, and every subsequent
# dashboard test that imports `ray._common` fails because 2.0.1
# predates that module.
conda create -y -n "${env_name}" python="${PYTHON_VERSION}" pip=25.2
conda activate "${env_name}"
python -m pip install --upgrade pip
# Pin pydantic version due to: https://github.com/ray-project/ray/issues/36990.
# ray<2.9 is only compatible with pydantic<2 and setuptools < 70.
python -m pip install -U "pydantic<2" ray=="${RAY_VERSION}" ray[default]=="${RAY_VERSION}" setuptools==69.5.1
printf "\n\n\n"
echo "========================================================="
printf "Installed ray job server version: "
SERVER_RAY_VERSION=$(python -c "import ray; print(ray.__version__)")
printf "%s \n" "${SERVER_RAY_VERSION}"
echo "========================================================="
printf "\n\n\n"
ray stop --force
ray start --head
conda deactivate
CLIENT_RAY_VERSION=$(python -c "import ray; print(ray.__version__)")
CLIENT_RAY_COMMIT=$(python -c "import ray; print(ray.__commit__)")
printf "\n\n\n"
echo "========================================================================================="
printf "Using Ray %s on %s as job client \n" "${CLIENT_RAY_VERSION}" "${CLIENT_RAY_COMMIT}"
echo "========================================================================================="
printf "\n\n\n"
export RAY_ADDRESS="http://127.0.0.1:8265"
cleanup () {
unset RAY_ADDRESS
ray stop --force
conda remove -y --name "${env_name}" --all
}
JOB_ID=$(python -c "import uuid; print(uuid.uuid4().hex)")
# Get directory of current file. https://stackoverflow.com/questions/59895/
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
if ! ray job submit --job-id="${JOB_ID}" --working-dir="${DIR}" --runtime-env-json='{"pip": ["requests==2.26.0", "setuptools==69.5.1"]}' -- python script.py; then
cleanup
exit 1
fi
if ! ray job status "${JOB_ID}"; then
cleanup
exit 1
fi
if ! ray job logs "${JOB_ID}"; then
cleanup
exit 1
fi
if ! pytest -vs "${DIR}"/../test_backwards_compatibility.py::test_error_message; then
cleanup
exit 1
fi
cleanup
done
@@ -0,0 +1,30 @@
import os
import ray
from ray._raylet import GcsClient
from ray.dashboard.modules.job.job_manager import JobManager
TEST_NAMESPACE = "jobs_test_namespace"
def create_ray_cluster(_tracing_startup_hook=None):
return ray.init(
num_cpus=16,
num_gpus=1,
resources={"Custom": 1},
namespace=TEST_NAMESPACE,
log_to_driver=True,
_tracing_startup_hook=_tracing_startup_hook,
)
def create_job_manager(ray_cluster, tmp_path):
address_info = ray_cluster
gcs_client = GcsClient(address=address_info["gcs_address"])
return JobManager(gcs_client, tmp_path)
def _driver_script_path(file_name: str) -> str:
return os.path.join(
os.path.dirname(__file__), "subprocess_driver_scripts", file_name
)
@@ -0,0 +1,31 @@
"""
A dummy ray driver script that executes in subprocess.
Prints global worker's `load_code_from_local` property that ought to be set
whenever `JobConfig.code_search_path` is specified
"""
def run():
import ray
from ray.job_config import JobConfig
ray.init(job_config=JobConfig(code_search_path=["/home/code/"]))
@ray.remote
def foo() -> bool:
return ray._private.worker.global_worker.load_code_from_local
load_code_from_local = ray.get(foo.remote())
statement = "propagated" if load_code_from_local else "NOT propagated"
# Step 1: Print the statement indicating that the code_search_path have been
# properly respected
print(f"Code search path is {statement}")
# Step 2: Print the whole runtime_env to validate that it's been passed
# appropriately from submit_job API
print(ray.get_runtime_context().runtime_env)
if __name__ == "__main__":
run()
@@ -0,0 +1,22 @@
import os
import ray
cuda_env = ray._private.accelerators.nvidia_gpu.NOSET_CUDA_VISIBLE_DEVICES_ENV_VAR
if os.environ.get("RAY_TEST_RESOURCES_SPECIFIED") == "1":
assert cuda_env not in os.environ
if os.environ.get("RAY_TEST_GPUS_SPECIFIED") == "1":
assert "CUDA_VISIBLE_DEVICES" in os.environ
else:
assert "CUDA_VISIBLE_DEVICES" not in os.environ
else:
assert os.environ[cuda_env] == "1"
@ray.remote
def f():
assert cuda_env not in os.environ
# Will raise if task fails.
ray.get(f.remote())
@@ -0,0 +1,23 @@
"""
A dummy ray driver script that executes in subprocess.
Checks that job manager's environment variable is different.
"""
import os
import ray
def run():
ray.init()
@ray.remote
def foo():
print("worker", os.nice(0))
ray.get(foo.remote())
if __name__ == "__main__":
print("driver", os.nice(0))
run()
@@ -0,0 +1,13 @@
import ray
ray.init()
@ray.remote(num_cpus=1)
def f():
pass
print("Hanging...")
ray.get(f.remote())
print("Success!")
@@ -0,0 +1,58 @@
import argparse
import sys
import time
import ray
# This prefix is used to identify the output log line that contains the runtime env.
RUNTIME_ENV_LOG_LINE_PREFIX = "ray_job_test_runtime_env_output:"
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Dashboard agent.")
parser.add_argument(
"--conflict",
type=str,
)
parser.add_argument(
"--worker-process-setup-hook",
type=str,
)
args = parser.parse_args()
if args.worker_process_setup_hook:
ray.init(
runtime_env={
"worker_process_setup_hook": lambda: print(
args.worker_process_setup_hook
)
}
)
@ray.remote
def f():
pass
ray.get(f.remote())
time.sleep(5)
sys.exit(0)
if args.conflict == "pip":
ray.init(runtime_env={"pip": ["numpy"]})
print(
RUNTIME_ENV_LOG_LINE_PREFIX + ray._private.worker.global_worker.runtime_env
)
elif args.conflict == "env_vars":
ray.init(runtime_env={"env_vars": {"A": "1"}})
print(
RUNTIME_ENV_LOG_LINE_PREFIX + ray._private.worker.global_worker.runtime_env
)
else:
ray.init(
runtime_env={
"env_vars": {"C": "1"},
}
)
print(
RUNTIME_ENV_LOG_LINE_PREFIX + ray._private.worker.global_worker.runtime_env
)
@@ -0,0 +1,27 @@
"""
Test script that attempts to set its own runtime_env, but we should ensure
we ended up using job submission API call's runtime_env instead of scripts
"""
def run():
import os
import ray
ray.init(
runtime_env={
"env_vars": {"TEST_SUBPROCESS_JOB_CONFIG_ENV_VAR": "SHOULD_BE_OVERRIDEN"}
},
)
@ray.remote
def foo():
return "bar"
ray.get(foo.remote())
print(os.environ.get("TEST_SUBPROCESS_JOB_CONFIG_ENV_VAR", None))
if __name__ == "__main__":
run()
@@ -0,0 +1,26 @@
import os
import ray
def run():
ray.init()
@ray.remote(runtime_env={"env_vars": {"FOO": "bar"}})
def get_task_working_dir():
# Check behavior of working_dir: The cwd should contain the
# current file, which is being used as a job entrypoint script.
assert os.path.exists("per_task_runtime_env.py")
return ray.get_runtime_context().runtime_env.working_dir()
driver_working_dir = ray.get_runtime_context().runtime_env.working_dir()
task_working_dir = ray.get(get_task_working_dir.remote())
assert driver_working_dir == task_working_dir, (
driver_working_dir,
task_working_dir,
)
if __name__ == "__main__":
run()
@@ -0,0 +1,22 @@
"""
A dummy ray driver script that executes in subprocess. Prints namespace
from ray's runtime context for job submission API testing.
"""
import ray
def run():
ray.init()
@ray.remote
def foo():
return "bar"
ray.get(foo.remote())
print(ray.get_runtime_context().namespace)
if __name__ == "__main__":
run()
@@ -0,0 +1,22 @@
"""
A dummy ray driver script that executes in subprocess. Prints runtime_env
from ray's runtime context for job submission API testing.
"""
import ray
def run():
ray.init()
@ray.remote
def foo():
return "bar"
ray.get(foo.remote())
print(ray.get_runtime_context().runtime_env)
if __name__ == "__main__":
run()
@@ -0,0 +1,15 @@
"""Tests that Ray Tune works with the working_dir set in Jobs.
Ray Tune internally sets environment variables using runtime_env.
If the inherited internal runtime environment overwrites the working_dir
from jobs with an empty working_dir, this test will fail. See #25484"""
from ray_tune_dependency import foo
from ray import tune
def objective(*args):
foo()
tune.run(objective)
@@ -0,0 +1,5 @@
"""A file dependency for testing working_dir behavior with Ray Tune."""
def foo():
pass
@@ -0,0 +1,6 @@
def run():
raise Exception("Script failed with exception !")
if __name__ == "__main__":
run()
@@ -0,0 +1,124 @@
import logging
import os
import subprocess
import sys
import uuid
from contextlib import contextmanager
import pytest
from ray.job_submission import JobStatus, JobSubmissionClient
logger = logging.getLogger(__name__)
@contextmanager
def conda_env(env_name):
# Set env name for shell script
os.environ["JOB_COMPATIBILITY_TEST_TEMP_ENV"] = env_name
# Delete conda env if it already exists
try:
yield
finally:
# Clean up created conda env upon test exit to prevent leaking
del os.environ["JOB_COMPATIBILITY_TEST_TEMP_ENV"]
subprocess.run(
f"conda env remove -y --name {env_name}", shell=True, stdout=subprocess.PIPE
)
def _compatibility_script_path(file_name: str) -> str:
return os.path.join(
os.path.dirname(__file__), "backwards_compatibility_scripts", file_name
)
class TestBackwardsCompatibility:
@pytest.mark.skipif(
sys.platform == "darwin",
reason="ray 2.0.1 runs differently on apple silicon than today's.",
)
def test_cli(self):
"""
Test that the current commit's CLI works with old server-side Ray versions.
1) Create a new conda environment with old ray version X installed;
inherits same env as current conda envionment except ray version
2) (Server) Start head node and dashboard with old ray version X
3) (Client) Use current commit's CLI code to do sample job submission flow
4) Deactivate the new conda environment and back to original place
"""
# Shell script creates and cleans up tmp conda environment regardless
# of the outcome
env_name = f"jobs-backwards-compatibility-{uuid.uuid4().hex}"
with conda_env(env_name):
shell_cmd = f"{_compatibility_script_path('test_backwards_compatibility.sh')}" # noqa: E501
try:
subprocess.check_output(shell_cmd, shell=True, stderr=subprocess.STDOUT)
except subprocess.CalledProcessError as e:
logger.error(str(e))
logger.error(e.stdout.decode())
raise e
@pytest.mark.skipif(
os.environ.get("JOB_COMPATIBILITY_TEST_TEMP_ENV") is None,
reason="This test is only meant to be run from the "
"test_backwards_compatibility.sh shell script.",
)
def test_error_message():
"""
Check that we get a good error message when running against an old server version.
"""
# Import lazily so the module still loads when the compatibility script
# installs an older Ray that does not expose `ray._common`.
from ray._common.test_utils import wait_for_condition
client = JobSubmissionClient("http://127.0.0.1:8265")
# Check that a basic job successfully runs.
job_id = client.submit_job(
entrypoint="echo 'hello world'",
)
wait_for_condition(lambda: client.get_job_status(job_id) == JobStatus.SUCCEEDED)
# `entrypoint_num_cpus`, `entrypoint_num_gpus`, `entrypoint_resources`, and
# `entrypoint_label_selector`
# are not supported in ray<2.2.0.
for unsupported_submit_kwargs in [
{"entrypoint_num_cpus": 1},
{"entrypoint_num_gpus": 1},
{"entrypoint_resources": {"custom": 1}},
{"entrypoint_label_selector": {"fragile_node": "!1"}},
]:
with pytest.raises(
Exception,
match="Ray version 2.0.1 is running on the cluster. "
"`entrypoint_num_cpus`, `entrypoint_num_gpus`, "
"`entrypoint_resources`, and `entrypoint_label_selector` kwargs"
" are not supported on the Ray cluster. Please ensure the cluster is "
"running Ray 2.2 or higher.",
):
client.submit_job(
entrypoint="echo hello",
**unsupported_submit_kwargs,
)
with pytest.raises(
Exception,
match="Ray version 2.0.1 is running on the cluster. "
"`entrypoint_memory` kwarg"
" is not supported on the Ray cluster. Please ensure the cluster is "
"running Ray 2.8 or higher.",
):
client.submit_job(
entrypoint="echo hello",
entrypoint_memory=4,
)
assert True
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,695 @@
import json
import logging
import os
import shlex
import sys
import tempfile
from contextlib import contextmanager
from pathlib import Path
from subprocess import list2cmdline
from typing import Optional
from unittest import mock
import pytest
import yaml
from click.testing import CliRunner
from ray.dashboard.modules.job.cli import job_cli_group
logger = logging.getLogger(__name__)
@pytest.fixture
def mock_sdk_client():
class AsyncIterator:
def __init__(self, seq):
self._seq = seq
self.iter = iter(self._seq)
def __aiter__(self):
return self
async def __anext__(self):
try:
return next(self.iter)
except StopIteration:
self.iter = iter(self._seq)
raise StopAsyncIteration
if "RAY_ADDRESS" in os.environ:
del os.environ["RAY_ADDRESS"]
with mock.patch("ray.dashboard.modules.job.cli.JobSubmissionClient") as mock_client:
# In python 3.6 it will fail with error
# 'async for' requires an object with __aiter__ method, got MagicMock"
mock_client().tail_job_logs.return_value = AsyncIterator(range(10))
# We need to return a string for the address and not a MagicMock
mock_client().get_address.return_value = ""
yield mock_client
@pytest.fixture
def runtime_env_formats():
with tempfile.TemporaryDirectory() as tmp_dir:
path = Path(tmp_dir)
test_env = {
"working_dir": "s3://bogus.zip",
"conda": "conda_env",
"pip": ["pip-install-test"],
"env_vars": {"hi": "hi2"},
}
yaml_file = path / "env.yaml"
with yaml_file.open(mode="w") as f:
yaml.dump(test_env, f)
yield test_env, json.dumps(test_env), yaml_file
@contextmanager
def set_env_var(key: str, val: Optional[str] = None):
old_val = os.environ.get(key, None)
if val is not None:
os.environ[key] = val
elif key in os.environ:
del os.environ[key]
yield
if key in os.environ:
del os.environ[key]
if old_val is not None:
os.environ[key] = old_val
def check_exit_code(result, exit_code):
assert result.exit_code == exit_code, result.output
def _expected_entrypoint(*args):
"""Return the expected entrypoint string for the current platform.
On Windows, the CLI uses subprocess.list2cmdline (double quotes).
On POSIX, it uses shlex.join (single quotes).
"""
if sys.platform == "win32":
return list2cmdline(args)
return shlex.join(args)
def _job_cli_group_test_address(mock_sdk_client, cmd, *args):
runner = CliRunner()
create_cluster_if_needed = True if cmd == "submit" else False
# Test passing address via command line.
result = runner.invoke(job_cli_group, [cmd, "--address=arg_addr", *args])
mock_sdk_client.assert_called_with(
"arg_addr", create_cluster_if_needed, headers=None, verify=True
)
with pytest.raises(AssertionError):
mock_sdk_client.assert_called_with(
"some_other_addr", True, headers=None, verify=True
)
check_exit_code(result, 0)
# Test passing address via env var.
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(job_cli_group, [cmd, *args])
check_exit_code(result, 0)
# RAY_ADDRESS is read inside the SDK client.
mock_sdk_client.assert_called_with(
None, create_cluster_if_needed, headers=None, verify=True
)
# Test passing no address.
result = runner.invoke(job_cli_group, [cmd, *args])
check_exit_code(result, 0)
mock_sdk_client.assert_called_with(
None, create_cluster_if_needed, headers=None, verify=True
)
class TestList:
def test_address(self, mock_sdk_client):
_job_cli_group_test_address(mock_sdk_client, "list")
def test_list(self, mock_sdk_client):
runner = CliRunner()
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(
job_cli_group,
["list"],
)
check_exit_code(result, 0)
result = runner.invoke(job_cli_group, ["submit", "--", "echo hello"])
check_exit_code(result, 0)
result = runner.invoke(
job_cli_group,
["list"],
)
check_exit_code(result, 0)
class TestSubmit:
def test_address(self, mock_sdk_client):
_job_cli_group_test_address(mock_sdk_client, "submit", "--", "echo", "hello")
def test_working_dir(self, mock_sdk_client):
runner = CliRunner()
mock_client_instance = mock_sdk_client.return_value
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(job_cli_group, ["submit", "--", "echo hello"])
check_exit_code(result, 0)
mock_client_instance.submit_job.assert_called_with(
entrypoint=_expected_entrypoint("echo hello"),
submission_id=None,
runtime_env={},
metadata=None,
entrypoint_num_cpus=None,
entrypoint_num_gpus=None,
entrypoint_memory=None,
entrypoint_resources=None,
entrypoint_label_selector=None,
)
result = runner.invoke(
job_cli_group,
["submit", "--working-dir", "blah", "--", "echo hello"],
)
check_exit_code(result, 0)
mock_client_instance.submit_job.assert_called_with(
entrypoint=_expected_entrypoint("echo hello"),
submission_id=None,
runtime_env={"working_dir": "blah"},
metadata=None,
entrypoint_num_cpus=None,
entrypoint_num_gpus=None,
entrypoint_memory=None,
entrypoint_resources=None,
entrypoint_label_selector=None,
)
result = runner.invoke(
job_cli_group, ["submit", "--working-dir='.'", "--", "echo hello"]
)
check_exit_code(result, 0)
mock_client_instance.submit_job.assert_called_with(
entrypoint=_expected_entrypoint("echo hello"),
submission_id=None,
runtime_env={"working_dir": "'.'"},
metadata=None,
entrypoint_num_cpus=None,
entrypoint_num_gpus=None,
entrypoint_memory=None,
entrypoint_resources=None,
entrypoint_label_selector=None,
)
def test_runtime_env(self, mock_sdk_client, runtime_env_formats):
runner = CliRunner()
mock_client_instance = mock_sdk_client.return_value
env_dict, env_json, env_yaml = runtime_env_formats
with set_env_var("RAY_ADDRESS", "env_addr"):
# Test passing via file.
result = runner.invoke(
job_cli_group, ["submit", "--runtime-env", env_yaml, "--", "echo hello"]
)
check_exit_code(result, 0)
mock_client_instance.submit_job.assert_called_with(
entrypoint=_expected_entrypoint("echo hello"),
submission_id=None,
runtime_env=env_dict,
metadata=None,
entrypoint_num_cpus=None,
entrypoint_num_gpus=None,
entrypoint_memory=None,
entrypoint_resources=None,
entrypoint_label_selector=None,
)
# Test passing via json.
result = runner.invoke(
job_cli_group,
["submit", "--runtime-env-json", env_json, "--", "echo hello"],
)
check_exit_code(result, 0)
mock_client_instance.submit_job.assert_called_with(
entrypoint=_expected_entrypoint("echo hello"),
submission_id=None,
runtime_env=env_dict,
metadata=None,
entrypoint_num_cpus=None,
entrypoint_num_gpus=None,
entrypoint_memory=None,
entrypoint_resources=None,
entrypoint_label_selector=None,
)
# Test passing both throws an error.
result = runner.invoke(
job_cli_group,
[
"submit",
"--runtime-env",
env_yaml,
"--runtime-env-json",
env_json,
"--",
"echo hello",
],
)
check_exit_code(result, 1)
assert "Only one of" in str(result.exception)
# Test overriding working_dir.
env_dict.update(working_dir=".")
result = runner.invoke(
job_cli_group,
[
"submit",
"--runtime-env",
env_yaml,
"--working-dir",
".",
"--",
"echo hello",
],
)
check_exit_code(result, 0)
mock_client_instance.submit_job.assert_called_with(
entrypoint=_expected_entrypoint("echo hello"),
submission_id=None,
runtime_env=env_dict,
metadata=None,
entrypoint_num_cpus=None,
entrypoint_num_gpus=None,
entrypoint_memory=None,
entrypoint_resources=None,
entrypoint_label_selector=None,
)
result = runner.invoke(
job_cli_group,
[
"submit",
"--runtime-env-json",
env_json,
"--working-dir",
".",
"--",
"echo hello",
],
)
check_exit_code(result, 0)
mock_client_instance.submit_job.assert_called_with(
entrypoint=_expected_entrypoint("echo hello"),
submission_id=None,
runtime_env=env_dict,
metadata=None,
entrypoint_num_cpus=None,
entrypoint_num_gpus=None,
entrypoint_memory=None,
entrypoint_resources=None,
entrypoint_label_selector=None,
)
def test_job_id(self, mock_sdk_client):
runner = CliRunner()
mock_client_instance = mock_sdk_client.return_value
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(job_cli_group, ["submit", "--", "echo hello"])
check_exit_code(result, 0)
mock_client_instance.submit_job.assert_called_with(
entrypoint=_expected_entrypoint("echo hello"),
submission_id=None,
runtime_env={},
metadata=None,
entrypoint_num_cpus=None,
entrypoint_num_gpus=None,
entrypoint_memory=None,
entrypoint_resources=None,
entrypoint_label_selector=None,
)
result = runner.invoke(
job_cli_group,
["submit", "--submission-id=my_job_id", "--", "echo hello"],
)
check_exit_code(result, 0)
mock_client_instance.submit_job.assert_called_with(
entrypoint=_expected_entrypoint("echo hello"),
submission_id="my_job_id",
runtime_env={},
metadata=None,
entrypoint_num_cpus=None,
entrypoint_num_gpus=None,
entrypoint_memory=None,
entrypoint_resources=None,
entrypoint_label_selector=None,
)
def test_entrypoint_num_cpus(self, mock_sdk_client):
runner = CliRunner()
mock_client_instance = mock_sdk_client.return_value
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(
job_cli_group,
["submit", "--entrypoint-num-cpus=2", "--", "echo hello"],
)
assert result.exit_code == 0
mock_client_instance.submit_job.assert_called_with(
entrypoint=_expected_entrypoint("echo hello"),
submission_id=None,
runtime_env={},
metadata=None,
entrypoint_num_cpus=2,
entrypoint_num_gpus=None,
entrypoint_memory=None,
entrypoint_resources=None,
entrypoint_label_selector=None,
)
def test_entrypoint_num_gpus(self, mock_sdk_client):
runner = CliRunner()
mock_client_instance = mock_sdk_client.return_value
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(
job_cli_group,
["submit", "--entrypoint-num-gpus=2", "--", "echo hello"],
)
assert result.exit_code == 0
mock_client_instance.submit_job.assert_called_with(
entrypoint=_expected_entrypoint("echo hello"),
submission_id=None,
runtime_env={},
metadata=None,
entrypoint_num_cpus=None,
entrypoint_num_gpus=2,
entrypoint_memory=None,
entrypoint_resources=None,
entrypoint_label_selector=None,
)
def test_entrypoint_memory(self, mock_sdk_client):
runner = CliRunner()
mock_client_instance = mock_sdk_client.return_value
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(
job_cli_group,
["submit", "--entrypoint-memory=4", "--", "echo hello"],
)
assert result.exit_code == 0
mock_client_instance.submit_job.assert_called_with(
entrypoint=_expected_entrypoint("echo hello"),
submission_id=None,
runtime_env={},
metadata=None,
entrypoint_num_cpus=None,
entrypoint_num_gpus=None,
entrypoint_memory=4,
entrypoint_resources=None,
entrypoint_label_selector=None,
)
@pytest.mark.parametrize(
"resources",
[
("--entrypoint-num-cpus=2", {"entrypoint_num_cpus": 2}),
("--entrypoint-num-gpus=2", {"entrypoint_num_gpus": 2}),
(
"""--entrypoint-resources={"Custom":3}""",
{"entrypoint_resources": {"Custom": 3}},
),
],
)
def test_entrypoint_resources(self, mock_sdk_client, resources):
runner = CliRunner()
mock_client_instance = mock_sdk_client.return_value
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(
job_cli_group,
["submit", resources[0], "--", "echo hello"],
)
print(result.output)
assert result.exit_code == 0
expected_kwargs = {
"entrypoint": _expected_entrypoint("echo hello"),
"submission_id": None,
"runtime_env": {},
"metadata": None,
"entrypoint_num_cpus": None,
"entrypoint_num_gpus": None,
"entrypoint_memory": None,
"entrypoint_resources": None,
"entrypoint_label_selector": None,
}
expected_kwargs.update(resources[1])
mock_client_instance.submit_job.assert_called_with(**expected_kwargs)
def test_entrypoint_resources_invalid_json(self, mock_sdk_client):
runner = CliRunner()
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(
job_cli_group,
[
"submit",
"""--entrypoint-resources={"Custom":3""",
"--",
"echo hello world",
],
)
print(result.output)
assert result.exit_code == 1
assert "not a valid JSON string" in result.output
def test_entrypoint_label_selector(self, mock_sdk_client):
runner = CliRunner()
mock_client_instance = mock_sdk_client.return_value
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(
job_cli_group,
[
"submit",
"""--entrypoint-label-selector={"fragile_node":"!1"}""",
"--",
"echo hello",
],
)
assert result.exit_code == 0
mock_client_instance.submit_job.assert_called_with(
entrypoint=_expected_entrypoint("echo hello"),
submission_id=None,
runtime_env={},
metadata=None,
entrypoint_num_cpus=None,
entrypoint_num_gpus=None,
entrypoint_memory=None,
entrypoint_resources=None,
entrypoint_label_selector={"fragile_node": "!1"},
)
def test_metadata(self, mock_sdk_client):
runner = CliRunner()
mock_client_instance = mock_sdk_client.return_value
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(
job_cli_group,
[
"submit",
"--metadata-json",
'{"key": "value"}',
"--",
"echo hello",
],
)
check_exit_code(result, 0)
mock_client_instance.submit_job.assert_called_with(
entrypoint=_expected_entrypoint("echo hello"),
submission_id=None,
runtime_env={},
entrypoint_num_cpus=None,
entrypoint_num_gpus=None,
entrypoint_memory=None,
entrypoint_resources=None,
entrypoint_label_selector=None,
metadata={"key": "value"},
)
def test_metadata_invalid_json(self, mock_sdk_client):
runner = CliRunner()
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(
job_cli_group,
[
"submit",
"--metadata-json",
'{"key": "value"',
"--",
"echo hello",
],
)
print(result.output)
check_exit_code(result, 1)
assert "not a valid JSON string" in result.output
@pytest.mark.parametrize(
"cli_val, verify_param",
[
("True", True),
("true", True),
("1", True),
("False", False),
("false", False),
("0", False),
("a/rel/path", "a/rel/path"),
("/an/abs/path", "/an/abs/path"),
],
)
def test_entrypoint_verify(self, mock_sdk_client, cli_val, verify_param):
runner = CliRunner()
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(
job_cli_group,
["submit", f"--verify={cli_val}", "--", "echo hello"],
)
assert result.exit_code == 0
mock_sdk_client.assert_called_with(
None, True, headers=None, verify=verify_param
)
class TestDelete:
def test_address(self, mock_sdk_client):
_job_cli_group_test_address(mock_sdk_client, "delete", "fake_job_id")
def test_delete(self, mock_sdk_client):
runner = CliRunner()
mock_client_instance = mock_sdk_client.return_value
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(job_cli_group, ["delete", "job_id"])
check_exit_code(result, 0)
mock_client_instance.delete_job.assert_called_with("job_id")
class TestStatus:
def test_address(self, mock_sdk_client):
_job_cli_group_test_address(mock_sdk_client, "status", "fake_job_id")
def test_status(self, mock_sdk_client):
runner = CliRunner()
mock_client_instance = mock_sdk_client.return_value
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(job_cli_group, ["status", "job_id"])
check_exit_code(result, 0)
mock_client_instance.get_job_info.assert_called_with("job_id")
class TestEntrypointShellQuoting:
"""Regression test for https://github.com/ray-project/ray/issues/56232.
`ray job submit` previously used `subprocess.list2cmdline` unconditionally
to join entrypoint arguments. That function wraps arguments in double
quotes, which causes POSIX shells on the server to expand $VAR references.
The fix uses `shlex.join` on POSIX platforms (which single-quotes
arguments to prevent expansion) and `list2cmdline` on Windows (which
double-quotes arguments as expected by cmd.exe).
"""
def test_entrypoint_preserves_shell_variables(self, mock_sdk_client):
"""Ensure $VAR in entrypoint is single-quoted on POSIX, not double-quoted."""
runner = CliRunner()
mock_client_instance = mock_sdk_client.return_value
with set_env_var("RAY_ADDRESS", "env_addr"):
with mock.patch("ray.dashboard.modules.job.cli.sys") as mock_sys:
mock_sys.platform = "linux"
result = runner.invoke(
job_cli_group,
[
"submit",
"--",
"python",
"-m",
"launcher",
"--config",
"$CONFIG_PATH",
],
)
check_exit_code(result, 0)
call_kwargs = mock_client_instance.submit_job.call_args
entrypoint = call_kwargs.kwargs["entrypoint"]
# shlex.join must single-quote the $VAR argument so that
# the server-side POSIX shell does NOT expand it.
assert "'$CONFIG_PATH'" in entrypoint, (
f"Expected single-quoted $CONFIG_PATH in entrypoint, "
f"got: {entrypoint!r}"
)
# Double quotes around $CONFIG_PATH would cause expansion.
assert '"$CONFIG_PATH"' not in entrypoint, (
f"Double-quoted $CONFIG_PATH would be expanded by the "
f"server shell, got: {entrypoint!r}"
)
def test_entrypoint_uses_list2cmdline_on_windows(self, mock_sdk_client):
"""On Windows, entrypoint should use list2cmdline (double quotes)."""
runner = CliRunner()
mock_client_instance = mock_sdk_client.return_value
with set_env_var("RAY_ADDRESS", "env_addr"):
with mock.patch("ray.dashboard.modules.job.cli.sys") as mock_sys:
mock_sys.platform = "win32"
result = runner.invoke(
job_cli_group,
[
"submit",
"--",
"echo",
"hello world",
],
)
check_exit_code(result, 0)
call_kwargs = mock_client_instance.submit_job.call_args
entrypoint = call_kwargs.kwargs["entrypoint"]
# list2cmdline wraps args with spaces in double quotes
assert (
entrypoint == 'echo "hello world"'
), f"Expected list2cmdline output on Windows, got: {entrypoint!r}"
def test_entrypoint_simple_args_not_over_quoted(self, mock_sdk_client):
"""Simple arguments without special chars should not be quoted."""
runner = CliRunner()
mock_client_instance = mock_sdk_client.return_value
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(
job_cli_group,
["submit", "--", "echo", "hello"],
)
check_exit_code(result, 0)
call_kwargs = mock_client_instance.submit_job.call_args
entrypoint = call_kwargs.kwargs["entrypoint"]
assert entrypoint == "echo hello"
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,356 @@
import json
import logging
import os
import subprocess
import sys
from contextlib import contextmanager
from typing import Optional, Tuple
import pytest
import ray
logger = logging.getLogger(__name__)
@pytest.fixture
def shutdown_only():
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()
@contextmanager
def set_env_var(key: str, val: Optional[str] = None):
old_val = os.environ.get(key, None)
if val is not None:
os.environ[key] = val
elif key in os.environ:
del os.environ[key]
try:
yield
finally:
if key in os.environ:
del os.environ[key]
if old_val is not None:
os.environ[key] = old_val
@pytest.fixture
def ray_start_stop():
subprocess.check_output(["ray", "start", "--head"])
try:
with set_env_var("RAY_ADDRESS", "http://127.0.0.1:8265"):
yield
finally:
subprocess.check_output(["ray", "stop", "--force"])
@contextmanager
def ray_cluster_manager():
"""
Used not as fixture in case we want to set RAY_ADDRESS first.
"""
subprocess.check_output(["ray", "start", "--head"])
try:
yield
finally:
subprocess.check_output(["ray", "stop", "--force"])
def _run_cmd(cmd: str, should_fail=False) -> Tuple[str, str]:
"""Convenience wrapper for subprocess.run.
We always run with shell=True to simulate the CLI.
Asserts that the process succeeds/fails depending on should_fail.
Returns (stdout, stderr).
"""
print(f"Running command: '{cmd}'")
p: subprocess.CompletedProcess = subprocess.run(
cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
if p.returncode == 0:
print("Command succeeded.")
if should_fail:
raise RuntimeError(
f"Expected command to fail, but got exit code: {p.returncode}."
)
else:
print(f"Command failed with exit code: {p.returncode}.")
if not should_fail:
raise RuntimeError(
f"Expected command to succeed, but got exit code: {p.returncode}."
)
return p.stdout.decode("utf-8"), p.stderr.decode("utf-8")
class TestJobSubmitHook:
"""Tests the RAY_JOB_SUBMIT_HOOK env var."""
def test_hook(self, ray_start_stop):
with set_env_var("RAY_JOB_SUBMIT_HOOK", "ray._private.test_utils.job_hook"):
stdout, _ = _run_cmd("ray job submit -- echo hello")
assert "hook intercepted: echo hello" in stdout
class TestRayJobHeaders:
"""
Integration version of job CLI test that ensures interaction with the
following components are working as expected:
1) Ray client: use of RAY_JOB_HEADERS and ray.init() in job_head.py
2) Ray dashboard: `ray start --head`
"""
def test_empty_ray_job_headers(self, ray_start_stop):
with set_env_var("RAY_JOB_HEADERS", None):
stdout, _ = _run_cmd("ray job submit -- echo hello")
assert "hello" in stdout
assert "succeeded" in stdout
@pytest.mark.parametrize("ray_job_headers", ['{"key": "value"}'])
def test_ray_job_headers(self, ray_start_stop, ray_job_headers: str):
with set_env_var("RAY_JOB_HEADERS", ray_job_headers):
_run_cmd("ray job submit -- echo hello", should_fail=False)
@pytest.mark.parametrize("ray_job_headers", ["{key value}"])
def test_ray_incorrectly_formatted_job_headers(
self, ray_start_stop, ray_job_headers: str
):
with set_env_var("RAY_JOB_HEADERS", ray_job_headers):
_run_cmd("ray job submit -- echo hello", should_fail=True)
class TestRayAddress:
"""
Integration version of job CLI test that ensures interaction with the
following components are working as expected:
1) Ray client: use of RAY_ADDRESS and ray.init() in job_head.py
2) Ray dashboard: `ray start --head`
"""
def test_empty_ray_address(self, ray_start_stop):
with set_env_var("RAY_ADDRESS", None):
stdout, _ = _run_cmd("ray job submit -- echo hello")
assert "hello" in stdout
assert "succeeded" in stdout
@pytest.mark.parametrize(
"ray_api_server_address,should_fail",
[
("http://127.0.0.1:8265", False), # correct API server
("127.0.0.1:8265", True), # wrong format without http
("http://127.0.0.1:9999", True), # wrong port
],
)
def test_ray_api_server_address(
self,
ray_start_stop,
ray_api_server_address: str,
should_fail: bool,
):
# Set a `RAY_ADDRESS` that would not work with the `ray job submit` CLI because it uses the `ray://` prefix.
# This verifies that the `RAY_API_SERVER_ADDRESS` env var takes precedence.
with set_env_var("RAY_ADDRESS", "ray://127.0.0.1:8265"):
with set_env_var("RAY_API_SERVER_ADDRESS", ray_api_server_address):
_run_cmd("ray job submit -- echo hello", should_fail=should_fail)
@pytest.mark.parametrize(
"ray_client_address,should_fail",
[
("127.0.0.1:8265", True),
("ray://127.0.0.1:8265", True),
("http://127.0.0.1:8265", False),
],
)
def test_ray_client_address(
self, ray_start_stop, ray_client_address: str, should_fail: bool
):
with set_env_var("RAY_ADDRESS", ray_client_address):
_run_cmd("ray job submit -- echo hello", should_fail=should_fail)
def test_valid_http_ray_address(self, ray_start_stop):
stdout, _ = _run_cmd("ray job submit -- echo hello")
assert "hello" in stdout
assert "succeeded" in stdout
class TestJobSubmit:
def test_basic_submit(self, ray_start_stop):
"""Should tail logs and wait for process to exit."""
cmd = "sleep 1 && echo hello && sleep 1 && echo hello"
stdout, _ = _run_cmd(f"ray job submit -- bash -c '{cmd}'")
# 'hello' should appear four times: twice when we print the entrypoint, then
# two more times in the logs from the `echo`.
assert stdout.count("hello") == 4
assert "succeeded" in stdout
def test_submit_no_wait(self, ray_start_stop):
"""Should exit immediately w/o printing logs."""
cmd = "echo hello && sleep 1000"
stdout, _ = _run_cmd(f"ray job submit --no-wait -- bash -c '{cmd}'")
assert "hello" not in stdout
assert "Tailing logs until the job exits" not in stdout
def test_submit_with_logs_instant_job(self, ray_start_stop):
"""Should exit immediately and print logs even if job returns instantly."""
cmd = "echo hello"
stdout, _ = _run_cmd(f"ray job submit -- bash -c '{cmd}'")
# 'hello' should appear twice: once when we print the entrypoint, then
# again from the `echo`.
assert stdout.count("hello") == 2
def test_multiple_ray_init(self, ray_start_stop):
cmd = (
"python -c 'import ray; ray.init(); ray.shutdown(); "
"ray.init(); ray.shutdown();'"
)
stdout, _ = _run_cmd(f"ray job submit -- {cmd}")
assert "succeeded" in stdout
def test_metadata(self, ray_start_stop):
cmd = "echo hello"
stdout, _ = _run_cmd(
f'ray job submit --metadata-json=\'{{"key": "value"}}\' -- {cmd}'
)
assert "hello" in stdout
assert "succeeded" in stdout
def test_job_failed(self, ray_start_stop):
cmd = "python -c 'import ray; ray.init(); assert 1 == 2;'"
_run_cmd(f"ray job submit -- {cmd}", should_fail=True)
class TestRuntimeEnv:
def test_bad_runtime_env(self, ray_start_stop):
"""Should fail with helpful error if runtime env setup fails."""
stdout, _ = _run_cmd(
'ray job submit --runtime-env-json=\'{"pip": '
'["does-not-exist"]}\' -- echo hi',
should_fail=True,
)
assert "Tailing logs until the job exits" in stdout
assert "runtime_env setup failed" in stdout
assert "No matching distribution found for does-not-exist" in stdout
class TestJobStop:
def test_basic_stop(self, ray_start_stop):
"""Should wait until the job is stopped."""
cmd = "sleep 1000"
job_id = "test_basic_stop"
_run_cmd(f"ray job submit --no-wait --job-id={job_id} -- {cmd}")
stdout, _ = _run_cmd(f"ray job stop {job_id}")
assert "Waiting for job" in stdout
assert f"Job '{job_id}' was stopped" in stdout
def test_stop_no_wait(self, ray_start_stop):
"""Should not wait until the job is stopped."""
cmd = "echo hello && sleep 1000"
job_id = "test_stop_no_wait"
_run_cmd(f"ray job submit --no-wait --job-id={job_id} -- bash -c '{cmd}'")
stdout, _ = _run_cmd(f"ray job stop --no-wait {job_id}")
assert "Waiting for job" not in stdout
assert f"Job '{job_id}' was stopped" not in stdout
class TestJobList:
def test_empty(self, ray_start_stop):
stdout, _ = _run_cmd("ray job list")
assert "[]" in stdout
def test_list(self, ray_start_stop):
_run_cmd("ray job submit --job-id='hello_id' -- echo hello")
runtime_env = {"env_vars": {"TEST": "123"}}
_run_cmd(
"ray job submit --job-id='hi_id' "
f"--runtime-env-json='{json.dumps(runtime_env)}' -- echo hi"
)
stdout, _ = _run_cmd("ray job list")
assert "123" in stdout
assert "hello_id" in stdout
assert "hi_id" in stdout
class TestJobDelete:
def test_basic_delete(self, ray_start_stop):
cmd = "sleep 1000"
job_id = "test_basic_delete"
_run_cmd(f"ray job submit --no-wait --submission-id={job_id} -- {cmd}")
# Job shouldn't be able to be deleted because it is not in a terminal state.
stdout, stderr = _run_cmd(f"ray job delete {job_id}", should_fail=True)
assert "it is in a non-terminal state" in stderr
# Submit a job that finishes quickly.
cmd = "echo hello"
job_id = "test_basic_delete_quick"
_run_cmd(f"ray job submit --submission-id={job_id} -- bash -c '{cmd}'")
# Job should be able to be deleted because it is finished.
stdout, _ = _run_cmd(f"ray job delete {job_id}")
assert f"Job '{job_id}' deleted successfully" in stdout
class TestJobStatus:
# `ray job status` should exit with 0 if the job exists and non-zero if it doesn't.
# This is the contract between Ray and KubRay v1.3.0.
def test_status_job_exists(self, ray_start_stop):
cmd = "echo hello"
job_id = "test_job_id"
_run_cmd(
f"ray job submit --submission-id={job_id} -- bash -c '{cmd}'",
should_fail=False,
)
_run_cmd(f"ray job status {job_id}", should_fail=False)
def test_status_job_does_not_exist(self, ray_start_stop):
job_id = "test_job_id"
_run_cmd(f"ray job status {job_id}", should_fail=True)
def test_quote_escaping(ray_start_stop):
cmd = "echo \"hello 'world'\""
job_id = "test_quote_escaping"
stdout, _ = _run_cmd(
f"ray job submit --job-id={job_id} -- {cmd}",
)
assert "hello 'world'" in stdout
def test_resources(shutdown_only):
ray.init(num_cpus=1, num_gpus=1, resources={"Custom": 1}, _memory=4)
# Check the case of too many resources.
for id, arg in [
("entrypoint_num_cpus", "--entrypoint-num-cpus=2"),
("entrypoint_num_gpus", "--entrypoint-num-gpus=2"),
("entrypoint_memory", "--entrypoint-memory=5"),
("entrypoint_resources", "--entrypoint-resources='{\"Custom\": 2}'"),
]:
_run_cmd(f"ray job submit --submission-id={id} --no-wait {arg} -- echo hi")
stdout, _ = _run_cmd(f"ray job status {id}")
assert "waiting for resources" in stdout
# Check the case of sufficient resources.
stdout, _ = _run_cmd(
"ray job submit --entrypoint-num-cpus=1 "
"--entrypoint-num-gpus=1 --entrypoint-memory=4 --entrypoint-resources='{"
'"Custom": 1}\' -- echo hello',
)
assert "hello" in stdout
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,302 @@
import asyncio
import json
from dataclasses import asdict
from unittest.mock import AsyncMock, MagicMock
import pytest
from google.protobuf.json_format import Parse
from ray.core.generated.gcs_pb2 import JobsAPIInfo
from ray.dashboard.modules.job.common import (
JobErrorType,
JobInfo,
JobInfoStorageClient,
JobStatus,
JobSubmitRequest,
http_uri_components_to_uri,
uri_to_http_components,
validate_request_type,
)
class TestJobSubmitRequestValidation:
def test_validate_entrypoint(self):
r = validate_request_type({"entrypoint": "abc"}, JobSubmitRequest)
assert r.entrypoint == "abc"
with pytest.raises(TypeError, match="required positional argument"):
validate_request_type({}, JobSubmitRequest)
with pytest.raises(TypeError, match="must be a string"):
validate_request_type({"entrypoint": 123}, JobSubmitRequest)
def test_validate_submission_id(self):
r = validate_request_type({"entrypoint": "abc"}, JobSubmitRequest)
assert r.entrypoint == "abc"
assert r.submission_id is None
r = validate_request_type(
{"entrypoint": "abc", "submission_id": "123"}, JobSubmitRequest
)
assert r.entrypoint == "abc"
assert r.submission_id == "123"
with pytest.raises(TypeError, match="must be a string"):
validate_request_type(
{"entrypoint": 123, "submission_id": 1}, JobSubmitRequest
)
def test_validate_runtime_env(self):
r = validate_request_type({"entrypoint": "abc"}, JobSubmitRequest)
assert r.entrypoint == "abc"
assert r.runtime_env is None
r = validate_request_type(
{"entrypoint": "abc", "runtime_env": {"hi": "hi2"}}, JobSubmitRequest
)
assert r.entrypoint == "abc"
assert r.runtime_env == {"hi": "hi2"}
with pytest.raises(TypeError, match="must be a dict"):
validate_request_type(
{"entrypoint": "abc", "runtime_env": 123}, JobSubmitRequest
)
with pytest.raises(TypeError, match="keys must be strings"):
validate_request_type(
{"entrypoint": "abc", "runtime_env": {1: "hi"}}, JobSubmitRequest
)
def test_validate_metadata(self):
r = validate_request_type({"entrypoint": "abc"}, JobSubmitRequest)
assert r.entrypoint == "abc"
assert r.metadata is None
r = validate_request_type(
{"entrypoint": "abc", "metadata": {"hi": "hi2"}}, JobSubmitRequest
)
assert r.entrypoint == "abc"
assert r.metadata == {"hi": "hi2"}
with pytest.raises(TypeError, match="must be a dict"):
validate_request_type(
{"entrypoint": "abc", "metadata": 123}, JobSubmitRequest
)
with pytest.raises(TypeError, match="keys must be strings"):
validate_request_type(
{"entrypoint": "abc", "metadata": {1: "hi"}}, JobSubmitRequest
)
with pytest.raises(TypeError, match="values must be strings"):
validate_request_type(
{"entrypoint": "abc", "metadata": {"hi": 1}}, JobSubmitRequest
)
def test_validate_entrypoint_label_selector(self):
r = validate_request_type(
{
"entrypoint": "abc",
"entrypoint_label_selector": {"fragile_node": "!1"},
},
JobSubmitRequest,
)
assert r.entrypoint_label_selector == {"fragile_node": "!1"}
with pytest.raises(TypeError, match="must be a dict"):
validate_request_type(
{"entrypoint": "abc", "entrypoint_label_selector": "bad"},
JobSubmitRequest,
)
with pytest.raises(TypeError, match="keys must be strings"):
validate_request_type(
{"entrypoint": "abc", "entrypoint_label_selector": {1: "bad"}},
JobSubmitRequest,
)
with pytest.raises(TypeError, match="values must be strings"):
validate_request_type(
{"entrypoint": "abc", "entrypoint_label_selector": {"k": 1}},
JobSubmitRequest,
)
def test_entrypoint_resources_disallow_strings(self):
with pytest.raises(TypeError, match="values must be numbers"):
validate_request_type(
{"entrypoint": "abc", "entrypoint_resources": {"Custom": "1"}},
JobSubmitRequest,
)
def test_uri_to_http_and_back():
assert uri_to_http_components("gcs://hello.zip") == ("gcs", "hello.zip")
assert uri_to_http_components("gcs://hello.whl") == ("gcs", "hello.whl")
with pytest.raises(ValueError, match="'blah' is not a valid Protocol"):
uri_to_http_components("blah://halb.zip")
with pytest.raises(ValueError, match="does not end in .zip or .whl"):
assert uri_to_http_components("gcs://hello.not_zip")
with pytest.raises(ValueError, match="does not end in .zip or .whl"):
assert uri_to_http_components("gcs://hello")
assert http_uri_components_to_uri("gcs", "hello.zip") == "gcs://hello.zip"
assert http_uri_components_to_uri("blah", "halb.zip") == "blah://halb.zip"
assert http_uri_components_to_uri("blah", "halb.whl") == "blah://halb.whl"
for original_uri in ["gcs://hello.zip", "gcs://fasdf.whl"]:
new_uri = http_uri_components_to_uri(*uri_to_http_components(original_uri))
assert new_uri == original_uri
def test_dynamic_status_message():
info = JobInfo(
status=JobStatus.PENDING, entrypoint="echo hi", entrypoint_num_cpus=1
)
assert "may be waiting for resources" in info.message
info = JobInfo(
status=JobStatus.PENDING, entrypoint="echo hi", entrypoint_num_gpus=1
)
assert "may be waiting for resources" in info.message
info = JobInfo(status=JobStatus.PENDING, entrypoint="echo hi", entrypoint_memory=4)
assert "may be waiting for resources" in info.message
info = JobInfo(
status=JobStatus.PENDING,
entrypoint="echo hi",
entrypoint_resources={"Custom": 1},
)
assert "may be waiting for resources" in info.message
info = JobInfo(
status=JobStatus.PENDING, entrypoint="echo hi", runtime_env={"conda": "env"}
)
assert "may be waiting for the runtime environment" in info.message
def test_job_info_to_json():
info = JobInfo(
status=JobStatus.PENDING,
entrypoint="echo hi",
entrypoint_num_cpus=1,
entrypoint_num_gpus=1,
entrypoint_memory=4,
entrypoint_resources={"Custom": 1},
runtime_env={"pip": ["pkg"]},
)
expected_items = {
"status": "PENDING",
"message": (
"Job has not started yet. It may be waiting for resources "
"(CPUs, GPUs, memory, custom resources) to become available. "
"It may be waiting for the runtime environment to be set up."
),
"entrypoint": "echo hi",
"entrypoint_num_cpus": 1,
"entrypoint_num_gpus": 1,
"entrypoint_memory": 4,
"entrypoint_resources": {"Custom": 1},
"runtime_env_json": '{"pip": ["pkg"]}',
}
# Check that the expected items are in the JSON.
assert expected_items.items() <= info.to_json().items()
new_job_info = JobInfo.from_json(info.to_json())
assert new_job_info == info
# If `status` is just a string, then operations like status.is_terminal()
# would fail, so we should make sure that it's a JobStatus.
assert isinstance(new_job_info.status, JobStatus)
def test_job_info_json_to_proto():
"""Test that JobInfo JSON can be converted to JobsAPIInfo protobuf."""
info = JobInfo(
status=JobStatus.PENDING,
entrypoint="echo hi",
error_type=JobErrorType.JOB_SUPERVISOR_ACTOR_UNSCHEDULABLE,
start_time=123,
end_time=456,
metadata={"hi": "hi2"},
entrypoint_num_cpus=1,
entrypoint_num_gpus=1,
entrypoint_memory=4,
entrypoint_resources={"Custom": 1},
runtime_env={"pip": ["pkg"]},
driver_agent_http_address="http://localhost:1234",
driver_node_id="node_id",
)
info_json = json.dumps(info.to_json())
info_proto = Parse(info_json, JobsAPIInfo())
assert info_proto.status == "PENDING"
assert info_proto.entrypoint == "echo hi"
assert info_proto.start_time == 123
assert info_proto.end_time == 456
assert info_proto.metadata == {"hi": "hi2"}
assert info_proto.entrypoint_num_cpus == 1
assert info_proto.entrypoint_num_gpus == 1
assert info_proto.entrypoint_memory == 4
assert info_proto.entrypoint_resources == {"Custom": 1}
assert info_proto.runtime_env_json == '{"pip": ["pkg"]}'
assert info_proto.message == (
"Job has not started yet. It may be waiting for resources "
"(CPUs, GPUs, memory, custom resources) to become available. "
"It may be waiting for the runtime environment to be set up."
)
assert info_proto.error_type == "JOB_SUPERVISOR_ACTOR_UNSCHEDULABLE"
assert info_proto.driver_agent_http_address == "http://localhost:1234"
assert info_proto.driver_node_id == "node_id"
minimal_info = JobInfo(status=JobStatus.PENDING, entrypoint="echo hi")
minimal_info_json = json.dumps(minimal_info.to_json())
minimal_info_proto = Parse(minimal_info_json, JobsAPIInfo())
assert minimal_info_proto.status == "PENDING"
assert minimal_info_proto.entrypoint == "echo hi"
for unset_optional_field in [
"entrypoint_num_cpus",
"entrypoint_num_gpus",
"entrypoint_memory",
"runtime_env_json",
"error_type",
"driver_agent_http_address",
"driver_node_id",
]:
assert not minimal_info_proto.HasField(unset_optional_field)
def test_get_all_jobs_filters_out_none_job_info():
prefix = JobInfoStorageClient.JOB_DATA_KEY_PREFIX
mock_gcs = MagicMock()
mock_gcs.async_internal_kv_keys = AsyncMock(
return_value=[
(prefix + "job1").encode(),
(prefix + "job2").encode(),
]
)
storage = JobInfoStorageClient(mock_gcs)
job_info_1 = JobInfo(status=JobStatus.RUNNING, entrypoint="echo 1")
async def mock_get_info(job_id, timeout=30):
if job_id == "job1":
return job_info_1
return None
storage.get_info = mock_get_info
result = asyncio.run(storage.get_all_jobs())
assert result == {"job1": job_info_1}
for job_id, job_info in result.items():
asdict(job_info) # This should not raise an exception
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,177 @@
import json
import os
import pprint
import sys
import jsonschema
import pytest
import requests
from ray._common.test_utils import (
run_string_as_driver,
wait_for_condition,
)
from ray._private.test_utils import (
format_web_url,
run_string_as_driver_nonblocking,
)
from ray.dashboard import dashboard
from ray.dashboard.consts import RAY_CLUSTER_ACTIVITY_HOOK
from ray.dashboard.modules.job.job_head import RayActivityResponse
from ray.dashboard.tests.conftest import * # noqa
@pytest.fixture
def set_ray_cluster_activity_hook(request):
"""
Fixture that sets RAY_CLUSTER_ACTIVITY_HOOK environment variable
for test_e2e_component_activities_hook.
"""
external_hook = request.param
assert (
external_hook
), "Please pass value of RAY_CLUSTER_ACTIVITY_HOOK env var to this fixture"
old_hook = os.environ.get(RAY_CLUSTER_ACTIVITY_HOOK)
os.environ[RAY_CLUSTER_ACTIVITY_HOOK] = external_hook
yield external_hook
if old_hook is not None:
os.environ[RAY_CLUSTER_ACTIVITY_HOOK] = old_hook
else:
del os.environ[RAY_CLUSTER_ACTIVITY_HOOK]
@pytest.mark.parametrize(
"set_ray_cluster_activity_hook",
[
"ray._private.test_utils.external_ray_cluster_activity_hook1",
"ray._private.test_utils.external_ray_cluster_activity_hook2",
"ray._private.test_utils.external_ray_cluster_activity_hook3",
"ray._private.test_utils.external_ray_cluster_activity_hook4",
"ray._private.test_utils.external_ray_cluster_activity_hook5",
],
indirect=True,
)
def test_component_activities_hook(set_ray_cluster_activity_hook, call_ray_start):
"""
Tests /api/component_activities returns correctly for various
responses of RAY_CLUSTER_ACTIVITY_HOOK.
Verify no active drivers are correctly reflected in response.
"""
external_hook = set_ray_cluster_activity_hook
response = requests.get("http://127.0.0.1:8265/api/component_activities")
response.raise_for_status()
# Validate schema of response
data = response.json()
schema_path = os.path.join(
os.path.dirname(dashboard.__file__),
"modules/job/component_activities_schema.json",
)
pprint.pprint(data)
jsonschema.validate(instance=data, schema=json.load(open(schema_path)))
# Validate driver response can be cast to RayActivityResponse object
# and that there are no active drivers.
driver_ray_activity_response = RayActivityResponse(**data["driver"])
assert driver_ray_activity_response.is_active == "INACTIVE"
assert driver_ray_activity_response.reason is None
# Validate external component response can be cast to RayActivityResponse object
if external_hook[-1] == "5":
external_activity_response = RayActivityResponse(**data["test_component5"])
assert external_activity_response.is_active == "ACTIVE"
assert external_activity_response.reason == "Counter: 1"
elif external_hook[-1] == "4":
external_activity_response = RayActivityResponse(**data["external_component"])
assert external_activity_response.is_active == "ERROR"
assert (
"'Error in external cluster activity hook'"
in external_activity_response.reason
)
elif external_hook[-1] == "3":
external_activity_response = RayActivityResponse(**data["external_component"])
assert external_activity_response.is_active == "ERROR"
elif external_hook[-1] == "2":
external_activity_response = RayActivityResponse(**data["test_component2"])
assert external_activity_response.is_active == "ERROR"
elif external_hook[-1] == "1":
external_activity_response = RayActivityResponse(**data["test_component1"])
assert external_activity_response.is_active == "ACTIVE"
assert external_activity_response.reason == "Counter: 1"
# Call endpoint again to validate different response
response = requests.get("http://127.0.0.1:8265/api/component_activities")
response.raise_for_status()
data = response.json()
jsonschema.validate(instance=data, schema=json.load(open(schema_path)))
external_activity_response = RayActivityResponse(**data["test_component1"])
assert external_activity_response.is_active == "ACTIVE"
assert external_activity_response.reason == "Counter: 2"
def test_active_component_activities(ray_start_with_dashboard):
# Verify drivers which don't have namespace starting with _ray_internal_
# are considered active.
webui_url = ray_start_with_dashboard["webui_url"]
webui_url = format_web_url(webui_url)
driver_template = """
import ray
ray.init(address="auto", namespace="{namespace}")
import time
time.sleep({sleep_time_s})
"""
run_string_as_driver(
driver_template.format(namespace="my_namespace", sleep_time_s=0)
)
run_string_as_driver_nonblocking(
driver_template.format(namespace="my_namespace", sleep_time_s=5)
)
run_string_as_driver_nonblocking(
driver_template.format(namespace="_ray_internal_job_info_id1", sleep_time_s=5)
)
# Simulate the default driver that gets created by dashboard
run_string_as_driver_nonblocking(
driver_template.format(namespace="_ray_internal_dashboard", sleep_time_s=5)
)
def verify_driver_response():
# Verify drivers are considered active after running script
response = requests.get(f"{webui_url}/api/component_activities")
response.raise_for_status()
# Validate schema of response
data = response.json()
schema_path = os.path.join(
os.path.dirname(dashboard.__file__),
"modules/job/component_activities_schema.json",
)
jsonschema.validate(instance=data, schema=json.load(open(schema_path)))
# Validate ray_activity_response field can be cast to RayActivityResponse object
driver_ray_activity_response = RayActivityResponse(**data["driver"])
print(driver_ray_activity_response)
assert driver_ray_activity_response.is_active == "ACTIVE"
# Drivers with namespace starting with "_ray_internal" are not
# considered active drivers. Two active drivers are the second one
# run with namespace "my_namespace" and the one started
# from ray_start_with_dashboard
assert driver_ray_activity_response.reason == "Number of active drivers: 2"
return True
wait_for_condition(verify_driver_response)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,777 @@
import json
import logging
import os
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from typing import Optional
from unittest.mock import patch
import pytest
import requests
import yaml
import ray
from ray._common.test_utils import wait_for_condition
from ray._private.runtime_env.packaging import (
create_package,
download_and_unpack_package,
get_uri_for_file,
)
from ray._private.test_utils import (
chdir,
format_web_url,
wait_until_server_available,
)
from ray.dashboard.modules.dashboard_sdk import ClusterInfo, parse_cluster_info
from ray.dashboard.modules.job.common import uri_to_http_components
from ray.dashboard.modules.job.pydantic_models import JobDetails
from ray.dashboard.modules.job.tests.test_cli_integration import set_env_var
from ray.dashboard.modules.version import CURRENT_VERSION
from ray.dashboard.tests.conftest import * # noqa
from ray.job_submission import JobStatus, JobSubmissionClient
from ray.runtime_env.runtime_env import RuntimeEnv, RuntimeEnvConfig
from ray.tests.conftest import _ray_start
# This test requires you have AWS credentials set up (any AWS credentials will
# do, this test only accesses a public bucket).
logger = logging.getLogger(__name__)
DRIVER_SCRIPT_DIR = os.path.join(os.path.dirname(__file__), "subprocess_driver_scripts")
@pytest.fixture(scope="module")
def headers():
return {"Connection": "keep-alive", "Authorization": "TOK:<MY_TOKEN>"}
@pytest.fixture(scope="module")
def ray_start_context():
with _ray_start(include_dashboard=True, num_cpus=1) as ctx:
yield ctx
@pytest.fixture(scope="module")
def job_sdk_client(headers, ray_start_context) -> JobSubmissionClient:
address = ray_start_context.address_info["webui_url"]
assert wait_until_server_available(address)
yield JobSubmissionClient(format_web_url(address), headers=headers)
@pytest.fixture
def shutdown_only():
yield None
# The code after the yield will run as teardown code.
ray.shutdown()
def test_submit_job_with_resources(shutdown_only):
ctx = ray.init(
include_dashboard=True,
num_cpus=1,
num_gpus=1,
resources={"Custom": 1},
dashboard_port=8269,
_memory=4,
)
address = ctx.address_info["webui_url"]
client = JobSubmissionClient(format_web_url(address))
# Check the case of too many resources.
for kwargs in [
{"entrypoint_num_cpus": 2},
{"entrypoint_num_gpus": 2},
{"entrypoint_memory": 4},
{"entrypoint_resources": {"Custom": 2}},
]:
job_id = client.submit_job(entrypoint="echo hello", **kwargs)
data = client.get_job_info(job_id)
assert "waiting for resources" in data.message
# Check the case of sufficient resources.
job_id = client.submit_job(
entrypoint="echo hello",
entrypoint_num_cpus=1,
entrypoint_num_gpus=1,
entrypoint_memory=4,
entrypoint_resources={"Custom": 1},
)
wait_for_condition(_check_job_succeeded, client=client, job_id=job_id, timeout=10)
@pytest.mark.parametrize("use_sdk", [True, False])
def test_list_jobs_empty(headers, use_sdk: bool):
# Create a cluster using `ray start` instead of `ray.init` to avoid creating a job
subprocess.check_output(["ray", "start", "--head"])
address = "http://127.0.0.1:8265"
try:
with set_env_var("RAY_ADDRESS", address):
client = JobSubmissionClient(format_web_url(address), headers=headers)
if use_sdk:
assert client.list_jobs() == []
else:
r = client._do_request(
"GET",
"/api/jobs/",
)
assert r.status_code == 200
assert json.loads(r.text) == []
finally:
subprocess.check_output(["ray", "stop", "--force"])
@pytest.mark.parametrize("use_sdk", [True, False])
def test_list_jobs(job_sdk_client: JobSubmissionClient, use_sdk: bool):
client = job_sdk_client
runtime_env = {"env_vars": {"TEST": "123"}}
metadata = {"foo": "bar"}
entrypoint = "echo hello"
submission_id = client.submit_job(
entrypoint=entrypoint, runtime_env=runtime_env, metadata=metadata
)
wait_for_condition(_check_job_succeeded, client=client, job_id=submission_id)
if use_sdk:
info: JobDetails = next(
job_info
for job_info in client.list_jobs()
if job_info.submission_id == submission_id
)
else:
r = client._do_request(
"GET",
"/api/jobs/",
)
assert r.status_code == 200
jobs_info_json = json.loads(r.text)
info_json = next(
job_info
for job_info in jobs_info_json
if job_info["submission_id"] == submission_id
)
info = JobDetails(**info_json)
assert info.entrypoint == entrypoint
assert info.status == JobStatus.SUCCEEDED
assert info.message is not None
assert info.end_time >= info.start_time
assert info.runtime_env == runtime_env
assert info.metadata == metadata
# Test get job status by job / driver id
status = client.get_job_status(info.submission_id)
assert status == JobStatus.SUCCEEDED
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}, info: {client.get_job_info(job_id)}"
)
assert status == JobStatus.SUCCEEDED
return True
def _check_job_failed(client: JobSubmissionClient, job_id: str) -> bool:
status = client.get_job_status(job_id)
return status == JobStatus.FAILED
def _check_job_stopped(client: JobSubmissionClient, job_id: str) -> bool:
status = client.get_job_status(job_id)
return status == JobStatus.STOPPED
@pytest.fixture(
scope="module",
params=[
"no_working_dir",
"local_working_dir",
"s3_working_dir",
"local_py_modules",
"working_dir_and_local_py_modules_whl",
"local_working_dir_zip",
"pip_txt",
"conda_yaml",
"local_py_modules",
],
)
def runtime_env_option(request):
import_in_task_script = """
import ray
ray.init(address="auto")
@ray.remote
def f():
import pip_install_test
ray.get(f.remote())
"""
if request.param == "no_working_dir":
yield {
"runtime_env": {},
"entrypoint": "echo hello",
"expected_logs": "hello\n",
}
elif request.param in {
"local_working_dir",
"local_working_dir_zip",
"local_py_modules",
"working_dir_and_local_py_modules_whl",
}:
with tempfile.TemporaryDirectory() as tmp_dir:
path = Path(tmp_dir)
hello_file = path / "test.py"
with hello_file.open(mode="w") as f:
f.write("from test_module import run_test\n")
f.write("print(run_test())")
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 run_test():\n")
f.write(" return 'Hello from test_module!'\n") # noqa: Q000
init_file = module_path / "__init__.py"
with init_file.open(mode="w") as f:
f.write("from test_module.test import run_test\n")
if request.param == "local_working_dir":
yield {
"runtime_env": {"working_dir": tmp_dir},
"entrypoint": "python test.py",
"expected_logs": "Hello from test_module!\n",
}
elif request.param == "local_working_dir_zip":
local_zipped_dir = shutil.make_archive(
os.path.join(tmp_dir, "test"), "zip", tmp_dir
)
yield {
"runtime_env": {"working_dir": local_zipped_dir},
"entrypoint": "python test.py",
"expected_logs": "Hello from test_module!\n",
}
elif request.param == "local_py_modules":
yield {
"runtime_env": {"py_modules": [str(Path(tmp_dir) / "test_module")]},
"entrypoint": (
"python -c 'import test_module;print(test_module.run_test())'"
),
"expected_logs": "Hello from test_module!\n",
}
elif request.param == "working_dir_and_local_py_modules_whl":
yield {
"runtime_env": {
"working_dir": "s3://runtime-env-test/script_runtime_env.zip",
"py_modules": [
Path(os.path.dirname(__file__))
/ "pip_install_test-0.5-py3-none-any.whl"
],
},
"entrypoint": (
"python script.py && python -c 'import pip_install_test'"
),
"expected_logs": (
"Executing main() from script.py !!\n"
"Good job! You installed a pip module."
),
}
else:
raise ValueError(f"Unexpected pytest fixture option {request.param}")
elif request.param == "s3_working_dir":
yield {
"runtime_env": {
"working_dir": "s3://runtime-env-test/script_runtime_env.zip",
},
"entrypoint": "python script.py",
"expected_logs": "Executing main() from script.py !!\n",
}
elif request.param == "pip_txt":
with tempfile.TemporaryDirectory() as tmpdir, chdir(tmpdir):
pip_list = ["pip-install-test==0.5"]
relative_filepath = "requirements.txt"
pip_file = Path(relative_filepath)
pip_file.write_text("\n".join(pip_list))
runtime_env = {"pip": {"packages": relative_filepath, "pip_check": False}}
yield {
"runtime_env": runtime_env,
"entrypoint": (
f"python -c 'import pip_install_test' && "
f"python -c '{import_in_task_script}'"
),
"expected_logs": "Good job! You installed a pip module.",
}
elif request.param == "conda_yaml":
with tempfile.TemporaryDirectory() as tmpdir, chdir(tmpdir):
conda_dict = {"dependencies": ["pip", {"pip": ["pip-install-test==0.5"]}]}
relative_filepath = "environment.yml"
conda_file = Path(relative_filepath)
conda_file.write_text(yaml.dump(conda_dict))
runtime_env = {"conda": relative_filepath}
yield {
"runtime_env": runtime_env,
"entrypoint": f"python -c '{import_in_task_script}'",
# TODO(architkulkarni): Uncomment after #22968 is fixed.
# "entrypoint": "python -c 'import pip_install_test'",
"expected_logs": "Good job! You installed a pip module.",
}
else:
assert False, f"Unrecognized option: {request.param}."
def test_submit_job(job_sdk_client, runtime_env_option, monkeypatch):
# This flag allows for local testing of runtime env conda functionality
# without needing a built Ray wheel. Rather than insert the link to the
# wheel into the conda spec, it links to the current Python site.
monkeypatch.setenv("RAY_RUNTIME_ENV_LOCAL_DEV_MODE", "1")
client = job_sdk_client
job_id = client.submit_job(
entrypoint=runtime_env_option["entrypoint"],
runtime_env=runtime_env_option["runtime_env"],
)
try:
job_start_time = time.time()
wait_for_condition(
_check_job_succeeded, client=client, job_id=job_id, timeout=300
)
job_duration = time.time() - job_start_time
print(f"The job took {job_duration}s to succeed.")
except RuntimeError as e:
# If the job is still pending, include job logs and info in error.
if client.get_job_status(job_id) == JobStatus.PENDING:
logs = client.get_job_logs(job_id)
info = client.get_job_info(job_id)
raise RuntimeError(
f"Job was stuck in PENDING.\nLogs: {logs}\nInfo: {info}"
) from e
logs = client.get_job_logs(job_id)
assert runtime_env_option["expected_logs"] in logs
def test_timeout(job_sdk_client):
client = job_sdk_client
job_id = client.submit_job(
entrypoint="echo hello",
# Assume pip packages take > 1s to download, or this test will spuriously fail.
runtime_env=RuntimeEnv(
pip={
"packages": ["tensorflow", "requests", "botocore", "torch"],
"pip_check": False,
"pip_version": "==23.3.2;python_version=='3.9.16'",
},
config=RuntimeEnvConfig(setup_timeout_seconds=1),
),
)
wait_for_condition(_check_job_failed, client=client, job_id=job_id, timeout=10)
data = client.get_job_info(job_id)
assert "Failed to set up runtime environment" in data.message
assert "Timeout" in data.message
assert "setup_timeout_seconds" in data.message
def test_per_task_runtime_env(job_sdk_client: JobSubmissionClient):
run_cmd = "python per_task_runtime_env.py"
job_id = job_sdk_client.submit_job(
entrypoint=run_cmd,
runtime_env={"working_dir": DRIVER_SCRIPT_DIR},
)
wait_for_condition(_check_job_succeeded, client=job_sdk_client, job_id=job_id)
def test_ray_tune_basic(job_sdk_client: JobSubmissionClient):
run_cmd = "python ray_tune_basic.py"
job_id = job_sdk_client.submit_job(
entrypoint=run_cmd,
runtime_env={"working_dir": DRIVER_SCRIPT_DIR},
)
wait_for_condition(
_check_job_succeeded, timeout=30, client=job_sdk_client, job_id=job_id
)
def test_http_bad_request(job_sdk_client):
"""
Send bad requests to job http server and ensure right return code and
error message is returned via http.
"""
client = job_sdk_client
# 400 - HTTPBadRequest
r = client._do_request(
"POST",
"/api/jobs/",
json_data={"key": "baaaad request"},
)
assert r.status_code == 400
assert "__init__() got an unexpected keyword argument" in r.text
def test_invalid_runtime_env(job_sdk_client):
client = job_sdk_client
with pytest.raises(ValueError, match="Only .zip, .tar.gz, and .tgz files"):
client.submit_job(
entrypoint="echo hello", runtime_env={"working_dir": "s3://not_a_zip"}
)
def test_runtime_env_setup_failure(job_sdk_client):
client = job_sdk_client
job_id = client.submit_job(
entrypoint="echo hello", runtime_env={"working_dir": "s3://does_not_exist.zip"}
)
wait_for_condition(_check_job_failed, client=client, job_id=job_id)
data = client.get_job_info(job_id)
assert "Failed to set up runtime environment" in data.message
def test_submit_job_with_exception_in_driver(job_sdk_client):
"""
Submit a job that's expected to throw exception while executing.
"""
client = job_sdk_client
with tempfile.TemporaryDirectory() as tmp_dir:
path = Path(tmp_dir)
driver_script = """
print('Hello !')
raise RuntimeError('Intentionally failed.')
"""
test_script_file = path / "test_script.py"
with open(test_script_file, "w+") as file:
file.write(driver_script)
job_id = client.submit_job(
entrypoint="python test_script.py", runtime_env={"working_dir": tmp_dir}
)
wait_for_condition(_check_job_failed, client=client, job_id=job_id)
logs = client.get_job_logs(job_id)
assert "Hello !" in logs
assert "RuntimeError: Intentionally failed." in logs
def test_stop_long_running_job(job_sdk_client):
"""
Submit a job that runs for a while and stop it in the middle.
"""
client = job_sdk_client
with tempfile.TemporaryDirectory() as tmp_dir:
path = Path(tmp_dir)
driver_script = """
print('Hello !')
import time
time.sleep(300) # This should never finish
raise RuntimeError('Intentionally failed.')
"""
test_script_file = path / "test_script.py"
with open(test_script_file, "w+") as file:
file.write(driver_script)
job_id = client.submit_job(
entrypoint="python test_script.py", runtime_env={"working_dir": tmp_dir}
)
assert client.stop_job(job_id) is True
wait_for_condition(_check_job_stopped, client=client, job_id=job_id)
def test_delete_job(job_sdk_client, capsys):
"""
Submit a job and delete it.
"""
client: JobSubmissionClient = job_sdk_client
job_id = client.submit_job(entrypoint="sleep 300 && echo hello")
with pytest.raises(Exception, match="but it is in a non-terminal state"):
# This should fail because the job is not in a terminal state.
client.delete_job(job_id)
# Check that the job appears in list_jobs
jobs = client.list_jobs()
assert job_id in [job.submission_id for job in jobs]
finished_job_id = client.submit_job(entrypoint="echo hello")
wait_for_condition(_check_job_succeeded, client=client, job_id=finished_job_id)
deleted = client.delete_job(finished_job_id)
assert deleted is True
# Check that the job no longer appears in list_jobs
jobs = client.list_jobs()
assert finished_job_id not in [job.submission_id for job in jobs]
def test_job_metadata(job_sdk_client):
client = job_sdk_client
print_metadata_cmd = (
'python -c"'
"import ray;"
"ray.init();"
"job_config=ray._private.worker.global_worker.core_worker.get_job_config();"
"print(dict(sorted(job_config.metadata.items())))"
'"'
)
job_id = client.submit_job(
entrypoint=print_metadata_cmd, metadata={"key1": "val1", "key2": "val2"}
)
wait_for_condition(_check_job_succeeded, client=client, job_id=job_id)
assert str(
{
"job_name": job_id,
"job_submission_id": job_id,
"key1": "val1",
"key2": "val2",
}
) in client.get_job_logs(job_id)
def test_pass_job_id(job_sdk_client):
client = job_sdk_client
job_id = "my_custom_id"
returned_id = client.submit_job(entrypoint="echo hello", job_id=job_id)
assert returned_id == job_id
wait_for_condition(_check_job_succeeded, client=client, job_id=returned_id)
# Test that a duplicate job_id is rejected.
with pytest.raises(Exception, match=f"{job_id} already exists"):
returned_id = client.submit_job(entrypoint="echo hello", job_id=job_id)
def test_nonexistent_job(job_sdk_client):
client = job_sdk_client
with pytest.raises(RuntimeError, match="nonexistent_job does not exist"):
client.get_job_status("nonexistent_job")
def test_submit_optional_args(job_sdk_client):
"""Check that job_id, runtime_env, and metadata are optional."""
client = job_sdk_client
r = client._do_request(
"POST",
"/api/jobs/",
json_data={"entrypoint": "ls"},
)
wait_for_condition(
_check_job_succeeded, client=client, job_id=r.json()["submission_id"]
)
def test_submit_still_accepts_job_id_or_submission_id(job_sdk_client):
"""Check that job_id, runtime_env, and metadata are optional."""
client = job_sdk_client
client._do_request(
"POST",
"/api/jobs/",
json_data={"entrypoint": "ls", "job_id": "raysubmit_12345"},
)
wait_for_condition(_check_job_succeeded, client=client, job_id="raysubmit_12345")
client._do_request(
"POST",
"/api/jobs/",
json_data={"entrypoint": "ls", "submission_id": "raysubmit_23456"},
)
wait_for_condition(_check_job_succeeded, client=client, job_id="raysubmit_23456")
def test_missing_resources(job_sdk_client):
"""Check that 404s are raised for resources that don't exist."""
client = job_sdk_client
conditions = [
("GET", "/api/jobs/fake_job_id"),
("GET", "/api/jobs/fake_job_id/logs"),
("POST", "/api/jobs/fake_job_id/stop"),
("GET", "/api/packages/fake_package_uri"),
]
for method, route in conditions:
assert client._do_request(method, route).status_code == 404
def test_version_endpoint(job_sdk_client):
client = job_sdk_client
r = client._do_request("GET", "/api/version")
assert r.status_code == 200
body = r.json()
assert body == {
"version": CURRENT_VERSION,
"ray_version": ray.__version__,
"ray_commit": ray.__commit__,
"session_name": body["session_name"],
}
def test_request_headers(job_sdk_client):
client = job_sdk_client
with patch("requests.request") as mock_request:
_ = client._do_request(
"POST",
"/api/jobs/",
json_data={"entrypoint": "ls"},
)
mock_request.assert_called_with(
"POST",
"http://127.0.0.1:8265/api/jobs/",
cookies=None,
data=None,
json={"entrypoint": "ls"},
headers={"Connection": "keep-alive", "Authorization": "TOK:<MY_TOKEN>"},
verify=True,
)
@pytest.mark.parametrize("scheme", ["http", "https", "fake_module"])
@pytest.mark.parametrize("host", ["127.0.0.1", "localhost", "fake.dns.name"])
@pytest.mark.parametrize("port", [None, 8265, 10000])
def test_parse_cluster_info(scheme: str, host: str, port: Optional[int]):
address = f"{scheme}://{host}"
if port is not None:
address += f":{port}"
if scheme in {"http", "https"}:
assert parse_cluster_info(address, False) == ClusterInfo(
address=address,
cookies=None,
metadata=None,
headers=None,
)
else:
with pytest.raises(RuntimeError):
parse_cluster_info(address, False)
@pytest.mark.asyncio
async def test_tail_job_logs(job_sdk_client):
client = job_sdk_client
with tempfile.TemporaryDirectory() as tmp_dir:
path = Path(tmp_dir)
driver_script = """
import time
for i in range(100):
print("Hello", i)
time.sleep(0.1)
"""
test_script_file = path / "test_script.py"
with open(test_script_file, "w+") as f:
f.write(driver_script)
job_id = client.submit_job(
entrypoint="python test_script.py", runtime_env={"working_dir": tmp_dir}
)
st = time.time()
while time.time() - st <= 10:
try:
i = 0
async for lines in client.tail_job_logs(job_id):
print(lines, end="")
for line in lines.strip().split("\n"):
assert line.split(" ") == ["Hello", str(i)]
i += 1
except Exception as ex:
print("Exception:", ex)
wait_for_condition(_check_job_succeeded, client=client, job_id=job_id)
def _hook(env):
with open(env["env_vars"]["TEMPPATH"], "w+") as f:
f.write(env["env_vars"]["TOKEN"])
return env
def test_jobs_env_hook(job_sdk_client: JobSubmissionClient):
client = job_sdk_client
_, path = tempfile.mkstemp()
runtime_env = {"env_vars": {"TEMPPATH": path, "TOKEN": "Ray rocks!"}}
run_job_script = """
import os
import ray
os.environ["RAY_RUNTIME_ENV_HOOK"] =\
"ray.dashboard.modules.job.tests.test_http_job_server._hook"
ray.init(address="auto")
"""
entrypoint = f"python -c '{run_job_script}'"
job_id = client.submit_job(entrypoint=entrypoint, runtime_env=runtime_env)
wait_for_condition(_check_job_succeeded, client=client, job_id=job_id)
with open(path) as f:
assert f.read().strip() == "Ray rocks!"
@pytest.mark.asyncio
async def test_get_upload_package(ray_start_context, tmp_path):
assert wait_until_server_available(ray_start_context["webui_url"])
webui_url = format_web_url(ray_start_context["webui_url"])
gcs_client = ray._private.worker.global_worker.gcs_client
url = webui_url + "/api/packages/{protocol}/{package_name}"
pkg_dir = tmp_path / "pkg"
pkg_dir.mkdir()
filename = "task.py"
file_content = b"Hello world"
with (pkg_dir / filename).open("wb") as f:
f.write(file_content)
package_uri = get_uri_for_file(str(pkg_dir / filename))
protocol, package_name = uri_to_http_components(package_uri)
package_file = tmp_path / package_name
create_package(str(pkg_dir), package_file, include_gitignore=True)
resp = requests.get(url.format(protocol=protocol, package_name=package_name))
assert resp.status_code == 404
resp = requests.put(
url.format(protocol=protocol, package_name=package_name),
data=package_file.read_bytes(),
)
assert resp.status_code == 200
resp = requests.get(url.format(protocol=protocol, package_name=package_name))
assert resp.status_code == 200
await download_and_unpack_package(package_uri, str(tmp_path), gcs_client)
assert (package_file.with_suffix("") / filename).read_bytes() == file_content
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,47 @@
import ssl
import sys
import pytest
import trustme
import ray
from ray.job_submission import JobSubmissionClient
@pytest.fixture(scope="session")
def ca():
return trustme.CA()
@pytest.fixture(scope="session")
def httpserver_ssl_context(ca):
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
localhost_cert = ca.issue_cert("localhost")
localhost_cert.configure_cert(context)
return context
@pytest.fixture(scope="session")
def httpclient_ssl_context(ca):
with ca.cert_pem.tempfile() as ca_temp_path:
return ssl.create_default_context(cafile=ca_temp_path)
def test_mock_https_connection(httpserver, ca):
"""Test connections to a mock HTTPS job submission server."""
httpserver.expect_request("/api/version").respond_with_json(
{"ray_version": ray.__version__}
)
mock_url = httpserver.url_for("/")
# Connection without SSL certificate should fail
with pytest.raises(ConnectionError):
JobSubmissionClient(mock_url)
# Connecton with SSL verification skipped should succeed
JobSubmissionClient(mock_url, verify=False)
# Connection with SSL verification should succeed
with ca.cert_pem.tempfile() as ca_temp_path:
JobSubmissionClient(mock_url, verify=ca_temp_path)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,692 @@
import logging
import os
import shutil
import sys
import tempfile
import time
from functools import partial
from pathlib import Path
import pytest
import pytest_asyncio
import requests
import yaml
import ray
from ray._common.network_utils import build_address
from ray._common.test_utils import async_wait_for_condition, wait_for_condition
from ray._common.utils import get_or_create_event_loop
from ray._private.ray_constants import DEFAULT_DASHBOARD_AGENT_LISTEN_PORT
from ray._private.runtime_env.py_modules import upload_py_modules_if_needed
from ray._private.runtime_env.working_dir import upload_working_dir_if_needed
from ray._private.test_utils import (
chdir,
format_web_url,
get_current_unused_port,
run_string_as_driver_nonblocking,
wait_until_server_available,
)
from ray.dashboard.modules.job.common import (
JOB_ACTOR_NAME_TEMPLATE,
SUPERVISOR_ACTOR_RAY_NAMESPACE,
JobSubmitRequest,
validate_request_type,
)
from ray.dashboard.modules.job.job_head import JobAgentSubmissionClient
from ray.dashboard.tests.conftest import * # noqa
from ray.job_submission import JobStatus, JobSubmissionClient
from ray.runtime_env.runtime_env import RuntimeEnv, RuntimeEnvConfig
from ray.tests.conftest import _ray_start
from ray.util.state import get_node, list_actors, list_nodes
# This test requires you have AWS credentials set up (any AWS credentials will
# do, this test only accesses a public bucket).
logger = logging.getLogger(__name__)
DRIVER_SCRIPT_DIR = os.path.join(os.path.dirname(__file__), "subprocess_driver_scripts")
EVENT_LOOP = get_or_create_event_loop()
def get_node_id_for_supervisor_actor_for_job(
address: str, job_submission_id: str
) -> str:
actors = list_actors(
address=address,
filters=[("ray_namespace", "=", SUPERVISOR_ACTOR_RAY_NAMESPACE)],
)
for actor in actors:
if actor.name == JOB_ACTOR_NAME_TEMPLATE.format(job_id=job_submission_id):
return actor.node_id
raise ValueError(f"actor not found for job_submission_id {job_submission_id}")
def get_node_ip_by_id(node_id: str) -> str:
node = get_node(id=node_id)
return node.node_ip
class JobAgentSubmissionBrowserClient(JobAgentSubmissionClient):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._session.headers[
"User-Agent"
] = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" # noqa: E501
@pytest_asyncio.fixture
async def job_sdk_client(make_sure_dashboard_http_port_unused):
with _ray_start(include_dashboard=True, num_cpus=1) as ctx:
node_ip = ctx.address_info["node_ip_address"]
agent_address = build_address(node_ip, DEFAULT_DASHBOARD_AGENT_LISTEN_PORT)
assert wait_until_server_available(agent_address)
head_address = ctx.address_info["webui_url"]
assert wait_until_server_available(head_address)
yield (
JobAgentSubmissionClient(format_web_url(agent_address)),
JobSubmissionClient(format_web_url(head_address)),
)
def _check_job(
client: JobSubmissionClient, job_id: str, status: JobStatus, timeout: int = 10
) -> bool:
res_status = client.get_job_status(job_id)
assert res_status == status
return True
@pytest.fixture(
scope="module",
params=[
"no_working_dir",
"local_working_dir",
"s3_working_dir",
"local_py_modules",
"working_dir_and_local_py_modules_whl",
"local_working_dir_zip",
"pip_txt",
"conda_yaml",
"local_py_modules",
],
)
def runtime_env_option(request):
import_in_task_script = """
import ray
ray.init(address="auto")
@ray.remote
def f():
import pip_install_test
ray.get(f.remote())
"""
if request.param == "no_working_dir":
yield {
"runtime_env": {},
"entrypoint": "echo hello",
"expected_logs": "hello\n",
}
elif request.param in {
"local_working_dir",
"local_working_dir_zip",
"local_py_modules",
"working_dir_and_local_py_modules_whl",
}:
with tempfile.TemporaryDirectory() as tmp_dir:
path = Path(tmp_dir)
hello_file = path / "test.py"
with hello_file.open(mode="w") as f:
f.write("from test_module import run_test\n")
f.write("print(run_test())")
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 run_test():\n")
f.write(" return 'Hello from test_module!'\n") # noqa: Q000
init_file = module_path / "__init__.py"
with init_file.open(mode="w") as f:
f.write("from test_module.test import run_test\n")
if request.param == "local_working_dir":
yield {
"runtime_env": {"working_dir": tmp_dir},
"entrypoint": "python test.py",
"expected_logs": "Hello from test_module!\n",
}
elif request.param == "local_working_dir_zip":
local_zipped_dir = shutil.make_archive(
os.path.join(tmp_dir, "test"), "zip", tmp_dir
)
yield {
"runtime_env": {"working_dir": local_zipped_dir},
"entrypoint": "python test.py",
"expected_logs": "Hello from test_module!\n",
}
elif request.param == "local_py_modules":
yield {
"runtime_env": {"py_modules": [str(Path(tmp_dir) / "test_module")]},
"entrypoint": (
"python -c 'import test_module;print(test_module.run_test())'"
),
"expected_logs": "Hello from test_module!\n",
}
elif request.param == "working_dir_and_local_py_modules_whl":
yield {
"runtime_env": {
"working_dir": "s3://runtime-env-test/script_runtime_env.zip",
"py_modules": [
Path(os.path.dirname(__file__))
/ "pip_install_test-0.5-py3-none-any.whl"
],
},
"entrypoint": (
"python script.py && python -c 'import pip_install_test'"
),
"expected_logs": (
"Executing main() from script.py !!\n"
"Good job! You installed a pip module."
),
}
else:
raise ValueError(f"Unexpected pytest fixture option {request.param}")
elif request.param == "s3_working_dir":
yield {
"runtime_env": {
"working_dir": "s3://runtime-env-test/script_runtime_env.zip",
},
"entrypoint": "python script.py",
"expected_logs": "Executing main() from script.py !!\n",
}
elif request.param == "pip_txt":
with tempfile.TemporaryDirectory() as tmpdir, chdir(tmpdir):
pip_list = ["pip-install-test==0.5"]
relative_filepath = "requirements.txt"
pip_file = Path(relative_filepath)
pip_file.write_text("\n".join(pip_list))
runtime_env = {"pip": {"packages": relative_filepath, "pip_check": False}}
yield {
"runtime_env": runtime_env,
"entrypoint": (
f"python -c 'import pip_install_test' && "
f"python -c '{import_in_task_script}'"
),
"expected_logs": "Good job! You installed a pip module.",
}
elif request.param == "conda_yaml":
with tempfile.TemporaryDirectory() as tmpdir, chdir(tmpdir):
conda_dict = {"dependencies": ["pip", {"pip": ["pip-install-test==0.5"]}]}
relative_filepath = "environment.yml"
conda_file = Path(relative_filepath)
conda_file.write_text(yaml.dump(conda_dict))
runtime_env = {"conda": relative_filepath}
yield {
"runtime_env": runtime_env,
"entrypoint": f"python -c '{import_in_task_script}'",
# TODO(architkulkarni): Uncomment after #22968 is fixed.
# "entrypoint": "python -c 'import pip_install_test'",
"expected_logs": "Good job! You installed a pip module.",
}
else:
assert False, f"Unrecognized option: {request.param}."
@pytest.mark.asyncio
async def test_submit_job(job_sdk_client, runtime_env_option, monkeypatch):
# This flag allows for local testing of runtime env conda functionality
# without needing a built Ray wheel. Rather than insert the link to the
# wheel into the conda spec, it links to the current Python site.
monkeypatch.setenv("RAY_RUNTIME_ENV_LOCAL_DEV_MODE", "1")
agent_client, head_client = job_sdk_client
runtime_env = runtime_env_option["runtime_env"]
runtime_env = upload_working_dir_if_needed(
runtime_env, include_gitignore=True, logger=logger
)
runtime_env = upload_py_modules_if_needed(
runtime_env, include_gitignore=True, logger=logger
)
runtime_env = RuntimeEnv(**runtime_env_option["runtime_env"]).to_dict()
request = validate_request_type(
{"runtime_env": runtime_env, "entrypoint": runtime_env_option["entrypoint"]},
JobSubmitRequest,
)
submit_result = await agent_client.submit_job_internal(request)
job_id = submit_result.submission_id
try:
job_start_time = time.time()
wait_for_condition(
partial(
_check_job,
client=head_client,
job_id=job_id,
status=JobStatus.SUCCEEDED,
),
timeout=300,
)
job_duration = time.time() - job_start_time
print(f"The job took {job_duration}s to succeed.")
except RuntimeError as e:
# If the job is still pending, include job logs and info in error.
if head_client.get_job_status(job_id) == JobStatus.PENDING:
logs = head_client.get_job_logs(job_id)
info = head_client.get_job_info(job_id)
raise RuntimeError(
f"Job was stuck in PENDING.\nLogs: {logs}\nInfo: {info}"
) from e
# There is only one node, so there is no need to replace the client of the JobAgent
resp = await agent_client.get_job_logs_internal(job_id)
assert runtime_env_option["expected_logs"] in resp.logs
@pytest.mark.asyncio
async def test_submit_job_rejects_browsers(
job_sdk_client, runtime_env_option, monkeypatch
):
# This flag allows for local testing of runtime env conda functionality
# without needing a built Ray wheel. Rather than insert the link to the
# wheel into the conda spec, it links to the current Python site.
monkeypatch.setenv("RAY_RUNTIME_ENV_LOCAL_DEV_MODE", "1")
agent_client, head_client = job_sdk_client
agent_address = agent_client._agent_address
agent_client = JobAgentSubmissionBrowserClient(agent_address)
runtime_env = runtime_env_option["runtime_env"]
runtime_env = upload_working_dir_if_needed(
runtime_env, include_gitignore=True, logger=logger
)
runtime_env = upload_py_modules_if_needed(
runtime_env, include_gitignore=True, logger=logger
)
runtime_env = RuntimeEnv(**runtime_env_option["runtime_env"]).to_dict()
request = validate_request_type(
{"runtime_env": runtime_env, "entrypoint": runtime_env_option["entrypoint"]},
JobSubmitRequest,
)
with pytest.raises(RuntimeError) as exc:
_ = await agent_client.submit_job_internal(request)
assert "status code 403" in str(exc.value)
@pytest.mark.asyncio
async def test_delete_job_rejects_browsers(job_sdk_client, monkeypatch):
"""Test that DELETE job requests from browsers are rejected."""
monkeypatch.setenv("RAY_RUNTIME_ENV_LOCAL_DEV_MODE", "1")
agent_client, head_client = job_sdk_client
# First, submit a job using the normal client
runtime_env = RuntimeEnv().to_dict()
request = validate_request_type(
{"runtime_env": runtime_env, "entrypoint": "echo hello"},
JobSubmitRequest,
)
submit_result = await agent_client.submit_job_internal(request)
job_id = submit_result.submission_id
# Now try to delete the job using browser-like headers
agent_address = agent_client._agent_address
browser_client = JobAgentSubmissionBrowserClient(agent_address)
with pytest.raises(RuntimeError) as exc:
_ = await browser_client.delete_job_internal(job_id)
assert "status code 403" in str(exc.value)
await browser_client.close()
@pytest.mark.asyncio
async def test_timeout(job_sdk_client):
agent_client, head_client = job_sdk_client
runtime_env = RuntimeEnv(
pip={
"packages": ["tensorflow", "requests", "botocore", "torch"],
"pip_check": False,
"pip_version": "==23.3.2;python_version=='3.9.16'",
},
config=RuntimeEnvConfig(setup_timeout_seconds=1),
).to_dict()
request = validate_request_type(
{"runtime_env": runtime_env, "entrypoint": "echo hello"},
JobSubmitRequest,
)
submit_result = await agent_client.submit_job_internal(request)
job_id = submit_result.submission_id
wait_for_condition(
partial(_check_job, client=head_client, job_id=job_id, status=JobStatus.FAILED),
timeout=10,
)
data = head_client.get_job_info(job_id)
assert "Failed to set up runtime environment" in data.message
assert "Timeout" in data.message
assert "setup_timeout_seconds" in data.message
@pytest.mark.asyncio
async def test_runtime_env_setup_failure(job_sdk_client):
agent_client, head_client = job_sdk_client
runtime_env = RuntimeEnv(working_dir="s3://does_not_exist.zip").to_dict()
request = validate_request_type(
{"runtime_env": runtime_env, "entrypoint": "echo hello"},
JobSubmitRequest,
)
submit_result = await agent_client.submit_job_internal(request)
job_id = submit_result.submission_id
wait_for_condition(
partial(_check_job, client=head_client, job_id=job_id, status=JobStatus.FAILED),
timeout=10,
)
data = head_client.get_job_info(job_id)
assert "Failed to set up runtime environment" in data.message
@pytest.mark.asyncio
async def test_stop_long_running_job(job_sdk_client):
"""
Submit a job that runs for a while and stop it in the middle.
"""
agent_client, head_client = job_sdk_client
with tempfile.TemporaryDirectory() as tmp_dir:
path = Path(tmp_dir)
driver_script = """
print('Hello !')
import time
time.sleep(300) # This should never finish
raise RuntimeError('Intentionally failed.')
"""
test_script_file = path / "test_script.py"
with open(test_script_file, "w+") as file:
file.write(driver_script)
runtime_env = {"working_dir": tmp_dir}
runtime_env = upload_working_dir_if_needed(
runtime_env, include_gitignore=True, scratch_dir=tmp_dir, logger=logger
)
runtime_env = RuntimeEnv(**runtime_env).to_dict()
request = validate_request_type(
{"runtime_env": runtime_env, "entrypoint": "python test_script.py"},
JobSubmitRequest,
)
submit_result = await agent_client.submit_job_internal(request)
job_id = submit_result.submission_id
resp = await agent_client.stop_job_internal(job_id)
assert resp.stopped is True
wait_for_condition(
partial(
_check_job, client=head_client, job_id=job_id, status=JobStatus.STOPPED
),
timeout=10,
)
@pytest.mark.asyncio
async def test_tail_job_logs_with_echo(job_sdk_client):
agent_client, head_client = job_sdk_client
runtime_env = RuntimeEnv().to_dict()
entrypoint = "python -c \"import time; [(print('Hello', i), time.sleep(0.1)) for i in range(100)]\"" # noqa: E501
request = validate_request_type(
{
"runtime_env": runtime_env,
"entrypoint": entrypoint,
},
JobSubmitRequest,
)
submit_result = await agent_client.submit_job_internal(request)
job_id = submit_result.submission_id
i = 0
async for lines in agent_client.tail_job_logs(job_id):
print(lines, end="")
for line in lines.strip().split("\n"):
if (
"Runtime env is setting up." in line
or "Running entrypoint for job" in line
):
continue
assert line.split(" ") == ["Hello", str(i)]
i += 1
wait_for_condition(
partial(
_check_job, client=head_client, job_id=job_id, status=JobStatus.SUCCEEDED
),
timeout=10,
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"ray_start_cluster_head",
[
{
"include_dashboard": True,
"dashboard_agent_listen_port": DEFAULT_DASHBOARD_AGENT_LISTEN_PORT,
}
],
indirect=True,
)
async def test_job_log_in_multiple_node(
make_sure_dashboard_http_port_unused,
enable_test_module,
disable_aiohttp_cache,
ray_start_cluster_head,
):
cluster = ray_start_cluster_head
assert wait_until_server_available(cluster.webui_url) is True
webui_url = cluster.webui_url
webui_url = format_web_url(webui_url)
cluster.add_node(
dashboard_agent_listen_port=DEFAULT_DASHBOARD_AGENT_LISTEN_PORT + 1
)
cluster.add_node(
dashboard_agent_listen_port=DEFAULT_DASHBOARD_AGENT_LISTEN_PORT + 2
)
node_ip = cluster.head_node.node_ip_address
agent_address = build_address(node_ip, DEFAULT_DASHBOARD_AGENT_LISTEN_PORT)
assert wait_until_server_available(agent_address)
client = JobAgentSubmissionClient(format_web_url(agent_address))
def _check_nodes():
try:
assert len(list_nodes()) == 3
return True
except Exception as ex:
logger.info(ex)
return False
wait_for_condition(_check_nodes, timeout=15)
job_ids = []
job_check_status = []
JOB_NUM = 10
job_agent_ports = [
DEFAULT_DASHBOARD_AGENT_LISTEN_PORT,
DEFAULT_DASHBOARD_AGENT_LISTEN_PORT + 1,
DEFAULT_DASHBOARD_AGENT_LISTEN_PORT + 2,
]
for index in range(JOB_NUM):
runtime_env = RuntimeEnv().to_dict()
request = validate_request_type(
{
"runtime_env": runtime_env,
"entrypoint": f"while true; do echo hello index-{index}"
" && sleep 3600; done",
},
JobSubmitRequest,
)
submit_result = await client.submit_job_internal(request)
job_ids.append(submit_result.submission_id)
job_check_status.append(False)
async def _check_all_jobs_log():
response = requests.get(webui_url + "/nodes?view=summary")
response.raise_for_status()
summary = response.json()
assert summary["result"] is True, summary["msg"]
summary = summary["data"]["summary"]
for index, job_id in enumerate(job_ids):
if job_check_status[index]:
continue
result_log = f"hello index-{index}"
# Try to get the node id which supervisor actor running in.
node_id = get_node_id_for_supervisor_actor_for_job(cluster.address, job_id)
for node_info in summary:
if node_info["raylet"]["nodeId"] == node_id:
break
assert node_info["raylet"]["nodeId"] == node_id, f"node id: {node_id}"
# Try to get the agent HTTP port by node id.
for agent_port in job_agent_ports:
if f"--listen-port={agent_port}" in " ".join(node_info["cmdline"]):
break
assert f"--listen-port={agent_port}" in " ".join(
node_info["cmdline"]
), f"port: {agent_port}"
# Finally, we got the whole agent address, and try to get the job log.
ip = get_node_ip_by_id(node_id)
agent_address = f"{ip}:{agent_port}"
assert wait_until_server_available(agent_address)
client = JobAgentSubmissionClient(format_web_url(agent_address))
resp = await client.get_job_logs_internal(job_id)
assert result_log in resp.logs, f"logs: {resp.logs}"
job_check_status[index] = True
return True
st = time.time()
while time.time() - st <= 30:
try:
await _check_all_jobs_log()
break
except Exception as ex:
print("error:", ex)
time.sleep(1)
assert all(job_check_status), job_check_status
def test_agent_logs_not_streamed_to_drivers():
"""Ensure when the job submission is used,
(ray.init is called from an agent), the agent logs are
not streamed to drivers.
Related: https://github.com/ray-project/ray/issues/29944
"""
script = """
import ray
from ray.job_submission import JobSubmissionClient, JobStatus
from ray._private.test_utils import format_web_url
from ray._common.test_utils import wait_for_condition
ray.init()
address = ray._private.worker._global_node.webui_url
address = format_web_url(address)
client = JobSubmissionClient(address)
submission_id = client.submit_job(entrypoint="ls")
wait_for_condition(
lambda: client.get_job_status(submission_id) == JobStatus.SUCCEEDED
)
"""
proc = run_string_as_driver_nonblocking(script)
out_str = proc.stdout.read().decode("ascii")
err_str = proc.stderr.read().decode("ascii")
print(out_str, err_str)
assert "(raylet)" not in out_str
assert "(raylet)" not in err_str
@pytest.mark.asyncio
async def test_non_default_dashboard_agent_http_port(tmp_path):
"""Test that we can connect to the dashboard agent with a non-default
http port.
"""
import subprocess
dashboard_agent_port = get_current_unused_port()
cmd = f"ray start --head --dashboard-agent-listen-port {dashboard_agent_port}"
subprocess.check_output(cmd, shell=True)
try:
# We will need to wait for the ray to be started in the subprocess.
address_info = ray.init("auto", ignore_reinit_error=True).address_info
node_ip = address_info["node_ip_address"]
dashboard_agent_listen_port = address_info["dashboard_agent_listen_port"]
agent_address = build_address(node_ip, dashboard_agent_listen_port)
print("agent address = ", agent_address)
agent_client = JobAgentSubmissionClient(format_web_url(agent_address))
head_client = JobSubmissionClient(format_web_url(address_info["webui_url"]))
assert wait_until_server_available(agent_address)
# Submit a job through the agent.
runtime_env = RuntimeEnv().to_dict()
request = validate_request_type(
{
"runtime_env": runtime_env,
"entrypoint": "echo hello",
},
JobSubmitRequest,
)
submit_result = await agent_client.submit_job_internal(request)
job_id = submit_result.submission_id
async def verify():
# Wait for job finished.
wait_for_condition(
partial(
_check_job,
client=head_client,
job_id=job_id,
status=JobStatus.SUCCEEDED,
),
timeout=10,
)
resp = await agent_client.get_job_logs_internal(job_id)
assert "hello" in resp.logs, resp.logs
return True
await async_wait_for_condition(verify, retry_interval_ms=2000)
finally:
subprocess.check_output("ray stop --force", shell=True)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,182 @@
import json
import sys
import time
import pytest
from ray.cluster_utils import Cluster
from ray.dashboard.consts import RAY_STREAM_RUNTIME_ENV_LOG_TO_JOB_DRIVER_LOG_ENV_VAR
from ray.dashboard.modules.job.tests.conftest import _driver_script_path
from ray.dashboard.modules.job.tests.subprocess_driver_scripts.driver_runtime_env_inheritance import ( # noqa: E501
RUNTIME_ENV_LOG_LINE_PREFIX,
)
from ray.job_submission import JobStatus, JobSubmissionClient
def wait_until_status(client, job_id, status_to_wait_for, timeout_seconds=20):
start = time.time()
while time.time() - start <= timeout_seconds:
status = client.get_job_status(job_id)
print(f"status: {status}")
if status in status_to_wait_for:
return
time.sleep(1)
raise Exception
def wait(client, job_id):
wait_until_status(
client,
job_id,
{JobStatus.SUCCEEDED, JobStatus.STOPPED, JobStatus.FAILED},
timeout_seconds=60,
)
def get_runtime_env_from_logs(client, job_id):
wait(client, job_id)
logs = client.get_job_logs(job_id)
print(logs)
assert client.get_job_status(job_id) == JobStatus.SUCCEEDED
# Split logs by line, find the unique line that starts with
# RUNTIME_ENV_LOG_LINE_PREFIX, strip it and parse it as JSON.
lines = logs.strip().split("\n")
assert len(lines) > 0
for line in lines:
if line.startswith(RUNTIME_ENV_LOG_LINE_PREFIX):
return json.loads(line[len(RUNTIME_ENV_LOG_LINE_PREFIX) :])
def test_job_driver_inheritance():
try:
c = Cluster()
c.add_node(num_cpus=1)
# If using a remote cluster, replace 127.0.0.1 with the head node's IP address.
client = JobSubmissionClient("http://127.0.0.1:8265")
driver_script_path = _driver_script_path("driver_runtime_env_inheritance.py")
job_id = client.submit_job(
entrypoint=f"python {driver_script_path}",
runtime_env={
"env_vars": {"A": "1", "B": "2"},
"pip": ["requests"],
},
)
# Test key is merged
print("Test key merged")
runtime_env = get_runtime_env_from_logs(client, job_id)
assert runtime_env["env_vars"] == {"A": "1", "B": "2", "C": "1"}
assert runtime_env["pip"] == {"packages": ["requests"], "pip_check": False}
# Test worker process setuphook works.
print("Test key setup hook")
expected_str = "HELLOWORLD"
job_id = client.submit_job(
entrypoint=(
f"python {driver_script_path} "
f"--worker-process-setup-hook {expected_str}"
),
runtime_env={
"env_vars": {"A": "1", "B": "2"},
},
)
wait(client, job_id)
logs = client.get_job_logs(job_id)
assert expected_str in logs
# Test raise an exception upon key conflict
print("Test conflicting pip")
job_id = client.submit_job(
entrypoint=f"python {driver_script_path} --conflict=pip",
runtime_env={"pip": ["numpy"]},
)
wait(client, job_id)
status = client.get_job_status(job_id)
logs = client.get_job_logs(job_id)
assert status == JobStatus.FAILED
assert "Failed to merge the Job's runtime env" in logs
# Test raise an exception upon env var conflict
print("Test conflicting env vars")
job_id = client.submit_job(
entrypoint=f"python {driver_script_path} --conflict=env_vars",
runtime_env={
"env_vars": {"A": "1"},
},
)
wait(client, job_id)
status = client.get_job_status(job_id)
logs = client.get_job_logs(job_id)
assert status == JobStatus.FAILED
assert "Failed to merge the Job's runtime env" in logs
finally:
c.shutdown()
@pytest.mark.parametrize("stream_runtime_env_log", ["1", "0"])
def test_runtime_env_logs_streamed_to_job_driver_log(
monkeypatch, stream_runtime_env_log
):
monkeypatch.setenv(
RAY_STREAM_RUNTIME_ENV_LOG_TO_JOB_DRIVER_LOG_ENV_VAR, stream_runtime_env_log
)
try:
c = Cluster()
c.add_node(num_cpus=1)
client = JobSubmissionClient("http://127.0.0.1:8265")
job_id = client.submit_job(
entrypoint="echo hello world",
runtime_env={"pip": ["requests==2.25.1"]},
)
wait(client, job_id)
logs = client.get_job_logs(job_id)
if stream_runtime_env_log == "0":
assert "Creating virtualenv at" not in logs
else:
assert "Creating virtualenv at" in logs
finally:
c.shutdown()
def test_job_driver_inheritance_override(monkeypatch):
monkeypatch.setenv("RAY_OVERRIDE_JOB_RUNTIME_ENV", "1")
try:
c = Cluster()
c.add_node(num_cpus=1)
# If using a remote cluster, replace 127.0.0.1 with the head node's IP address.
client = JobSubmissionClient("http://127.0.0.1:8265")
driver_script_path = _driver_script_path("driver_runtime_env_inheritance.py")
job_id = client.submit_job(
entrypoint=f"python {driver_script_path}",
runtime_env={
"env_vars": {"A": "1", "B": "2"},
"pip": ["requests"],
},
)
# Test conflict resolution regular field
job_id = client.submit_job(
entrypoint=f"python {driver_script_path} --conflict=pip",
runtime_env={"pip": ["pip-install-test==0.5"]},
)
runtime_env = get_runtime_env_from_logs(client, job_id)
print(runtime_env)
assert runtime_env["pip"] == {"packages": ["numpy"], "pip_check": False}
# Test raise an exception upon env var conflict
job_id = client.submit_job(
entrypoint=f"python {driver_script_path} --conflict=env_vars",
runtime_env={
"env_vars": {"A": "2"},
},
)
runtime_env = get_runtime_env_from_logs(client, job_id)
print(runtime_env)
assert runtime_env["env_vars"]["A"] == "1"
finally:
c.shutdown()
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,73 @@
import sys
import pytest
from ray._common.test_utils import async_wait_for_condition
from ray.dashboard.modules.job.tests.conftest import (
_driver_script_path,
create_job_manager,
create_ray_cluster,
)
from ray.dashboard.modules.job.tests.test_job_manager import check_job_succeeded
@pytest.mark.asyncio
class TestRuntimeEnvStandalone:
"""NOTE: PLEASE READ CAREFULLY BEFORE MODIFYING
This test is extracted into a standalone module such that it can bootstrap its own
(standalone) Ray cluster while avoiding affecting the shared one used by other
JobManager tests
"""
@pytest.mark.parametrize(
"tracing_enabled",
[
False,
# TODO(issues/38633): local code loading is broken when tracing is enabled
# True,
],
)
async def test_user_provided_job_config_honored_by_worker(
self, tracing_enabled, tmp_path
):
"""Ensures that the JobConfig instance injected into ray.init in the driver
script is honored even in case when job is submitted via JobManager.submit_job
API (involving RAY_JOB_CONFIG_JSON_ENV_VAR being set in child process env)
"""
if tracing_enabled:
tracing_startup_hook = (
"ray.util.tracing.setup_local_tmp_tracing:setup_tracing"
)
else:
tracing_startup_hook = None
with create_ray_cluster(_tracing_startup_hook=tracing_startup_hook) as cluster:
job_manager = create_job_manager(cluster, tmp_path)
driver_script_path = _driver_script_path(
"check_code_search_path_is_propagated.py"
)
job_id = await job_manager.submit_job(
entrypoint=f"python {driver_script_path}",
# NOTE: We inject runtime_env in here, but also specify the JobConfig in
# the driver script: settings to JobConfig (other than the
# runtime_env) passed in via ray.init(...) have to be respected
# along with the runtime_env passed from submit_job API
runtime_env={"env_vars": {"TEST_SUBPROCESS_RANDOM_VAR": "0xDEEDDEED"}},
)
await async_wait_for_condition(
check_job_succeeded, job_manager=job_manager, job_id=job_id
)
logs = job_manager.get_job_logs(job_id)
assert "Code search path is propagated" in logs, logs
assert "0xDEEDDEED" in logs, logs
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,435 @@
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__]))
@@ -0,0 +1,303 @@
import os
import sys
from tempfile import NamedTemporaryFile
import pytest
from ray.dashboard.modules.job.common import JobSubmitRequest
from ray.dashboard.modules.job.utils import (
fast_tail_last_n_lines,
file_tail_iterator,
parse_and_validate_request,
redact_url_password,
strip_keys_with_value_none,
)
# Polyfill anext() function for Python 3.9 compatibility
# May raise StopAsyncIteration.
async def anext_polyfill(iterator):
return await iterator.__anext__()
# Use the built-in anext() for Python 3.10+, otherwise use our polyfilled function
if sys.version_info < (3, 10):
anext = anext_polyfill
@pytest.fixture
def tmp():
with NamedTemporaryFile() as f:
yield f.name
def test_strip_keys_with_value_none():
d = {"a": 1, "b": None, "c": 3}
assert strip_keys_with_value_none(d) == {"a": 1, "c": 3}
d = {"a": 1, "b": 2, "c": 3}
assert strip_keys_with_value_none(d) == d
d = {"a": 1, "b": None, "c": None}
assert strip_keys_with_value_none(d) == {"a": 1}
def test_redact_url_password():
url = "http://user:password@host:port"
assert redact_url_password(url) == "http://user:<redacted>@host:port"
url = "http://user:password@host:port?query=1"
assert redact_url_password(url) == "http://user:<redacted>@host:port?query=1"
url = "http://user:password@host:port?query=1&password=2"
assert (
redact_url_password(url)
== "http://user:<redacted>@host:port?query=1&password=2"
)
url = "https://user:password@127.0.0.1:8080"
assert redact_url_password(url) == "https://user:<redacted>@127.0.0.1:8080"
url = "https://user:password@host:port?query=1"
assert redact_url_password(url) == "https://user:<redacted>@host:port?query=1"
url = "https://user:password@host:port?query=1&password=2"
assert (
redact_url_password(url)
== "https://user:<redacted>@host:port?query=1&password=2"
)
# Mock for aiohttp.web.Request, which should not be constructed directly.
class MockRequest:
def __init__(self, **kwargs):
self._json = kwargs
async def json(self):
return self._json
@pytest.mark.asyncio
async def test_mock_request():
request = MockRequest(a=1, b=2)
assert await request.json() == {"a": 1, "b": 2}
request = MockRequest(a=1, b=None)
assert await request.json() == {"a": 1, "b": None}
# async test
@pytest.mark.asyncio
class TestParseAndValidateRequest:
async def test_basic(self):
request = MockRequest(entrypoint="echo hi")
expected = JobSubmitRequest(entrypoint="echo hi")
assert await parse_and_validate_request(request, JobSubmitRequest) == expected
async def test_forward_compatibility(self):
request = MockRequest(entrypoint="echo hi", new_client_field=None)
expected = JobSubmitRequest(entrypoint="echo hi")
assert await parse_and_validate_request(request, JobSubmitRequest) == expected
class TestIterLine:
@pytest.mark.asyncio
async def test_invalid_type(self):
with pytest.raises(TypeError, match="path must be a string"):
await anext(file_tail_iterator(1))
@pytest.mark.asyncio
async def test_file_not_created(self, tmp):
it = file_tail_iterator(tmp)
assert await anext(it) is None
f = open(tmp, "w")
f.write("hi\n")
f.flush()
assert await anext(it) is not None
@pytest.mark.asyncio
async def test_wait_for_newline(self, tmp):
it = file_tail_iterator(tmp)
assert await anext(it) is None
f = open(tmp, "w")
f.write("no_newline_yet")
assert await anext(it) is None
f.write("\n")
f.flush()
assert await anext(it) == ["no_newline_yet\n"]
@pytest.mark.asyncio
async def test_multiple_lines(self, tmp):
it = file_tail_iterator(tmp)
assert await anext(it) is None
f = open(tmp, "w")
num_lines = 10
for i in range(num_lines):
s = f"{i}\n"
f.write(s)
f.flush()
assert await anext(it) == [s]
assert await anext(it) is None
@pytest.mark.asyncio
async def test_batching(self, tmp):
it = file_tail_iterator(tmp)
assert await anext(it) is None
f = open(tmp, "w")
# Write lines in batches of 10, check that we get them back in batches.
for _ in range(100):
num_lines = 10
for i in range(num_lines):
f.write(f"{i}\n")
f.flush()
assert await anext(it) == [f"{i}\n" for i in range(10)]
assert await anext(it) is None
@pytest.mark.asyncio
async def test_max_line_batching(self, tmp):
it = file_tail_iterator(tmp)
assert await anext(it) is None
f = open(tmp, "w")
# Write lines in batches of 50, check that we get them back in batches of 10.
for _ in range(100):
num_lines = 50
for i in range(num_lines):
f.write(f"{i}\n")
f.flush()
assert await anext(it) == [f"{i}\n" for i in range(10)]
assert await anext(it) == [f"{i}\n" for i in range(10, 20)]
assert await anext(it) == [f"{i}\n" for i in range(20, 30)]
assert await anext(it) == [f"{i}\n" for i in range(30, 40)]
assert await anext(it) == [f"{i}\n" for i in range(40, 50)]
assert await anext(it) is None
@pytest.mark.asyncio
async def test_max_char_batching(self, tmp):
it = file_tail_iterator(tmp)
assert await anext(it) is None
f = open(tmp, "w")
# Write a single line that is 60k characters
f.write(f"{'1234567890' * 6000}\n")
# Write a 4 lines that are 10k characters each
for _ in range(4):
f.write(f"{'1234567890' * 500}\n")
f.flush()
# First line will come in a batch of its own
assert await anext(it) == [f"{'1234567890' * 6000}\n"]
# Other 4 lines will be batched together
assert (
await anext(it)
== [
f"{'1234567890' * 500}\n",
]
* 4
)
assert await anext(it) is None
@pytest.mark.asyncio
async def test_delete_file(self):
with NamedTemporaryFile() as tmp:
it = file_tail_iterator(tmp.name)
f = open(tmp.name, "w")
assert await anext(it) is None
f.write("hi\n")
f.flush()
assert await anext(it) == ["hi\n"]
# Calls should continue returning None after file deleted.
assert await anext(it) is None
class TestFastTailLastNLines:
def test_nonexistent_path(self, tmp):
missing = tmp + ".missing"
assert not os.path.exists(missing)
with pytest.raises(FileNotFoundError):
fast_tail_last_n_lines(missing, num_lines=10, max_chars=1000)
def test_basic_last_n(self, tmp):
# Write 100 lines, check that we get the last 10 lines.
with open(tmp, "w") as f:
for i in range(100):
f.write(f"line-{i}\n")
out = fast_tail_last_n_lines(tmp, num_lines=10, max_chars=1000)
expected = "".join([f"line-{i}\n" for i in range(90, 100)])
assert out == expected
def test_truncate_max_chars(self, tmp):
# Construct a log file with two lines, each over max_chars,
# check that we truncate to max_chars.
with open(tmp, "w") as f:
f.write("x" * 5000 + "\n")
f.write("y" * 5000 + "\n")
out = fast_tail_last_n_lines(tmp, num_lines=2, max_chars=3000)
assert len(out) == 3000
# Check that we truncate to max_chars, and include the last line.
assert out.endswith("\n")
def test_partial_last_line(self, tmp):
# Write a log file with a partial last line, check that we include it.
with open(tmp, "w") as f:
f.write("a\n")
f.write("b\n")
f.write("partial_last_line") # No newline at end
out = fast_tail_last_n_lines(tmp, num_lines=3, max_chars=1000)
assert out == "a\nb\npartial_last_line"
def test_small_block_size(self, tmp):
# Write 30 lines, check that we can read a small block size and get the last N lines.
with open(tmp, "w") as f:
for i in range(30):
f.write(f"{i}\n")
out = fast_tail_last_n_lines(tmp, num_lines=5, max_chars=1000, block_size=16)
expected = "".join([f"{i}\n" for i in range(25, 30)])
assert out == expected
def test_mixed_long_lines(self, tmp):
# Write a log file with a mix of short and long lines, check that we get the last N lines.
with open(tmp, "w") as f:
f.write("short-1\n")
f.write("short-2\n")
f.write("long-" + ("Z" * 10000) + "\n")
f.write("short-3\n")
f.write("short-4\n")
out = fast_tail_last_n_lines(tmp, num_lines=3, max_chars=20000)
# Check that we get the last 3 lines, including the long line.
assert out.splitlines()[-1] == "short-4"
assert out.splitlines()[-2] == "short-3"
assert out.splitlines()[-3].startswith("long-Z")
def test_sparse_large_file_tail_max_chars(self, tmp):
"""Simulate ~8 GiB sparse file tail and verify max_chars=20000 truncation."""
size_8g = 8 * 1024 * 1024 * 1024
# Build tail of two extremely long lines
tail = "\n" + ("Q" * 25000 + "\n") + ("R" * 25000 + "\n")
tail_bytes = tail.encode("utf-8")
print("Start writing sparse file tail...")
# Create a sparse file: seek to near EOF then write only the tail.
with open(tmp, "wb") as f:
f.seek(size_8g - len(tail_bytes))
f.write(tail_bytes)
f.flush()
print("Finish writing sparse file tail.")
out = fast_tail_last_n_lines(tmp, num_lines=2, max_chars=20000)
print("Finish reading sparse file tail.")
assert len(out) == 20000
assert out.endswith("\n")
assert "R" * 100 in out # sampling check for last line content
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
+361
View File
@@ -0,0 +1,361 @@
import asyncio
import dataclasses
import logging
import os
import re
import traceback
from dataclasses import dataclass
from typing import Any, AsyncIterator, Dict, List, Optional, Tuple, Union
from ray._raylet import RAY_INTERNAL_NAMESPACE_PREFIX, GcsClient
from ray.dashboard.modules.job.common import (
JOB_ID_METADATA_KEY,
JobInfoStorageClient,
JobStatus,
validate_request_type,
)
from ray.dashboard.modules.job.pydantic_models import DriverInfo, JobDetails, JobType
from ray.runtime_env import RuntimeEnv
try:
# package `aiohttp` is not in ray's minimal dependencies
import aiohttp
from aiohttp.web import Request, Response
except Exception:
aiohttp = None
Request = None
Response = None
logger = logging.getLogger(__name__)
MAX_CHUNK_LINE_LENGTH = 10
MAX_CHUNK_CHAR_LENGTH = 20000
def strip_keys_with_value_none(d: Dict[str, Any]) -> Dict[str, Any]:
"""Strip keys with value None from a dictionary."""
return {k: v for k, v in d.items() if v is not None}
def redact_url_password(url: str) -> str:
"""Redact any passwords in a URL."""
secret = re.findall(r"https?:\/\/.*:(.*)@.*", url)
if len(secret) > 0:
url = url.replace(f":{secret[0]}@", ":<redacted>@")
return url
async def file_tail_iterator(path: str) -> AsyncIterator[Optional[List[str]]]:
"""Yield lines from a file as it's written.
Returns lines in batches of up to 10 lines or 20000 characters,
whichever comes first. If it's a chunk of 20000 characters, then
the last line that is yielded could be an incomplete line.
New line characters are kept in the line string.
Returns None until the file exists or if no new line has been written.
"""
if not isinstance(path, str):
raise TypeError(f"path must be a string, got {type(path)}.")
while not os.path.exists(path):
logger.debug(f"Path {path} doesn't exist yet.")
yield None
EOF = ""
with open(path, "r") as f:
lines = []
chunk_char_count = 0
curr_line = None
while True:
# We want to flush current chunk in following cases:
# - We accumulated 10 lines
# - We accumulated at least MAX_CHUNK_CHAR_LENGTH total chars
# - We reached EOF
if (
len(lines) >= 10
or chunk_char_count > MAX_CHUNK_CHAR_LENGTH
or curr_line == EOF
):
# Too many lines, return 10 lines in this chunk, and then
# continue reading the file.
yield lines or None
lines = []
chunk_char_count = 0
# Read next line
curr_line = f.readline()
# `readline` will return
# - '' for EOF
# - '\n' for an empty line in the file
if curr_line != EOF:
# Add line to current chunk
lines.append(curr_line)
chunk_char_count += len(curr_line)
else:
# If EOF is reached sleep for 1s before continuing
await asyncio.sleep(1)
async def parse_and_validate_request(
req: Request, request_type: dataclass
) -> Union[dataclass, Response]:
"""Parse request and cast to request type.
Remove keys with value None to allow newer client versions with new optional fields
to work with older servers.
If parsing failed, return a Response object with status 400 and stacktrace instead.
Args:
req: aiohttp request object.
request_type: dataclass type to cast request to.
Returns:
Parsed request object or Response object with status 400 and stacktrace.
"""
import aiohttp
json_data = strip_keys_with_value_none(await req.json())
try:
return validate_request_type(json_data, request_type)
except Exception as e:
logger.info(f"Got invalid request type: {e}")
return Response(
text=traceback.format_exc(),
status=aiohttp.web.HTTPBadRequest.status_code,
)
async def get_driver_jobs(
gcs_client: GcsClient,
job_or_submission_id: Optional[str] = None,
timeout: Optional[int] = None,
) -> Tuple[Dict[str, JobDetails], Dict[str, DriverInfo]]:
"""Returns a tuple of dictionaries related to drivers.
The first dictionary contains all driver jobs and is keyed by the job's id.
The second dictionary contains drivers that belong to submission jobs.
It's keyed by the submission job's submission id.
Only the last driver of a submission job is returned.
An optional job_or_submission_id filter can be provided to only return
jobs with the job id or submission id.
"""
job_infos = await gcs_client.async_get_all_job_info(
job_or_submission_id=job_or_submission_id,
skip_submission_job_info_field=True,
skip_is_running_tasks_field=True,
timeout=timeout,
)
# Sort jobs from GCS to follow convention of returning only last driver
# of submission job.
sorted_job_infos = sorted(
job_infos.values(), key=lambda job_table_entry: job_table_entry.job_id.hex()
)
jobs = {}
submission_job_drivers = {}
for job_table_entry in sorted_job_infos:
if job_table_entry.config.ray_namespace.startswith(
RAY_INTERNAL_NAMESPACE_PREFIX
):
# Skip jobs in any _ray_internal_ namespace
continue
job_id = job_table_entry.job_id.hex()
metadata = dict(job_table_entry.config.metadata)
job_submission_id = metadata.get(JOB_ID_METADATA_KEY)
if not job_submission_id:
driver = DriverInfo(
id=job_id,
node_ip_address=job_table_entry.driver_address.ip_address,
pid=str(job_table_entry.driver_pid),
)
job = JobDetails(
job_id=job_id,
type=JobType.DRIVER,
status=JobStatus.SUCCEEDED
if job_table_entry.is_dead
else JobStatus.RUNNING,
entrypoint=job_table_entry.entrypoint,
start_time=job_table_entry.start_time,
end_time=job_table_entry.end_time,
metadata=metadata,
runtime_env=RuntimeEnv.deserialize(
job_table_entry.config.runtime_env_info.serialized_runtime_env
).to_dict(),
driver_info=driver,
)
jobs[job_id] = job
else:
driver = DriverInfo(
id=job_id,
node_ip_address=job_table_entry.driver_address.ip_address,
pid=str(job_table_entry.driver_pid),
)
submission_job_drivers[job_submission_id] = driver
return jobs, submission_job_drivers
async def find_job_by_ids(
gcs_client: GcsClient,
job_info_client: JobInfoStorageClient,
job_or_submission_id: str,
) -> Optional[JobDetails]:
"""
Attempts to find the job with a given submission_id or job id.
"""
# First try to find by job_id
driver_jobs, submission_job_drivers = await get_driver_jobs(
gcs_client, job_or_submission_id=job_or_submission_id
)
job = driver_jobs.get(job_or_submission_id)
if job:
return job
# Try to find a driver with the given id
submission_id = next(
(
id
for id, driver in submission_job_drivers.items()
if driver.id == job_or_submission_id
),
None,
)
if not submission_id:
# If we didn't find a driver with the given id,
# then lets try to search for a submission with given id
submission_id = job_or_submission_id
job_info = await job_info_client.get_info(submission_id)
if job_info:
driver = submission_job_drivers.get(submission_id)
job = JobDetails(
**dataclasses.asdict(job_info),
submission_id=submission_id,
job_id=driver.id if driver else None,
driver_info=driver,
type=JobType.SUBMISSION,
)
return job
return None
async def find_jobs_by_job_ids(
gcs_client: GcsClient,
job_info_client: JobInfoStorageClient,
job_ids: List[str],
) -> Dict[str, JobDetails]:
"""
Returns a dictionary of submission jobs with the given job ids, keyed by the job id.
This only accepts job ids and not submission ids.
"""
driver_jobs, submission_job_drivers = await get_driver_jobs(gcs_client)
# Filter down to the request job_ids
driver_jobs = {key: job for key, job in driver_jobs.items() if key in job_ids}
submission_job_drivers = {
key: job for key, job in submission_job_drivers.items() if job.id in job_ids
}
# Fetch job details for each job
job_submission_ids = submission_job_drivers.keys()
job_infos = await asyncio.gather(
*[
job_info_client.get_info(submission_id)
for submission_id in job_submission_ids
]
)
return {
**driver_jobs,
**{
submission_job_drivers.get(submission_id).id: JobDetails(
**dataclasses.asdict(job_info),
submission_id=submission_id,
job_id=submission_job_drivers.get(submission_id).id,
driver_info=submission_job_drivers.get(submission_id),
type=JobType.SUBMISSION,
)
for job_info, submission_id in zip(job_infos, job_submission_ids)
},
}
def fast_tail_last_n_lines(
path: str,
num_lines: int,
max_chars: int,
block_size: int = 8192,
) -> str:
"""Return the last ``num_lines`` lines from a large log file efficiently.
This function avoids scanning the entire file. It seeks to the end of
the file and reads backwards in fixed-size blocks until enough lines are
collected. This is much faster for large files compared to using
``file_tail_iterator()``, which performs a full sequential scan.
Args:
path: The file path to read.
num_lines: Number of lines to return.
max_chars: Maximum number of characters in the returned string.
block_size: Read size for each backward block.
Returns:
A string containing at most ``num_lines`` of the last lines in the file,
truncated to ``max_chars`` characters.
"""
if num_lines < 0:
raise ValueError(f"num_lines must be non-negative, got {num_lines}")
if num_lines == 0:
return ""
if max_chars < 0:
raise ValueError(f"max_chars must be non-negative, got {max_chars}")
if max_chars == 0:
return ""
if block_size <= 0:
raise ValueError(f"block_size must be positive, got {block_size}")
logger.debug(
f"Start reading log file {path} with num_lines={num_lines} max_chars={max_chars} block_size={block_size}"
)
with open(path, "rb") as f:
f.seek(0, os.SEEK_END)
file_size = f.tell()
if file_size == 0:
return ""
chunks = []
position = file_size
newlines_found = 0
# We read backwards in chunks until we have enough newlines for num_lines.
# We may need one more newline to capture the content before the first newline.
while position > 0 and newlines_found < num_lines + 1:
read_size = min(block_size, position)
position -= read_size
f.seek(position)
chunk = f.read(read_size)
newlines_found += chunk.count(b"\n")
chunks.insert(0, chunk)
buffer = b"".join(chunks)
lines = buffer.decode("utf-8", errors="replace").splitlines(keepends=True)
if len(lines) <= num_lines:
result = "".join(lines)
else:
result = "".join(lines[-num_lines:])
return result[-max_chars:]