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

609 lines
19 KiB
Python

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