import sys from typing import Any, Dict, List import pandas as pd import pytest import ray from ray.data import Dataset from ray.data._internal.logical.interfaces import LogicalOperator, Plan from ray.data._internal.logical.operators import Download, Limit from ray.data._internal.logical.rules.limit_pushdown import LimitPushdownRule from ray.data._internal.util import rows_same from ray.data.block import BlockMetadata from ray.data.datasource import Datasource from ray.data.datasource.datasource import ReadTask from ray.data.tests.conftest import * # noqa from ray.tests.conftest import * # noqa def _check_valid_plan_and_result( ds: Dataset, expected_plan: Plan, expected_result: List[Dict[str, Any]], expected_physical_plan_ops=None, check_ordering=True, ): actual_result = ds.take_all() if check_ordering: assert actual_result == expected_result else: assert rows_same(pd.DataFrame(actual_result), pd.DataFrame(expected_result)) assert ds._logical_plan.dag.dag_str == expected_plan expected_physical_plan_ops = expected_physical_plan_ops or [] for op in expected_physical_plan_ops: assert op in ds.stats(), f"Operator {op} not found: {ds.stats()}" class _DummyLogicalOperator(LogicalOperator): def __init__(self, input_dependencies, name=None): object.__setattr__(self, "_input_dependencies", input_dependencies) if name is not None: object.__setattr__(self, "_name", name) def test_limit_pushdown_recreates_frozen_download(): input_op = _DummyLogicalOperator(input_dependencies=[], name="DummyInput") download_op = Download( uri_column_names=["uri"], output_bytes_column_names=["bytes"], input_dependencies=[input_op], ) limit_op = Limit(1, input_dependencies=[download_op]) result = LimitPushdownRule()._push_limit_down(limit_op) assert isinstance(result, Download) assert isinstance(result.input_dependencies[0], Limit) assert result.input_dependencies[0].limit == 1 assert result.input_dependencies[0].input_dependencies[0] is input_op def test_limit_pushdown_basic_limit_fusion(ray_start_regular_shared_2_cpus): """Test basic Limit -> Limit fusion.""" # Use override_num_blocks=1 for deterministic row ordering. ds = ray.data.range(100, override_num_blocks=1).limit(5).limit(100) _check_valid_plan_and_result( ds, "Read[ReadRange] -> Limit[limit=5]", [{"id": i} for i in range(5)], check_ordering=False, ) def test_limit_pushdown_limit_fusion_reversed(ray_start_regular_shared_2_cpus): """Test Limit fusion with reversed order.""" # Use override_num_blocks=1 for deterministic row ordering. ds = ray.data.range(100, override_num_blocks=1).limit(100).limit(5) _check_valid_plan_and_result( ds, "Read[ReadRange] -> Limit[limit=5]", [{"id": i} for i in range(5)], check_ordering=False, ) def test_limit_pushdown_multiple_limit_fusion(ray_start_regular_shared_2_cpus): """Test multiple Limit operations fusion.""" # Use override_num_blocks=1 for deterministic row ordering. ds = ( ray.data.range(100, override_num_blocks=1) .limit(50) .limit(80) .limit(5) .limit(20) ) _check_valid_plan_and_result( ds, "Read[ReadRange] -> Limit[limit=5]", [{"id": i} for i in range(5)], check_ordering=False, ) def test_limit_pushdown_through_maprows(ray_start_regular_shared_2_cpus): """Test that Limit pushes through MapRows operations.""" def f1(x): return x ds = ray.data.range(100, override_num_blocks=100).map(f1).limit(1) _check_valid_plan_and_result( ds, "Read[ReadRange] -> Limit[limit=1] -> MapRows[Map(f1)]", [{"id": 0}], check_ordering=False, ) def test_limit_pushdown_through_mapbatches(ray_start_regular_shared_2_cpus): """Test that Limit pushes through MapBatches operations.""" def f2(x): return x ds = ( ray.data.range(100, override_num_blocks=100) .map_batches(f2, udf_modifying_row_count=False) .limit(1) ) _check_valid_plan_and_result( ds, "Read[ReadRange] -> Limit[limit=1] -> MapBatches[MapBatches(f2)]", [{"id": 0}], check_ordering=False, ) def test_limit_pushdown_stops_at_filter(ray_start_regular_shared_2_cpus): """Test that Limit does NOT push through Filter operations (conservative).""" ds = ( ray.data.range(100, override_num_blocks=100) .filter(lambda x: x["id"] < 50) .limit(1) ) _check_valid_plan_and_result( ds, "Read[ReadRange] -> Filter[Filter()] -> Limit[limit=1]", [{"id": 0}], check_ordering=False, ) def test_limit_pushdown_through_project(ray_start_regular_shared_2_cpus): """Test that Limit pushes through Project operations.""" ds = ray.data.range(100, override_num_blocks=100).select_columns(["id"]).limit(5) _check_valid_plan_and_result( ds, "Read[ReadRange] -> Limit[limit=5] -> Project[Project]", [{"id": i} for i in range(5)], check_ordering=False, ) def test_limit_pushdown_stops_at_sort(ray_start_regular_shared_2_cpus): """Test that Limit stops at Sort operations (AllToAll).""" ds = ray.data.range(100).sort("id").limit(5) _check_valid_plan_and_result( ds, "Read[ReadRange] -> Sort[Sort] -> Limit[limit=5]", [{"id": i} for i in range(5)], ) def test_limit_pushdown_complex_interweaved_operations(ray_start_regular_shared_2_cpus): """Test Limit pushdown with complex interweaved operations.""" def f1(x): return x def f2(x): return x ds = ray.data.range(100).sort("id").map(f1).limit(20).sort("id").map(f2).limit(5) _check_valid_plan_and_result( ds, "Read[ReadRange] -> Sort[Sort] -> Limit[limit=20] -> MapRows[Map(f1)] -> " "Sort[Sort] -> Limit[limit=5] -> MapRows[Map(f2)]", [{"id": i} for i in range(5)], ) def test_limit_pushdown_between_two_map_operators(ray_start_regular_shared_2_cpus): """Test Limit pushdown between two Map operators.""" def f1(x): return x def f2(x): return x ds = ray.data.range(100, override_num_blocks=100).map(f1).limit(1).map(f2) _check_valid_plan_and_result( ds, "Read[ReadRange] -> Limit[limit=1] -> MapRows[Map(f1)] -> MapRows[Map(f2)]", [{"id": 0}], check_ordering=False, ) def test_limit_pushdown_correctness(ray_start_regular_shared_2_cpus): """Test that limit pushdown produces correct results in various scenarios.""" # Test 1: Simple project + limit ds = ray.data.range(100).select_columns(["id"]).limit(10) result = ds.take_all() expected = [{"id": i} for i in range(10)] assert result == expected # Test 2: Multiple operations + limit (with MapRows pushdown) ds = ( ray.data.range(100) .map(lambda x: {"id": x["id"], "squared": x["id"] ** 2}) .select_columns(["id"]) .limit(5) ) result = ds.take_all() expected = [{"id": i} for i in range(5)] assert result == expected # Test 3: MapRows operations should get limit pushed (safe) ds = ray.data.range(100).map(lambda x: {"id": x["id"] * 2}).limit(5) result = ds.take_all() expected = [{"id": i * 2} for i in range(5)] assert result == expected # Test 4: MapBatches operations should not get limit pushed ds = ray.data.range(100).map_batches(lambda batch: {"id": batch["id"] * 2}).limit(5) result = ds.take_all() expected = [{"id": i * 2} for i in range(5)] assert result == expected # Test 5: Filter operations should not get limit pushed (conservative) ds = ray.data.range(100).filter(lambda x: x["id"] % 2 == 0).limit(3) result = ds.take_all() expected = [{"id": i} for i in [0, 2, 4]] assert result == expected # Test 6: Complex chain with both safe operations (should all get limit pushed) ds = ( ray.data.range(100) .select_columns(["id"]) # Project - could be safe if it was the immediate input .map(lambda x: {"id": x["id"] + 1}) # MapRows - NOT safe, stops pushdown .limit(3) ) result = ds.take_all() expected = [{"id": i + 1} for i in range(3)] assert result == expected # The plan should show all operations after the limit plan_str = ds._logical_plan.dag.dag_str assert ( "Read[ReadRange] -> Limit[limit=3] -> Project[Project] -> MapRows[Map()]" == plan_str ) def test_limit_pushdown_scan_efficiency(ray_start_regular_shared_2_cpus): """Test that limit pushdown scans fewer rows from the data source.""" @ray.remote class Counter: def __init__(self): self.value = 0 def increment(self, amount=1): self.value += amount return self.value def get(self): return self.value def reset(self): self.value = 0 # Create a custom datasource that tracks how many rows it produces class CountingDatasource(Datasource): def __init__(self): self.counter = Counter.remote() def prepare_read(self, parallelism, n_per_block=10): def read_fn(block_idx): # Each block produces n_per_block rows ray.get(self.counter.increment.remote(n_per_block)) return [ pd.DataFrame( { "id": range( block_idx * n_per_block, (block_idx + 1) * n_per_block ) } ) ] return [ ReadTask( lambda i=i: read_fn(i), BlockMetadata( num_rows=n_per_block, size_bytes=n_per_block * 8, # rough estimate input_files=None, exec_stats=None, ), ) for i in range(parallelism) ] def get_rows_produced(self): return ray.get(self.counter.get.remote()) # Test 1: Project + Limit should scan fewer rows due to pushdown source = CountingDatasource() ds = ray.data.read_datasource(source, override_num_blocks=20, n_per_block=10) ds = ds.select_columns(["id"]).limit(5) result = ds.take_all() # Should get correct results assert len(result) == 5 assert result == [{"id": i} for i in range(5)] # Should have scanned significantly fewer than all 200 rows (20 blocks * 10 rows) # Due to pushdown, we should scan much less rows_produced_1 = source.get_rows_produced() assert rows_produced_1 < 200 # Should be much less than total # Test 2: MapRows + Limit should also scan fewer rows due to pushdown source2 = CountingDatasource() ds2 = ray.data.read_datasource(source2, override_num_blocks=20, n_per_block=10) ds2 = ds2.map(lambda x: x).limit(5) result2 = ds2.take_all() # Should get correct results assert len(result2) == 5 assert result2 == [{"id": i} for i in range(5)] # Should also scan fewer than total due to pushdown rows_produced_2 = source2.get_rows_produced() assert rows_produced_2 < 200 # Both should be efficient with pushdown assert rows_produced_1 < 100 # Should be much less than total assert rows_produced_2 < 100 # Should be much less than total # Test 3: Filter + Limit should scan fewer due to early termination, but not pushdown source3 = CountingDatasource() ds3 = ray.data.read_datasource(source3, override_num_blocks=20, n_per_block=10) ds3 = ds3.filter(lambda x: x["id"] % 2 == 0).limit(3) result3 = ds3.take_all() # Should get correct results assert len(result3) == 3 assert result3 == [{"id": i} for i in [0, 2, 4]] # Should still scan fewer than total due to early termination rows_produced_3 = source3.get_rows_produced() assert rows_produced_3 < 200 def test_limit_pushdown_union(ray_start_regular_shared_2_cpus): """Test limit pushdown behavior with Union operations.""" # Create two datasets and union with limit ds1 = ray.data.range(100, override_num_blocks=10) ds2 = ray.data.range(200, override_num_blocks=10) ds = ds1.union(ds2).limit(5) expected_plan = "Read[ReadRange] -> Limit[limit=5], Read[ReadRange] -> Limit[limit=5] -> Union[Union] -> Limit[limit=5]" _check_valid_plan_and_result( ds, expected_plan, [{"id": i} for i in range(5)], check_ordering=False ) def test_limit_pushdown_union_with_maprows(ray_start_regular_shared_2_cpus): """Limit after Union + MapRows: limit should be pushed before the MapRows and inside each Union branch.""" ds1 = ray.data.range(100, override_num_blocks=10) ds2 = ray.data.range(200, override_num_blocks=10) ds = ds1.union(ds2).map(lambda x: x).limit(5) expected_plan = ( "Read[ReadRange] -> Limit[limit=5], " "Read[ReadRange] -> Limit[limit=5] -> Union[Union] -> " "Limit[limit=5] -> MapRows[Map()]" ) _check_valid_plan_and_result( ds, expected_plan, [{"id": i} for i in range(5)], check_ordering=False ) def test_limit_pushdown_union_with_sort(ray_start_regular_shared_2_cpus): """Limit after Union + Sort: limit must NOT push through the Sort.""" ds1 = ray.data.range(100, override_num_blocks=4) ds2 = ray.data.range(50, override_num_blocks=4).map( lambda x: {"id": x["id"] + 1000} ) ds = ds1.union(ds2).sort("id").limit(5) expected_plan = ( "Read[ReadRange], " "Read[ReadRange] -> MapRows[Map()] -> " "Union[Union] -> Sort[Sort] -> Limit[limit=5]" ) _check_valid_plan_and_result(ds, expected_plan, [{"id": i} for i in range(5)]) def test_limit_pushdown_multiple_unions(ray_start_regular_shared_2_cpus): """Outer limit over nested unions should create a branch-local limit for every leaf plus the global one.""" ds = ( ray.data.range(100) .union(ray.data.range(100, override_num_blocks=5)) .union(ray.data.range(50)) .limit(5) ) expected_plan = ( "Read[ReadRange] -> Limit[limit=5], " "Read[ReadRange] -> Limit[limit=5] -> Union[Union] -> Limit[limit=5], " "Read[ReadRange] -> Limit[limit=5] -> Union[Union] -> Limit[limit=5]" ) _check_valid_plan_and_result( ds, expected_plan, [{"id": i} for i in range(5)], check_ordering=False ) def test_limit_pushdown_union_with_groupby(ray_start_regular_shared_2_cpus): """Limit after Union + Aggregate: limit should stay after Aggregate.""" ds1 = ray.data.range(100) ds2 = ray.data.range(100).map(lambda x: {"id": x["id"] + 1000}) ds = ds1.union(ds2).groupby("id").count().limit(5) # Result should contain 5 distinct ids with count == 1. res = ds.take_all() # Plan suffix check (no branch limits past Aggregate). assert ds._logical_plan.dag.dag_str.endswith( "Union[Union] -> Aggregate[Aggregate] -> Limit[limit=5]" ) assert len(res) == 5 and all(r["count()"] == 1 for r in res) def test_limit_pushdown_complex_chain(ray_start_regular_shared_2_cpus): """ Complex end-to-end case: 1. Two branches each with a branch-local Limit pushed to Read. • left : Project • right : MapRows 2. Union of the two branches. 3. Global Aggregate (groupby/count). 4. Sort (descending id) – pushes stop here. 5. Final Limit. Verifies both plan rewrite and result correctness. """ # ── left branch ──────────────────────────────────────────────── left = ray.data.range(50).select_columns(["id"]).limit(10) # ── right branch ─────────────────────────────────────────────── right = ray.data.range(50).map(lambda x: {"id": x["id"] + 1000}).limit(10) # ── union → aggregate → sort → limit ────────────────────────── ds = left.union(right).groupby("id").count().sort("id", descending=True).limit(3) # Expected logical-plan string. expected_plan = ( "Read[ReadRange] -> Limit[limit=10] -> Project[Project], " "Read[ReadRange] -> Limit[limit=10] -> MapRows[Map()] " "-> Union[Union] -> Aggregate[Aggregate] -> Sort[Sort] -> Limit[limit=3]" ) # Top-3 ids are the three largest (1009, 1008, 1007) with count()==1. expected_result = [ {"id": 1009, "count()": 1}, {"id": 1008, "count()": 1}, {"id": 1007, "count()": 1}, ] _check_valid_plan_and_result(ds, expected_plan, expected_result) def test_limit_pushdown_union_maps_projects(ray_start_regular_shared_2_cpus): r""" Read -> MapBatches -> MapRows -> Project \ / -------- Union ------------- → Limit The limit should be pushed in front of each branch (past MapRows, Project) while the original global Limit is preserved after the Union. """ # Left branch. left = ( ray.data.range(30) .map_batches(lambda b: b, udf_modifying_row_count=False) .map(lambda r: {"id": r["id"]}) .select_columns(["id"]) ) # Right branch with shifted ids. right = ( ray.data.range(30) .map_batches(lambda b: b, udf_modifying_row_count=False) .map(lambda r: {"id": r["id"] + 100}) .select_columns(["id"]) ) ds = left.union(right).limit(3) expected_plan = ( "Read[ReadRange] -> " "Limit[limit=3] -> MapBatches[MapBatches()] -> MapRows[Map()] -> " "Project[Project], " "Read[ReadRange] -> " "Limit[limit=3] -> MapBatches[MapBatches()] -> MapRows[Map()] -> " "Project[Project] -> Union[Union] -> Limit[limit=3]" ) expected_result = [{"id": i} for i in range(3)] # First 3 rows from left branch. _check_valid_plan_and_result( ds, expected_plan, expected_result, check_ordering=False ) def test_limit_pushdown_map_per_block_limit_applied(ray_start_regular_shared_2_cpus): """Test that per-block limits are actually applied during map execution.""" # Create a global counter using Ray @ray.remote class Counter: def __init__(self): self.value = 0 def increment(self): self.value += 1 return self.value def get(self): return self.value counter = Counter.remote() def track_processing(row): # Record that this row was processed ray.get(counter.increment.remote()) return row # Create dataset with limit pushed through map ds = ray.data.range(1000, override_num_blocks=10).map(track_processing).limit(50) # Execute and get results result = ds.take_all() # Verify correct results expected = [{"id": i} for i in range(50)] assert result == expected # Check how many rows were actually processed processed_count = ray.get(counter.get.remote()) # With per-block limits, we should process fewer rows than the total dataset # but at least the number we need for the final result assert ( processed_count >= 50 ), f"Expected at least 50 rows processed, got {processed_count}" assert ( processed_count < 1000 ), f"Expected fewer than 1000 rows processed, got {processed_count}" print(f"Processed {processed_count} rows to get {len(result)} results") def test_limit_pushdown_preserves_map_behavior(ray_start_regular_shared_2_cpus): """Test that adding per-block limits doesn't change the logical result.""" def add_one(row): row["id"] += 1 return row # Compare with and without limit pushdown ds_with_limit = ray.data.range(100).map(add_one).limit(10) ds_without_limit = ray.data.range(100).limit(10).map(add_one) result_with = ds_with_limit.take_all() result_without = ds_without_limit.take_all() # Results should be identical assert result_with == result_without # Both should have the expected transformation applied expected = [{"id": i + 1} for i in range(10)] assert result_with == expected @pytest.mark.parametrize( "udf_modifying_row_count,expected_plan", [ ( False, "Read[ReadRange] -> Limit[limit=10] -> MapBatches[MapBatches()]", ), ( True, "Read[ReadRange] -> MapBatches[MapBatches()] -> Limit[limit=10]", ), ], ) def test_limit_pushdown_udf_modifying_row_count_with_map_batches( ray_start_regular_shared_2_cpus, udf_modifying_row_count, expected_plan, ): """Test that limit pushdown preserves the row count with map batches.""" ds = ( ray.data.range(100) .map_batches(lambda x: x, udf_modifying_row_count=udf_modifying_row_count) .limit(10) ) _check_valid_plan_and_result( ds, expected_plan, [{"id": i} for i in range(10)], ) def test_does_not_pushdown_limit_past_map_batches_by_default( ray_start_regular_shared_2_cpus, ): def duplicate_id(batch): yield {"data": list(batch["id"]) * 2} # If the optimizer incorrectly pushes the limit past the map operator, then the # returned count is 2. num_rows = ray.data.range(1).map_batches(duplicate_id).limit(1).count() assert num_rows == 1, num_rows def test_does_not_pushdown_limit_past_map_groups_by_default( ray_start_regular_shared_2_cpus, ): def duplicate_id(batch): yield {"data": list(batch["id"]) * 2} # If the optimizer incorrectly pushes the limit past the map operator, then the # returned count is 2. num_rows = ray.data.range(1).groupby("id").map_groups(duplicate_id).limit(1).count() assert num_rows == 1, num_rows if __name__ == "__main__": sys.exit(pytest.main(["-v", __file__]))