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

161 lines
6.3 KiB
Python

from unittest.mock import MagicMock
import pyarrow as pa
import pyarrow.parquet as pq
import pytest
import ray
from ray.data._internal.execution.interfaces import ExecutionOptions
from ray.data._internal.execution.operators.input_data_buffer import InputDataBuffer
from ray.data._internal.execution.operators.limit_operator import LimitOperator
from ray.data._internal.execution.streaming_executor import StreamingExecutor
from ray.data._internal.execution.util import make_ref_bundles
from ray.data._internal.logical.optimizers import get_execution_plan
from ray.data.context import DataContext
from ray.data.tests.conftest import noop_counter
from ray.data.tests.util import run_op_tasks_sync
from ray.tests.conftest import * # noqa
def test_limit_estimated_num_output_bundles(ray_start_regular_shared):
# Test limit operator estimation
input_op = InputDataBuffer(
DataContext.get_current(), make_ref_bundles([[i, i] for i in range(100)])
)
op = LimitOperator(100, input_op, DataContext.get_current())
while input_op.has_next():
op.add_input(input_op.get_next(), 0)
run_op_tasks_sync(op)
assert op._estimated_num_output_bundles == 50
op.all_inputs_done()
# 2 rows per bundle, 100 / 2 = 50 blocks output
assert op._estimated_num_output_bundles == 50
# Test limit operator estimation where: limit > # of rows
input_op = InputDataBuffer(
DataContext.get_current(), make_ref_bundles([[i, i] for i in range(100)])
)
op = LimitOperator(300, input_op, DataContext.get_current())
while input_op.has_next():
op.add_input(input_op.get_next(), 0)
run_op_tasks_sync(op)
assert op._estimated_num_output_bundles == 100
op.all_inputs_done()
# all blocks are outputted
assert op._estimated_num_output_bundles == 100
def test_limit_operator(ray_start_regular_shared):
"""Test basic functionalities of LimitOperator."""
num_refs = 3
num_rows_per_block = 3
total_rows = num_refs * num_rows_per_block
# Test limits with different values, from 0 to more than input size.
limits = list(range(0, total_rows + 2))
for limit in limits:
refs = make_ref_bundles([[i] * num_rows_per_block for i in range(num_refs)])
input_op = InputDataBuffer(DataContext.get_current(), refs)
limit_op = LimitOperator(limit, input_op, DataContext.get_current())
counter = noop_counter()
input_op.start(ExecutionOptions(), counter)
limit_op.start(ExecutionOptions(), counter)
limit_op.mark_execution_finished = MagicMock(
wraps=limit_op.mark_execution_finished
)
if limit == 0:
# If the limit is 0, the operator should be completed immediately.
assert limit_op.has_completed()
assert limit_op._limit_reached()
cur_rows = 0
loop_count = 0
while input_op.has_next() and not limit_op._limit_reached():
loop_count += 1
assert not limit_op.has_completed(), limit
assert not limit_op.has_execution_finished(), limit
limit_op.add_input(input_op.get_next(), 0)
while limit_op.has_next():
# Drain the outputs. So the limit operator
# will be completed when the limit is reached.
limit_op.get_next()
cur_rows += num_rows_per_block
if cur_rows >= limit:
assert limit_op.mark_execution_finished.call_count == 1, limit
assert limit_op.has_completed(), limit
assert limit_op._limit_reached(), limit
assert limit_op.has_execution_finished(), limit
else:
assert limit_op.mark_execution_finished.call_count == 0, limit
assert not limit_op.has_completed(), limit
assert not limit_op._limit_reached(), limit
assert not limit_op.has_execution_finished(), limit
limit_op.mark_execution_finished()
# After inputs done, the number of output bundles
# should be the same as the number of `add_input`s.
assert limit_op.num_outputs_total() == loop_count, limit
assert limit_op.has_completed(), limit
def test_limit_operator_memory_leak_fix(ray_start_regular_shared, tmp_path):
"""Test that LimitOperator properly drains upstream output queues.
This test verifies the memory leak fix by directly using StreamingExecutor
to access the actual topology and check queued blocks after execution.
"""
for i in range(100):
data = [{"id": i * 5 + j, "value": f"row_{i * 5 + j}"} for j in range(5)]
table = pa.Table.from_pydict(
{"id": [row["id"] for row in data], "value": [row["value"] for row in data]}
)
parquet_file = tmp_path / f"test_data_{i}.parquet"
pq.write_table(table, str(parquet_file))
parquet_files = [str(tmp_path / f"test_data_{i}.parquet") for i in range(100)]
ds = (
ray.data.read_parquet(parquet_files, override_num_blocks=100)
.limit(5)
.map(lambda x: x)
)
physical_plan, _ = get_execution_plan(ds._logical_plan)
# Use StreamingExecutor directly to have access to the actual topology
executor = StreamingExecutor(DataContext.get_current())
output_iterator = executor.execute(physical_plan.dag)
# Collect all results and count rows
total_rows = 0
for bundle in output_iterator:
for block_ref in bundle.block_refs:
block = ray.get(block_ref)
total_rows += block.num_rows
assert (
total_rows == 5
), f"Expected exactly 5 rows after limit(5), but got {total_rows}"
# Find the parquet read operator's OpState. Covers both the V1
# ``ReadParquet`` op name and the V2 ``ReadFilesParquet{V2,}`` name
# under the ``DataContext.use_datasource_v2`` path.
topology = executor._topology
read_parquet_op_state = None
for op, op_state in topology.items():
if "Parquet" in op.name and ("Read" in op.name or "ReadFiles" in op.name):
read_parquet_op_state = op_state
break
# Check the output queue size
output_queue_size = len(read_parquet_op_state.output_queue)
assert output_queue_size == 0, f"Expected 0 items, but got {output_queue_size}."
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))