Files
ray-project--ray/python/ray/data/tests/test_streaming_executor.py
T
2026-07-13 13:17:40 +08:00

2192 lines
79 KiB
Python

import logging
import os
import pickle
import random
import threading
import time
import unittest
from concurrent.futures import ThreadPoolExecutor
from dataclasses import replace
from typing import List, Literal, Optional, Union
from unittest.mock import MagicMock, patch
import numpy as np
import pyarrow as pa
import pyarrow.parquet as pq
import pytest
import ray
from ray._private.test_utils import run_string_as_driver_nonblocking, wait_for_condition
from ray.data._internal.datasource.parquet_datasink import ParquetDatasink
from ray.data._internal.datasource.parquet_datasource import ParquetDatasource
from ray.data._internal.delegating_block_builder import DelegatingBlockBuilder
from ray.data._internal.execution.backpressure_policy.resource_budget_backpressure_policy import (
ResourceBudgetBackpressurePolicy,
)
from ray.data._internal.execution.block_ref_counter import BlockRefCounter
from ray.data._internal.execution.execution_callback import ExecutionCallback
from ray.data._internal.execution.interfaces import (
ExecutionOptions,
ExecutionResources,
PhysicalOperator,
)
from ray.data._internal.execution.interfaces.physical_operator import (
DataOpTask,
MetadataOpTask,
)
from ray.data._internal.execution.metadata_fetcher import (
InlineMetadataFetcher,
MetadataFetcher,
ThreadedMetadataFetcher,
)
from ray.data._internal.execution.operators.input_data_buffer import InputDataBuffer
from ray.data._internal.execution.operators.limit_operator import LimitOperator
from ray.data._internal.execution.operators.map_operator import MapOperator
from ray.data._internal.execution.operators.map_transformer import (
BlockMapTransformFn,
MapTransformer,
)
from ray.data._internal.execution.ranker import DefaultRanker
from ray.data._internal.execution.resource_manager import ResourceManager
from ray.data._internal.execution.streaming_executor import (
StreamingExecutor,
_debug_dump_topology,
)
from ray.data._internal.execution.streaming_executor_state import (
OpBufferQueue,
OpState,
OutputBackpressureGuard,
build_streaming_topology,
format_op_state_summary,
get_eligible_operators,
process_completed_tasks,
select_operator_to_run,
update_operator_states,
)
from ray.data._internal.execution.util import make_ref_bundles
from ray.data._internal.logical.operators import (
ListFiles,
MapRows,
Read,
ReadFiles,
Write,
)
from ray.data._internal.util import MiB
from ray.data.block import BlockAccessor, BlockMetadataWithSchema, TaskExecWorkerStats
from ray.data.context import EXECUTION_CALLBACKS_ENV_VAR, DataContext
from ray.data.tests.conftest import * # noqa
from ray.data.tests.conftest import noop_counter
from ray.data.tests.util import fetcher_has_pending_work
from ray.exceptions import GetTimeoutError
def mock_resource_manager(
global_limits=None,
global_usage=None,
):
empty_resource = ExecutionResources(0, 0, 0)
global_limits = global_limits or empty_resource
global_usage = global_usage or empty_resource
return MagicMock(
get_global_limits=MagicMock(return_value=global_limits),
get_global_usage=MagicMock(return_value=global_usage),
op_resource_allocator_enabled=MagicMock(return_value=True),
)
def mock_autoscaler():
return MagicMock()
@ray.remote
def sleep():
time.sleep(999)
def make_map_transformer(block_fn):
def map_fn(block_iter):
for block in block_iter:
yield block_fn(block)
return MapTransformer([BlockMapTransformFn(map_fn)])
def make_ref_bundle(x):
return make_ref_bundles([[x]])[0]
@pytest.mark.parametrize(
"verbose_progress",
[True, False],
)
def test_build_streaming_topology(verbose_progress, ray_start_regular_shared):
inputs = make_ref_bundles([[x] for x in range(20)])
o1 = InputDataBuffer(DataContext.get_current(), inputs)
o2 = MapOperator.create(
make_map_transformer(lambda block: [b * -1 for b in block]),
o1,
DataContext.get_current(),
)
o3 = MapOperator.create(
make_map_transformer(lambda block: [b * 2 for b in block]),
o2,
DataContext.get_current(),
)
topo = build_streaming_topology(
o3, ExecutionOptions(verbose_progress=verbose_progress), noop_counter()
)
assert len(topo) == 3, topo
assert o1 in topo, topo
assert not topo[o1].input_queues, topo
assert topo[o1].output_queue == topo[o2].input_queues[0], topo
assert topo[o2].output_queue == topo[o3].input_queues[0], topo
assert list(topo) == [o1, o2, o3]
def test_disallow_non_unique_operators(ray_start_regular_shared):
inputs = make_ref_bundles([[x] for x in range(20)])
# An operator [o1] cannot used in the same DAG twice.
o1 = InputDataBuffer(DataContext.get_current(), inputs)
o2 = MapOperator.create(
make_map_transformer(lambda block: [b * -1 for b in block]),
o1,
DataContext.get_current(),
)
o3 = MapOperator.create(
make_map_transformer(lambda block: [b * -1 for b in block]),
o1,
DataContext.get_current(),
)
o4 = PhysicalOperator(
"test_combine",
[o2, o3],
DataContext.get_current(),
)
with pytest.raises(ValueError):
build_streaming_topology(
o4, ExecutionOptions(verbose_progress=True), noop_counter()
)
def _make_disabled_guard() -> MagicMock:
"""Return a stub guard whose escape hatch never fires."""
guard = MagicMock(spec=OutputBackpressureGuard)
guard.should_unblock.return_value = False
return guard
def _process_completed_tasks_sync(
topo, backpressure_policies, max_errored_blocks, output_backpressure_guard
):
"""Run ``process_completed_tasks`` in the synchronous inline mode, so all
output is fetched and emitted before the call returns (test post-conditions
hold immediately). The default executor uses the threaded fetcher, which
lets emits land across iterations."""
return process_completed_tasks(
topo,
backpressure_policies,
max_errored_blocks,
output_backpressure_guard,
metadata_fetcher=InlineMetadataFetcher(),
)
def _process_completed_tasks_threaded(
topo, backpressure_policies, max_errored_blocks, output_backpressure_guard
):
"""Run ``process_completed_tasks`` in the threaded mode (the executor
default) and pump the fetcher until every background metadata fetch has
emitted, so test post-conditions hold once it returns — the threaded
counterpart of ``_process_completed_tasks_sync``.
Most executor-level tests drive the threaded mode through this helper;
``_process_completed_tasks_sync`` is kept for the few inline-mode tests.
"""
fetcher = ThreadedMetadataFetcher()
fetcher.start()
try:
result = process_completed_tasks(
topo,
backpressure_policies,
max_errored_blocks,
output_backpressure_guard,
metadata_fetcher=fetcher,
)
deadline = time.time() + 30
while fetcher_has_pending_work(fetcher):
if time.time() >= deadline:
raise TimeoutError("threaded metadata fetch did not finish within 30s")
fetcher.emit_ready_and_fire_done_callbacks()
time.sleep(0.005)
return result
finally:
fetcher.stop()
def test_process_completed_tasks_threaded(ray_start_regular_shared):
"""End-to-end check of the threaded mode through ``process_completed_tasks``:
pulled pairs are deferred, fetched on the background thread, and emitted
(per-op order) by ``emit_ready_and_fire_done_callbacks`` — yielding the same outputs as the
inline mode."""
inputs = make_ref_bundles([[x] for x in range(20)])
o1 = InputDataBuffer(DataContext.get_current(), inputs)
o2 = MapOperator.create(
make_map_transformer(lambda block: [b * -1 for b in block]),
o1,
DataContext.get_current(),
)
topo = build_streaming_topology(
o2, ExecutionOptions(verbose_progress=True), noop_counter()
)
assert len(topo[o1].output_queue) == 0, topo
_process_completed_tasks_threaded(topo, [], 0, _make_disabled_guard())
update_operator_states(topo)
assert len(topo[o1].output_queue) == 20, topo
def test_process_completed_tasks_threaded_multi_step(ray_start_regular_shared):
"""Threaded mode driven the way the executor does it: one long-lived
fetcher, ``process_completed_tasks`` called once per scheduling iteration.
Deferred pairs are emitted across successive calls, ending with the same
outputs as the single inline call in ``test_process_completed_tasks_inline``."""
inputs = make_ref_bundles([[x] for x in range(20)])
o1 = InputDataBuffer(DataContext.get_current(), inputs)
o2 = MapOperator.create(
make_map_transformer(lambda block: [b * -1 for b in block]),
o1,
DataContext.get_current(),
)
topo = build_streaming_topology(
o2, ExecutionOptions(verbose_progress=True), noop_counter()
)
assert len(topo[o1].output_queue) == 0, topo
fetcher = ThreadedMetadataFetcher()
fetcher.start()
try:
guard = _make_disabled_guard()
deadline = time.time() + 30
while len(topo[o1].output_queue) < 20 and time.time() < deadline:
process_completed_tasks(topo, [], 0, guard, metadata_fetcher=fetcher)
update_operator_states(topo)
time.sleep(0.005)
finally:
fetcher.stop()
assert len(topo[o1].output_queue) == 20, topo
@pytest.fixture
def sleep_task_ref():
sleep_task_ref = sleep.remote()
yield sleep_task_ref
ray.cancel(sleep_task_ref, force=True)
def test_process_completed_tasks_inline(sleep_task_ref, ray_start_regular_shared):
inputs = make_ref_bundles([[x] for x in range(20)])
o1 = InputDataBuffer(DataContext.get_current(), inputs)
o2 = MapOperator.create(
make_map_transformer(lambda block: [b * -1 for b in block]),
o1,
DataContext.get_current(),
)
topo = build_streaming_topology(
o2, ExecutionOptions(verbose_progress=True), noop_counter()
)
# Test processing output bundles.
assert len(topo[o1].output_queue) == 0, topo
_process_completed_tasks_sync(topo, [], 0, _make_disabled_guard())
update_operator_states(topo)
assert len(topo[o1].output_queue) == 20, topo
# Test processing completed work items.
sleep_task_callback = MagicMock()
sleep_task = MetadataOpTask(0, sleep_task_ref, sleep_task_callback)
done_task_callback = MagicMock()
done_task = MetadataOpTask(0, ray.put("done"), done_task_callback)
o2.get_active_tasks = MagicMock(return_value=[sleep_task, done_task])
o2.all_inputs_done = MagicMock()
o1.mark_execution_finished = MagicMock()
_process_completed_tasks_sync(topo, [], 0, _make_disabled_guard())
update_operator_states(topo)
sleep_task_callback.assert_not_called()
done_task_callback.assert_called_once()
o2.all_inputs_done.assert_not_called()
o1.mark_execution_finished.assert_not_called()
# Test input finalization.
done_task_callback = MagicMock()
done_task = MetadataOpTask(0, ray.put("done"), done_task_callback)
o2.get_active_tasks = MagicMock(return_value=[done_task])
o2.all_inputs_done = MagicMock()
o1.mark_execution_finished = MagicMock()
o1.has_completed = MagicMock(return_value=True)
topo[o1].output_queue.clear()
_process_completed_tasks_sync(topo, [], 0, _make_disabled_guard())
update_operator_states(topo)
done_task_callback.assert_called_once()
o2.all_inputs_done.assert_called_once()
o1.mark_execution_finished.assert_not_called()
# Test dependents completed.
o1 = InputDataBuffer(DataContext.get_current(), inputs)
o2 = MapOperator.create(
make_map_transformer(lambda block: [b * -1 for b in block]),
o1,
DataContext.get_current(),
)
o3 = MapOperator.create(
make_map_transformer(lambda block: [b * -1 for b in block]),
o2,
DataContext.get_current(),
)
topo = build_streaming_topology(
o3, ExecutionOptions(verbose_progress=True), noop_counter()
)
o3.mark_execution_finished()
o2.mark_execution_finished = MagicMock()
_process_completed_tasks_sync(topo, [], 0, _make_disabled_guard())
update_operator_states(topo)
o2.mark_execution_finished.assert_called_once()
def test_update_operator_states_drains_upstream(ray_start_regular_shared):
"""Test that update_operator_states drains upstream output queues when
execution_finished() is called on a downstream operator.
"""
inputs = make_ref_bundles([[x] for x in range(10)])
o1 = InputDataBuffer(DataContext.get_current(), inputs)
o2 = MapOperator.create(
make_map_transformer(lambda block: [b * -1 for b in block]),
o1,
DataContext.get_current(),
)
o3 = MapOperator.create(
make_map_transformer(lambda block: [b * 2 for b in block]),
o2,
DataContext.get_current(),
)
topo = build_streaming_topology(
o3, ExecutionOptions(verbose_progress=True), noop_counter()
)
# First, populate the upstream output queues by processing some tasks
_process_completed_tasks_threaded(topo, [], 0, _make_disabled_guard())
update_operator_states(topo)
# Verify that o1 (upstream) has output in its queue
assert (
len(topo[o1].output_queue) > 0
), "Upstream operator should have output in queue"
# Store initial queue size for verification
initial_o1_queue_size = len(topo[o1].output_queue)
# Manually mark o2 as execution finished (simulating limit operator behavior)
o2.mark_execution_finished()
assert o2.has_execution_finished(), "o2 should be execution finished"
# Call update_operator_states - this should drain o1's output queue
update_operator_states(topo)
# Verify that o1's output queue was drained due to o2 being execution finished
assert len(topo[o1].output_queue) == 0, (
f"Upstream operator o1 output queue should be drained when downstream o2 is execution finished. "
f"Expected 0, got {len(topo[o1].output_queue)}. "
f"Initial size was {initial_o1_queue_size}"
)
def test_get_eligible_operators_to_run(ray_start_regular_shared):
opts = ExecutionOptions()
inputs = make_ref_bundles([[x] for x in range(1)])
o1 = InputDataBuffer(DataContext.get_current(), inputs)
o2 = MapOperator.create(
make_map_transformer(lambda block: block),
o1,
DataContext.get_current(),
name="O2",
)
o3 = MapOperator.create(
make_map_transformer(lambda block: block),
o2,
DataContext.get_current(),
name="O3",
)
topo = build_streaming_topology(o3, opts, noop_counter())
resource_manager = mock_resource_manager(
global_limits=ExecutionResources.for_limits(1, 1, 1),
)
memory_usage = {o1: 0, o2: 0, o3: 0}
resource_manager.get_op_usage = MagicMock(
side_effect=lambda op: ExecutionResources(0, 0, memory_usage[op])
)
def _get_eligible_ops_to_run(ensure_liveness: bool):
return get_eligible_operators(topo, [], ensure_liveness=ensure_liveness)
# Test empty.
assert _get_eligible_ops_to_run(ensure_liveness=False) == []
# `o2` is the only operator with at least one input.
topo[o1].output_queue.append(make_ref_bundle("dummy1"))
memory_usage[o1] += 1
assert _get_eligible_ops_to_run(ensure_liveness=False) == [o2]
# Both `o2` and `o3` have at least one input, but `o3` has less memory usage.
topo[o2].output_queue.append(make_ref_bundle("dummy3"))
memory_usage[o2] += 1
assert _get_eligible_ops_to_run(ensure_liveness=False) == [o2, o3]
# `o2`s queue is not empty, but it can't accept new inputs anymore
with patch.object(o2, "can_add_input") as _mock:
_mock.return_value = False
assert _get_eligible_ops_to_run(ensure_liveness=False) == [o3]
# Completed ops are not eligible
with patch.object(o3, "has_completed") as _mock:
_mock.return_value = True
assert _get_eligible_ops_to_run(ensure_liveness=False) == [o2]
# `o2` operator is now back-pressured
with patch.object(
ResourceBudgetBackpressurePolicy, "can_add_input"
) as mock_can_add_input:
mock_can_add_input.side_effect = lambda op: op is not o2
test_policy = ResourceBudgetBackpressurePolicy(
MagicMock(), MagicMock(), MagicMock()
)
def _get_eligible_ops_to_run_with_policy(ensure_liveness: bool):
return get_eligible_operators(
topo, [test_policy], ensure_liveness=ensure_liveness
)
assert _get_eligible_ops_to_run_with_policy(ensure_liveness=False) == [o3]
# Complete `o3`
with patch.object(o3, "has_completed") as _mock:
_mock.return_value = True
# Clear up input queue
topo[o3].input_queues[0].clear()
# To ensure liveness back-pressure limits will be ignored
assert _get_eligible_ops_to_run_with_policy(ensure_liveness=True) == [o2]
def test_backpressure_policy_tracking(ray_start_regular_shared):
"""Test that backpressure policies that triggered are tracked correctly."""
opts = ExecutionOptions()
inputs = make_ref_bundles([[x] for x in range(1)])
o1 = InputDataBuffer(DataContext.get_current(), inputs)
o2 = MapOperator.create(
make_map_transformer(lambda block: block),
o1,
DataContext.get_current(),
name="O2",
)
topo = build_streaming_topology(o2, opts, noop_counter())
# Add input to o2's input queue so it becomes eligible
topo[o1].output_queue.append(make_ref_bundle("dummy1"))
# Create mock backpressure policies with name property
class MockPolicy1:
@property
def name(self):
return "MockPolicy1"
def can_add_input(self, op):
return op is not o2 # Block o2
def max_task_output_bytes_to_read(self, op):
return None
class MockPolicy2:
@property
def name(self):
return "MockPolicy2"
def can_add_input(self, op):
return True # Allow all
def max_task_output_bytes_to_read(self, op):
return None
class MockPolicy3:
@property
def name(self):
return "MockPolicy3"
def can_add_input(self, op):
return op is not o2 # Block o2
def max_task_output_bytes_to_read(self, op):
return None
policies = [MockPolicy1(), MockPolicy2(), MockPolicy3()]
# Call get_eligible_operators which should track triggered policies
get_eligible_operators(topo, policies, ensure_liveness=False)
# Check that o2 has the first triggered policy tracked
assert o2._in_task_submission_backpressure is True
assert o2._task_submission_backpressure_policy == "MockPolicy1"
# Now test with no backpressure
class AllowAllPolicy:
@property
def name(self):
return "AllowAll"
def can_add_input(self, op):
return True
def max_task_output_bytes_to_read(self, op):
return None
get_eligible_operators(topo, [AllowAllPolicy()], ensure_liveness=False)
# Check that o2 is no longer in backpressure
assert o2._in_task_submission_backpressure is False
assert o2._task_submission_backpressure_policy is None
def test_output_backpressure_policy_tracking(ray_start_regular_shared):
"""Test that output backpressure policies are tracked correctly."""
opts = ExecutionOptions()
inputs = make_ref_bundles([[x] for x in range(1)])
o1 = InputDataBuffer(DataContext.get_current(), inputs)
o2 = MapOperator.create(
make_map_transformer(lambda block: block),
o1,
DataContext.get_current(),
name="O2",
)
topo = build_streaming_topology(o2, opts, noop_counter())
# Create mock backpressure policies for output limiting with name property
class LimitingPolicy:
@property
def name(self):
return "Limiting"
def can_add_input(self, op):
return True
def max_task_output_bytes_to_read(self, op):
return 0 if op is o2 else None # Block o2 output
class NonLimitingPolicy:
@property
def name(self):
return "NonLimiting"
def can_add_input(self, op):
return True
def max_task_output_bytes_to_read(self, op):
return 1000 # Allow some output
class NoLimitPolicy:
@property
def name(self):
return "NoLimit"
def can_add_input(self, op):
return True
def max_task_output_bytes_to_read(self, op):
return None # No limit
policies = [LimitingPolicy(), NonLimitingPolicy(), NoLimitPolicy()]
# Call process_completed_tasks which tracks output policies
_process_completed_tasks_threaded(topo, policies, 0, _make_disabled_guard())
# Check that o2 has the first limiting policy tracked
assert o2._in_task_output_backpressure is True
assert o2._task_output_backpressure_policy == "Limiting"
# Now test with no output backpressure
_process_completed_tasks_threaded(
topo, [NonLimitingPolicy()], 0, _make_disabled_guard()
)
# Check that o2 is no longer in output backpressure
assert o2._in_task_output_backpressure is False
assert o2._task_output_backpressure_policy is None
def test_process_completed_tasks_unblocks_when_non_resource_budget_policy_zeros_limit(
ray_start_regular_shared,
):
"""Test that the escape hatch fires when a non-resource-budget policy drives the aggregated output limit to 0."""
inputs = make_ref_bundles([[x] for x in range(1)])
o1 = InputDataBuffer(DataContext.get_current(), inputs)
o2 = MapOperator.create(
make_map_transformer(lambda block: block),
o1,
DataContext.get_current(),
name="O2",
)
topo = build_streaming_topology(o2, ExecutionOptions(), noop_counter())
resource_manager = ResourceManager(
topo,
ExecutionOptions(),
MagicMock(),
DataContext.get_current(),
BlockRefCounter(),
)
guard = OutputBackpressureGuard(topo, resource_manager)
# Fake policy that returns 0 for o2
class ZeroLimitPolicy:
@property
def name(self):
return "ZeroLimit"
def can_add_input(self, op):
return True
def max_task_output_bytes_to_read(self, op):
return 0 if op is o2 else None
_process_completed_tasks_threaded(topo, [ZeroLimitPolicy()], 0, guard)
# o2 is terminal with no downstream eligible ops and no external
# consumer — the guard's terminal-op branch should unblock, bumping
# the limit from 0 to 1, so o2 is NOT flagged as in output backpressure
# and the policy attribution should be cleared.
assert o2._in_task_output_backpressure is False
assert o2._task_output_backpressure_policy is None
def test_summary_str_backpressure_policies(ray_start_regular_shared):
"""Test that summary_str correctly displays backpressure policy names."""
opts = ExecutionOptions()
inputs = make_ref_bundles([[x] for x in range(1)])
o1 = InputDataBuffer(DataContext.get_current(), inputs)
o2 = MapOperator.create(
make_map_transformer(lambda block: block),
o1,
DataContext.get_current(),
name="O2",
)
topo = build_streaming_topology(o2, opts, noop_counter())
resource_manager = mock_resource_manager()
# Test with no backpressure
summary = format_op_state_summary(topo[o2], resource_manager)
assert "backpressured" not in summary
# Set task submission backpressure with policy (using UX name)
o2._in_task_submission_backpressure = True
o2._task_submission_backpressure_policy = "ConcurrencyCap"
summary = format_op_state_summary(topo[o2], resource_manager)
assert "tasks(ConcurrencyCap)" in summary
# Set output backpressure with policy (using UX name)
o2._in_task_output_backpressure = True
o2._task_output_backpressure_policy = "ResourceBudget"
summary = format_op_state_summary(topo[o2], resource_manager)
assert "tasks(ConcurrencyCap)" in summary
assert "outputs(ResourceBudget)" in summary
# Clear backpressure
o2._in_task_submission_backpressure = False
o2._task_submission_backpressure_policy = None
o2._in_task_output_backpressure = False
o2._task_output_backpressure_policy = None
summary = format_op_state_summary(topo[o2], resource_manager)
assert "backpressured" not in summary
def test_rank_operators(ray_start_regular_shared):
inputs = make_ref_bundles([[x] for x in range(1)])
o1 = InputDataBuffer(DataContext.get_current(), inputs)
o2 = MapOperator.create(
make_map_transformer(lambda block: block), o1, DataContext.get_current()
)
o3 = MapOperator.create(
make_map_transformer(lambda block: block), o2, DataContext.get_current()
)
o4 = LimitOperator(1, o3, DataContext.get_current())
resource_manager = mock_resource_manager(
global_usage=ExecutionResources(cpu=1),
global_limits=ExecutionResources.for_limits(cpu=1),
)
def _get_op_usage_mocked(op):
if op is o1:
return ExecutionResources(object_store_memory=1024)
elif op is o2:
return ExecutionResources(object_store_memory=2048)
elif op is o3:
return ExecutionResources(object_store_memory=4096)
return ExecutionResources(object_store_memory=8092)
resource_manager.get_op_usage.side_effect = _get_op_usage_mocked
ranker = DefaultRanker()
ranks = ranker.rank_operators([o1, o2, o3, o4], {}, resource_manager)
assert [(1, 1024), (1, 2048), (1, 4096), (0, 8092)] == ranks
def test_select_ops_to_run(ray_start_regular_shared):
opts = ExecutionOptions()
inputs = make_ref_bundles([[x] for x in range(1)])
o1 = InputDataBuffer(DataContext.get_current(), inputs)
o2 = MapOperator.create(
make_map_transformer(lambda block: block), o1, DataContext.get_current()
)
o3 = MapOperator.create(
make_map_transformer(lambda block: block), o2, DataContext.get_current()
)
o4 = LimitOperator(1, o3, DataContext.get_current())
resource_manager = mock_resource_manager(
global_usage=ExecutionResources(cpu=1),
global_limits=ExecutionResources.for_limits(cpu=1),
)
def _get_op_usage_mocked(op):
if op is o1:
return ExecutionResources(object_store_memory=1024)
elif op is o2:
return ExecutionResources(object_store_memory=2048)
elif op is o3:
return ExecutionResources(object_store_memory=4096)
return ExecutionResources(object_store_memory=8092)
resource_manager.get_op_usage.side_effect = _get_op_usage_mocked
# NOTE: This value is irrelevant since we mock out get_eligible_operators
ensure_liveness = False
with patch(
"ray.data._internal.execution.streaming_executor_state.get_eligible_operators"
) as _mock:
# Case 1: Should pick the `o4` since it has throttling disabled
_mock.return_value = [o1, o2, o3, o4]
topo = build_streaming_topology(o4, opts, noop_counter())
selected = select_operator_to_run(
topo,
resource_manager,
[],
ensure_liveness=ensure_liveness,
ranker=DefaultRanker(),
)
assert selected is o4
# Case 2: Should pick the `o1` since it has lowest object store usage
_mock.return_value = [o1, o2, o3]
topo = build_streaming_topology(o3, opts, noop_counter())
selected = select_operator_to_run(
topo,
resource_manager,
[],
ensure_liveness=ensure_liveness,
ranker=DefaultRanker(),
)
assert selected is o1
def test_dispatch_next_task(ray_start_regular_shared):
inputs = make_ref_bundles([[x] for x in range(20)])
o1 = InputDataBuffer(DataContext.get_current(), inputs)
o1_state = OpState(o1, [])
o2 = MapOperator.create(
make_map_transformer(lambda block: [b * -1 for b in block]),
o1,
DataContext.get_current(),
)
op_state = OpState(o2, [o1_state.output_queue])
# TODO: test multiple inqueues with the union operator.
ref1 = make_ref_bundle("dummy1")
ref2 = make_ref_bundle("dummy2")
op_state.input_queues[0].append(ref1)
op_state.input_queues[0].append(ref2)
o2.add_input = MagicMock()
op_state.dispatch_next_task()
o2.add_input.assert_called_once_with(ref1, input_index=0)
o2.add_input = MagicMock()
op_state.dispatch_next_task()
o2.add_input.assert_called_once_with(ref2, input_index=0)
def test_debug_dump_topology(ray_start_regular_shared):
opt = ExecutionOptions()
inputs = make_ref_bundles([[x] for x in range(20)])
o1 = InputDataBuffer(DataContext.get_current(), inputs)
o2 = MapOperator.create(
make_map_transformer(lambda block: [b * -1 for b in block]),
o1,
DataContext.get_current(),
)
o3 = MapOperator.create(
make_map_transformer(lambda block: [b * 2 for b in block]),
o2,
DataContext.get_current(),
)
topo = build_streaming_topology(o3, opt, noop_counter())
resource_manager = ResourceManager(
topo,
ExecutionOptions(),
MagicMock(return_value=ExecutionResources.zero()),
DataContext.get_current(),
BlockRefCounter(),
)
resource_manager.update_usages()
# Just a sanity check to ensure it doesn't crash.
_debug_dump_topology(topo, resource_manager)
def test_configure_output_locality_is_deprecated_noop(restore_data_context):
data_context = ray.data.DataContext.get_current()
with pytest.warns(DeprecationWarning, match="no-op"):
data_context.execution_options.locality_with_output = True
assert data_context.execution_options.locality_with_output is False
class OpBufferQueueTest(unittest.TestCase):
def test_invalid_split_index(self):
"""Test that out-of-range split index raises AssertionError."""
queue = OpBufferQueue(num_splits=2)
with pytest.raises(AssertionError):
queue.has_next(2)
with pytest.raises(AssertionError):
queue.pop(2)
def test_e2e_multi_threading(self):
num_blocks = 5_000
num_splits = 20
num_per_split = num_blocks // num_splits
ref_bundles = make_ref_bundles([[[i]] for i in range(num_blocks)])
q = OpBufferQueue(num_splits=num_splits)
start = threading.Event()
done = threading.Event()
def produce():
# Sync producers and consumers
start.wait()
try:
for i, ref_bundle in enumerate(ref_bundles):
if i % 10 == 1:
print(f">>> Queue size {len(q)}")
# Introduce jitter
time.sleep(random.random() * 1e-4)
ref_bundle = replace(ref_bundle, output_split_idx=i % num_splits)
q.append(ref_bundle)
except Exception as e:
print(f">>> Caught exception: {e}")
raise
done.set()
return True
consumed_splits = [[] for _ in range(num_splits)]
def consume(output_split_idx):
# Sync producers and consumers
start.wait()
try:
# Iterate until both are true:
# - Producer is done
# - Queue is empty
while not done.is_set() or q.has_next(output_split_idx):
# We add tiny sleep of 1us to avoid thrashing GIL with
# tight loops
time.sleep(1e-6)
b = q.pop(output_split_idx)
if b is not None:
assert b.output_split_idx == output_split_idx
consumed_splits[output_split_idx].append(b)
except Exception as e:
print(f">>> Caught exception: {e}")
raise
return True
with ThreadPoolExecutor(max_workers=num_splits + 1) as executor:
futures = [executor.submit(produce)] + [
executor.submit(consume, i) for i in range(num_splits)
]
# Start the test
start.set()
print(">>> Test started")
for f in futures:
assert f.result() is True, f.result()
# Verify count, FIFO order per split
for split_idx in range(num_splits):
consumed = consumed_splits[split_idx]
expected = [
replace(b, output_split_idx=split_idx)
for b in ref_bundles[split_idx::num_splits]
]
assert len(consumed) == num_per_split
assert consumed == expected, f"Split {split_idx}: FIFO order violated"
# Verify queue is fully drained
assert len(q) == 0
assert q.num_blocks == 0
assert q.memory_usage == 0
class GetOutputBlockingTest(unittest.TestCase):
def test_num_waiting_consumers_tracking(self):
"""num_waiting_consumers is incremented/decremented by get_output_blocking."""
o1 = InputDataBuffer(ray.data.DataContext.get_current(), [])
o2 = LimitOperator(1, o1, ray.data.DataContext.get_current())
topo = build_streaming_topology(o2, ExecutionOptions(), noop_counter())
state = topo[o2]
assert state.num_waiting_consumers == 0
# Consumer blocks — counter should be 1.
t = threading.Thread(target=state.get_output_blocking, args=(None,))
t.start()
wait_for_condition(lambda: state.num_waiting_consumers == 1)
# Unblock by adding a bundle — counter should go back to 0.
bundle = make_ref_bundles([[0]])[0]
state.output_queue.append(bundle)
t.join()
assert state.num_waiting_consumers == 0
# Counter is decremented after StopIteration.
def get_until_stop():
with pytest.raises(StopIteration):
state.get_output_blocking(None)
t2 = threading.Thread(target=get_until_stop)
t2.start()
wait_for_condition(lambda: state.num_waiting_consumers == 1)
state.mark_finished()
t2.join()
assert state.num_waiting_consumers == 0
def test_num_waiting_consumers_concurrent(self):
"""num_waiting_consumers reflects multiple blocked consumers.
For example, this happens for multiple streaming_split iterators."""
o1 = InputDataBuffer(ray.data.DataContext.get_current(), [])
o2 = LimitOperator(1, o1, ray.data.DataContext.get_current())
topo = build_streaming_topology(o2, ExecutionOptions(), noop_counter())
state = topo[o2]
def blocking_consumer():
try:
state.get_output_blocking(None)
except StopIteration:
pass
t1 = threading.Thread(target=blocking_consumer)
t2 = threading.Thread(target=blocking_consumer)
t1.start()
t2.start()
wait_for_condition(lambda: state.num_waiting_consumers == 2)
# Unblock one consumer.
state.output_queue.append(make_ref_bundles([[0]])[0])
wait_for_condition(lambda: state.num_waiting_consumers == 1)
# Unblock the other consumer.
state.mark_finished()
wait_for_condition(lambda: state.num_waiting_consumers == 0)
t1.join()
t2.join()
assert state.num_waiting_consumers == 0
def test_exception_concise_stacktrace():
driver_script = """
import ray
def map(_):
raise ValueError("foo")
ray.data.range(1).map(map).take_all()
"""
proc = run_string_as_driver_nonblocking(driver_script)
out_str = proc.stdout.read().decode("utf-8") + proc.stderr.read().decode("utf-8")
# Test that the stack trace only contains the UDF exception, but not any other
# exceptions raised when the executor is handling the UDF exception.
assert (
"During handling of the above exception, another exception occurred"
not in out_str
), out_str
def test_streaming_exec_schedule_s(ray_start_regular_shared):
ds = ray.data.range(1)
for _ in ds.iter_batches():
continue
ds_stats = ds._raw_stats()
assert ds_stats.streaming_exec_schedule_s.get() > 0
def test_execution_callbacks_on_success(ray_start_regular_shared):
before_execution_starts_called = False
after_execution_succeeds_called = False
on_execution_step_called = False
execution_error = None
class CustomExecutionCallback(ExecutionCallback):
def before_execution_starts(self, executor: StreamingExecutor):
nonlocal before_execution_starts_called
before_execution_starts_called = True
def on_execution_step(self, executor: "StreamingExecutor"):
nonlocal on_execution_step_called
on_execution_step_called = True
def after_execution_succeeds(self, executor: StreamingExecutor):
nonlocal after_execution_succeeds_called
after_execution_succeeds_called = True
def after_execution_fails(self, executor: StreamingExecutor, error: Exception):
nonlocal execution_error
execution_error = error
ctx = DataContext.get_current()
ctx.custom_execution_callback_classes.append(CustomExecutionCallback)
ray.data.range(1).take_all()
assert before_execution_starts_called
assert after_execution_succeeds_called
assert on_execution_step_called
assert execution_error is None
def test_execution_callbacks_on_error(ray_start_regular_shared):
before_execution_starts_called = False
after_execution_succeeds_called = False
on_execution_step_called = False
execution_error = None
class CustomExecutionCallback(ExecutionCallback):
def before_execution_starts(self, executor: StreamingExecutor):
nonlocal before_execution_starts_called
before_execution_starts_called = True
def on_execution_step(self, executor: "StreamingExecutor"):
nonlocal on_execution_step_called
on_execution_step_called = True
def after_execution_succeeds(self, executor: StreamingExecutor):
nonlocal after_execution_succeeds_called
after_execution_succeeds_called = True
def after_execution_fails(self, executor: StreamingExecutor, error: Exception):
nonlocal execution_error
execution_error = error
ctx = DataContext.get_current()
ctx.raise_original_map_exception = True
ctx.custom_execution_callback_classes.append(CustomExecutionCallback)
def map_fn(_):
raise ValueError("")
with pytest.raises(ValueError):
ray.data.range(1).map(map_fn).take_all()
assert before_execution_starts_called
assert not after_execution_succeeds_called
assert on_execution_step_called
assert isinstance(execution_error, ValueError), execution_error
def test_execution_callbacks_on_cancel(ray_start_regular_shared):
execution_error = None
class CustomExecutionCallback(ExecutionCallback):
def after_execution_fails(self, executor: StreamingExecutor, error: Exception):
nonlocal execution_error
execution_error = error
ctx = DataContext.get_current()
ctx.custom_execution_callback_classes.append(CustomExecutionCallback)
def patched_get_outupt_blocking(*args, **kwargs):
raise KeyboardInterrupt()
with patch(
"ray.data._internal.execution.streaming_executor.OpState.get_output_blocking",
new=patched_get_outupt_blocking,
):
with pytest.raises(KeyboardInterrupt):
ray.data.range(1).take_all()
assert isinstance(execution_error, KeyboardInterrupt), execution_error
@patch("importlib.import_module")
@patch.dict(os.environ, {EXECUTION_CALLBACKS_ENV_VAR: "my.module.TestCallback"})
def test_env_callbacks_loaded(mock_import):
"""Test loading execution callbacks from environment variable."""
class TestCallback(ExecutionCallback):
pass
mock_module = MagicMock()
mock_module.TestCallback = TestCallback
mock_import.return_value = mock_module
ctx = DataContext.get_current()
callback_classes = ctx.execution_callback_classes
mock_import.assert_called_with("my.module")
assert TestCallback in callback_classes
@patch("importlib.import_module")
@patch.dict(
os.environ, {EXECUTION_CALLBACKS_ENV_VAR: "module1.Callback1,module2.Callback2"}
)
def test_multiple_env_callbacks(mock_import):
"""Test loading multiple callbacks from environment variable."""
class Callback1(ExecutionCallback):
pass
class Callback2(ExecutionCallback):
pass
mock_module1 = MagicMock()
mock_module2 = MagicMock()
mock_module1.Callback1 = Callback1
mock_module2.Callback2 = Callback2
def side_effect(name):
if name == "module1":
return mock_module1
elif name == "module2":
return mock_module2
raise ImportError(f"No module named '{name}'")
mock_import.side_effect = side_effect
ctx = DataContext.get_current()
callback_classes = ctx.execution_callback_classes
assert Callback1 in callback_classes
assert Callback2 in callback_classes
@patch.dict(os.environ, {EXECUTION_CALLBACKS_ENV_VAR: "invalid_module"})
def test_invalid_callback_path():
"""Test handling of invalid callback paths in environment variable."""
with pytest.raises(ValueError):
_ = DataContext.get_current().execution_callback_classes
@patch("importlib.import_module")
@patch.dict(
os.environ, {EXECUTION_CALLBACKS_ENV_VAR: "nonexistent.module.TestCallback"}
)
def test_import_error_handling(mock_import):
"""Test handling of import errors when loading callbacks."""
mock_import.side_effect = ImportError("No module named 'nonexistent'")
with pytest.raises(ValueError):
_ = DataContext.get_current().execution_callback_classes
def test_execution_callbacks_executor_arg(tmp_path, restore_data_context):
"""Test the executor arg in ExecutionCallback."""
_executor = None
class CustomExecutionCallback(ExecutionCallback):
def after_execution_succeeds(self, executor: StreamingExecutor):
nonlocal _executor
_executor = executor
input_path = tmp_path / "input"
os.makedirs(input_path)
pq.write_table(pa.table({"value": [1]}), input_path / "data.parquet")
output_path = tmp_path / "output"
ctx = DataContext.get_current()
ctx.custom_execution_callback_classes.append(CustomExecutionCallback)
ds = ray.data.read_parquet(input_path)
def udf(row):
return row
ds = ds.map(udf)
ds = ds.write_parquet(output_path)
# Test inspecting the metadata of each operator.
# E.g., the original input and output paths and the UDF.
assert _executor is not None
physical_ops = list(_executor._topology.keys())
assert isinstance(physical_ops[0], InputDataBuffer)
if ctx.use_datasource_v2:
# V2 splits read into a ``ListFiles`` source op and a fused
# ``ReadFiles->Map(udf)->Write`` op.
assert len(_executor._topology) == 3
assert isinstance(physical_ops[1], MapOperator)
assert isinstance(physical_ops[2], MapOperator)
list_files_logical_ops = physical_ops[1]._logical_operators
assert len(list_files_logical_ops) == 1
assert isinstance(list_files_logical_ops[0], ListFiles)
read_logical_ops = physical_ops[2]._logical_operators
assert len(read_logical_ops) == 3
assert isinstance(read_logical_ops[0], ReadFiles)
assert read_logical_ops[0].datasource_name == "ParquetV2"
assert isinstance(read_logical_ops[1], MapRows)
assert read_logical_ops[1].fn == udf
assert isinstance(read_logical_ops[2], Write)
datasink = read_logical_ops[2].datasink_or_legacy_datasource
assert isinstance(datasink, ParquetDatasink)
assert datasink.unresolved_path == output_path
else:
# V1 inserts a ``SplitBlocks`` after the read to hit the target
# number of output blocks, which prevents the read from fusing
# with the downstream ``Map(udf)->Write`` op.
assert len(_executor._topology) == 3
assert isinstance(physical_ops[1], MapOperator)
assert isinstance(physical_ops[2], MapOperator)
read_logical_ops = physical_ops[1]._logical_operators
assert len(read_logical_ops) == 1
assert isinstance(read_logical_ops[0], Read)
datasource = read_logical_ops[0].datasource
assert isinstance(datasource, ParquetDatasource)
assert datasource._source_paths == input_path
map_write_logical_ops = physical_ops[2]._logical_operators
assert len(map_write_logical_ops) == 2
assert isinstance(map_write_logical_ops[0], MapRows)
assert map_write_logical_ops[0].fn == udf
assert isinstance(map_write_logical_ops[1], Write)
datasink = map_write_logical_ops[1].datasink_or_legacy_datasource
assert isinstance(datasink, ParquetDatasink)
assert datasink.unresolved_path == output_path
def test_create_topology_metadata():
"""Test that create_topology_metadata correctly serializes the DAG structure."""
from ray.data._internal.metadata_exporter import Topology as TopologyMetadata
# Create a simple DAG with a few connected operators
inputs = make_ref_bundles([[x] for x in range(10)])
o1 = InputDataBuffer(DataContext.get_current(), inputs)
o2 = MapOperator.create(
make_map_transformer(lambda block: [b * -1 for b in block]),
o1,
DataContext.get_current(),
)
o3 = MapOperator.create(
make_map_transformer(lambda block: [b * 2 for b in block]),
o2,
DataContext.get_current(),
)
# Create a StreamingExecutor instance
executor = StreamingExecutor(DataContext.get_current())
# Initialize the topology on the executor
executor._topology = build_streaming_topology(
o3, ExecutionOptions(), noop_counter()
)
# Call the _dump_dag_structure method
op_to_id = {
op: executor._get_operator_id(op, i)
for i, op in enumerate(executor._topology.keys())
}
topology_metadata = TopologyMetadata.create_topology_metadata(o3, op_to_id)
# Verify the structure of the returned dictionary
assert len(topology_metadata.operators) == 3 # We should have 3 operators
# Find each operator by name - the operators are simplified in the representation
operators_by_name = {op.name: op for op in topology_metadata.operators}
# Check input data buffer (appears as "Input" in the structure)
assert "Input" in operators_by_name
input_buffer = operators_by_name["Input"]
assert input_buffer.id is not None
assert input_buffer.uuid is not None
assert input_buffer.input_dependencies == []
# Check map operators (appear as "Map" in the structure)
assert "Map" in operators_by_name
# Since there are two Map operators with the same name, we need to identify them by their ID
map_ops = [op for op in topology_metadata.operators if op.name == "Map"]
assert len(map_ops) == 2
# Sort by ID to get them in order
map_ops.sort(key=lambda op: op.id)
map_op1, map_op2 = map_ops
# First map operator should depend on the input buffer
assert len(map_op1.input_dependencies) == 1
assert map_op1.input_dependencies[0] == input_buffer.id
# Second map operator should depend on the first map operator
assert len(map_op2.input_dependencies) == 1
assert map_op2.input_dependencies[0] == map_op1.id
def test_create_topology_metadata_with_sub_stages():
"""Test that _dump_dag_structure correctly handles sub-stages."""
from ray.data._internal.metadata_exporter import Topology as TopologyMetadata
inputs = make_ref_bundles([[x] for x in range(5)])
# Create a base operator
o1 = InputDataBuffer(DataContext.get_current(), inputs)
# Create an operator with sub-stages
o2 = MapOperator.create(
make_map_transformer(lambda block: [b * 2 for b in block]),
o1,
DataContext.get_current(),
)
# Add fake sub-stages to test the sub-stages feature
o2._sub_progress_bar_names = ["SubStage1", "SubStage2"]
# Create the executor and set up topology
executor = StreamingExecutor(DataContext.get_current())
executor._topology = build_streaming_topology(
o2, ExecutionOptions(), noop_counter()
)
# Get the DAG structure
op_to_id = {
op: executor._get_operator_id(op, i)
for i, op in enumerate(executor._topology.keys())
}
topology_metadata = TopologyMetadata.create_topology_metadata(o2, op_to_id)
# Find the operator with sub-stages (appears as "Map" in the structure)
map_op = None
for op in topology_metadata.operators:
if op.name == "Map":
map_op = op
break
assert map_op is not None
assert len(map_op.sub_stages) == 2
# Check that sub-stages have the expected structure
sub_stage1, sub_stage2 = map_op.sub_stages
assert sub_stage1.name == "SubStage1"
assert sub_stage1.id.endswith("_sub_0")
assert sub_stage2.name == "SubStage2"
assert sub_stage2.id.endswith("_sub_1")
def create_stub_streaming_gen(
block_nbytes: List[int], raise_exception: Optional[Exception] = None
) -> ray.ObjectRefGenerator:
"""Creating a streaming generator for testing.
The streaming generator passed to the ``DataOpTask`` constructor must yield blocks
then block metadata, and buffer the number of blocks specified by
``_max_num_blocks_in_streaming_gen_buffer``. This function is a utility to create
streaming generators that satisfy these requirements.
Args:
block_nbytes: A list of the sizes of blocks yielded by the returned streaming
generator.
raise_exception: An exception that the streaming generator immediately raises.
Returns:
A streaming generator that you can pass to ``DataOpTask``.
"""
@ray.remote
def stub_map_task():
import time as _time
if raise_exception is not None:
raise raise_exception
task_start_s = _time.perf_counter()
for nbytes in block_nbytes:
# Create a block with a single row of the specified size.
builder = DelegatingBlockBuilder()
builder.add_batch({"data": np.zeros((1, nbytes), dtype=np.uint8)})
block = builder.build()
yield block
block_accessor = BlockAccessor.for_block(block)
block_metadata = block_accessor.get_metadata(
task_exec_stats=TaskExecWorkerStats(
task_wall_time_s=_time.perf_counter() - task_start_s,
)
)
yield pickle.dumps(
BlockMetadataWithSchema.from_metadata(
block_metadata, schema=block_accessor.schema()
)
)
generator_backpressure_num_objects = (
ray.data.DataContext.get_current()._max_num_blocks_in_streaming_gen_buffer
* 2 # Multiply by two because we yield a metadata object for each block.
)
streaming_gen = stub_map_task.options(
_generator_backpressure_num_objects=generator_backpressure_num_objects
).remote()
return streaming_gen
@pytest.fixture
def ensure_block_metadata_stored_in_plasma(monkeypatch):
# Ray inlines small objects (including metadata) by storing them directly with
# the object reference itself rather than in the remote node's object store.
# Consequently, when the streaming executor calls `ray.get` on metadata from a
# node that has died, the call succeeds because the inlined metadata is not
# stored in the failed node's object store. To explicitly test the case where
# metadata resides in the object store (and becomes unavailable when the node
# dies), we disable inlining by setting the maximum inline size to 0. This
# simulates scenarios where metadata is too large to inline, which can occur in
# practice when schemas contain many fields.
#
# For context, see https://github.com/ray-project/ray/pull/56451.
monkeypatch.setenv("RAY_max_direct_call_object_size", 0)
class TestDataOpTask:
def test_on_data_ready_single_output(self, ray_start_regular_shared):
streaming_gen = create_stub_streaming_gen(block_nbytes=[128 * MiB])
def verify_output(bundle):
assert bundle.size_bytes() == pytest.approx(128 * MiB), bundle.size_bytes()
data_op_task = DataOpTask(
0,
streaming_gen,
BlockRefCounter(),
"test_op",
output_ready_callback=verify_output,
)
bytes_read = 0
while not data_op_task.has_finished:
ray.wait([streaming_gen], fetch_local=False)
nbytes_read = data_op_task.on_data_ready(None, InlineMetadataFetcher())
bytes_read += nbytes_read
assert bytes_read == pytest.approx(128 * MiB)
def test_on_data_ready_multiple_outputs(self, ray_start_regular_shared):
streaming_gen = create_stub_streaming_gen(block_nbytes=[128 * MiB, 128 * MiB])
def verify_output(bundle):
assert bundle.size_bytes() == pytest.approx(128 * MiB), bundle.size_bytes()
data_op_task = DataOpTask(
0,
streaming_gen,
BlockRefCounter(),
"test_op",
output_ready_callback=verify_output,
)
bytes_read = 0
while not data_op_task.has_finished:
ray.wait([streaming_gen], fetch_local=False)
nbytes_read = data_op_task.on_data_ready(None, InlineMetadataFetcher())
bytes_read += nbytes_read
assert bytes_read == pytest.approx(256 * MiB)
def test_on_data_ready_exception(self, ray_start_regular_shared):
# In the inline (default) mode a task failure fires the done-callback
# with the exception and re-raises it from ``on_data_ready``, exactly as
# before the threaded-fetch refactor.
streaming_gen = create_stub_streaming_gen(
block_nbytes=[128 * MiB],
raise_exception=AssertionError("Block generation failed"),
)
def verify_exception(exc, task_exec_stats, task_exec_driver_stats):
assert isinstance(exc, AssertionError)
assert task_exec_stats is None
assert task_exec_driver_stats is None
data_op_task = DataOpTask(
0,
streaming_gen,
BlockRefCounter(),
"test_op",
task_done_callback=verify_exception,
)
with pytest.raises(AssertionError, match="Block generation failed"):
while not data_op_task.has_finished:
ray.wait([streaming_gen], fetch_local=False)
data_op_task.on_data_ready(None, InlineMetadataFetcher())
def test_operator_name_parameter(self, ray_start_regular_shared):
streaming_gen = create_stub_streaming_gen(block_nbytes=[1])
task = DataOpTask(
0,
streaming_gen,
BlockRefCounter(),
"test_op",
operator_name="MapBatches(fn)",
)
assert task._operator_name == "MapBatches(fn)"
streaming_gen2 = create_stub_streaming_gen(block_nbytes=[1])
task_default = DataOpTask(1, streaming_gen2, BlockRefCounter(), "test_op")
assert task_default._operator_name == "Unknown"
def test_on_data_ready_deferred_threading(self, ray_start_regular_shared):
"""In the threaded mode, on_data_ready appends to the deferred list
without emitting RefBundles or updating ``_last_block_meta``. Emission
happens later, when the fetcher delivers the fetched metadata."""
streaming_gen = create_stub_streaming_gen(block_nbytes=[1024])
outputs = []
task = DataOpTask(
0,
streaming_gen,
BlockRefCounter(),
"test_op",
output_ready_callback=outputs.append,
)
ray.wait([streaming_gen], fetch_local=False)
fetcher = ThreadedMetadataFetcher()
task.on_data_ready(None, fetcher)
# The pair was deferred: no emit yet, _last_block_meta still None.
assert outputs == []
assert task._last_block_meta is None
deferred = list(fetcher._pending_deferred)
assert len(deferred) >= 1
fetcher.submit("op", [task])
with fetcher._results_lock:
for d, meta_bytes in zip(deferred, ray.get([d.meta_ref for d in deferred])):
fetcher._results[d.meta_ref] = meta_bytes
fetcher.emit_ready_and_fire_done_callbacks()
assert len(outputs) == len(deferred)
assert task._last_block_meta is not None
def test_threaded_emits_in_per_op_order(self, ray_start_regular_shared):
"""The threaded fetcher fetches ``meta_ref``s on a
background thread and emits each op's RefBundles in append order
(same sequence the synchronous replay would produce), and fires the
postponed done callback only after all of a task's pairs are emitted."""
gen_a = create_stub_streaming_gen(block_nbytes=[100, 200])
gen_b = create_stub_streaming_gen(block_nbytes=[300, 400])
outputs: list[tuple[int, int]] = [] # (task_idx, bytes)
done: list[int] = []
task_a = DataOpTask(
0,
gen_a,
BlockRefCounter(),
"test_op",
output_ready_callback=lambda b: outputs.append((0, b.size_bytes())),
task_done_callback=lambda *a: done.append(0),
)
task_b = DataOpTask(
1,
gen_b,
BlockRefCounter(),
"test_op",
output_ready_callback=lambda b: outputs.append((1, b.size_bytes())),
task_done_callback=lambda *a: done.append(1),
)
ray.wait([gen_a, gen_b], fetch_local=False)
fetcher = ThreadedMetadataFetcher()
fetcher.start()
try:
# Drain each task to end-of-stream, then submit it under its own op
# key (the real per-op flow: the fetcher accumulates the task's
# deferred pairs and ``submit`` hands them to the fetch thread). Loop
# because on a small cluster the second generator may not have
# started when the first is ready.
deadline = time.time() + 30
for op_key, task in (("a", task_a), ("b", task_b)):
while not task.is_drained() and time.time() < deadline:
task.on_data_ready(None, fetcher)
time.sleep(0.01)
fetcher.submit(op_key, [task])
assert task_a.is_drained() and task_b.is_drained()
deadline = time.time() + 30
while len(outputs) < 4 and time.time() < deadline:
fetcher.emit_ready_and_fire_done_callbacks()
time.sleep(0.01)
finally:
fetcher.stop()
# Per-op append order is preserved. Cross-op interleaving is NOT
# asserted: each op emits into its own downstream queue, so the order
# ops emit relative to each other doesn't matter (and depends on which
# op's metadata lands first). Sizes are the payload plus a constant
# 8-byte block-format overhead.
op0 = [b for t, b in outputs if t == 0]
op1 = [b for t, b in outputs if t == 1]
assert op0 == [108, 208]
assert op1 == [308, 408]
# Done callbacks fire only after each task's pairs are fully emitted.
assert sorted(done) == [0, 1]
def test_prefetcher_holds_later_ready_outputs_for_order(
self, ray_start_regular_shared
):
"""Within a task, a later pair whose metadata is fetched FIRST must
be held until the earlier pair's metadata arrives, so the task's
outputs are emitted in yield order."""
gen = create_stub_streaming_gen(block_nbytes=[100, 200])
outputs: list = []
task = DataOpTask(
0,
gen,
BlockRefCounter(),
"test_op",
output_ready_callback=lambda b: outputs.append(b),
)
ray.wait([gen], fetch_local=False)
# Don't start the fetch thread: publish fetch results by hand to
# control which pair's metadata is "fetched" first.
fetcher = ThreadedMetadataFetcher()
deadline = time.time() + 30
while not task.is_drained() and time.time() < deadline:
task.on_data_ready(None, fetcher)
time.sleep(0.01)
deferred = list(fetcher._pending_deferred)
assert len(deferred) == 2
first, second = deferred
fetcher.submit("op", [task])
meta_bytes_first, meta_bytes_second = ray.get([first.meta_ref, second.meta_ref])
# Only the LATER pair's metadata is available: it must be held.
with fetcher._results_lock:
fetcher._results[second.meta_ref] = meta_bytes_second
fetcher.emit_ready_and_fire_done_callbacks()
assert outputs == []
assert not task.has_finished
# Once the EARLIER pair's metadata arrives, both emit, in yield order.
with fetcher._results_lock:
fetcher._results[first.meta_ref] = meta_bytes_first
fetcher.emit_ready_and_fire_done_callbacks()
assert [b.size_bytes() for b in outputs] == [108, 208]
assert task.has_finished
def test_prefetcher_fetch_failure_is_returned_not_raised(
self, ray_start_regular_shared
):
"""A metadata-fetch error is returned from emit_ready() (so the caller
can apply max_errored_blocks) rather than raised; the bad block is
dropped, the other pair still emits, and the task still completes."""
gen = create_stub_streaming_gen(block_nbytes=[100, 200])
outputs: list = []
done: list = []
task = DataOpTask(
0,
gen,
BlockRefCounter(),
"test_op",
output_ready_callback=lambda b: outputs.append(b),
task_done_callback=lambda *a: done.append(1),
operator_name="Map(fn)",
)
ray.wait([gen], fetch_local=False)
fetcher = ThreadedMetadataFetcher()
deadline = time.time() + 30
while not task.is_drained() and time.time() < deadline:
task.on_data_ready(None, fetcher)
time.sleep(0.01)
deferred = list(fetcher._pending_deferred)
assert len(deferred) == 2
first, second = deferred
fetcher.submit("op", [task])
good_bytes = ray.get(first.meta_ref)
boom = ValueError("metadata fetch boom")
# First pair fetches fine; the second resolves to an exception.
with fetcher._results_lock:
fetcher._results[first.meta_ref] = good_bytes
fetcher._results[second.meta_ref] = boom
failures = fetcher.emit_ready_and_fire_done_callbacks()
# The error is surfaced (not raised), tagged with the operator name.
assert failures == [("Map(fn)", boom)]
# The good block still emitted; the failed one was dropped.
assert len(outputs) == 1
assert outputs[0].size_bytes() == 108
# The task completed despite the dropped block (done callback fired).
assert done == [1]
assert task.has_finished
assert not fetcher_has_pending_work(fetcher)
def test_threaded_marks_drained_task_finished(self, ray_start_regular_shared):
"""A drained task registered via ``submit`` is NOT finished until
``emit_ready_and_fire_done_callbacks`` has emitted all of its pairs;
then the done-callback fires exactly once and the task is FINISHED."""
gen = create_stub_streaming_gen(block_nbytes=[100])
done: list = []
task = DataOpTask(
0,
gen,
BlockRefCounter(),
"test_op",
task_done_callback=lambda *a: done.append(a),
)
ray.wait([gen], fetch_local=False)
fetcher = ThreadedMetadataFetcher()
fetcher.start()
try:
deadline = time.time() + 30
while not task.is_drained() and time.time() < deadline:
task.on_data_ready(None, fetcher)
time.sleep(0.01)
assert task.is_drained()
fetcher.submit("op", [task])
# Completion is postponed: the done-callback only fires from
# emit_ready_and_fire_done_callbacks on this thread, so the task
# can't be finished yet.
assert not task.has_finished
assert done == []
deadline = time.time() + 30
while fetcher_has_pending_work(fetcher) and time.time() < deadline:
fetcher.emit_ready_and_fire_done_callbacks()
time.sleep(0.01)
finally:
fetcher.stop()
assert task.has_finished
assert len(done) == 1
# The task was deregistered; a later call must not re-fire.
fetcher.emit_ready_and_fire_done_callbacks()
assert len(done) == 1
def test_threaded_fetch_falls_back_to_per_ref_get(self):
"""When the batched ``ray.get`` in ``_fetch`` raises, the fetcher
retries per-ref: refs that fetch fine are published, a ref that raced
out of the local store (GetTimeoutError) is returned for retry."""
ref_ok, ref_gone = object(), object()
def fake_get(arg, timeout=None):
if isinstance(arg, list):
# The batched get hides which ref failed.
raise ValueError("batched get fails")
if arg is ref_gone:
raise GetTimeoutError("reported ready but no longer local")
return b"meta-bytes"
# Inject the ray primitives instead of patching the module.
fetcher = ThreadedMetadataFetcher(
get_objects=fake_get,
wait_for_objects=lambda pending, **kwargs: ([ref_ok, ref_gone], []),
)
retry = fetcher._fetch([ref_ok, ref_gone])
# The good ref's bytes were published; the raced ref is re-queued
# (not published, not treated as a block error).
with fetcher._results_lock:
assert fetcher._results == {ref_ok: b"meta-bytes"}
assert retry == [ref_gone]
def test_threaded_object_size_not_ready(self, ray_start_regular_shared):
"""If the block's local ``object_size`` is unavailable, the threaded
fetcher falls back to a short metadata ``ray.get`` for the size; if the
metadata isn't local yet either, the pair stays pending (nothing
deferred, refs retained) and is retried on a later call."""
gen = create_stub_streaming_gen(block_nbytes=[100])
task = DataOpTask(0, gen, BlockRefCounter(), "test_op")
ray.wait([gen], fetch_local=False)
# Force the local-size lookup to miss (empty locations) so we always
# take the metadata fallback. The injected get controls that fallback:
# stage 1 times out (metadata not local), stage 2 delegates to the real
# ray.get so the size comes from the actual metadata.
stage = {"n": 1}
def fake_get(ref, timeout=None):
if stage["n"] == 1:
raise GetTimeoutError("metadata not local yet")
return ray.get(ref, timeout=timeout)
fetcher = ThreadedMetadataFetcher(
get_object_locations=lambda refs: {},
get_objects=fake_get,
)
# Stage 1: no local size AND metadata not local -> the pair is held
# (refs stay set), nothing is deferred, nothing is charged.
deadline = time.time() + 30
bytes_read = 0
while task.pending_meta_ref.is_nil() and time.time() < deadline:
bytes_read += task.on_data_ready(None, fetcher)
time.sleep(0.01)
assert not task.pending_block_ref.is_nil()
assert bytes_read == 0
assert fetcher._pending_deferred == []
# Stage 2: still no local size, but the metadata fetch now succeeds ->
# the size comes from meta.size_bytes and the held pair is deferred
# (the retry path works).
stage["n"] = 2
deadline = time.time() + 30
while not task.is_drained() and time.time() < deadline:
bytes_read += task.on_data_ready(None, fetcher)
time.sleep(0.01)
assert bytes_read == 108
assert len(fetcher._pending_deferred) == 1
def test_threaded_size_bytes_none_still_consumes_pair(
self, ray_start_regular_shared
):
"""The threaded metadata-fallback path must not return None when the
fetched metadata's ``size_bytes`` is unset: the pair was already
deferred, and a None return would make ``on_data_ready`` hand the same
pair back on the next call, deferring (and eventually emitting) it
twice."""
gen = create_stub_streaming_gen(block_nbytes=[100])
task = DataOpTask(0, gen, BlockRefCounter(), "test_op")
ray.wait([gen], fetch_local=False)
meta_bytes = pickle.dumps(
BlockMetadataWithSchema(
num_rows=1,
size_bytes=None,
exec_stats=None,
task_exec_stats=None,
input_files=None,
schema=None,
)
)
# Force the fallback (no local size) and make it resolve to metadata
# with size_bytes=None.
fetcher = ThreadedMetadataFetcher(
get_object_locations=lambda refs: {},
get_objects=lambda ref, timeout=None: meta_bytes,
)
deadline = time.time() + 30
while not task.is_drained() and time.time() < deadline:
task.on_data_ready(None, fetcher)
time.sleep(0.01)
# The size-less pair was consumed (size 0, not None) and deferred
# exactly once. With the bug it returns None, so on_data_ready keeps
# handing the same pair back — the task never drains and the pair is
# deferred repeatedly.
assert task.is_drained()
assert len(fetcher._pending_deferred) == 1
def test_on_data_ready_branches_with_fake_fetcher(self, ray_start_regular_shared):
"""Mock the fetcher interface to drive ``on_data_ready``'s per-pair
outcomes deterministically — no cluster timing or polling.
Covers: a pair not-ready (``in_data_ready_get_object_size`` returns None -> the loop
stops, refs stay set), then the same pair becoming ready (size returned
-> charged + refs advanced), then end-of-stream completion (inline
fetcher -> done-callback fires)."""
class FakeFetcher(MetadataFetcher):
def __init__(self):
self.calls = []
# Per call, the size to return (None = not ready yet).
self.script = [None, 4096]
def in_data_ready_get_object_size(self, task):
self.calls.append((task.pending_block_ref, task.pending_meta_ref))
size = self.script.pop(0) if self.script else 0
if size is not None:
# Emulate the inline fetcher emitting the pair.
task._output_ready_callback(object())
return size
def in_data_ready_done(self, task):
# Emulate the inline fetcher: fire the done-callback at drain.
task.mark_done()
gen = create_stub_streaming_gen(block_nbytes=[1024])
emits: list = []
done: list = []
task = DataOpTask(
0,
gen,
BlockRefCounter(),
"test_op",
output_ready_callback=emits.append,
task_done_callback=lambda *a: done.append(a),
)
ray.wait([gen], fetch_local=False)
fake = FakeFetcher()
# 1st call: fetcher reports not-ready -> nothing charged, not finished,
# the pair's refs are retained for a retry.
assert task.on_data_ready(None, fake) == 0
assert emits == [] and done == [] and not task.has_finished
assert not task._pending_block_ref.is_nil()
# 2nd call: same pair now ready (4096) -> charged + emitted, then the
# generator drains and the inline done-callback fires.
bytes_read = 0
deadline = time.time() + 30
while not task.has_finished and time.time() < deadline:
ray.wait([gen], fetch_local=False)
bytes_read += task.on_data_ready(None, fake)
assert bytes_read == 4096
assert len(emits) == 1
assert len(done) == 1
def test_make_metadata_fetcher_mode_selection(self, monkeypatch):
"""The env var selects the fetcher implementation."""
import ray.data._internal.execution.metadata_fetcher as mf
monkeypatch.setattr(mf, "_PREFETCH_ON_THREAD", True)
assert isinstance(mf.make_metadata_fetcher(), ThreadedMetadataFetcher)
monkeypatch.setattr(mf, "_PREFETCH_ON_THREAD", False)
assert isinstance(mf.make_metadata_fetcher(), InlineMetadataFetcher)
@pytest.mark.parametrize(
"preempt_on", ["block_ready_callback", "metadata_ready_callback"]
)
def test_on_data_ready_with_preemption_during_call(
self,
preempt_on: Union[
Literal["block_ready_callback"], Literal["metadata_ready_callback"]
],
ray_start_cluster_enabled,
ensure_block_metadata_stored_in_plasma,
):
"""Test that ``on_data_ready`` works when a node dies during its execution."""
# Shutdown Ray incase it's already initialized.
ray.shutdown()
# Create a single-worker-node cluster with 1 logical CPU.
cluster = ray_start_cluster_enabled
head_node = cluster.add_node(num_cpus=0) # noqa: F841
cluster.wait_for_nodes()
ray.init()
worker_node = cluster.add_node(num_cpus=1)
cluster.wait_for_nodes()
# Create a streaming generator that produces a single 128 MiB output block, and
# configure it so that it preempts the worker node in the specified callback.
streaming_gen = create_stub_streaming_gen(block_nbytes=[128 * MiB])
def remove_and_add_back_worker_node(_):
cluster.remove_node(worker_node)
new_worker_node = cluster.add_node(num_cpus=1) # noqa: F841
cluster.wait_for_nodes()
data_op_task = DataOpTask(
0,
streaming_gen,
BlockRefCounter(),
"test_op",
**{preempt_on: remove_and_add_back_worker_node},
)
# Run the task to completion.
bytes_read = 0
while not data_op_task.has_finished:
ray.wait([streaming_gen], fetch_local=False)
bytes_read += data_op_task.on_data_ready(None, InlineMetadataFetcher())
# Ensure that we read the expected amount of data. Since the streaming generator
# yields a single 128 MiB block, we should read 128 MiB.
assert bytes_read == pytest.approx(128 * MiB)
def test_on_data_ready_with_preemption_after_wait(
self, ray_start_cluster_enabled, ensure_block_metadata_stored_in_plasma
):
# Shutdown Ray incase it's already initialized.
ray.shutdown()
# Create a single-worker-node cluster with 1 logical CPU.
cluster = ray_start_cluster_enabled
head_node = cluster.add_node(num_cpus=0) # noqa: F841
cluster.wait_for_nodes()
ray.init()
worker_node = cluster.add_node(num_cpus=1)
cluster.wait_for_nodes()
# Create a streaming generator that produces a single 128 MiB output block.
streaming_gen = create_stub_streaming_gen(block_nbytes=[128 * MiB])
data_op_task = DataOpTask(0, streaming_gen, BlockRefCounter(), "test_op")
# Wait for the block to be ready, then remove the worker node.
ray.wait([streaming_gen], fetch_local=False)
cluster.remove_node(worker_node)
# The block shouldn't be available anymore, so we shouldn't read any data.
bytes_read = data_op_task.on_data_ready(None, InlineMetadataFetcher())
assert bytes_read == 0
# Re-add the worker node, and run the task to completion.
new_worker_node = cluster.add_node(num_cpus=1) # noqa: F841
cluster.wait_for_nodes()
while not data_op_task.has_finished:
ray.wait([streaming_gen], fetch_local=False)
bytes_read += data_op_task.on_data_ready(None, InlineMetadataFetcher())
# We should now be able to read the 128 MiB block.
assert bytes_read == pytest.approx(128 * MiB)
@patch("time.perf_counter")
def test_on_data_ready_output_backpressure_tracking(
self, mock_perf_counter, ray_start_regular_shared
):
"""Test that DataOpTask tracks output backpressure time correctly.
Output backpressure occurs when max_bytes_to_read=0, meaning the
downstream can't consume more data. The task should track the total
time spent in this state and report it via TaskExecDriverStats.
"""
# Use 2 blocks so the task stays alive across both BP periods.
streaming_gen = create_stub_streaming_gen(block_nbytes=[128 * MiB, 128 * MiB])
captured_stats = {}
def capture_done(exc, task_exec_stats, task_exec_driver_stats):
captured_stats["exc"] = exc
captured_stats["task_exec_stats"] = task_exec_stats
captured_stats["task_exec_driver_stats"] = task_exec_driver_stats
data_op_task = DataOpTask(
0,
streaming_gen,
BlockRefCounter(),
"test_op",
task_done_callback=capture_done,
)
clock = 0.0
mock_perf_counter.return_value = clock
# Wait for data to become available
ray.wait([streaming_gen], fetch_local=False)
# 1st backpressure period: 2.5s
clock = 1.0
mock_perf_counter.return_value = clock
assert data_op_task.on_data_ready(0, InlineMetadataFetcher()) == 0
clock = 3.5
mock_perf_counter.return_value = clock
# Resume: ends 1st BP period (2.5s), reads block 1 (limited to 1 byte
# so it reads exactly one block and stops)
data_op_task.on_data_ready(None, InlineMetadataFetcher())
assert not data_op_task.has_finished
# 2nd backpressure period: 1.5s
clock = 5.0
mock_perf_counter.return_value = clock
data_op_task.on_data_ready(0, InlineMetadataFetcher())
clock = 6.5
mock_perf_counter.return_value = clock
# Drain to completion
while not data_op_task.has_finished:
ray.wait([streaming_gen], fetch_local=False)
data_op_task.on_data_ready(None, InlineMetadataFetcher())
# Verify stats were captured
assert captured_stats["exc"] is None
assert captured_stats["task_exec_stats"] is not None
assert captured_stats["task_exec_driver_stats"] is not None
# Total backpressure = 2.5s + 1.5s = 4.0s
bp_time = captured_stats["task_exec_driver_stats"].task_output_backpressure_s
assert bp_time == pytest.approx(4.0)
def test_streaming_executor_logs_relevant_env_vars(
monkeypatch, caplog, propagate_logs, ray_start_regular_shared
):
monkeypatch.setenv("RAY_DATA_TEST_FOO", "bar")
monkeypatch.setenv("RAY_DEFAULT_OBJECT_STORE_MEMORY_PROPORTION", "0.3")
ctx = DataContext.get_current()
inputs = make_ref_bundles([[x] for x in range(1)])
dag = InputDataBuffer(ctx, inputs)
executor = StreamingExecutor(ctx)
with caplog.at_level(
logging.DEBUG,
logger="ray.data._internal.execution.streaming_executor",
):
executor.execute(dag)
assert "RAY_DATA_TEST_FOO=bar" in caplog.text
assert "RAY_DEFAULT_OBJECT_STORE_MEMORY_PROPORTION=0.3" in caplog.text
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))