Files
2026-07-13 13:17:40 +08:00

195 lines
6.5 KiB
Python

import itertools
import pandas as pd
import pyarrow as pa
import pytest
import ray
from ray.data import Schema
from ray.data.tests.conftest import * # noqa
from ray.data.tests.util import column_udf, named_values
from ray.tests.conftest import * # noqa
@pytest.mark.parametrize("num_datasets", [2, 3, 4, 5, 10])
def test_zip_multiple_datasets(ray_start_regular_shared, num_datasets):
# Create multiple datasets with different transformations
datasets = []
for i in range(num_datasets):
ds = ray.data.range(5, override_num_blocks=5)
if i > 0: # Apply transformation to all but the first dataset
ds = ds.map(column_udf("id", lambda x, offset=i: x + offset))
datasets.append(ds)
ds = datasets[0].zip(*datasets[1:])
# Verify schema names
expected_names = ["id"] + [f"id_{i}" for i in range(1, num_datasets)]
assert ds.schema().names == expected_names
# Verify data
expected_data = []
for row_idx in range(5):
row_data = tuple(row_idx + i for i in range(num_datasets))
expected_data.append(row_data)
assert ds.take() == named_values(expected_names, expected_data)
@pytest.mark.parametrize(
"num_blocks1,num_blocks2",
list(itertools.combinations_with_replacement([1, 2, 4, 16], 2)),
)
def test_zip_different_num_blocks_combinations(
ray_start_regular_shared, num_blocks1, num_blocks2
):
n = 12
ds1 = ray.data.range(n, override_num_blocks=num_blocks1)
ds2 = ray.data.range(n, override_num_blocks=num_blocks2).map(
column_udf("id", lambda x: x + 1)
)
ds = ds1.zip(ds2)
assert ds.schema().names == ["id", "id_1"]
assert ds.take() == named_values(
["id", "id_1"], list(zip(range(n), range(1, n + 1)))
)
@pytest.mark.parametrize(
"num_cols1,num_cols2,should_invert",
[
(1, 1, False),
(4, 1, False),
(1, 4, True),
(1, 10, True),
(10, 10, False),
],
)
def test_zip_different_num_blocks_split_smallest(
ray_start_regular_shared,
num_cols1,
num_cols2,
should_invert,
):
n = 12
num_blocks1 = 4
num_blocks2 = 2
ds1 = ray.data.from_items(
[{str(i): i for i in range(num_cols1)}] * n, override_num_blocks=num_blocks1
)
ds2 = ray.data.from_items(
[{str(i): i for i in range(num_cols1, num_cols1 + num_cols2)}] * n,
override_num_blocks=num_blocks2,
)
ds = ds1.zip(ds2).materialize()
bundles = ds.iter_internal_ref_bundles()
num_blocks = sum(len(b.block_refs) for b in bundles)
assert ds.take() == [{str(i): i for i in range(num_cols1 + num_cols2)}] * n
if should_invert:
assert num_blocks == num_blocks2
else:
assert num_blocks == num_blocks1
def test_zip_pandas(ray_start_regular_shared):
ds1 = ray.data.from_pandas(pd.DataFrame({"col1": [1, 2], "col2": [4, 5]}))
ds2 = ray.data.from_pandas(pd.DataFrame({"col3": ["a", "b"], "col4": ["d", "e"]}))
ds = ds1.zip(ds2)
assert ds.count() == 2
result = list(ds.take())
assert result[0] == {"col1": 1, "col2": 4, "col3": "a", "col4": "d"}
ds3 = ray.data.from_pandas(pd.DataFrame({"col2": ["a", "b"], "col4": ["d", "e"]}))
ds = ds1.zip(ds3)
assert ds.count() == 2
result = list(ds.take())
assert result[0] == {"col1": 1, "col2": 4, "col2_1": "a", "col4": "d"}
def test_zip_arrow(ray_start_regular_shared):
ds1 = ray.data.range(5).map(lambda r: {"id": r["id"]})
ds2 = ray.data.range(5).map(lambda r: {"a": r["id"] + 1, "b": r["id"] + 2})
ds = ds1.zip(ds2)
assert ds.count() == 5
assert ds.schema() == Schema(
pa.schema([("id", pa.int64()), ("a", pa.int64()), ("b", pa.int64())])
)
result = list(ds.take())
assert result[0] == {"id": 0, "a": 1, "b": 2}
# Test duplicate column names.
ds = ds1.zip(ds1).zip(ds1)
assert ds.count() == 5
assert ds.schema() == Schema(
pa.schema([("id", pa.int64()), ("id_1", pa.int64()), ("id_2", pa.int64())])
)
result = list(ds.take())
assert result[0] == {"id": 0, "id_1": 0, "id_2": 0}
def test_zip_multiple_block_types(ray_start_regular_shared):
df = pd.DataFrame({"spam": [0]})
ds_pd = ray.data.from_pandas(df)
ds2_arrow = ray.data.from_items([{"ham": [0]}])
assert ds_pd.zip(ds2_arrow).take_all() == [{"spam": 0, "ham": [0]}]
def test_zip_preserve_order(ray_start_regular_shared):
def foo(x):
import time
if x["item"] < 5:
time.sleep(1)
return x
num_items = 10
items = list(range(num_items))
ds1 = ray.data.from_items(items, override_num_blocks=num_items)
ds2 = ray.data.from_items(items, override_num_blocks=num_items)
ds2 = ds2.map_batches(foo, batch_size=1)
result = ds1.zip(ds2).take_all()
assert result == named_values(
["item", "item_1"], list(zip(range(num_items), range(num_items)))
), result
def test_zip_does_not_free_shared_materialized_blocks(ray_start_regular_shared):
"""Regression test: ZipOperator should not free blocks from a materialized
dataset that is shared with another consumer.
Previously, ZipOperator._zip() called _split_at_indices() without specifying
owned_by_consumer, which defaulted to True. This caused ray.internal.free()
to be called on blocks that were shared with other operators in the DAG,
leading to ObjectFreedError.
"""
# Create a dataset with 3 blocks (rows [7, 7, 6]) and materialize it.
# The materialized blocks have owns_blocks=False.
ds = ray.data.range(20, override_num_blocks=3).materialize()
assert not ds._execute().owns_blocks
# Consumer 1: a map_batches that uses the same materialized dataset.
mapped_ds = ds.map_batches(lambda batch: batch, batch_format="pandas")
# Consumer 2: zip the same materialized dataset with another dataset.
# This triggers _split_at_indices inside ZipOperator._zip().
# Use 2 blocks (rows [10, 10]) so that block boundaries are NOT aligned
# with ds's blocks (rows [7, 7, 6]). This forces actual block splitting
# (e.g., the first 10-row block gets split at row 7), which exercises
# the owned_by_consumer code path in _split_all_blocks.
other_ds = ray.data.range(20, override_num_blocks=2)
zipped = other_ds.zip(ds)
# Consuming the zipped result should not raise ObjectFreedError.
result = zipped.take_all()
assert len(result) == 20
# The mapped_ds should also work fine (blocks not freed by the zip).
result2 = mapped_ds.take_all()
assert len(result2) == 20
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))