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
@@ -0,0 +1,607 @@
"""
This script provides extra functionality for Anyscale Jobs tests.
It will be ran on the cluster.
We need to reimplement some utility functions here as it will not
have access to the ray_release package.
"""
import argparse
import json
import logging
import multiprocessing
import os
import subprocess
import sys
import time
from pathlib import Path
from typing import List, Optional, Tuple
from urllib.parse import urlparse
AZURE_STORAGE_ACCOUNT = "rayreleasetests"
OUTPUT_JSON_FILENAME = "output.json"
AWS_CP_TIMEOUT = 300
TIMEOUT_RETURN_CODE = 124 # same as bash timeout
# Prometheus metric type for idle worker evictions. We expect Ray to kill idle workers
# under memory pressure, so we exclude them from the OOM check.
IDLE_WORKER_EVICTION_METRIC_TYPE = "MemoryManager.IdleWorkerEviction.Total"
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
handler = logging.StreamHandler(stream=sys.stderr)
formatter = logging.Formatter(
fmt="[%(levelname)s %(asctime)s] %(filename)s: %(lineno)d %(message)s"
)
handler.setFormatter(formatter)
logger.addHandler(handler)
def exponential_backoff_retry(
f, retry_exceptions, initial_retry_delay_s, max_retries
) -> None:
retry_cnt = 0
retry_delay_s = initial_retry_delay_s
while True:
try:
return f()
except retry_exceptions as e:
retry_cnt += 1
if retry_cnt > max_retries:
raise
logger.warning(
f"Retry function call failed due to {e} "
f"in {retry_delay_s} seconds..."
)
time.sleep(retry_delay_s)
retry_delay_s *= 2
def run_storage_cp(source: str, target: str):
if not source or not target:
return False
if not Path(source).exists():
logger.warning(f"Couldn't upload to cloud storage: '{source}' does not exist.")
return False
storage_service = urlparse(target).scheme
if target.startswith(f"https://{AZURE_STORAGE_ACCOUNT}.dfs.core.windows.net"):
storage_service = "azure_blob"
cp_cmd_args = []
if storage_service == "s3":
cp_cmd_args = [
"aws",
"s3",
"cp",
source,
target,
"--acl",
"bucket-owner-full-control",
]
elif storage_service == "gs":
cp_cmd_args = [
"gcloud",
"storage",
"cp",
source,
target,
]
elif storage_service == "azure_blob":
subprocess.run(["azcopy", "login", "--identity"], check=True)
cp_cmd_args = [
"azcopy",
"copy",
source,
target,
]
else:
raise Exception(f"Not supporting storage service: {storage_service}")
try:
exponential_backoff_retry(
lambda: subprocess.run(
cp_cmd_args,
timeout=AWS_CP_TIMEOUT,
check=True,
),
subprocess.SubprocessError,
initial_retry_delay_s=10,
max_retries=3,
)
return True
except subprocess.SubprocessError:
logger.exception("Couldn't upload to cloud storage.")
return False
def collect_metrics(start_time: float, time_taken: float) -> bool:
if "METRICS_OUTPUT_JSON" not in os.environ:
return False
# Timeout is the time the test took divided by 200
# (~7 minutes for a 24h test) but no less than 90s
# and no more than 900s
metrics_timeout = max(90, min(time_taken / 200, 900))
logger.info(f"Collecting Prometheus metrics (timeout: {metrics_timeout:.0f}s).")
try:
subprocess.run(
[
"python",
"prometheus_metrics.py",
str(start_time),
"--path",
os.environ["METRICS_OUTPUT_JSON"],
],
timeout=metrics_timeout,
check=True,
)
logger.info("Metrics collection subprocess finished successfully.")
return True
# TimeoutExpired and CalledProcessError are SubprocessError subclasses, so
# they must be caught first to differentiate them in the logs.
except subprocess.TimeoutExpired:
logger.error(
f"Metrics collection TIMED OUT after {metrics_timeout:.0f}s. The metrics "
"file may be missing or incomplete. This is a metrics-collection timeout, "
"distinct from an actual metric/OOM/spill issue."
)
return False
except subprocess.CalledProcessError as e:
logger.error(
f"Metrics collection subprocess exited with non-zero return code "
f"{e.returncode}. See the prometheus_metrics.py output above for the "
"specific failure."
)
return False
except subprocess.SubprocessError:
logger.exception("Couldn't collect metrics due to an unexpected error.")
return False
# Has to be here so it can be pickled
def _run_bash_command_subprocess(command: str, timeout: float):
"""Ran in a multiprocessing process."""
try:
subprocess.run(command, check=True, timeout=timeout)
return_code = 0
except subprocess.TimeoutExpired:
return_code = TIMEOUT_RETURN_CODE
except subprocess.CalledProcessError as e:
return_code = e.returncode
print(f"Subprocess return code: {return_code}", file=sys.stderr)
# Exit so the return code is propagated to the outer process
sys.exit(return_code)
def run_bash_command(workload: str, timeout: float):
timeout = timeout if timeout > 0 else None
cwd = Path.cwd()
workload_path = cwd / "workload.sh"
workload_path = workload_path.resolve()
with open(workload_path, "w") as fp:
fp.write(workload)
command = ["bash", "-x", str(workload_path)]
logger.info(f"Running command {workload}")
# Pop job's runtime env to allow workload's runtime env to take precedence
# TODO: Confirm this is safe
os.environ.pop("RAY_JOB_CONFIG_JSON_ENV_VAR", None)
# We use multiprocessing with 'spawn' context to avoid
# forking (as happens when using subprocess directly).
# Forking messes up Ray interactions and causes deadlocks.
return_code = None
try:
ctx = multiprocessing.get_context("spawn")
p = ctx.Process(target=_run_bash_command_subprocess, args=(command, timeout))
p.start()
logger.info(f"Starting process {p.pid}.")
# Add a little extra to the timeout as _run_bash_command_subprocess
# also has a timeout internally and it's cleaner to use that
p.join(timeout=timeout + 10)
except multiprocessing.TimeoutError:
return_code = TIMEOUT_RETURN_CODE
except multiprocessing.ProcessError:
pass
finally:
if p.is_alive():
logger.warning(f"Terminating process {p.pid} forcefully.")
p.terminate()
if return_code is None:
return_code = p.exitcode
os.remove(str(workload_path))
logger.info(f"Process {p.pid} exited with return code {return_code}.")
assert return_code is not None
return return_code
def run_prepare_commands(
prepare_commands: List[str], prepare_commands_timeouts: List[float]
) -> Tuple[bool, List[int], float]:
"""Run prepare commands. All commands must pass. Fails fast."""
prepare_return_codes = []
prepare_passed = True
prepare_time_taken = None
if not prepare_commands:
return prepare_passed, prepare_return_codes, prepare_time_taken
logger.info("### Starting prepare commands ###")
for prepare_command, timeout in zip(prepare_commands, prepare_commands_timeouts):
command_start_time = time.monotonic()
prepare_return_codes.append(run_bash_command(prepare_command, timeout))
prepare_time_taken = time.monotonic() - command_start_time
return_code = prepare_return_codes[-1]
if return_code == 0:
continue
timed_out = return_code == TIMEOUT_RETURN_CODE
if timed_out:
logger.error(
"Prepare command timed out. " f"Time taken: {prepare_time_taken}"
)
else:
logger.info(
f"Prepare command finished with return code {return_code}. "
f"Time taken: {prepare_time_taken}"
)
logger.error("Prepare command failed.")
prepare_passed = False
break
return prepare_passed, prepare_return_codes, prepare_time_taken
def _load_metrics_for_check(check_name: str, env_var: str) -> Optional[dict]:
"""Load the Prometheus metrics file for a failure check.
Returns the parsed metrics dict, or ``None`` when the metrics could not be
obtained at all (file missing, unreadable, or an empty ``{}`` written
because every Prometheus query failed). In every ``None`` case this is a
metrics-collection/infra failure rather than an actual metric signal, and
the caller should treat it as such.
"""
metrics_path = os.environ.get("METRICS_OUTPUT_JSON", None)
if not (metrics_path and Path(metrics_path).exists()):
logger.error(
f"{check_name}: {env_var} is set to 1, but no metrics file was found "
f"at path: {metrics_path}. Metrics collection failed entirely."
)
return None
try:
with open(metrics_path, "r") as f:
metrics = json.load(f)
except (OSError, json.JSONDecodeError) as e:
logger.error(f"{check_name}: could not read metrics file {metrics_path}: {e}")
return None
if not isinstance(metrics, dict) or not metrics:
logger.error(
f"{check_name}: metrics file at {metrics_path} is empty. "
"See the prometheus_metrics.py output above for the cause."
)
return None
return metrics
def _metric_unavailable(check_name: str, metrics: dict, key: str) -> bool:
"""Return True if ``key`` could not be collected (missing or null).
Distinguishes a metrics-collection/infra failure (logged here) from an
actual metric signal, which the caller inspects when this returns False.
A ``None`` value means the Prometheus query failed; an empty list ``[]``
means the query succeeded but matched no series (i.e. a healthy result).
"""
if key not in metrics:
logger.error(
f"{check_name}: '{key}' is missing from the metrics file, likely a collection issue."
)
return True
if metrics[key] is None:
logger.error(
f"{check_name}: '{key}' is None, likely the Prometheus query failed "
"(timeout / connection error / non-200)"
)
return True
return False
def run_oom_check():
metrics = _load_metrics_for_check("OOM check", "RAYTEST_FAIL_ON_WORKER_OOM")
if metrics is None:
return 1
return_code = 0
if _metric_unavailable("OOM check", metrics, "worker_oom_kills"):
return_code = 1
else:
worker_oom_kills = _filter_idle_worker_kills(metrics["worker_oom_kills"])
if worker_oom_kills:
logger.error(
f"Test failed: OOM worker kills detected. Details: {worker_oom_kills}"
)
return_code = 1
if _metric_unavailable("OOM check", metrics, "unexpected_worker_failures"):
return_code = 1
elif metrics["unexpected_worker_failures"]:
logger.error(
"Test failed: Unexpected worker failures detected "
"(potential kernel OOM kills or SIGKILLs not captured by Ray's memory monitor). "
f"Details: {metrics['unexpected_worker_failures']}"
)
return_code = 1
return return_code
def _filter_idle_worker_kills(worker_oom_kills: list) -> list:
"""Drop idle-worker evictions from the worker OOM kill series.
Idle-worker evictions are expected behavior, so we exclude them and only keep task
and actor kills.
"""
return [
series
for series in worker_oom_kills
if series.get("metric", {}).get("Type") != IDLE_WORKER_EVICTION_METRIC_TYPE
]
def run_spilling_check():
metrics = _load_metrics_for_check("Spilling check", "RAYTEST_FAIL_ON_SPILLING")
if metrics is None:
return 1
return_code = 0
if _metric_unavailable("Spilling check", metrics, "spilled_bytes"):
return_code = 1
elif metrics["spilled_bytes"]:
logger.error(
"Test failed: unexpected object-store spilling detected. "
f"Details: {metrics['spilled_bytes']}"
)
return_code = 1
return return_code
def run_dead_node_check():
# Connect to the cluster and check for dead nodes
import ray
from ray.core.generated import common_pb2
return_code = 0
try:
ray.init(address="auto") # Connect to the local cluster
unexpected_termination = common_pb2.NodeDeathInfo.Reason.Value(
"UNEXPECTED_TERMINATION"
)
unspecified = common_pb2.NodeDeathInfo.Reason.Value("UNSPECIFIED")
dead_nodes = [
node["NodeID"]
for node in ray.nodes()
if not node["Alive"]
and node.get("DeathReason") in [unexpected_termination, unspecified]
]
if dead_nodes:
logger.error(f"Dead nodes found, node IDs: {dead_nodes}")
return_code = 1
except Exception as e:
logger.error(f"Error during dead node check: {e}")
return_code = 1
finally:
ray.shutdown() # Disconnect from the cluster
return return_code
def main(
test_workload: str,
test_workload_timeout: float,
test_no_raise_on_timeout: bool,
results_cloud_storage_uri: Optional[str],
metrics_cloud_storage_uri: Optional[str],
output_cloud_storage_uri: Optional[str],
upload_cloud_storage_uri: Optional[str],
artifact_path: Optional[str],
prepare_commands: List[str],
prepare_commands_timeouts: List[str],
):
"""
This function provides extra functionality for an Anyscale Job.
1. Runs prepare commands and handles their timeouts
2. Runs the actual test workload and handles its timeout
3. Uploads test results.json
4. Gathers prometheus metrics
5. Uploads prometheus metrics.json
6. Uploads output.json
"""
logger.info("### Starting ###")
start_time = time.monotonic()
if len(prepare_commands) != len(prepare_commands_timeouts):
raise ValueError(
"`prepare_commands` and `prepare_commands_timeouts` must "
"have the same length."
)
# Run prepare commands. All prepare commands must pass.
(
prepare_passed,
prepare_return_codes,
last_prepare_time_taken,
) = run_prepare_commands(prepare_commands, prepare_commands_timeouts)
uploaded_results = False
collected_metrics = False
uploaded_metrics = False
uploaded_artifact = artifact_path is not None
workload_time_taken = None
# If all prepare commands passed, run actual test workload.
if prepare_passed:
logger.info("### Starting entrypoint ###")
command_start_time = time.monotonic()
workload_start_unix_time = time.time()
return_code = run_bash_command(test_workload, test_workload_timeout)
workload_time_taken = time.monotonic() - command_start_time
timed_out = return_code == TIMEOUT_RETURN_CODE
if timed_out:
msg = f"Timed out. Time taken: {workload_time_taken}"
if test_no_raise_on_timeout:
logger.info(msg)
else:
logger.error(msg)
else:
logger.info(
f"Finished with return code {return_code}. "
f"Time taken: {workload_time_taken}"
)
test_fail_on_dead_nodes = os.environ.get("RAYTEST_FAIL_ON_DEAD_NODES") == "1"
if return_code == 0 and test_fail_on_dead_nodes:
return_code = run_dead_node_check()
# Upload results.json
uploaded_results = run_storage_cp(
os.environ.get("TEST_OUTPUT_JSON", None), results_cloud_storage_uri
)
# Collect prometheus metrics
collected_metrics = collect_metrics(
workload_start_unix_time, workload_time_taken
)
if collected_metrics:
# Upload prometheus metrics
uploaded_metrics = run_storage_cp(
os.environ.get("METRICS_OUTPUT_JSON", None), metrics_cloud_storage_uri
)
test_fail_on_worker_oom = os.environ.get("RAYTEST_FAIL_ON_WORKER_OOM") == "1"
# Fail if any OOM kills occurred
if return_code == 0 and test_fail_on_worker_oom:
return_code = run_oom_check()
test_fail_on_spilling = os.environ.get("RAYTEST_FAIL_ON_SPILLING") == "1"
# Fail if any object-store spilling occurred
if return_code == 0 and test_fail_on_spilling:
return_code = run_spilling_check()
uploaded_artifact = run_storage_cp(
artifact_path,
os.path.join(
upload_cloud_storage_uri, os.environ["USER_GENERATED_ARTIFACT"]
)
if "USER_GENERATED_ARTIFACT" in os.environ
else None,
)
else:
return_code = None
total_time_taken = time.monotonic() - start_time
output_json = {
"return_code": return_code,
"prepare_return_codes": prepare_return_codes,
"last_prepare_time_taken": last_prepare_time_taken,
"workload_time_taken": workload_time_taken,
"total_time_taken": total_time_taken,
"uploaded_results": uploaded_results,
"collected_metrics": collected_metrics,
"uploaded_metrics": uploaded_metrics,
"uploaded_artifact": uploaded_artifact,
}
output_json = json.dumps(
output_json, ensure_ascii=True, sort_keys=True, separators=(",", ":")
)
output_json_file = (Path.cwd() / OUTPUT_JSON_FILENAME).resolve()
with open(output_json_file, "w") as fp:
fp.write(output_json)
# Upload output.json
run_storage_cp(str(output_json_file), output_cloud_storage_uri)
logger.info("### Finished ###")
# This will be read by the AnyscaleJobRunner on the buildkite runner
# if output.json cannot be obtained from cloud storage
logger.info(f"### JSON |{output_json}| ###")
# Flush buffers
logging.shutdown()
print("", flush=True)
print("", file=sys.stderr, flush=True)
if return_code == TIMEOUT_RETURN_CODE and test_no_raise_on_timeout:
return_code = 0
elif return_code is None:
return_code = 1
time.sleep(1)
return return_code
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument(
"test_workload", type=str, help="test workload, eg. python workloads/script.py"
)
parser.add_argument(
"--test-workload-timeout",
default=3600,
type=float,
help="test workload timeout (set to <0 for infinite)",
)
parser.add_argument(
"--test-no-raise-on-timeout",
action="store_true",
help="don't fail on timeout",
)
parser.add_argument(
"--results-cloud-storage-uri",
type=str,
help="bucket address to upload results.json to",
required=False,
)
parser.add_argument(
"--metrics-cloud-storage-uri",
type=str,
help="bucket address to upload metrics.json to",
required=False,
)
parser.add_argument(
"--output-cloud-storage-uri",
type=str,
help="bucket address to upload output.json to",
required=False,
)
parser.add_argument(
"--upload-cloud-storage-uri",
type=str,
help="root cloud-storage bucket address to upload stuff",
required=False,
)
parser.add_argument(
"--artifact-path",
type=str,
help="user provided artifact path (on head node), must be a single file path",
required=False,
)
parser.add_argument(
"--prepare-commands", type=str, nargs="*", help="prepare commands to run"
)
parser.add_argument(
"--prepare-commands-timeouts",
default=3600,
type=float,
nargs="*",
help="timeout for prepare commands (set to <0 for infinite)",
)
args = parser.parse_args()
sys.exit(main(**args.__dict__))
@@ -0,0 +1,278 @@
import argparse
import asyncio
import json
import logging
import os
import time
import traceback
from typing import Optional
from urllib.parse import quote
import aiohttp
logger = logging.getLogger(__name__)
DEFAULT_PROMETHEUS_HOST = "http://localhost:9090"
PROMETHEUS_HOST_ENV_VAR = "RAY_PROMETHEUS_HOST"
RETRIES = 3
class PrometheusQueryError(Exception):
def __init__(self, status, message):
self.message = (
"Error fetching data from prometheus. "
f"status: {status}, message: {message}"
)
super().__init__(self.message)
class PrometheusClient:
def __init__(self) -> None:
self.http_session = aiohttp.ClientSession()
self.prometheus_host = os.environ.get(
PROMETHEUS_HOST_ENV_VAR, DEFAULT_PROMETHEUS_HOST
)
async def query_prometheus(self, query_type, **kwargs):
url = f"{self.prometheus_host}/api/v1/{query_type}?" + "&".join(
[f"{k}={quote(str(v), safe='')}" for k, v in kwargs.items()]
)
query_str = kwargs.get("query", url)
logger.debug(f"Running Prometheus query {url}")
last_error = None
for attempt in range(RETRIES):
try:
async with self.http_session.get(url) as resp:
if resp.status == 200:
prom_data = await resp.json()
return prom_data["data"]["result"]
body = (await resp.text())[:500]
last_error = f"non-200 status {resp.status}: {body}"
logger.warning(
f"Prometheus query returned non-200 status {resp.status} "
f"(attempt {attempt + 1}/{RETRIES}). Query: {query_str!r}. "
f"Body: {body}"
)
except asyncio.TimeoutError:
last_error = "request timed out"
logger.warning(
f"Prometheus query timed out "
f"(attempt {attempt + 1}/{RETRIES}). Query: {query_str!r}."
)
except aiohttp.ClientError as e:
last_error = f"connection error: {e}"
logger.warning(
f"Prometheus query connection error "
f"(attempt {attempt + 1}/{RETRIES}). Query: {query_str!r}. "
f"Error: {e}"
)
if attempt < RETRIES - 1:
await asyncio.sleep(1)
logger.error(
f"Prometheus query failed after {RETRIES} attempts and returned no data. "
f"Query: {query_str!r}. Last error: {last_error}. "
"This is a metrics-collection failure (Prometheus unreachable/erroring), "
"NOT an empty result for a healthy metric."
)
return None
async def close(self):
await self.http_session.close()
# Metrics here mirror what we have in Grafana.
async def _get_prometheus_metrics(
start_time: float, end_time: float, session_name: Optional[str] = None
) -> dict:
client = PrometheusClient()
kwargs = {
"query_type": "query_range",
"start": int(start_time),
"end": int(end_time),
"step": 15,
}
sf = f'{{SessionName="{session_name}"}}' if session_name else ""
sf_spilled = (
f'{{SessionName="{session_name}",State="Spilled"}}'
if session_name
else '{State="Spilled"}'
)
metrics = {
"cpu_utilization": client.query_prometheus(
query=f"ray_node_cpu_utilization{sf} * ray_node_cpu_count{sf} / 100",
**kwargs,
),
"cpu_count": client.query_prometheus(query=f"ray_node_cpu_count{sf}", **kwargs),
"gpu_utilization": client.query_prometheus(
query=f"ray_node_gpus_utilization{sf} / 100", **kwargs
),
"gpu_count": client.query_prometheus(
query=f"ray_node_gpus_available{sf}", **kwargs
),
"disk_usage": client.query_prometheus(
query=f"ray_node_disk_usage{sf}", **kwargs
),
"disk_space": client.query_prometheus(
query=f"sum(ray_node_disk_free{sf}) + sum(ray_node_disk_usage{sf})",
**kwargs,
),
"memory_usage": client.query_prometheus(
query=f"ray_node_mem_used{sf}", **kwargs
),
"total_memory": client.query_prometheus(
query=f"ray_node_mem_total{sf}", **kwargs
),
"memory_usage_host": client.query_prometheus(
query=f"ray_node_mem_used_host{sf}", **kwargs
),
"total_memory_host": client.query_prometheus(
query=f"ray_node_mem_total_host{sf}", **kwargs
),
"memory_usage_cgroup": client.query_prometheus(
query=f"ray_node_cgroup_mem_used{sf}", **kwargs
),
"total_memory_cgroup": client.query_prometheus(
query=f"ray_node_cgroup_mem_total{sf}", **kwargs
),
"gpu_memory_usage": client.query_prometheus(
query=f"ray_node_gram_used{sf} * 1024 * 1024", **kwargs
),
"gpu_total_memory": client.query_prometheus(
query=(
f"(sum(ray_node_gram_available{sf}) + sum(ray_node_gram_used{sf}))"
" * 1024 * 1024"
),
**kwargs,
),
"network_receive_speed": client.query_prometheus(
query=f"ray_node_network_receive_speed{sf}", **kwargs
),
"network_send_speed": client.query_prometheus(
query=f"ray_node_network_send_speed{sf}", **kwargs
),
"cluster_active_nodes": client.query_prometheus(
query=f"ray_cluster_active_nodes{sf}", **kwargs
),
"cluster_failed_nodes": client.query_prometheus(
query=f"ray_cluster_failed_nodes{sf}", **kwargs
),
"cluster_pending_nodes": client.query_prometheus(
query=f"ray_cluster_pending_nodes{sf}", **kwargs
),
"worker_oom_kills": client.query_prometheus(
query=(
f"sum(ray_memory_manager_worker_eviction_total{sf}) by (Type, Name)"
),
**kwargs,
),
"unexpected_worker_failures": client.query_prometheus(
query=f"sum(ray_node_manager_unexpected_worker_failure_total{sf}) by (Type, Name)",
**kwargs,
),
# `State="Spilled"` is the cumulative-bytes counter (the other States
# are point-in-time / transient); `> 0` drops always-emitted 0 points.
"spilled_bytes": client.query_prometheus(
query=f"sum(ray_spill_manager_objects_bytes{sf_spilled}) > 0",
**kwargs,
),
}
metrics = {k: await v for k, v in metrics.items()}
await client.close()
# Summarise the outcome so a glance at the logs tells whether the metrics
# are trustworthy. `None` => the query failed to collect (infra/timeout);
# `[]` => collected fine but no matching series (e.g. no OOMs happened);
# truthy => collected data.
failed = sorted(k for k, v in metrics.items() if v is None)
empty = sorted(k for k, v in metrics.items() if v == [])
with_data = sorted(k for k, v in metrics.items() if v)
logger.info(
f"Prometheus collection summary: {len(with_data)} metric(s) with data, "
f"{len(empty)} empty, {len(failed)} failed to collect "
f"(out of {len(metrics)} total)."
)
if failed:
logger.error(
f"{len(failed)} metric(s) FAILED to collect and will be null in the "
f"output: {failed}. See the per-query warnings above for the cause "
"(timeout / connection error / non-200). This indicates a "
"metrics-collection/infra problem, not a real metric signal."
)
return metrics
def get_prometheus_metrics(start_time: float, end_time: float) -> dict:
session_name = None
try:
import ray
if not ray.is_initialized():
ray.init("auto")
session_name = ray.get_runtime_context().get_session_name()
except Exception:
logger.warning(
"Couldn't obtain Ray session name for Prometheus query filtering. "
f"Exception below:\n{traceback.format_exc()}"
)
try:
return asyncio.run(_get_prometheus_metrics(start_time, end_time, session_name))
except Exception:
logger.error(
"Couldn't obtain Prometheus metrics. "
f"Exception below:\n{traceback.format_exc()}"
)
return {}
def save_prometheus_metrics(
start_time: float,
end_time: Optional[float] = None,
path: Optional[str] = None,
use_ray: bool = False,
) -> bool:
path = path or os.environ.get("METRICS_OUTPUT_JSON", None)
if path:
if not end_time:
end_time = time.time()
if use_ray:
import ray
from ray.air.util.node import _force_on_current_node
addr = os.environ.get("RAY_ADDRESS", None)
ray.init(addr)
@ray.remote(num_cpus=0)
def get_metrics():
end_time = time.time()
return get_prometheus_metrics(start_time, end_time)
remote_run = _force_on_current_node(get_metrics)
ref = remote_run.remote()
metrics = ray.get(ref, timeout=900)
else:
metrics = get_prometheus_metrics(start_time, end_time)
with open(path, "w") as metrics_output_file:
json.dump(metrics, metrics_output_file)
return path
return None
if __name__ == "__main__":
logging.basicConfig(
level=logging.INFO,
format="[%(levelname)s %(asctime)s] %(filename)s: %(lineno)d %(message)s",
)
parser = argparse.ArgumentParser()
parser.add_argument("start_time", type=float, help="Start time")
parser.add_argument(
"--path", default="", type=str, help="Where to save the metrics json"
)
parser.add_argument(
"--use_ray",
default=False,
action="store_true",
help="Whether to run this script in a ray.remote call (for Ray Client)",
)
args = parser.parse_args()
save_prometheus_metrics(args.start_time, path=args.path, use_ray=args.use_ray)
@@ -0,0 +1,54 @@
import argparse
import time
import ray
ray.init(address="auto")
parser = argparse.ArgumentParser()
parser.add_argument(
"num_nodes", type=int, help="Wait for this number of nodes (includes head)"
)
parser.add_argument("max_time_s", type=int, help="Wait for this number of seconds")
parser.add_argument(
"--feedback_interval_s",
type=int,
default=10,
help="Wait for this number of seconds",
)
args = parser.parse_args()
curr_nodes = 0
start = time.time()
next_feedback = start
max_time = start + args.max_time_s
while not curr_nodes >= args.num_nodes:
now = time.time()
if now >= max_time:
raise RuntimeError(
f"Maximum wait time reached, but only "
f"{curr_nodes}/{args.num_nodes} nodes came up. Aborting."
)
if now >= next_feedback:
passed = now - start
print(
f"Waiting for more nodes to come up: "
f"{curr_nodes}/{args.num_nodes} "
f"({passed:.0f} seconds passed)"
)
next_feedback = now + args.feedback_interval_s
time.sleep(5)
curr_nodes = sum(1 for node in ray.nodes() if node["Alive"])
passed = time.time() - start
print(
f"Cluster is up: {curr_nodes}/{args.num_nodes} nodes online after "
f"{passed:.0f} seconds"
)
@@ -0,0 +1,434 @@
import json
import os
import re
import shlex
import shutil
import tempfile
from typing import TYPE_CHECKING, Any, Dict, Optional
from ray_release.cloud_util import (
convert_abfss_uri_to_https,
generate_tmp_cloud_storage_path,
upload_working_dir_to_azure,
)
from ray_release.cluster_manager.cluster_manager import ClusterManager
from ray_release.command_runner.command_runner import CommandRunner
from ray_release.exception import (
FetchResultError,
JobBrokenError,
JobNoLogsError,
JobOutOfRetriesError,
LogsError,
PrepareCommandError,
PrepareCommandTimeout,
TestCommandError,
TestCommandTimeout,
)
from ray_release.file_manager.job_file_manager import JobFileManager
from ray_release.job_manager.anyscale_job_manager import (
JOB_SOFT_INFRA_ERROR,
JOB_STATE_UNKNOWN,
AnyscaleJobManager,
)
from ray_release.logger import logger
from ray_release.reporter.artifacts import DEFAULT_ARTIFACTS_DIR
from ray_release.util import (
AZURE_CLOUD_STORAGE,
AZURE_STORAGE_CONTAINER,
S3_CLOUD_STORAGE,
get_anyscale_sdk,
)
if TYPE_CHECKING:
from anyscale.sdk.anyscale_client.sdk import AnyscaleSDK
TIMEOUT_RETURN_CODE = 124
def _join_cloud_storage_paths(*paths: str):
paths = list(paths)
if len(paths) > 1:
for i in range(1, len(paths)):
while paths[i][0] == "/":
paths[i] = paths[i][1:]
joined_path = os.path.join(*paths)
while joined_path[-1] == "/":
joined_path = joined_path[:-1]
return joined_path
def _get_env_str(env: Dict[str, str]) -> str:
if env:
env_str = " ".join(f"{k}={v}" for k, v in env.items()) + " "
else:
env_str = ""
return env_str
class AnyscaleJobRunner(CommandRunner):
# the directory for runners to dump files to (on buildkite runner instances).
# Write to this directory. run_release_tests.sh will ensure that the content
# shows up under buildkite job's "Artifacts" UI tab.
_DEFAULT_ARTIFACTS_DIR = DEFAULT_ARTIFACTS_DIR
# the artifact file name put under s3 bucket root.
# AnyscalejobWrapper will upload user generated artifact to this path
# and AnyscaleJobRunner will then download from there.
_USER_GENERATED_ARTIFACT = "user_generated_artifact"
# the path where result json will be written to on both head node
# as well as the relative path where result json will be uploaded to on s3.
_RESULT_OUTPUT_JSON = "/tmp/release_test_out.json"
# the path where output json will be written to on both head node
# as well as the relative path where metrics json will be uploaded to on s3.
_METRICS_OUTPUT_JSON = "/tmp/metrics_test_out.json"
def __init__(
self,
cluster_manager: ClusterManager,
file_manager: JobFileManager,
working_dir: str,
sdk: Optional["AnyscaleSDK"] = None,
artifact_path: Optional[str] = None,
):
super().__init__(
cluster_manager=cluster_manager,
working_dir=working_dir,
)
self.file_manager = file_manager
self.sdk = sdk or get_anyscale_sdk()
self.job_manager = AnyscaleJobManager(cluster_manager)
self.last_command_scd_id = None
self.path_in_bucket = _join_cloud_storage_paths(
"working_dirs",
self.cluster_manager.test.get_name().replace(" ", "_"),
generate_tmp_cloud_storage_path(),
)
# The root cloud storage bucket path. result, metric, artifact files
# will be uploaded to under it on cloud storage.
cloud_storage_provider = os.environ.get(
"ANYSCALE_CLOUD_STORAGE_PROVIDER",
S3_CLOUD_STORAGE,
)
if cloud_storage_provider == AZURE_CLOUD_STORAGE:
# Azure ABFSS involves container and account name in the path
# and in a specific format/order.
self.upload_path = _join_cloud_storage_paths(
f"{AZURE_CLOUD_STORAGE}://{AZURE_STORAGE_CONTAINER}@{self.file_manager.bucket}.dfs.core.windows.net",
self.path_in_bucket,
)
else:
self.upload_path = _join_cloud_storage_paths(
f"{cloud_storage_provider}://{self.file_manager.bucket}",
self.path_in_bucket,
)
self.output_json = "/tmp/output.json"
self.prepare_commands = []
self._wait_for_nodes_timeout = 0
self._results_uploaded = True
self._metrics_uploaded = True
# artifact related
# user provided path to where they write the artifact to.
self._artifact_path = artifact_path
self._artifact_uploaded = artifact_path is not None
def _copy_script_to_working_dir(self, script_name):
script = os.path.join(os.path.dirname(__file__), f"_{script_name}")
shutil.copy(script, script_name)
def prepare_remote_env(self):
self._copy_script_to_working_dir("anyscale_job_wrapper.py")
self._copy_script_to_working_dir("wait_cluster.py")
self._copy_script_to_working_dir("prometheus_metrics.py")
def run_prepare_command(
self, command: str, env: Optional[Dict] = None, timeout: float = 3600.0
):
self.prepare_commands.append((command, env, timeout))
def wait_for_nodes(self, num_nodes: int, timeout: float = 900):
self._wait_for_nodes_timeout = timeout
self.job_manager.cluster_startup_timeout += timeout
# Give 30 seconds more to account for communication
self.run_prepare_command(
f"python wait_cluster.py {num_nodes} {timeout}", timeout=timeout + 30
)
def _handle_command_output(
self, job_return_code: int, raise_on_timeout: bool = True
):
if job_return_code == JOB_SOFT_INFRA_ERROR:
raise JobOutOfRetriesError(
"Job returned non-success state: 'FAILED' "
"(command has not been run or no logs could be obtained)."
)
if job_return_code == JOB_STATE_UNKNOWN:
raise JobBrokenError("Job state is 'UNKNOWN'.")
# First try to obtain the output.json from S3.
# If that fails, try logs.
try:
output_json = self.fetch_output()
except Exception:
logger.exception("Exception when obtaining output from S3.")
try:
logs = self.get_last_logs()
output_json = re.search(r"### JSON \|([^\|]*)\| ###", logs)
output_json = json.loads(output_json.group(1))
except Exception:
output_json = None
workload_status_code = None
if output_json:
logger.info(f"Output: {output_json}")
workload_status_code = output_json["return_code"]
workload_time_taken = output_json["workload_time_taken"]
prepare_return_codes = output_json["prepare_return_codes"]
last_prepare_time_taken = output_json["last_prepare_time_taken"]
# If we know results/metrics were not uploaded, we can fail fast
# fetching later.
self._results_uploaded = output_json["uploaded_results"]
self._metrics_uploaded = output_json["uploaded_metrics"]
self._artifact_uploaded = output_json["uploaded_artifact"]
if prepare_return_codes and prepare_return_codes[-1] != 0:
if prepare_return_codes[-1] == TIMEOUT_RETURN_CODE:
raise PrepareCommandTimeout(
"Prepare command timed out after "
f"{last_prepare_time_taken} seconds."
)
raise PrepareCommandError(
f"Prepare command '{self.prepare_commands[-1]}' returned "
f"non-success status: {prepare_return_codes[-1]}."
)
else:
raise JobNoLogsError("Could not obtain logs for the job.")
if workload_status_code == TIMEOUT_RETURN_CODE:
if not raise_on_timeout:
# Expected - treat as success.
return
raise TestCommandTimeout(
f"Command timed out after {workload_time_taken} seconds."
)
if workload_status_code is None or workload_status_code != 0:
raise TestCommandError(
f"Command returned non-success status: {workload_status_code}."
)
def _get_full_command_env(self, env: Optional[Dict[str, str]] = None):
full_env = {
"TEST_OUTPUT_JSON": self._RESULT_OUTPUT_JSON,
"METRICS_OUTPUT_JSON": self._METRICS_OUTPUT_JSON,
"USER_GENERATED_ARTIFACT": self._USER_GENERATED_ARTIFACT,
"BUILDKITE_BRANCH": os.environ.get("BUILDKITE_BRANCH", ""),
"PYTHONUNBUFFERED": "1",
}
if env:
full_env.update(env)
return full_env
def run_command(
self,
command: str,
env: Optional[Dict[str, str]] = None,
timeout: float = 3600.0,
raise_on_timeout: bool = True,
) -> float:
prepare_command_strs = []
prepare_command_timeouts = []
# Convert the prepare commands, envs and timeouts into shell-compliant
# strings that can be passed to the wrapper script
for prepare_command, prepare_env, prepare_timeout in self.prepare_commands:
prepare_env = self._get_full_command_env(prepare_env)
env_str = _get_env_str(prepare_env)
prepare_command_strs.append(f"{env_str} {prepare_command}")
prepare_command_timeouts.append(prepare_timeout)
prepare_commands_shell = " ".join(
shlex.quote(str(x)) for x in prepare_command_strs
)
prepare_commands_timeouts_shell = " ".join(
shlex.quote(str(x)) for x in prepare_command_timeouts
)
full_env = self._get_full_command_env(env)
no_raise_on_timeout_str = (
" --test-no-raise-on-timeout" if not raise_on_timeout else ""
)
results_cloud_storage_uri = _join_cloud_storage_paths(
self.upload_path, self._RESULT_OUTPUT_JSON
)
metrics_cloud_storage_uri = _join_cloud_storage_paths(
self.upload_path, self._METRICS_OUTPUT_JSON
)
output_cloud_storage_uri = _join_cloud_storage_paths(
self.upload_path, self.output_json
)
upload_cloud_storage_uri = self.upload_path
# Convert ABFSS URI to HTTPS URI for Azure
# since azcopy doesn't support ABFSS.
# azcopy is used to fetch these artifacts on Buildkite
# after job is done.
if self.upload_path.startswith(AZURE_CLOUD_STORAGE):
results_cloud_storage_uri = convert_abfss_uri_to_https(
results_cloud_storage_uri
)
metrics_cloud_storage_uri = convert_abfss_uri_to_https(
metrics_cloud_storage_uri
)
output_cloud_storage_uri = convert_abfss_uri_to_https(
output_cloud_storage_uri
)
upload_cloud_storage_uri = convert_abfss_uri_to_https(
upload_cloud_storage_uri
)
full_command = (
f"python anyscale_job_wrapper.py '{command}' "
f"--test-workload-timeout {timeout}{no_raise_on_timeout_str} "
"--results-cloud-storage-uri "
f"'{results_cloud_storage_uri}' "
"--metrics-cloud-storage-uri "
f"'"
f"{metrics_cloud_storage_uri}' "
"--output-cloud-storage-uri "
f"'{output_cloud_storage_uri}' "
f"--upload-cloud-storage-uri '{upload_cloud_storage_uri}' "
f"--prepare-commands {prepare_commands_shell} "
f"--prepare-commands-timeouts {prepare_commands_timeouts_shell} "
)
if self._artifact_path:
full_command += f"--artifact-path '{self._artifact_path}' "
timeout = min(
(self.cluster_manager.maximum_uptime_minutes - 1) * 60,
# The timeout set here is just for the prepare commands + test workload
# WITHOUT wait for nodes time included, as that is set separately.
# Since wait for nodes is a part of prepare_commands, we manually
# subtract the timeout for it here.
# We also add 15 mins for upload & metrics collection.
timeout
+ sum(prepare_command_timeouts)
- self._wait_for_nodes_timeout
+ 900,
)
working_dir = "."
# If running on Azure, upload working dir to Azure blob storage first
if self.upload_path.startswith(AZURE_CLOUD_STORAGE):
azure_file_path = upload_working_dir_to_azure(
working_dir=os.getcwd(), azure_directory_uri=self.upload_path
)
working_dir = azure_file_path
logger.info(f"Working dir uploaded to {working_dir}")
job_return_code, time_taken = self.job_manager.run_and_wait(
full_command,
full_env,
working_dir=working_dir,
upload_path=self.upload_path,
timeout=int(timeout),
)
self._handle_command_output(job_return_code, raise_on_timeout=raise_on_timeout)
return time_taken
def get_last_logs_ex(self) -> Optional[str]:
try:
return self.job_manager.get_last_logs()
except Exception as e:
raise LogsError(f"Could not get last logs: {e}") from e
def _fetch_json(self, path: str) -> Dict[str, Any]:
try:
tmpfile = tempfile.mkstemp(suffix=".json")[1]
logger.info(tmpfile)
self.file_manager.download_from_cloud(
path, tmpfile, delete_after_download=True
)
with open(tmpfile, "rt") as f:
content = f.read()
try:
data = json.loads(content)
except json.JSONDecodeError as e:
logger.info(f"Result content = {content}")
raise e
os.unlink(tmpfile)
return data
except Exception as e:
raise FetchResultError(f"Could not fetch results from session: {e}") from e
def fetch_results(self) -> Dict[str, Any]:
if not self._results_uploaded:
raise FetchResultError(
"Could not fetch results from session as they were not uploaded."
)
return self._fetch_json(
_join_cloud_storage_paths(self.path_in_bucket, self._RESULT_OUTPUT_JSON)
)
def fetch_metrics(self) -> Dict[str, Any]:
if not self._metrics_uploaded:
raise FetchResultError(
"Could not fetch metrics from session as they were not uploaded."
)
return self._fetch_json(
_join_cloud_storage_paths(self.path_in_bucket, self._METRICS_OUTPUT_JSON)
)
def fetch_artifact(self) -> None:
"""Fetch artifact (file) from `self._artifact_path` on Anyscale cluster
head node.
Note, an implementation detail here is that by the time this function is called,
the artifact file is already present in s3 bucket by the name of
`self._USER_GENERATED_ARTIFACT`. This is because, the uploading to s3 portion is
done by `_anyscale_job_wrapper`.
The fetched artifact will be placed under `self._DEFAULT_ARTIFACTS_DIR`,
which will ultimately show up in buildkite Artifacts UI tab.
The fetched file will have the same filename and extension as the one
on Anyscale cluster head node (same as `self._artifact_path`).
"""
if not self._artifact_uploaded:
raise FetchResultError(
"Could not fetch artifact from session as they "
"were either not generated or not uploaded."
)
# first make sure that `self._DEFAULT_ARTIFACTS_DIR` exists.
if not os.path.exists(self._DEFAULT_ARTIFACTS_DIR):
os.makedirs(self._DEFAULT_ARTIFACTS_DIR, 0o755)
# we use the same artifact file name and extension specified by user
# and put it under `self._DEFAULT_ARTIFACTS_DIR`.
artifact_file_name = os.path.basename(self._artifact_path)
self.file_manager.download_from_cloud(
_join_cloud_storage_paths(
self.path_in_bucket, self._USER_GENERATED_ARTIFACT
),
os.path.join(self._DEFAULT_ARTIFACTS_DIR, artifact_file_name),
)
def fetch_output(self) -> Dict[str, Any]:
return self._fetch_json(
_join_cloud_storage_paths(self.path_in_bucket, self.output_json),
)
def job_url(self) -> Optional[str]:
return self.job_manager.job_url()
def job_id(self) -> Optional[str]:
return self.job_manager.job_id()
@@ -0,0 +1,85 @@
import abc
from typing import Any, Dict, Optional
from click.exceptions import ClickException
from ray_release.cluster_manager.cluster_manager import ClusterManager
from ray_release.logger import logger
from ray_release.util import exponential_backoff_retry
class CommandRunner(abc.ABC):
"""This is run on Buildkite runners."""
def __init__(
self,
cluster_manager: ClusterManager,
working_dir: str,
artifact_path: Optional[str] = None,
):
self.cluster_manager = cluster_manager
self.working_dir = working_dir
def prepare_remote_env(self):
"""Prepare remote environment, e.g. upload files."""
raise NotImplementedError
def wait_for_nodes(self, num_nodes: int, timeout: float = 900.0):
"""Wait for cluster nodes to be up.
Args:
num_nodes: Number of nodes to wait for.
timeout: Timeout in seconds to wait for nodes before
raising a ``PrepareCommandTimeoutError``.
Returns:
None
Raises:
PrepareCommandTimeoutError
"""
raise NotImplementedError
def run_command(
self,
command: str,
env: Optional[Dict] = None,
timeout: float = 3600.0,
raise_on_timeout: bool = True,
) -> float:
"""Run command."""
raise NotImplementedError
def run_prepare_command(
self, command: str, env: Optional[Dict] = None, timeout: float = 3600.0
):
"""Run prepare command.
Command runners may choose to run this differently than the
test command.
"""
return exponential_backoff_retry(
lambda: self.run_command(command, env, timeout),
ClickException,
initial_retry_delay_s=5,
max_retries=3,
)
def get_last_logs(self) -> Optional[str]:
try:
return self.get_last_logs_ex()
except Exception as e:
logger.exception(f"Error fetching logs: {e}")
return None
def get_last_logs_ex(self):
raise NotImplementedError
def fetch_results(self) -> Dict[str, Any]:
raise NotImplementedError
def fetch_metrics(self) -> Dict[str, Any]:
raise NotImplementedError
def fetch_artifact(self) -> None:
raise NotImplementedError