""" 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__))