import sys import time import pandas as pd import pyarrow as pa import pytest import ray from ray.data.block import BlockMetadata from ray.data.context import DataContext from ray.data.datasource.datasource import Datasource, ReadTask from ray.data.tests.conftest import * # noqa from ray.data.tests.conftest import ( CoreExecutionMetrics, assert_core_execution_metrics_equals, get_initial_core_execution_metrics_snapshot, ) from ray.data.tests.util import extract_values from ray.tests.conftest import * # noqa def test_limit_execution(ray_start_regular): last_snapshot = get_initial_core_execution_metrics_snapshot() override_num_blocks = 20 ds = ray.data.range(100, override_num_blocks=override_num_blocks) # Add some delay to the output to prevent all tasks from finishing # immediately. def delay(row): time.sleep(0.1) return row ds = ds.map(delay) last_snapshot = assert_core_execution_metrics_equals( CoreExecutionMetrics(task_count={}), last_snapshot=last_snapshot, ) # During lazy execution, we should not execute too many more tasks than is # needed to produce the requested number of rows. for i in [1, 11]: assert extract_values("id", ds.limit(i).take(200)) == list(range(i)) last_snapshot = assert_core_execution_metrics_equals( CoreExecutionMetrics( task_count={ "ReadRange->Map(delay)": lambda count: count < override_num_blocks / 2, "slice_fn": lambda count: count <= 1, } ), last_snapshot=last_snapshot, ) # .materialize().limit() should only trigger execution once. ds = ray.data.range(100, override_num_blocks=20).materialize() last_snapshot = assert_core_execution_metrics_equals( CoreExecutionMetrics( task_count={ "ReadRange": 20, } ), last_snapshot=last_snapshot, ) for i in [1, 10]: assert extract_values("id", ds.limit(i).take(200)) == list(range(i)) assert_core_execution_metrics_equals( CoreExecutionMetrics(task_count={"slice_fn": lambda count: count <= 1}), last_snapshot=last_snapshot, ) @pytest.mark.parametrize("lazy", [False, True]) def test_limit(ray_start_regular_shared, lazy): ds = ray.data.range(100, override_num_blocks=20) if not lazy: ds = ds.materialize() for i in range(100): assert extract_values("id", ds.limit(i).take(200)) == list(range(i)) # NOTE: We test outside the power-of-2 range in order to ensure that we're not reading # redundant files due to exponential ramp-up. @pytest.mark.parametrize("limit", [10, 20, 30, 60]) def test_limit_no_redundant_read( ray_start_regular_shared, limit, ): # Test that dataset truncation eliminates redundant reads. @ray.remote class Counter: def __init__(self): self.count = 0 def increment(self): self.count += 1 def get(self): return self.count def reset(self): self.count = 0 class CountingRangeDatasource(Datasource): def __init__(self): self.counter = Counter.remote() def prepare_read(self, parallelism, n): def range_(i): ray.get(self.counter.increment.remote()) return [ pd.DataFrame({"id": range(parallelism * i, parallelism * i + n)}) ] return [ ReadTask( lambda i=i: range_(i), BlockMetadata( num_rows=n, size_bytes=sum( sys.getsizeof(i) for i in range(parallelism * i, parallelism * i + n) ), input_files=None, exec_stats=None, ), schema=pa.lib.Schema.from_pandas(pd.DataFrame({"id": []})), ) for i in range(parallelism) ] source = CountingRangeDatasource() total_rows = 1000 override_num_blocks = 100 ds = ray.data.read_datasource( source, override_num_blocks=override_num_blocks, n=total_rows // override_num_blocks, ) # Apply multiple limit ops. # Once the smallest limit is reached, the entire dataset should stop execution. ds = ds.limit(total_rows) ds = ds.limit(limit) ds = ds.limit(total_rows) # Check content. assert len(ds.take(limit)) == limit # Check number of read tasks launched. # min_read_tasks is the minimum number of read tasks needed for the limit. # We may launch more tasks than this number, in order to to maximize throughput. # But the actual number of read tasks should be less than the parallelism. count = ray.get(source.counter.get.remote()) min_read_tasks = limit // (total_rows // override_num_blocks) assert min_read_tasks <= count < override_num_blocks def test_limit_no_num_row_info(ray_start_regular_shared): # Test that datasources with no number-of-rows metadata available are still able to # be truncated, falling back to kicking off all read tasks. class DumbOnesDatasource(Datasource): def prepare_read(self, parallelism, n): return parallelism * [ ReadTask( lambda: [pd.DataFrame({"id": [1] * n})], BlockMetadata( num_rows=None, size_bytes=sys.getsizeof(1) * n, input_files=None, exec_stats=None, ), schema=pa.lib.Schema.from_pandas(pd.DataFrame({"id": []})), ) ] ds = ray.data.read_datasource(DumbOnesDatasource(), override_num_blocks=10, n=10) for i in range(1, 100): assert extract_values("id", ds.limit(i).take(100)) == [1] * i def test_per_task_row_limit_basic(ray_start_regular_shared, restore_data_context): """Test basic per-block limiting functionality.""" # NOTE: It's critical to preserve ordering for assertions in this test to work DataContext.get_current().execution_options.preserve_order = True # Simple test that should work with the existing range datasource ds = ray.data.range(1000, override_num_blocks=10).limit(50) result = ds.take_all() # Verify we get the correct results assert len(result) == 50 assert [row["id"] for row in result] == list(range(50)) def test_per_task_row_limit_with_custom_readtask(ray_start_regular_shared): """Test per-block limiting directly with ReadTask implementation.""" def read_data_with_limit(): # This simulates a ReadTask that reads 200 rows return [pd.DataFrame({"id": range(200)})] # Create ReadTask with per-block limit task_with_limit = ReadTask( read_fn=read_data_with_limit, metadata=BlockMetadata( num_rows=200, size_bytes=1600, input_files=None, exec_stats=None ), schema=pa.lib.Schema.from_pandas(pd.DataFrame({"id": []})), per_task_row_limit=50, ) # Execute the ReadTask result_blocks = list(task_with_limit()) # Should get only 50 rows due to per-block limiting assert len(result_blocks) == 1 assert len(result_blocks[0]) == 50 assert result_blocks[0]["id"].tolist() == list(range(50)) def test_per_task_row_limit_multiple_blocks_per_task(ray_start_regular_shared): """Test per-block limiting when ReadTasks return multiple blocks.""" def read_multiple_blocks_with_limit(): # This simulates a ReadTask that returns 3 blocks of 30 rows each return [ pd.DataFrame({"id": range(0, 30)}), pd.DataFrame({"id": range(30, 60)}), pd.DataFrame({"id": range(60, 90)}), ] # Create ReadTask with per-block limit of 70 (should get 2.33 blocks) task = ReadTask( read_fn=read_multiple_blocks_with_limit, metadata=BlockMetadata( num_rows=90, size_bytes=720, input_files=None, exec_stats=None ), schema=pa.lib.Schema.from_pandas(pd.DataFrame({"id": []})), per_task_row_limit=70, ) result_blocks = list(task()) # Should get first 2 full blocks (60 rows) plus 10 rows from third block total_rows = sum(len(block) for block in result_blocks) assert total_rows == 70 # Verify the data is correct all_ids = [] for block in result_blocks: all_ids.extend(block["id"].tolist()) assert all_ids == list(range(70)) def test_per_task_row_limit_larger_than_data( ray_start_regular_shared, restore_data_context ): """Test per-block limiting when limit is larger than available data.""" # NOTE: It's critical to preserve ordering for assertions in this test to work DataContext.get_current().execution_options.preserve_order = True total_rows = 50 ds = ray.data.range(total_rows, override_num_blocks=5) limited_ds = ds.limit(100) # Limit larger than data result = limited_ds.take_all() assert len(result) == total_rows assert [row["id"] for row in result] == list(range(total_rows)) def test_per_task_row_limit_exact_block_boundary( ray_start_regular_shared, restore_data_context ): """Test per-block limiting when limit exactly matches block boundaries.""" # NOTE: It's critical to preserve ordering for assertions in this test to work DataContext.get_current().execution_options.preserve_order = True rows_per_block = 20 num_blocks = 5 limit = rows_per_block * 2 # Exactly 2 blocks ds = ray.data.range(rows_per_block * num_blocks, override_num_blocks=num_blocks) limited_ds = ds.limit(limit) result = limited_ds.take_all() assert len(result) == limit assert [row["id"] for row in result] == list(range(limit)) @pytest.mark.parametrize("limit", [1, 5, 10, 25, 50, 99]) def test_per_task_row_limit_various_sizes( ray_start_regular_shared, limit, restore_data_context ): """Test per-block limiting with various limit sizes.""" # NOTE: It's critical to preserve ordering for assertions in this test to work DataContext.get_current().execution_options.preserve_order = True total_rows = 100 num_blocks = 10 ds = ray.data.range(total_rows, override_num_blocks=num_blocks) limited_ds = ds.limit(limit) result = limited_ds.take_all() expected_len = min(limit, total_rows) assert len(result) == expected_len assert [row["id"] for row in result] == list(range(expected_len)) def test_per_task_row_limit_with_transformations( ray_start_regular_shared, restore_data_context ): """Test that per-block limiting works correctly with transformations.""" # NOTE: It's critical to preserve ordering for assertions in this test to work DataContext.get_current().execution_options.preserve_order = True # Test with map operation after limit ds = ray.data.range(100, override_num_blocks=10) limited_ds = ds.limit(20).map(lambda x: {"doubled": x["id"] * 2}) result = limited_ds.take_all() assert len(result) == 20 assert [row["doubled"] for row in result] == [i * 2 for i in range(20)] # Test with map operation before limit ds = ray.data.range(100, override_num_blocks=10) limited_ds = ds.map(lambda x: {"doubled": x["id"] * 2}).limit(20) result = limited_ds.take_all() assert len(result) == 20 assert [row["doubled"] for row in result] == [i * 2 for i in range(20)] def test_per_task_row_limit_with_filter(ray_start_regular_shared, restore_data_context): """Test per-block limiting with filter operations.""" # NOTE: It's critical to preserve ordering for assertions in this test to work DataContext.get_current().execution_options.preserve_order = True # Filter before limit - per-block limiting should still work at read level ds = ray.data.range(200, override_num_blocks=10) filtered_limited = ds.filter(lambda x: x["id"] % 2 == 0).limit(15) result = filtered_limited.take_all() assert len(result) == 15 # Should get first 15 even numbers assert [row["id"] for row in result] == [i * 2 for i in range(15)] def test_per_task_row_limit_readtask_properties(ray_start_regular_shared): """Test ReadTask per_block_limit property.""" def dummy_read(): return [pd.DataFrame({"id": [1, 2, 3]})] # Test ReadTask without per_block_limit task_no_limit = ReadTask( read_fn=dummy_read, metadata=BlockMetadata( num_rows=3, size_bytes=24, input_files=None, exec_stats=None ), ) assert task_no_limit.per_task_row_limit is None # Test ReadTask with per_block_limit task_with_limit = ReadTask( read_fn=dummy_read, metadata=BlockMetadata( num_rows=3, size_bytes=24, input_files=None, exec_stats=None ), per_task_row_limit=10, ) assert task_with_limit.per_task_row_limit == 10 def test_per_task_row_limit_edge_cases(ray_start_regular_shared, restore_data_context): """Test per-block limiting edge cases.""" # NOTE: It's critical to preserve ordering for assertions in this test to work DataContext.get_current().execution_options.preserve_order = True # Test with single row ds = ray.data.range(1, override_num_blocks=1).limit(1) result = ds.take_all() assert len(result) == 1 assert result[0]["id"] == 0 # Test with limit of 1 on large dataset ds = ray.data.range(10000, override_num_blocks=100).limit(1) result = ds.take_all() assert len(result) == 1 assert result[0]["id"] == 0 # Test with very large limit ds = ray.data.range(100, override_num_blocks=10).limit(999999) result = ds.take_all() assert len(result) == 100 assert [row["id"] for row in result] == list(range(100)) if __name__ == "__main__": sys.exit(pytest.main(["-v", __file__]))