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

362 lines
12 KiB
Python

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:]