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