248 lines
8.8 KiB
Python
248 lines
8.8 KiB
Python
import io
|
|
import logging
|
|
import re
|
|
import time
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
import ray
|
|
from ray.data._internal.execution.interfaces.physical_operator import (
|
|
OpTask,
|
|
PhysicalOperator,
|
|
RefBundle,
|
|
TaskExecDriverStats,
|
|
)
|
|
from ray.data._internal.execution.interfaces.ref_bundle import BlockEntry
|
|
from ray.data._internal.execution.operators.input_data_buffer import (
|
|
InputDataBuffer,
|
|
)
|
|
from ray.data._internal.execution.operators.task_pool_map_operator import (
|
|
MapOperator,
|
|
)
|
|
from ray.data._internal.issue_detection.detectors.hanging_detector import (
|
|
DEFAULT_OP_TASK_STATS_MIN_COUNT,
|
|
DEFAULT_OP_TASK_STATS_STD_FACTOR,
|
|
HangingExecutionIssueDetector,
|
|
HangingExecutionIssueDetectorConfig,
|
|
)
|
|
from ray.data._internal.issue_detection.detectors.high_memory_detector import (
|
|
HighMemoryIssueDetector,
|
|
)
|
|
from ray.data._internal.util import GiB
|
|
from ray.data.block import BlockMetadata, TaskExecWorkerStats
|
|
from ray.data.context import DataContext
|
|
from ray.tests.conftest import * # noqa
|
|
|
|
|
|
class FakeOpTask(OpTask):
|
|
"""A fake OpTask for testing purposes."""
|
|
|
|
def __init__(self, task_index: int):
|
|
super().__init__(task_index)
|
|
|
|
def get_waitable(self):
|
|
"""Return a dummy waitable."""
|
|
return ray.put(None)
|
|
|
|
|
|
class FakeOperator(PhysicalOperator):
|
|
def __init__(self, name: str, data_context: DataContext):
|
|
super().__init__(name=name, input_dependencies=[], data_context=data_context)
|
|
|
|
def _add_input_inner(self, refs: RefBundle, input_index: int) -> None:
|
|
pass
|
|
|
|
def has_next(self) -> bool:
|
|
return False
|
|
|
|
def _get_next_inner(self) -> RefBundle:
|
|
assert False
|
|
|
|
def get_stats(self):
|
|
return {}
|
|
|
|
def get_active_tasks(self):
|
|
# Return active tasks based on what's in _running_tasks
|
|
# This ensures has_execution_finished() works correctly
|
|
return [FakeOpTask(task_idx) for task_idx in self.metrics._running_tasks]
|
|
|
|
|
|
class TestHangingExecutionIssueDetector:
|
|
def test_hanging_detector_configuration(self, restore_data_context):
|
|
"""Test hanging detector configuration and initialization."""
|
|
# Test default configuration from DataContext
|
|
ctx = DataContext.get_current()
|
|
default_config = ctx.issue_detectors_config.hanging_detector_config
|
|
assert default_config.op_task_stats_min_count == DEFAULT_OP_TASK_STATS_MIN_COUNT
|
|
assert (
|
|
default_config.op_task_stats_std_factor == DEFAULT_OP_TASK_STATS_STD_FACTOR
|
|
)
|
|
|
|
# Test custom configuration
|
|
min_count = 5
|
|
std_factor = 3.0
|
|
custom_config = HangingExecutionIssueDetectorConfig(
|
|
op_task_stats_min_count=min_count,
|
|
op_task_stats_std_factor=std_factor,
|
|
)
|
|
ctx.issue_detectors_config.hanging_detector_config = custom_config
|
|
|
|
detector = HangingExecutionIssueDetector(
|
|
dataset_id="id", operators=[], config=custom_config
|
|
)
|
|
assert detector._op_task_stats_min_count == min_count
|
|
assert detector._op_task_stats_std_factor_threshold == std_factor
|
|
|
|
@patch(
|
|
"ray.data._internal.execution.interfaces.op_runtime_metrics.DistributionTracker"
|
|
)
|
|
def test_basic_hanging_detection(
|
|
self, mock_stats_cls, ray_start_regular_shared, restore_data_context
|
|
):
|
|
# Set up logging capture
|
|
log_capture = io.StringIO()
|
|
handler = logging.StreamHandler(log_capture)
|
|
logger = logging.getLogger("ray.data._internal.issue_detection")
|
|
logger.addHandler(handler)
|
|
|
|
# Set up mock stats to return values that will trigger adaptive threshold
|
|
mocked_mean = 2.0 # Increase from 0.5 to 2.0 seconds
|
|
mocked_stddev = 0.2 # Increase from 0.05 to 0.2 seconds
|
|
mock_stats = mock_stats_cls.return_value
|
|
mock_stats.num_samples = 20 # Enough samples
|
|
mock_stats.mean = mocked_mean
|
|
mock_stats.stddev = mocked_stddev
|
|
|
|
# Explicitly enable hanging detection for this test
|
|
ctx = DataContext.get_current()
|
|
ctx.issue_detectors_config.detectors = [HangingExecutionIssueDetector]
|
|
detector_cfg = ctx.issue_detectors_config.hanging_detector_config
|
|
detector_cfg.detection_time_interval_s = 0.00
|
|
|
|
# test no hanging doesn't log hanging warning
|
|
def f1(x):
|
|
return x
|
|
|
|
_ = ray.data.range(1).map(f1).materialize()
|
|
|
|
log_output = log_capture.getvalue()
|
|
warn_msg = r"A task \(task_id=.+\) of operator .+(?:\(pid=.+, node_id=.+, attempt=.+\) )?has been running or stuck in scheduling for [\d\.]+s"
|
|
assert re.search(warn_msg, log_output) is None, log_output
|
|
|
|
# # test hanging does log hanging warning
|
|
def f2(x):
|
|
time.sleep(5.0) # Increase from 1.1 to 5.0 seconds to exceed new threshold
|
|
return x
|
|
|
|
_ = ray.data.range(1).map(f2).materialize()
|
|
|
|
log_output = log_capture.getvalue()
|
|
assert re.search(warn_msg, log_output) is not None, log_output
|
|
|
|
@patch("time.perf_counter")
|
|
def test_hanging_detector_detects_issues(
|
|
self, mock_perf_counter, ray_start_regular_shared
|
|
):
|
|
"""Test that the hanging detector correctly identifies tasks that exceed the adaptive threshold."""
|
|
|
|
# Configure hanging detector with extreme std_factor values
|
|
config = HangingExecutionIssueDetectorConfig(
|
|
op_task_stats_min_count=1,
|
|
op_task_stats_std_factor=1,
|
|
detection_time_interval_s=0,
|
|
)
|
|
op = FakeOperator("TestOperator", DataContext.get_current())
|
|
detector = HangingExecutionIssueDetector(
|
|
dataset_id="test_dataset", operators=[op], config=config
|
|
)
|
|
|
|
# Create a simple RefBundle for testing
|
|
block_ref = ray.put([{"id": 0}])
|
|
metadata = BlockMetadata(
|
|
num_rows=1, size_bytes=1, exec_stats=None, input_files=None
|
|
)
|
|
input_bundle = RefBundle(
|
|
blocks=(BlockEntry(block_ref, metadata),),
|
|
owns_blocks=True,
|
|
schema=None,
|
|
)
|
|
|
|
mock_perf_counter.return_value = 0.0
|
|
|
|
# Submit three tasks. Two of them finish immediately, while the third one hangs.
|
|
op.metrics.on_task_submitted(0, input_bundle)
|
|
op.metrics.on_task_submitted(1, input_bundle)
|
|
op.metrics.on_task_submitted(2, input_bundle)
|
|
op.metrics.on_task_finished(
|
|
0,
|
|
exception=None,
|
|
task_exec_stats=TaskExecWorkerStats(task_wall_time_s=1.0),
|
|
task_exec_driver_stats=TaskExecDriverStats(task_output_backpressure_s=0),
|
|
)
|
|
op.metrics.on_task_finished(
|
|
1,
|
|
exception=None,
|
|
task_exec_stats=TaskExecWorkerStats(task_wall_time_s=1.0),
|
|
task_exec_driver_stats=TaskExecDriverStats(task_output_backpressure_s=0),
|
|
)
|
|
|
|
# Start detecting — all tasks were submitted at t=0, so no time has elapsed.
|
|
issues = detector.detect()
|
|
assert len(issues) == 0
|
|
|
|
# Advance perf_counter to trigger the issue detection
|
|
mock_perf_counter.return_value = 10.0
|
|
|
|
# On the second detect() call, the hanging task should be detected
|
|
issues = detector.detect()
|
|
assert len(issues) > 0, "Expected hanging issue to be detected"
|
|
assert issues[0].issue_type.value == "hanging"
|
|
assert "has been running or stuck in scheduling for" in issues[0].message
|
|
assert "longer than the average task duration" in issues[0].message
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"configured_memory, actual_memory, should_return_issue",
|
|
[
|
|
# User has appropriately configured memory, so no issue.
|
|
(8 * GiB, 8 * GiB, False),
|
|
# User hasn't configured memory correctly and memory use is high, so issue.
|
|
(None, 8 * GiB, True),
|
|
(1 * GiB, 8 * GiB, True),
|
|
# User hasn't configured memory correctly but memory use is low, so no issue.
|
|
(None, 1 * GiB, False),
|
|
],
|
|
)
|
|
def test_high_memory_detection(
|
|
configured_memory, actual_memory, should_return_issue, restore_data_context
|
|
):
|
|
ctx = DataContext.get_current()
|
|
|
|
input_data_buffer = InputDataBuffer(ctx, input_data=[])
|
|
map_operator = MapOperator.create(
|
|
map_transformer=MagicMock(),
|
|
input_op=input_data_buffer,
|
|
data_context=ctx,
|
|
ray_remote_args={"memory": configured_memory},
|
|
)
|
|
map_operator._metrics = MagicMock(average_max_uss_per_task=actual_memory)
|
|
topology = {input_data_buffer: MagicMock(), map_operator: MagicMock()}
|
|
|
|
operators = list(topology.keys())
|
|
|
|
detector = HighMemoryIssueDetector(
|
|
dataset_id="id",
|
|
operators=operators,
|
|
config=ctx.issue_detectors_config.high_memory_detector_config,
|
|
)
|
|
issues = detector.detect()
|
|
|
|
assert should_return_issue == bool(issues)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
sys.exit(pytest.main(["-v", __file__]))
|