import io import logging import os import re import subprocess import sys import time from collections import Counter, defaultdict from contextlib import redirect_stderr, redirect_stdout from glob import glob from pathlib import Path from typing import Dict, List, Tuple from unittest.mock import MagicMock, Mock, patch import colorama import pytest import ray from ray._common.test_utils import ( run_string_as_driver, wait_for_condition, ) from ray._private import ray_constants from ray._private.log_monitor import ( LOG_NAME_UPDATE_INTERVAL_S, RAY_LOG_MONITOR_MANY_FILES_THRESHOLD, LogFileInfo, LogMonitor, is_proc_alive, ) from ray._private.ray_constants import ( PROCESS_TYPE_DASHBOARD, PROCESS_TYPE_DASHBOARD_AGENT, PROCESS_TYPE_GCS_SERVER, PROCESS_TYPE_LOG_MONITOR, PROCESS_TYPE_MONITOR, PROCESS_TYPE_PYTHON_CORE_WORKER, PROCESS_TYPE_PYTHON_CORE_WORKER_DRIVER, PROCESS_TYPE_RAY_CLIENT_SERVER, PROCESS_TYPE_RAYLET, PROCESS_TYPE_REAPER, PROCESS_TYPE_REDIS_SERVER, PROCESS_TYPE_RUNTIME_ENV_AGENT, PROCESS_TYPE_WORKER, ) from ray._private.test_utils import ( get_log_batch, get_log_data, get_log_message, init_log_pubsub, ) from ray._private.worker import print_worker_logs from ray.autoscaler._private.cli_logger import cli_logger def set_logging_config(monkeypatch, max_bytes, backup_count): monkeypatch.setenv("RAY_ROTATION_MAX_BYTES", str(max_bytes)) monkeypatch.setenv("RAY_ROTATION_BACKUP_COUNT", str(backup_count)) def test_reopen_changed_inode_seeks_on_non_empty_file(tmp_path): """Make sure that when we reopen a file because the inode has changed, we open to the right location when the file has content.""" path1 = tmp_path / "file" path2 = tmp_path / "changed_file" with open(path1, "w") as f: for i in range(1000): print(f"{i}", file=f) with open(path2, "w") as f: for i in range(2000): print(f"{i}", file=f) file_info = LogFileInfo( filename=path1, size_when_last_opened=0, file_position=0, file_handle=None, is_err_file=False, job_id=None, worker_pid=None, ) file_info.reopen_if_necessary() assert file_info.size_when_last_opened == os.path.getsize(path1) for _ in range(1000): file_info.file_handle.readline() orig_file_pos = file_info.file_handle.tell() file_info.file_position = orig_file_pos # NOTE: On windows, an open file can't be deleted. file_info.file_handle.close() os.remove(path1) os.rename(path2, path1) file_info.reopen_if_necessary() assert file_info.file_position == orig_file_pos assert file_info.file_handle.tell() == orig_file_pos assert file_info.size_when_last_opened == os.path.getsize(path1) def test_reopen_changed_inode_seeks_beginning_if_smaller(tmp_path): """Test that after log rotation, we read from the beginning of the new file.""" original_log = tmp_path / "worker.log" # Create original log file with content. with open(original_log, "w") as f: for i in range(100): print(f"Log line {i}", file=f) file_info = LogFileInfo( filename=original_log, size_when_last_opened=0, file_position=0, file_handle=None, is_err_file=False, job_id=None, worker_pid=None, ) # Start monitoring and read some lines file_info.reopen_if_necessary() for i in range(50): line = file_info.file_handle.readline().strip() assert line == f"Log line {i}".encode("utf-8") assert file_info.size_when_last_opened == os.path.getsize(original_log) # Save position file_info.file_position = file_info.file_handle.tell() file_info.file_handle.close() # Simulate log rotation: move old file and create new one os.rename(original_log, original_log.with_suffix(".log.1")) with open(original_log, "w") as f: print("New log line 0", file=f) # Reopen after rotation file_info.reopen_if_necessary() # Should start from beginning of new file line = file_info.file_handle.readline().strip() assert line == b"New log line 0", f"Expected to read from beginning, got: '{line}'" assert ( file_info.file_position == 0 ), f"Expected position 0, got: {file_info.file_position}" assert file_info.size_when_last_opened == os.path.getsize(original_log) @pytest.mark.skipif(sys.platform == "win32", reason="Fails on windows") def test_deleted_file_does_not_throw_error(tmp_path): filename = tmp_path / "file" Path(filename).touch() file_info = LogFileInfo( filename=filename, size_when_last_opened=0, file_position=0, file_handle=None, is_err_file=False, job_id=None, worker_pid=None, ) file_info.reopen_if_necessary() os.remove(filename) file_info.reopen_if_necessary() def test_log_rotation_config(ray_start_cluster, monkeypatch): cluster = ray_start_cluster max_bytes = 100 backup_count = 3 # Create a cluster. set_logging_config(monkeypatch, max_bytes, backup_count) head_node = cluster.add_node(num_cpus=0) # Set a different env var for a worker node. set_logging_config(monkeypatch, 0, 0) worker_node = cluster.add_node(num_cpus=0) cluster.wait_for_nodes() config = head_node.logging_config assert config["log_rotation_max_bytes"] == max_bytes assert config["log_rotation_backup_count"] == backup_count config = worker_node.logging_config assert config["log_rotation_max_bytes"] == 0 assert config["log_rotation_backup_count"] == 0 def test_log_files_exist(shutdown_only): """Verify all log files exist as specified in https://docs.ray.io/en/master/ray-observability/user-guides/configure-logging.html#logging-directory-structure # noqa """ ray.init(num_cpus=1) session_dir = ray._private.worker.global_worker.node.address_info["session_dir"] session_path = Path(session_dir) log_dir_path = session_path / "logs" # Run a no-op task to ensure all logs are created. # Use a runtime_env to ensure that the agents are alive. @ray.remote(runtime_env={"env_vars": {"FOO": "BAR"}}) def f() -> Tuple[str, int]: return ray.get_runtime_context().get_worker_id(), os.getpid() driver_id, driver_pid = (ray.get_runtime_context().get_worker_id(), os.getpid()) worker_id, worker_pid = ray.get(f.remote()) python_core_driver_filename = ( PROCESS_TYPE_PYTHON_CORE_WORKER_DRIVER + f"-{driver_id}_{driver_pid}" ) python_core_worker_filename = ( PROCESS_TYPE_PYTHON_CORE_WORKER + f"-{worker_id}_{worker_pid}" ) job_id = ray.get_runtime_context().get_job_id() worker_filename = PROCESS_TYPE_WORKER + f"-{worker_id}-{job_id}-{worker_pid}" component_to_extensions = [ (PROCESS_TYPE_DASHBOARD, [".log", ".out", ".err"]), (PROCESS_TYPE_DASHBOARD_AGENT, [".log"]), (PROCESS_TYPE_GCS_SERVER, [".out", ".err"]), (PROCESS_TYPE_LOG_MONITOR, [".log", ".err"]), (PROCESS_TYPE_MONITOR, [".log", ".out", ".err"]), (PROCESS_TYPE_RAYLET, [".out", ".err"]), (PROCESS_TYPE_RUNTIME_ENV_AGENT, [".log", ".out", ".err"]), (python_core_driver_filename, [".log"]), (python_core_worker_filename, [".log"]), (worker_filename, [".out", ".err"]), ] paths = list(log_dir_path.iterdir()) def _assert_component_logs_exist( paths: List[str], component_name: str, extensions: List[str] ): extensions_to_find = set(extensions) for path in paths: if path.stem != component_name: continue if path.suffix in extensions_to_find: extensions_to_find.remove(path.suffix) assert len(extensions_to_find) == 0, ( f"Missing extensions {(extensions_to_find)} for component '{component_name}'. " f"All paths: {paths}." ) for (component_name, extensions) in component_to_extensions: _assert_component_logs_exist(paths, component_name, extensions) # Rotation is disable in the unit test. def test_log_rotation_disable_rotation_params(shutdown_only, monkeypatch): max_bytes = 0 backup_count = 1 set_logging_config(monkeypatch, max_bytes, backup_count) ray.init(num_cpus=1) session_dir = ray._private.worker.global_worker.node.address_info["session_dir"] session_path = Path(session_dir) log_dir_path = session_path / "logs" # NOTE: There's no PROCESS_TYPE_WORKER because "worker" is a # substring of "python-core-worker". log_rotating_components = [ PROCESS_TYPE_DASHBOARD, PROCESS_TYPE_DASHBOARD_AGENT, PROCESS_TYPE_LOG_MONITOR, PROCESS_TYPE_MONITOR, PROCESS_TYPE_PYTHON_CORE_WORKER_DRIVER, PROCESS_TYPE_PYTHON_CORE_WORKER, PROCESS_TYPE_RAYLET, PROCESS_TYPE_GCS_SERVER, ] # Run the basic workload. @ray.remote def f(): for i in range(10): print(f"test {i}") # Create a runtime env to make sure dashboard agent is alive. ray.get(f.options(runtime_env={"env_vars": {"A": "a", "B": "b"}}).remote()) # Filter out only paths that end in .log, .log.1, (which is produced by python # rotating log handler) and .out.1 and so on (which is produced by C++ spdlog # rotation handler) . etc. These paths are handled by the logger; the others (.err) # are not. paths = [] for path in log_dir_path.iterdir(): # Match all rotated files, which suffixes with `log.x` or `log.x.out`. if re.search(r".*\.log(\.\d+)?", str(path)): paths.append(path) elif re.search(r".*\.out(\.\d+)?", str(path)): paths.append(path) def component_exist(component, paths): """Return whether there's at least one log file path is for the given [component].""" for path in paths: filename = path.stem if component in filename: return True return False for component in log_rotating_components: assert component_exist(component, paths), paths # Check if the backup count is respected. file_cnts = defaultdict(int) for path in paths: filename = path.name parts = filename.split(".") if len(parts) == 3: filename_without_suffix = parts[0] file_type = parts[1] # eg. err, log, out file_cnts[f"{filename_without_suffix}.{file_type}"] += 1 for filename, file_cnt in file_cnts.items(): assert file_cnt == backup_count, ( f"{filename} has files that are more than " f"backup count {backup_count}, file count: {file_cnt}" ) # Test application log, which starts with `worker-`. # Should be tested separately with other components since "worker" is a substring of "python-core-worker". # # Check file count. application_stdout_paths = [] for path in paths: if ( path.stem.startswith("worker-") and re.search(r".*\.out(\.\d+)?", str(path)) and path.stat().st_size > 0 ): application_stdout_paths.append(path) assert len(application_stdout_paths) == 1, application_stdout_paths @pytest.mark.skipif( sys.platform == "win32", reason="Log rotation is disable on windows platform." ) def test_log_rotation(shutdown_only, monkeypatch): max_bytes = 1 backup_count = 3 set_logging_config(monkeypatch, max_bytes, backup_count) ray.init(num_cpus=1) session_dir = ray._private.worker.global_worker.node.address_info["session_dir"] session_path = Path(session_dir) log_dir_path = session_path / "logs" # NOTE: There's no PROCESS_TYPE_WORKER because "worker" is a # substring of "python-core-worker". log_rotating_components = [ PROCESS_TYPE_DASHBOARD, PROCESS_TYPE_DASHBOARD_AGENT, PROCESS_TYPE_LOG_MONITOR, PROCESS_TYPE_MONITOR, PROCESS_TYPE_PYTHON_CORE_WORKER_DRIVER, PROCESS_TYPE_PYTHON_CORE_WORKER, PROCESS_TYPE_RAYLET, PROCESS_TYPE_GCS_SERVER, ] # Run the basic workload. @ray.remote def f(): for i in range(10): print(f"test {i}") # Create a runtime env to make sure dashboard agent is alive. ray.get(f.options(runtime_env={"env_vars": {"A": "a", "B": "b"}}).remote()) # Filter out only paths that end in .log, .log.1, (which is produced by python # rotating log handler) and .out.1 and so on (which is produced by C++ spdlog # rotation handler) . etc. These paths are handled by the logger; the others (.err) # are not. paths = [] for path in log_dir_path.iterdir(): # Match all rotated files, which suffixes with `log.x` or `log.x.out`. if re.search(r".*\.log(\.\d+)?", str(path)): paths.append(path) elif re.search(r".*\.out(\.\d+)?", str(path)): paths.append(path) def component_exist(component, paths): """Return whether there's at least one log file path is for the given [component].""" for path in paths: filename = path.stem if component in filename: return True return False def component_file_only_one_log_entry(component): """Since max_bytes is 1, the log file should only have at most one log entry. """ for path in paths: if not component_exist(component, [path]): continue with open(path) as file: found = False for line in file: if re.match(r"^\[?\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d", line): if found: return False found = True return True for component in log_rotating_components: assert component_exist(component, paths), paths assert component_file_only_one_log_entry(component) # Check if the backup count is respected. file_cnts = defaultdict(int) for path in paths: filename = path.name parts = filename.split(".") if len(parts) == 3: filename_without_suffix = parts[0] file_type = parts[1] # eg. err, log, out file_cnts[f"{filename_without_suffix}.{file_type}"] += 1 for filename, file_cnt in file_cnts.items(): assert file_cnt <= backup_count, ( f"{filename} has files that are more than " f"backup count {backup_count}, file count: {file_cnt}" ) # Test application log, which starts with `worker-`. # Should be tested separately with other components since "worker" is a substring of "python-core-worker". # # Check file count. application_stdout_paths = [] for path in paths: if ( path.stem.startswith("worker-") and re.search(r".*\.out(\.\d+)?", str(path)) and path.stat().st_size > 0 ): application_stdout_paths.append(path) assert len(application_stdout_paths) == 4, application_stdout_paths # Check file content, each file should have one line. for cur_path in application_stdout_paths: with cur_path.open() as f: lines = f.readlines() assert len(lines) == 1, lines def test_periodic_event_stats(shutdown_only): ray.init( num_cpus=1, _system_config={"event_stats_print_interval_ms": 100, "event_stats": True}, ) session_dir = ray._private.worker.global_worker.node.address_info["session_dir"] session_path = Path(session_dir) log_dir_path = session_path / "logs" # Run the basic workload. @ray.remote def f(): pass ray.get(f.remote()) paths = list(log_dir_path.iterdir()) def is_event_loop_stats_found(path): found = False with open(path) as f: event_loop_stats_identifier = "Event stats" for line in f.readlines(): if event_loop_stats_identifier in line: found = True return found for path in paths: # Need to remove suffix to avoid reading log rotated files. if "python-core-driver" in str(path): wait_for_condition(lambda: is_event_loop_stats_found(path)) if "raylet.out" in str(path): wait_for_condition(lambda: is_event_loop_stats_found(path)) if "gcs_server.out" in str(path): wait_for_condition(lambda: is_event_loop_stats_found(path)) def test_worker_id_names(shutdown_only): ray.init( num_cpus=1, _system_config={"event_stats_print_interval_ms": 100, "event_stats": True}, ) session_dir = ray._private.worker.global_worker.node.address_info["session_dir"] session_path = Path(session_dir) log_dir_path = session_path / "logs" # Run the basic workload. @ray.remote def f(): print("hello") ray.get(f.remote()) paths = list(log_dir_path.iterdir()) worker_log_files = list() ids = [] for path in paths: if "python-core-worker" in str(path): pattern = ".*-([a-f0-9]*).*" elif "worker" in str(path): pattern = ".*worker-([a-f0-9]*)-.*-.*" else: continue worker_id = re.match(pattern, str(path)).group(1) worker_log_files.append(str(paths)) ids.append(worker_id) counts = Counter(ids).values() for count in counts: # For each worker, there should be a "python-core-.*.log", "worker-.*.out", # and "worker-.*.err". assert count == 3, worker_log_files def test_log_pid_with_hex_job_id(ray_start_cluster): cluster = ray_start_cluster cluster.add_node(num_cpus=4) def submit_job(): # Connect a driver to the Ray cluster. ray.init(address=cluster.address, ignore_reinit_error=True) p = init_log_pubsub() # It always prints the monitor messages. logs = get_log_message(p, 1) @ray.remote def f(): print("remote func") ray.get(f.remote()) def matcher(log_batch): return log_batch["task_name"] == "f" logs = get_log_batch(p, 1, matcher=matcher) # It should logs with pid of hex job id instead of None assert logs[0]["pid"] is not None ray.shutdown() # NOTE(xychu): loop ten times to make job id from 01000000 to 0a000000, # in order to trigger hex pattern for _ in range(10): submit_job() def test_ignore_windows_access_violation(ray_start_regular_shared): @ray.remote def print_msg(): print("Windows fatal exception: access violation\n") @ray.remote def print_after(_obj): print("done") p = init_log_pubsub() print_after.remote(print_msg.remote()) msgs = get_log_message( p, num=6, timeout=10, job_id=ray.get_runtime_context().get_job_id() ) assert len(msgs) == 1, msgs assert msgs[0][0] == "done" def test_log_redirect_to_stderr(shutdown_only): log_components = { PROCESS_TYPE_DASHBOARD: "Starting dashboard metrics server on port", PROCESS_TYPE_DASHBOARD_AGENT: "Dashboard agent grpc address", PROCESS_TYPE_RUNTIME_ENV_AGENT: "Starting runtime env agent", PROCESS_TYPE_GCS_SERVER: "Loading job table data", # No log monitor output if all components are writing to stderr. PROCESS_TYPE_LOG_MONITOR: "", PROCESS_TYPE_MONITOR: "Starting monitor using ray installation", PROCESS_TYPE_PYTHON_CORE_WORKER: "worker server started", PROCESS_TYPE_PYTHON_CORE_WORKER_DRIVER: "driver server started", # TODO(Clark): Add coverage for Ray Client. # PROCESS_TYPE_RAY_CLIENT_SERVER: "Starting Ray Client server", PROCESS_TYPE_RAY_CLIENT_SERVER: "", PROCESS_TYPE_RAYLET: "Starting object store with directory", # No reaper process run (kernel fate-sharing). PROCESS_TYPE_REAPER: "", # Unused. PROCESS_TYPE_WORKER: "", } script = """ import os from pathlib import Path import ray os.environ["RAY_LOG_TO_STDERR"] = "1" ray.init() session_dir = ray._private.worker.global_worker.node.address_info["session_dir"] session_path = Path(session_dir) log_dir_path = session_path / "logs" # Run the basic workload. @ray.remote def f(): for i in range(10): print(f"test {{i}}") ray.get(f.remote()) log_component_names = {} # Confirm that no log files are created for any of the components. paths = list(path.stem for path in log_dir_path.iterdir()) assert set(log_component_names).isdisjoint(set(paths)), paths """.format( str(list(log_components.keys())) ) stderr = run_string_as_driver(script) # Make sure that the expected startup log records for each of the # components appears in the stderr stream. for component, canonical_record in log_components.items(): if not canonical_record: # Process not run or doesn't generate logs; skip. continue assert canonical_record in stderr, stderr if component == PROCESS_TYPE_REDIS_SERVER: # Redis doesn't expose hooks for custom log formats, so we aren't able to # inject the Redis server component name into the log records. continue # NOTE: We do a prefix match instead of including the enclosing right # parentheses since some components, like the core driver and worker, add a # unique ID suffix. assert f"({component}" in stderr, stderr def test_custom_logging_format(shutdown_only): script = """ import ray ray.init(logging_format='custom logging format - %(message)s') """ stderr = run_string_as_driver(script) assert "custom logging format - " in stderr def test_segfault_stack_trace(ray_start_cluster, capsys): @ray.remote def f(): import ctypes ctypes.string_at(0) with pytest.raises( ray.exceptions.WorkerCrashedError, match="The worker died unexpectedly" ): ray.get(f.remote()) stderr = capsys.readouterr().err assert ( "*** SIGSEGV received at" in stderr ), f"C++ stack trace not found in stderr: {stderr}" assert ( "Fatal Python error: Segmentation fault" in stderr ), f"Python stack trace not found in stderr: {stderr}" """ Unit testing log monitor. """ def create_file(dir, filename, content): f = dir / filename f.write_text(content) @pytest.fixture def live_dead_pids(): p1 = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(6000)"]) p2 = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(6000)"]) p2.kill() # avoid zombie processes p2.wait() yield p1.pid, p2.pid p1.kill() p1.wait() def test_log_monitor(tmp_path, live_dead_pids): log_dir = tmp_path / "logs" log_dir.mkdir() # Create an old dir. (log_dir / "old").mkdir() worker_id = "6df6d5dd8ca5215658e4a8f9a569a9d98e27094f9cc35a4ca43d272c" job_id = "01000000" alive_pid, dead_pid = live_dead_pids mock_publisher = MagicMock() log_monitor = LogMonitor( "127.0.0.1", str(log_dir), mock_publisher, is_proc_alive, max_files_open=5 ) # files worker_out_log_file = f"worker-{worker_id}-{job_id}-{dead_pid}.out" worker_err_log_file = f"worker-{worker_id}-{job_id}-{dead_pid}.err" monitor = "monitor.log" raylet_out = "raylet.out" raylet_err = "raylet.err" gcs_server_err = "gcs_server.1.err" contents = "123" create_file(log_dir, raylet_err, contents) create_file(log_dir, raylet_out, contents) create_file(log_dir, gcs_server_err, contents) create_file(log_dir, monitor, contents) create_file(log_dir, worker_out_log_file, contents) create_file(log_dir, worker_err_log_file, contents) """ Test files are updated. """ log_monitor.update_log_filenames() assert len(log_monitor.open_file_infos) == 0 assert len(log_monitor.closed_file_infos) == 5 assert log_monitor.can_open_more_files is True assert len(log_monitor.log_filenames) == 5 def file_exists(log_filenames, filename): for f in log_filenames: if filename in f: return True return False assert file_exists(log_monitor.log_filenames, raylet_err) assert not file_exists(log_monitor.log_filenames, raylet_out) assert file_exists(log_monitor.log_filenames, gcs_server_err) assert file_exists(log_monitor.log_filenames, monitor) assert file_exists(log_monitor.log_filenames, worker_out_log_file) assert file_exists(log_monitor.log_filenames, worker_err_log_file) def get_file_info(file_infos, filename): for file_info in file_infos: if filename in file_info.filename: return file_info assert False, "Shouldn't reach." raylet_err_info = get_file_info(log_monitor.closed_file_infos, raylet_err) gcs_server_err_info = get_file_info(log_monitor.closed_file_infos, gcs_server_err) monitor_info = get_file_info(log_monitor.closed_file_infos, monitor) worker_out_log_file_info = get_file_info( log_monitor.closed_file_infos, worker_out_log_file ) worker_err_log_file_info = get_file_info( log_monitor.closed_file_infos, worker_err_log_file ) assert raylet_err_info.is_err_file assert gcs_server_err_info.is_err_file assert not monitor_info.is_err_file assert not worker_out_log_file_info.is_err_file assert worker_err_log_file_info.is_err_file assert worker_out_log_file_info.job_id is None assert worker_err_log_file_info.job_id is None assert worker_out_log_file_info.worker_pid == int(dead_pid) assert worker_out_log_file_info.worker_pid == int(dead_pid) """ Test files are opened. """ log_monitor.open_closed_files() assert len(log_monitor.open_file_infos) == 5 assert len(log_monitor.closed_file_infos) == 0 assert not log_monitor.can_open_more_files """ Test files are published. """ assert log_monitor.check_log_files_and_publish_updates() assert raylet_err_info.worker_pid == "raylet" assert gcs_server_err_info.worker_pid == "gcs_server" assert monitor_info.worker_pid == "autoscaler" assert mock_publisher.publish_logs.call_count for file_info in log_monitor.open_file_infos: mock_publisher.publish_logs.assert_any_call( { "ip": log_monitor.ip, "pid": file_info.worker_pid, "job": file_info.job_id, "is_err": file_info.is_err_file, "lines": [contents], "actor_name": file_info.actor_name, "task_name": file_info.task_name, } ) # If there's no new update, it should return False. assert not log_monitor.check_log_files_and_publish_updates() # Test max lines read == 99 is repsected. lines = "1\n" * int(1.5 * ray_constants.LOG_MONITOR_NUM_LINES_TO_READ) with open(raylet_err_info.filename, "a") as f: # Write 150 more lines. f.write(lines) assert log_monitor.check_log_files_and_publish_updates() mock_publisher.publish_logs.assert_any_call( { "ip": log_monitor.ip, "pid": raylet_err_info.worker_pid, "job": raylet_err_info.job_id, "is_err": raylet_err_info.is_err_file, "lines": ["1" for _ in range(ray_constants.LOG_MONITOR_NUM_LINES_TO_READ)], "actor_name": file_info.actor_name, "task_name": file_info.task_name, } ) """ Test files are closed. """ # log_monitor.open_closed_files() should close all files # if it cannot open new files. new_worker_err_file = f"worker-{worker_id}-{job_id}-{alive_pid}.err" create_file(log_dir, new_worker_err_file, contents) log_monitor.update_log_filenames() # System logs are not closed. # - raylet, gcs, monitor # Dead workers are not tracked anymore. They will be moved to old folder. # - dead pid out & err # alive worker is going to be newly opened. log_monitor.open_closed_files() assert len(log_monitor.open_file_infos) == 2 assert log_monitor.can_open_more_files # Two dead workers are not tracked anymore, and they will be in the old folder. # monitor.err and gcs_server.1.err have not been updated, so they remain closed. assert len(log_monitor.closed_file_infos) == 2 assert len(list((log_dir / "old").iterdir())) == 2 def test_tpu_logs(tmp_path): # Create the log directories. tpu_logs would be a symlink to the # /tmp/tpu_logs directory created in Node _init_temp. log_dir = tmp_path / "logs" log_dir.mkdir() tpu_log_dir = log_dir / "tpu_logs" tpu_log_dir.mkdir() # Create TPU device log file in tpu_logs directory. tpu_device_log_file = "tpu-device.log" first_line = "First line\n" create_file(tpu_log_dir, tpu_device_log_file, first_line) mock_publisher = MagicMock() log_monitor = LogMonitor( "127.0.0.1", str(log_dir), mock_publisher, is_proc_alive, max_files_open=5, ) # Verify TPU logs are ingested by LogMonitor. log_monitor.update_log_filenames() log_monitor.open_closed_files() assert len(log_monitor.open_file_infos) == 1 file_info = log_monitor.open_file_infos[0] assert Path(file_info.filename) == tpu_log_dir / tpu_device_log_file def test_log_monitor_actor_task_name_and_job_id(tmp_path): log_dir = tmp_path / "logs" log_dir.mkdir() worker_id = "6df6d5dd8ca5215658e4a8f9a569a9d98e27094f9cc35a4ca43d272c" job_id = "01000000" pid = "47660" mock_publisher = MagicMock() log_monitor = LogMonitor( "127.0.0.1", str(log_dir), mock_publisher, lambda _: True, max_files_open=5 ) worker_out_log_file = f"worker-{worker_id}-{job_id}-{pid}.out" first_line = "First line\n" create_file(log_dir, worker_out_log_file, first_line) log_monitor.update_log_filenames() log_monitor.open_closed_files() assert len(log_monitor.open_file_infos) == 1 file_info = log_monitor.open_file_infos[0] # Test task name updated. task_name = "task" with open(file_info.filename, "a") as f: # Write 150 more lines. f.write(f"{ray_constants.LOG_PREFIX_TASK_NAME}{task_name}\n") f.write("line") log_monitor.check_log_files_and_publish_updates() assert file_info.task_name == task_name assert file_info.actor_name is None mock_publisher.publish_logs.assert_any_call( { "ip": log_monitor.ip, "pid": file_info.worker_pid, "job": file_info.job_id, "is_err": file_info.is_err_file, "lines": ["line"], "actor_name": None, "task_name": task_name, } ) # Test the actor name is updated. actor_name = "actor" with open(file_info.filename, "a") as f: # Write 150 more lines. f.write(f"{ray_constants.LOG_PREFIX_ACTOR_NAME}{actor_name}\n") f.write("line2") log_monitor.check_log_files_and_publish_updates() assert file_info.task_name is None assert file_info.actor_name == actor_name mock_publisher.publish_logs.assert_any_call( { "ip": log_monitor.ip, "pid": file_info.worker_pid, "job": file_info.job_id, "is_err": file_info.is_err_file, "lines": ["line2"], "actor_name": actor_name, "task_name": None, } ) # Test the job_id is updated. job_id = "01000000" with open(file_info.filename, "a") as f: # Write 150 more lines. f.write(f"{ray_constants.LOG_PREFIX_JOB_ID}{job_id}\n") f.write("line2") log_monitor.check_log_files_and_publish_updates() assert file_info.job_id == job_id mock_publisher.publish_logs.assert_any_call( { "ip": log_monitor.ip, "pid": file_info.worker_pid, "job": file_info.job_id, "is_err": file_info.is_err_file, "lines": ["line2"], "actor_name": actor_name, "task_name": None, } ) @pytest.fixture def mock_timer(): f = time.time time.time = MagicMock() yield time.time time.time = f def test_log_monitor_update_backpressure(tmp_path, mock_timer): log_dir = tmp_path / "logs" log_dir.mkdir() mock_publisher = MagicMock() log_monitor = LogMonitor( "127.0.0.1", str(log_dir), mock_publisher, lambda _: True, max_files_open=5 ) current = 0 mock_timer.return_value = current log_monitor.log_filenames = [] # When threshold < RAY_LOG_MONITOR_MANY_FILES_THRESHOLD, update should happen. assert log_monitor.should_update_filenames(current) # Add a new file. log_monitor.log_filenames = [ "raylet.out" for _ in range(RAY_LOG_MONITOR_MANY_FILES_THRESHOLD) ] # If the threshold is met, we should update the file after # LOG_NAME_UPDATE_INTERVAL_S. assert not log_monitor.should_update_filenames(current) mock_timer.return_value = LOG_NAME_UPDATE_INTERVAL_S - 0.1 assert not log_monitor.should_update_filenames(current) mock_timer.return_value = LOG_NAME_UPDATE_INTERVAL_S assert not log_monitor.should_update_filenames(current) mock_timer.return_value = LOG_NAME_UPDATE_INTERVAL_S + 0.1 assert log_monitor.should_update_filenames(current) def test_repr_inheritance(shutdown_only): """Tests that a subclass's repr is used in logging.""" logger = logging.getLogger(__name__) class MyClass: def __repr__(self) -> str: return "ThisIsMyCustomActorName" + ray_constants.TESTING_NEVER_DEDUP_TOKEN def do(self): logger.warning("text" + ray_constants.TESTING_NEVER_DEDUP_TOKEN) class MySubclass(MyClass): pass my_class_remote = ray.remote(MyClass) my_subclass_remote = ray.remote(MySubclass) f = io.StringIO() with redirect_stderr(f): my_class_actor = my_class_remote.remote() ray.get(my_class_actor.do.remote()) # Wait a little to be sure that we have captured the output time.sleep(1) print("", flush=True) print("", flush=True, file=sys.stderr) f = f.getvalue() assert "ThisIsMyCustomActorName" in f and "MySubclass" not in f f2 = io.StringIO() with redirect_stderr(f2): my_subclass_actor = my_subclass_remote.remote() ray.get(my_subclass_actor.do.remote()) # Wait a little to be sure that we have captured the output time.sleep(1) print("", flush=True, file=sys.stderr) f2 = f2.getvalue() assert "ThisIsMyCustomActorName" in f2 and "MySubclass" not in f2 def test_ray_does_not_break_makeRecord(): """Importing Ray used to cause `logging.makeRecord` to use the default record factory, rather than the factory set by `logging.setRecordFactory`. This tests validates that this bug is fixed. """ # Make a call with the cli logger to be sure that invoking the # cli logger does not mess up logging.makeRecord. with redirect_stdout(None): cli_logger.info("Cli logger invoked.") mockRecordFactory = Mock() try: logging.setLogRecordFactory(mockRecordFactory) # makeRecord needs 7 positional args. What the args are isn't consequential. makeRecord_args = [None] * 7 logging.Logger("").makeRecord(*makeRecord_args) # makeRecord called the expected factory. mockRecordFactory.assert_called_once() finally: # Set it back to the default factory. logging.setLogRecordFactory(logging.LogRecord) @pytest.mark.parametrize( "logger_name,logger_level", ( ("ray", logging.INFO), ("ray.air", logging.INFO), ("ray.data", logging.INFO), ("ray.rllib", logging.WARNING), ("ray.serve", logging.INFO), ("ray.train", logging.INFO), ("ray.tune", logging.INFO), ), ) @pytest.mark.parametrize( "test_level", ( logging.NOTSET, logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR, logging.CRITICAL, ), ) def test_log_level_settings( propagate_logs, caplog, logger_name, logger_level, test_level ): """Test that logs of lower level than the ray subpackage is configured for are rejected. """ logger = logging.getLogger(logger_name) logger.log(test_level, "Test!") if test_level >= logger_level: assert caplog.records, "Log message missing where one is expected." assert caplog.records[-1].levelno == test_level, "Log message level mismatch." else: assert len(caplog.records) == 0, "Log message found where none are expected." def test_log_with_import(): logger = logging.getLogger(__name__) assert not logger.disabled ray.log.logger_initialized = False ray.log.generate_logging_config() assert not logger.disabled @pytest.mark.skipif(sys.platform != "linux", reason="Only works on linux.") def test_log_monitor_ip_correct(ray_start_cluster): cluster = ray_start_cluster # add first node cluster.add_node( node_ip_address="127.0.0.2", resources={"pin_task": 1}, ) address = cluster.address ray.init(address) # add second node cluster.add_node(node_ip_address="127.0.0.3") @ray.remote(resources={"pin_task": 1}) def print_msg(): print("abc") p = init_log_pubsub() ray.get(print_msg.remote()) data = get_log_data( p, num=6, timeout=10, job_id=ray.get_runtime_context().get_job_id() ) assert data[0]["ip"] == "127.0.0.2" def get_print_worker_logs_output(data: Dict[str, str]) -> str: """ Helper function that returns the output of `print_worker_logs` as a str. """ out = io.StringIO() print_worker_logs(data, out) out.seek(0) return out.readline() def test_print_worker_logs_default_color() -> None: # Test multiple since pid may affect color for pid in (0, 1): data = dict( ip="10.0.0.1", localhost="172.0.0.1", pid=str(pid), task_name="my_task", lines=["is running"], ) output = get_print_worker_logs_output(data) assert output == ( f"{colorama.Fore.CYAN}(my_task pid={pid}, ip=10.0.0.1)" + f"{colorama.Style.RESET_ALL} is running\n" ) # Special case raylet = dict( ip="10.0.0.1", localhost="172.0.0.1", pid="raylet", task_name="my_task", lines=["Warning: uh oh"], ) output = get_print_worker_logs_output(raylet) assert output == ( f"{colorama.Fore.YELLOW}(raylet, ip=10.0.0.1){colorama.Style.RESET_ALL} " + "Warning: uh oh\n" ) @patch.dict(os.environ, {"RAY_COLOR_PREFIX": "0"}) def test_print_worker_logs_no_color() -> None: for pid in (0, 1): data = dict( ip="10.0.0.1", localhost="172.0.0.1", pid=str(pid), task_name="my_task", lines=["is running"], ) output = get_print_worker_logs_output(data) assert output == f"(my_task pid={pid}, ip=10.0.0.1) is running\n" raylet = dict( ip="10.0.0.1", localhost="172.0.0.1", pid="raylet", task_name="my_task", lines=["Warning: uh oh"], ) output = get_print_worker_logs_output(raylet) assert output == "(raylet, ip=10.0.0.1) Warning: uh oh\n" @patch.dict(os.environ, {"RAY_COLOR_PREFIX": "1"}) def test_print_worker_logs_multi_color() -> None: data_pid_0 = dict( ip="10.0.0.1", localhost="172.0.0.1", pid="0", task_name="my_task", lines=["is running"], ) output = get_print_worker_logs_output(data_pid_0) assert output == ( f"{colorama.Fore.MAGENTA}(my_task pid=0, ip=10.0.0.1)" + f"{colorama.Style.RESET_ALL} is running\n" ) data_pid_2 = dict( ip="10.0.0.1", localhost="172.0.0.1", pid="2", task_name="my_task", lines=["is running"], ) output = get_print_worker_logs_output(data_pid_2) assert output == ( f"{colorama.Fore.GREEN}(my_task pid=2, ip=10.0.0.1){colorama.Style.RESET_ALL} " + "is running\n" ) class TestSetupLogRecordFactory: def test_setup_log_record_factory_directly(self): # Reset the log record factory to the default. logging.setLogRecordFactory(logging.LogRecord) record_old = logging.makeLogRecord({}) # Set up the log record factory with _setup_log_record_factory(). ray.log._setup_log_record_factory() record_new = logging.makeLogRecord({}) assert "_ray_timestamp_ns" not in record_old.__dict__ assert "_ray_timestamp_ns" in record_new.__dict__ def test_setup_log_record_factory_in_generate_logging_config(self): # Reset the log record factory to the default. logging.setLogRecordFactory(logging.LogRecord) record_old = logging.makeLogRecord({}) # generate_logging_config() also setup the log record factory. ray.log.logger_initialized = False ray.log.generate_logging_config() record_new = logging.makeLogRecord({}) assert "_ray_timestamp_ns" not in record_old.__dict__ assert "_ray_timestamp_ns" in record_new.__dict__ def test_agent_log_auto_increment(ray_start_cluster): cluster = ray_start_cluster # add first node cluster.add_node(num_cpus=1) ray.init(address=cluster.address) # add second node cluster.add_node(num_cpus=1) session_dir = cluster.head_node.get_session_dir_path() logs_dir = os.path.join(session_dir, "logs") def verify_logs_present(): dashboard_logs = sorted(glob(os.path.join(logs_dir, "dashboard_agent*.log"))) runtime_env_logs = sorted( glob(os.path.join(logs_dir, "runtime_env_agent*.log")) ) dashboard_log_names = [os.path.basename(p) for p in dashboard_logs] runtime_env_log_names = [os.path.basename(p) for p in runtime_env_logs] assert "dashboard_agent.log" in dashboard_log_names, dashboard_log_names assert "dashboard_agent.1.log" in dashboard_log_names, dashboard_log_names assert "runtime_env_agent.log" in runtime_env_log_names, runtime_env_log_names assert "runtime_env_agent.1.log" in runtime_env_log_names, runtime_env_log_names return True wait_for_condition(verify_logs_present, timeout=15) if __name__ == "__main__": # Make subprocess happy in bazel. os.environ["LC_ALL"] = "en_US.UTF-8" os.environ["LANG"] = "en_US.UTF-8" sys.exit(pytest.main(["-sv", __file__]))