import uuid from typing import Any, List import pandas as pd import pytest import ray from ray.data._internal.execution.bundle_queue import ( EstimateSize, ExactMultipleSize, RebundleQueue, ) from ray.data._internal.execution.interfaces.ref_bundle import BlockEntry, RefBundle from ray.data.block import BlockAccessor def _make_ref_bundles_for_unit_test(raw_bundles: List[List[List[Any]]]) -> tuple: """Create RefBundles with fake object refs for unit testing (no Ray required). Args: raw_bundles: A list of bundles, where each bundle is a list of blocks, and each block is a list of values. Returns: A tuple of (list of RefBundles, block_data_map) where block_data_map maps fake object refs to their actual DataFrame data. """ output_bundles = [] block_data_map = {} for raw_bundle in raw_bundles: blocks = [] schema = None for raw_block in raw_bundle: block = pd.DataFrame({"id": raw_block}) # Use UUID to generate unique fake object refs block_ref = ray.ObjectRef(uuid.uuid4().hex[:28].encode()) block_data_map[block_ref] = block blocks.append( BlockEntry(block_ref, BlockAccessor.for_block(block).get_metadata()) ) schema = BlockAccessor.for_block(block).schema() output_bundle = RefBundle(blocks=blocks, owns_blocks=True, schema=schema) output_bundles.append(output_bundle) return output_bundles, block_data_map def _get_bundle_values(bundle: RefBundle, block_data_map: dict) -> List[List[Any]]: """Extract values from a bundle using block_data_map (no ray.get needed).""" output = [] for block_ref in bundle.block_refs: output.append(list(block_data_map[block_ref]["id"])) return output @pytest.mark.parametrize( "target,in_bundles,expected_row_counts", [ ( # Target of 2 rows per bundle 2, [[[1]], [[2]], [[3]], [[4]]], [2, 2], # Expected output: 2 bundles of 2 rows each ), ( # Target of 3 rows with uneven inputs 3, [[[1, 2]], [[3, 4, 5]], [[6]]], [3, 3], # Expected: [1,2,3] and [4,5,6] ), ( # Target of 4 rows with leftover 4, [[[1, 2]], [[3, 4]], [[5, 6, 7]]], [4, 3], # Expected: [1,2,3,4] and [5,6,7] ), ( # Larger target with various input sizes 5, [[[1, 2, 3]], [[4, 5, 6, 7]], [[8, 9]], [[10, 11, 12]]], [5, 5, 2], # Expected: [1-5], [6-10], [11-12] ), ( # Test with empty blocks 3, [[[1]], [[]], [[2, 3]], [[]], [[4, 5]]], [3, 2], # Expected: [1,2,3] and [4,5] ), ( # Test with last block smaller than target num rows per block 100, [[[1]], [[2]], [[3]], [[4]], [[5]]], [5], ), ], ) def test_streaming_repartition_ref_bundler(target, in_bundles, expected_row_counts): """Test RebundleQueue with various input patterns (unit test).""" bundler = RebundleQueue(ExactMultipleSize(target)) bundles, block_data_map = _make_ref_bundles_for_unit_test(in_bundles) out_bundles = [] for bundle in bundles: bundler.add(bundle) # NOTE: The check for num bundles/blocks is harder to reason about since we rebundle bundles together og_total_size_bytes = bundler.estimate_size_bytes() og_total_num_rows = bundler.num_rows() assert sum(bundle.size_bytes() for bundle in bundles) == og_total_size_bytes assert sum(bundle.num_rows() for bundle in bundles) == og_total_num_rows all_original_bundles = [] while bundler.has_next(): out_bundle, original_bundles = bundler.get_next_with_original() out_bundles.append(out_bundle) all_original_bundles.extend(original_bundles) bundler.finalize() while bundler.has_next(): out_bundle, original_bundles = bundler.get_next_with_original() out_bundles.append(out_bundle) all_original_bundles.extend(original_bundles) # Verify number of output bundles assert len(out_bundles) == len( expected_row_counts ), f"Expected {len(expected_row_counts)} bundles, got {len(out_bundles)}" # Verify row counts for each bundle for i, (out_bundle, expected_count) in enumerate( zip(out_bundles, expected_row_counts) ): assert ( out_bundle.num_rows() == expected_count ), f"Bundle {i}: expected {expected_count} rows, got {out_bundle.num_rows()}" # Verify all bundles have been ingested assert bundler.num_blocks() == 0 assert bundler.num_rows() == 0 assert len(bundler) == 0 assert bundler.estimate_size_bytes() == 0 # Verify all output bundles except the last are exact multiples of target for i, out_bundle in enumerate(out_bundles[:-1]): assert ( out_bundle.num_rows() % target == 0 ), f"Bundle {i} has {out_bundle.num_rows()} rows, not a multiple of {target}" # Verify data integrity - all input data is preserved in order (bundler slicing is in order) total_input_rows = sum(sum(len(block) for block in bundle) for bundle in in_bundles) total_output_rows = sum(bundle.num_rows() for bundle in out_bundles) assert total_output_rows == total_input_rows # Verify block content - extract all values from output bundles output_values = [] total_num_rows = 0 total_size_bytes = 0 for bundle in out_bundles: for entry, block_slice in zip(bundle.blocks, bundle.slices): block_ref = entry.ref # Look up the actual block data from our map (no ray.get needed) data = block_data_map[block_ref]["id"] if block_slice is not None: # We apply the slice here manually because this is just for testing bundler # and the block slicing is happened in map operator for streaming repartition data = data[block_slice.start_offset : block_slice.end_offset] output_values.extend(data) total_num_rows += bundle.num_rows() total_size_bytes += bundle.size_bytes() assert og_total_size_bytes == total_size_bytes assert og_total_num_rows == total_num_rows # Expected values are all input values flattened in order expected_values = [ value for bundle in in_bundles for block in bundle for value in block ] assert ( output_values == expected_values ), f"Output values {output_values} don't match expected {expected_values}" # Verify get_next_with_original tracks all non-empty original bundles # (empty bundles are accumulated separately and have no originals) non_empty_bundles = [b for b in bundles if b.num_rows() > 0] assert len(all_original_bundles) == len(non_empty_bundles) for orig, expected in zip(all_original_bundles, non_empty_bundles): assert orig is expected def test_peek_next(): """Test that peek_next returns the next bundle without removing it.""" bundler = RebundleQueue(ExactMultipleSize(2)) bundles, _ = _make_ref_bundles_for_unit_test([[[1]], [[2]], [[3]]]) # Peek on empty queue returns None assert bundler.peek_next() is None # Add bundles until we have a ready bundle bundler.add(bundles[0]) assert bundler.peek_next() is None # Not enough rows yet bundler.add(bundles[1]) assert bundler.has_next() # Peek should return the bundle without removing it peeked = bundler.peek_next() assert peeked is not None assert peeked.num_rows() == 2 # Peek again should return the same bundle peeked2 = bundler.peek_next() assert peeked2 is peeked # Metrics should be unchanged after peek initial_rows = bundler.num_rows() initial_len = len(bundler) bundler.peek_next() assert bundler.num_rows() == initial_rows assert len(bundler) == initial_len # get_next should return the same bundle got = bundler.get_next() assert got.num_rows() == peeked.num_rows() def test_clear(): """Test that clear resets the bundler to empty state.""" bundler = RebundleQueue(ExactMultipleSize(2)) bundles, _ = _make_ref_bundles_for_unit_test([[[1]], [[2]], [[3]], [[4]]]) # Add some bundles for bundle in bundles: bundler.add(bundle) # Verify bundler has content assert bundler.has_next() assert bundler.num_rows() > 0 assert len(bundler) > 0 assert bundler.estimate_size_bytes() > 0 # Clear the bundler bundler.clear() # Verify bundler is empty assert not bundler.has_next() assert bundler.num_rows() == 0 assert len(bundler) == 0 assert bundler.num_blocks() == 0 assert bundler.estimate_size_bytes() == 0 assert bundler.peek_next() is None # Verify we can add bundles again after clear new_bundles, _ = _make_ref_bundles_for_unit_test([[[10]], [[20]]]) for bundle in new_bundles: bundler.add(bundle) assert bundler.has_next() out = bundler.get_next() assert out.num_rows() == 2 def test_add_updates_metrics(): """Test that add correctly updates queue metrics.""" bundler = RebundleQueue(ExactMultipleSize(10)) # High target so nothing gets built bundles, _ = _make_ref_bundles_for_unit_test([[[1, 2]], [[3, 4, 5]]]) # Initially empty assert bundler.num_rows() == 0 assert bundler.num_blocks() == 0 assert bundler.estimate_size_bytes() == 0 # Add first bundle bundler.add(bundles[0]) assert bundler.num_rows() == 2 assert bundler.num_blocks() == 1 assert bundler.estimate_size_bytes() == bundles[0].size_bytes() # Add second bundle bundler.add(bundles[1]) assert bundler.num_rows() == 5 assert bundler.num_blocks() == 2 expected_bytes = bundles[0].size_bytes() + bundles[1].size_bytes() assert bundler.estimate_size_bytes() == expected_bytes # ============================================================================= # Tests for EstimateSize strategy # ============================================================================= @pytest.mark.parametrize( "target,in_bundles,expected_bundles", [ ( # Unit target, should leave unchanged. 1, [ # Input bundles [[1]], [[2]], [[3, 4]], [[5]], ], [ # Output bundles [[1]], [[2]], [[3, 4]], [[5]], ], ), ( # No target, should leave unchanged. None, [ # Input bundles [[1]], [[2]], [[3, 4]], [[5]], ], [ # Output bundles [[1]], [[2]], [[3, 4]], [[5]], ], ), ( # Proper handling of empty blocks 2, [ # Input bundles [[1]], [[]], [[]], [[2, 3]], [[]], [[]], ], [ # Output bundles [[1], [], [], [2, 3]], [[], []], ], ), ( # Test bundling, finalizing, passing, leftovers, etc. 2, [ # Input bundles [[1], [2]], [[3, 4, 5]], [[6], [7]], [[8]], [[9, 10], [11]], ], [[[1], [2]], [[3, 4, 5]], [[6], [7]], [[8], [9, 10], [11]]], ), ( # Test bundling, finalizing, passing, leftovers, etc. 3, [ # Input bundles [[1]], [[2, 3]], [[4, 5, 6, 7]], [[8, 9], [10, 11]], ], [ # Output bundles [[1], [2, 3]], [[4, 5, 6, 7]], [[8, 9], [10, 11]], ], ), ], ) def test_estimate_size_bundler_basic(target, in_bundles, expected_bundles): """Test RebundleQueue with EstimateSize strategy creates expected output bundles.""" bundler = RebundleQueue(EstimateSize(target)) bundles, block_data_map = _make_ref_bundles_for_unit_test(in_bundles) out_bundles = [] for bundle in bundles: bundler.add(bundle) while bundler.has_next(): out_bundle = _get_bundle_values(bundler.get_next(), block_data_map) out_bundles.append(out_bundle) bundler.finalize() if bundler.has_next(): out_bundle = _get_bundle_values(bundler.get_next(), block_data_map) out_bundles.append(out_bundle) # Assert expected output assert out_bundles == expected_bundles # Assert that all bundles have been ingested assert bundler.num_blocks() == 0 for bundle, expected in zip(out_bundles, expected_bundles): assert bundle == expected @pytest.mark.parametrize( "target,n,num_bundles,num_out_bundles,out_bundle_size", [ (5, 20, 20, 4, 5), (5, 24, 10, 4, 6), (8, 16, 4, 2, 8), ], ) def test_estimate_size_bundler_uniform( target, n, num_bundles, num_out_bundles, out_bundle_size ): """Test RebundleQueue with EstimateSize creates expected number of bundles.""" import numpy as np bundler = RebundleQueue(EstimateSize(target)) data = np.arange(n) pre_bundles = [arr.tolist() for arr in np.array_split(data, num_bundles)] # Convert to expected format: each bundle has one block raw_bundles = [[list(arr)] for arr in pre_bundles] bundles, block_data_map = _make_ref_bundles_for_unit_test(raw_bundles) out_bundles = [] for bundle in bundles: bundler.add(bundle) while bundler.has_next(): out_bundle = bundler.get_next() out_bundles.append(out_bundle) bundler.finalize() if bundler.has_next(): out_bundle = bundler.get_next() out_bundles.append(out_bundle) assert len(out_bundles) == num_out_bundles for out_bundle in out_bundles: assert out_bundle.num_rows() == out_bundle_size flat_out = [ i for bundle in out_bundles for block_ref in bundle.block_refs for i in list(block_data_map[block_ref]["id"]) ] assert flat_out == list(range(n)) def test_estimate_size_peek_next(): """Test peek_next with EstimateSize strategy.""" bundler = RebundleQueue(EstimateSize(2)) bundles, _ = _make_ref_bundles_for_unit_test([[[1]], [[2]], [[3]]]) # Peek on empty queue returns None assert bundler.peek_next() is None # Add bundles until we have a ready bundle bundler.add(bundles[0]) assert bundler.peek_next() is None # Not enough rows yet bundler.add(bundles[1]) assert bundler.has_next() # Peek should return the bundle without removing it peeked = bundler.peek_next() assert peeked is not None assert peeked.num_rows() == 2 # Peek again should return the same bundle peeked2 = bundler.peek_next() assert peeked2 is peeked # get_next should return the same bundle got = bundler.get_next() assert got.num_rows() == peeked.num_rows() def test_estimate_size_clear(): """Test clear with EstimateSize strategy.""" bundler = RebundleQueue(EstimateSize(2)) bundles, _ = _make_ref_bundles_for_unit_test([[[1]], [[2]], [[3]], [[4]]]) # Add some bundles for bundle in bundles: bundler.add(bundle) # Verify bundler has content assert bundler.has_next() assert bundler.num_rows() > 0 # Clear the bundler bundler.clear() # Verify bundler is empty assert not bundler.has_next() assert bundler.num_rows() == 0 assert len(bundler) == 0 assert bundler.num_blocks() == 0 # Verify we can add bundles again after clear new_bundles, _ = _make_ref_bundles_for_unit_test([[[10]], [[20]]]) for bundle in new_bundles: bundler.add(bundle) assert bundler.has_next() out = bundler.get_next() assert out.num_rows() == 2 def test_estimate_size_add_updates_metrics(): """Test add updates metrics with EstimateSize strategy.""" bundler = RebundleQueue(EstimateSize(10)) # High target so nothing gets built bundles, _ = _make_ref_bundles_for_unit_test([[[1, 2]], [[3, 4, 5]]]) # Initially empty assert bundler.num_rows() == 0 assert bundler.num_blocks() == 0 assert bundler.estimate_size_bytes() == 0 # Add first bundle bundler.add(bundles[0]) assert bundler.num_rows() == 2 assert bundler.num_blocks() == 1 assert bundler.estimate_size_bytes() == bundles[0].size_bytes() # Add second bundle bundler.add(bundles[1]) assert bundler.num_rows() == 5 assert bundler.num_blocks() == 2 expected_bytes = bundles[0].size_bytes() + bundles[1].size_bytes() assert bundler.estimate_size_bytes() == expected_bytes def test_empty_bundle_merges_with_previous_pending(): """Test that empty bundles merge into the last pending bundle rather than accumulating separately.""" bundler = RebundleQueue(EstimateSize(3)) bundles, _ = _make_ref_bundles_for_unit_test([[[1, 2]], [[]], [[]], [[3]]]) for bundle in bundles: bundler.add(bundle) # The two empty bundles merged into the [1,2] pending bundle, # then [3] arrived pushing total to 3 → ready bundle built. assert bundler.has_next() out = bundler.get_next() assert out.num_rows() == 3 # Should have 4 blocks: [1,2], [], [], [3] assert len(out.block_refs) == 4 assert bundler.num_rows() == 0 assert len(bundler) == 0 def test_empty_bundle_no_previous_pending(): """Test that empty bundles with no previous pending just go to pending and merge with subsequent empties.""" bundler = RebundleQueue(EstimateSize(2)) bundles, _ = _make_ref_bundles_for_unit_test([[[]], [[]], [[1, 2]]]) for bundle in bundles: bundler.add(bundle) # Two empties merged together in pending, then [1,2] triggers ready. assert bundler.has_next() out = bundler.get_next() assert out.num_rows() == 2 # 3 blocks: [], [], [1,2] assert len(out.block_refs) == 3 assert bundler.num_rows() == 0 assert len(bundler) == 0 def test_empty_bundle_only(): """Test that a queue receiving only empty bundles flushes at finalize.""" bundler = RebundleQueue(EstimateSize(2)) bundles, _ = _make_ref_bundles_for_unit_test([[[]], [[]]]) for bundle in bundles: bundler.add(bundle) # Empties sit in pending (merged together), can't trigger a ready bundle assert not bundler.has_next() bundler.finalize() assert bundler.has_next() out = bundler.get_next() assert out.num_rows() == 0 assert len(out.block_refs) == 2 assert not bundler.has_next() assert bundler.num_rows() == 0 assert len(bundler) == 0 if __name__ == "__main__": import sys sys.exit(pytest.main(["-v", __file__]))