blob: 9bcfd5e929feb90e84c329b245ae961acb41ed2e [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pyarrow as pa
from pypaimon.read.reader.concat_batch_reader import (
DataEvolutionMergeReader,
MergeAllBatchReader,
)
from pypaimon.read.reader.iface.record_batch_reader import RecordBatchReader
class _BatchReader(RecordBatchReader):
def __init__(self, batches):
self._batches = iter(batches)
def read_arrow_batch(self):
return next(self._batches, None)
def close(self):
pass
class ConcatBatchReaderTest(unittest.TestCase):
def test_merge_all_resolves_blob_refs_and_keeps_row_payloads(self):
from pypaimon.common.options import Options
from pypaimon.common.options.core_options import CoreOptions
from pypaimon.filesystem.local_file_io import LocalFileIO
from pypaimon.read.reader.blob_descriptor_convert_reader import (
BlobDataBatchReader, BlobInlineConvertReader)
from pypaimon.table.row.blob import BlobDescriptor
with tempfile.TemporaryDirectory() as root:
# Both representations can have the same descriptor-shaped bytes.
payload = BlobDescriptor(str(Path(root) / 'must-not-read'), 0, 1).serialize()
source = Path(root) / 'source'
source.write_bytes(payload)
reference = BlobDescriptor(str(source), 0, len(payload)).serialize()
table = SimpleNamespace(
options=CoreOptions(Options({'blob-descriptor-field': 'payload'})),
file_io=LocalFileIO.create(),
catalog_environment=SimpleNamespace(catalog_loader=None))
schema = pa.schema([pa.field(
'payload', pa.large_binary(), metadata={b'description': b'keep'})])
for reverse in (False, True):
with self.subTest(reverse=reverse):
refs = _BatchReader([pa.RecordBatch.from_arrays(
[pa.array([reference], type=pa.large_binary())], schema=schema)])
data = BlobDataBatchReader(_BatchReader([pa.RecordBatch.from_arrays(
[pa.array([payload, None], type=pa.large_binary())], schema=schema)]), {'payload'})
sources = [data, refs] if reverse else [refs, data]
merged = MergeAllBatchReader(
[lambda reader=reader: reader for reader in sources], batch_size=3)
reader = BlobInlineConvertReader(merged, table)
values = []
sizes = []
try:
while True:
batch = reader.read_arrow_batch()
if batch is None:
break
values.extend(batch.column(0).to_pylist())
sizes.append(batch.num_rows)
metadata = batch.schema.field(0).metadata or {}
self.assertNotIn(b'paimon.row-sidecar.blob-data', metadata)
if batch.num_rows == 2:
self.assertEqual(metadata, {b'description': b'keep'})
finally:
reader.close()
self.assertEqual(values, [payload, None, payload] if reverse else [payload, payload, None])
self.assertEqual(sizes, [2, 1] if reverse else [1, 2])
def test_merge_all_zero_column_batches_keep_row_counts(self):
batches = [pa.record_batch([pa.array(range(size))], names=['id']).select([])
for size in (1, 2, 2)]
reader = MergeAllBatchReader(
[lambda batch=batch: _BatchReader([batch]) for batch in batches], batch_size=2)
actual = []
while True:
batch = reader.read_arrow_batch()
if batch is None:
break
self.assertEqual(batch.num_columns, 0)
actual.append(batch.num_rows)
self.assertEqual(actual, [2, 2, 1])
def test_merge_all_coalesces_small_batches(self):
batches = [
pa.record_batch([pa.array(["a", "b"])], names=["value"]),
pa.record_batch([pa.array(["c", "d", "e"])], names=["value"]),
]
reader = MergeAllBatchReader(
[lambda batch=batch: _BatchReader([batch]) for batch in batches],
batch_size=2,
)
actual = []
while True:
batch = reader.read_arrow_batch()
if batch is None:
break
actual.append(batch.column(0).to_pylist())
self.assertEqual(actual, [["a", "b"], ["c", "d"], ["e"]])
def test_merge_all_flushes_before_arrow_offset_limit(self):
batches = [
pa.record_batch([pa.array(["aaaa"])], names=["value"]),
pa.record_batch([pa.array(["bbbb"])], names=["value"]),
]
reader = MergeAllBatchReader(
[lambda batch=batch: _BatchReader([batch]) for batch in batches],
batch_size=2,
)
with patch(
"pypaimon.read.reader.concat_batch_reader._MAX_ARROW_OFFSET",
4):
actual = []
while True:
batch = reader.read_arrow_batch()
if batch is None:
break
actual.append(batch.column(0).to_pylist())
self.assertEqual(actual, [["aaaa"], ["bbbb"]])
def test_merge_all_flushes_before_list_offset_limit(self):
child_count = 1100000000
def batch():
values = pa.nulls(child_count)
offsets = pa.array([0, child_count], type=pa.int32())
return pa.record_batch(
[pa.ListArray.from_arrays(offsets, values)], names=["value"])
batches = [batch(), batch()]
self.assertTrue(all(item.nbytes < child_count for item in batches))
reader = MergeAllBatchReader(
[lambda item=item: _BatchReader([item]) for item in batches],
batch_size=2,
)
actual = []
while True:
item = reader.read_arrow_batch()
if item is None:
break
actual.append(item.column(0).value_lengths()[0].as_py())
self.assertEqual(actual, [child_count, child_count])
def test_merge_all_checks_nested_and_map_offsets(self):
arrays = [
pa.array([["aaa"]], type=pa.list_(pa.string())),
pa.array(
[[("a", 1), ("b", 2)]],
type=pa.map_(pa.string(), pa.int32()),
),
]
for array in arrays:
with self.subTest(data_type=array.type):
batches = [
pa.record_batch([array], names=["value"]),
pa.record_batch([array], names=["value"]),
]
reader = MergeAllBatchReader(
[
lambda item=item: _BatchReader([item])
for item in batches
],
batch_size=2,
)
with patch(
"pypaimon.read.reader.concat_batch_reader."
"_MAX_ARROW_OFFSET",
3):
sizes = []
while True:
item = reader.read_arrow_batch()
if item is None:
break
sizes.append(item.num_rows)
self.assertEqual(sizes, [1, 1])
def test_misaligned_small_files_keep_bounded_batch_count(self):
row_count = 10000
left = MergeAllBatchReader([
lambda value=value: _BatchReader([
pa.record_batch([pa.array([value])], names=["left"])
])
for value in range(row_count)
])
right = _BatchReader([
pa.record_batch(
[pa.array(range(start, min(start + 1024, row_count)))],
names=["right"],
)
for start in range(0, row_count, 1024)
])
reader = DataEvolutionMergeReader(
row_offsets=[0, 1],
field_offsets=[0, 0],
readers=[left, right],
schema=pa.schema([("left", pa.int64()), ("right", pa.int64())]),
)
batch_sizes = []
values = []
while True:
batch = reader.read_arrow_batch()
if batch is None:
break
batch_sizes.append(batch.num_rows)
values.extend(batch.column(0).to_pylist())
self.assertEqual(batch_sizes, [1024] * 9 + [784])
self.assertEqual(values, list(range(row_count)))
def test_merge_reader_does_not_join_buffered_remainders(self):
left = _BatchReader([
pa.record_batch([pa.array([0])], names=["left"]),
pa.record_batch([pa.array([1, 2])], names=["left"]),
])
right = _BatchReader([
pa.record_batch([pa.array([10, 11])], names=["right"]),
pa.record_batch([pa.array([12])], names=["right"]),
])
reader = DataEvolutionMergeReader(
row_offsets=[0, 1],
field_offsets=[0, 0],
readers=[left, right],
schema=pa.schema([("left", pa.int64()), ("right", pa.int64())]),
)
with patch.object(
pa, "concat_arrays",
side_effect=AssertionError("must preserve Arrow chunks")):
actual = []
while True:
batch = reader.read_arrow_batch()
if batch is None:
break
actual.extend(
{"left": left, "right": right}
for left, right in zip(
batch.column(0).to_pylist(),
batch.column(1).to_pylist(),
)
)
self.assertEqual(actual, [
{"left": 0, "right": 10},
{"left": 1, "right": 11},
{"left": 2, "right": 12},
])
if __name__ == "__main__":
unittest.main()