This is an automated email from the ASF dual-hosted git repository.
XiaoHongbo-Hope pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new ed26b66a9c [python] Avoid Arrow offset overflow when merging read
batches (#9209)
ed26b66a9c is described below
commit ed26b66a9c0e98a11dbc482c707f623142fc3d2c
Author: XiaoHongbo <[email protected]>
AuthorDate: Thu Aug 13 23:20:53 2026 +0800
[python] Avoid Arrow offset overflow when merging read batches (#9209)
### Purpose
Fix Data Evolution reads failing when ordinary variable-length columns
exceed the Arrow 2 GiB offset limit across input batches:
```text
pyarrow.lib.ArrowInvalid: offset overflow while concatenating arrays,
consider casting input from `string` to `large_string` first.
```
`MergeAllBatchReader` previously collected every supplier batch and
forced each column into one Arrow array. This could overflow the 32-bit
offsets used by `string`, `binary`, and similar types.
### Changes
- Stream supplier batches instead of materializing the complete range.
- Coalesce small inputs into bounded output batches to avoid excessive
one-row batches.
- Recursively track 32-bit offsets: payload bytes for STRING/BINARY and
child
element counts for ARRAY/MAP, including nested values.
- Flush before concatenation reaches any Arrow 32-bit offset limit.
- Preserve buffered remainders in `DataEvolutionMergeReader` without
unbounded concatenation.
- Keep the original Arrow schema; columns are not changed to
`large_string` or `large_binary`.
### Tests
- Verify the original data and batch slicing behavior.
- Verify concatenation flushes before the Arrow offset limit.
- Verify two single-row ARRAY batches with 1.1 billion child elements
each do
not overflow when coalesced.
- Verify nested ARRAY and MAP offsets are checked recursively.
- Verify 10,000 misaligned one-row inputs produce 10 bounded output
batches rather than 10,000 batches.
- Related Data Evolution, deletion vector, format, and BLOB tests: 184
passed, 49 subtests passed.
- flake8 and `git diff --check` passed.
---
.../pypaimon/read/reader/concat_batch_reader.py | 187 ++++++++++--------
.../pypaimon/tests/concat_batch_reader_test.py | 218 +++++++++++++++++++++
2 files changed, 328 insertions(+), 77 deletions(-)
diff --git a/paimon-python/pypaimon/read/reader/concat_batch_reader.py
b/paimon-python/pypaimon/read/reader/concat_batch_reader.py
index 7a0c89f18b..831e41012a 100644
--- a/paimon-python/pypaimon/read/reader/concat_batch_reader.py
+++ b/paimon-python/pypaimon/read/reader/concat_batch_reader.py
@@ -16,10 +16,10 @@
# under the License.
import collections
+import struct
from typing import Callable, Dict, List, Optional, Tuple
import pyarrow as pa
-import pyarrow.dataset as ds
from pyarrow import RecordBatch
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
@@ -29,7 +29,65 @@ from pypaimon.schema.data_types import DataField,
PyarrowFieldParser
from pypaimon.table.row.blob import Blob
from pypaimon.utils.range import Range
-_MIN_BATCH_SIZE_TO_REFILL = 1024
+_MAX_ARROW_OFFSET = (1 << 31) - 1
+
+
+def _offset_bounds(array, width=4):
+ offsets = array.buffers()[1]
+ if offsets is None:
+ return 0, 0
+ value_format = "=i" if width == 4 else "=q"
+ start = struct.unpack_from(
+ value_format, offsets, array.offset * width)[0]
+ end = struct.unpack_from(
+ value_format, offsets, (array.offset + len(array)) * width)[0]
+ return start, end
+
+
+def _collect_offset_usage(array, path, usage):
+ data_type = array.type
+ if pa.types.is_string(data_type) or pa.types.is_binary(data_type):
+ start, end = _offset_bounds(array)
+ usage[path] = end - start
+ return
+
+ if pa.types.is_list(data_type) or pa.types.is_map(data_type):
+ start, end = _offset_bounds(array)
+ usage[path] = end - start
+ if pa.types.is_map(data_type):
+ _collect_offset_usage(
+ array.keys.slice(start, end - start), path + ("key",), usage)
+ _collect_offset_usage(
+ array.items.slice(start, end - start), path + ("item",), usage)
+ else:
+ _collect_offset_usage(
+ array.values.slice(start, end - start), path + ("item",),
usage)
+ return
+
+ if pa.types.is_large_list(data_type):
+ start, end = _offset_bounds(array, 8)
+ _collect_offset_usage(
+ array.values.slice(start, end - start), path + ("item",), usage)
+ return
+
+ if pa.types.is_fixed_size_list(data_type):
+ size = data_type.list_size
+ start = array.offset * size
+ _collect_offset_usage(
+ array.values.slice(start, len(array) * size), path + ("item",),
usage)
+ return
+
+ if pa.types.is_struct(data_type):
+ for index in range(data_type.num_fields):
+ _collect_offset_usage(
+ array.field(index), path + ("field", index), usage)
+
+
+def _batch_offset_usage(batch):
+ usage = {}
+ for index in range(batch.num_columns):
+ _collect_offset_usage(batch.column(index), (index,), usage)
+ return usage
class _BlobFileState:
@@ -83,76 +141,65 @@ class ConcatBatchReader(RecordBatchReader):
class MergeAllBatchReader(RecordBatchReader):
"""
- A reader that accepts multiple reader suppliers and concatenates all their
arrow batches
- into one big batch. This is useful when you want to merge all data from
multiple sources
- into a single batch for processing.
+ Read multiple suppliers as one bounded stream.
+
+ Small input batches are combined up to ``batch_size`` without allowing one
+ output batch to exceed Arrow's 32-bit offset limit.
"""
def __init__(self, reader_suppliers: List[Callable], batch_size: int =
1024):
- self.reader_suppliers = reader_suppliers
- self.merged_batch: Optional[RecordBatch] = None
- self.reader = None
- self._batch_size = batch_size
+ self._reader = ConcatBatchReader(reader_suppliers)
+ self._remainder: Optional[RecordBatch] = None
+ self._batch_size = max(1, batch_size)
def read_arrow_batch(self) -> Optional[RecordBatch]:
- if self.reader:
- try:
- return self.reader.read_next_batch()
- except StopIteration:
- return None
+ batches = []
+ num_rows = 0
+ offset_usage = {}
+ while True:
+ batch = self._remainder
+ self._remainder = None
+ if batch is None:
+ batch = self._reader.read_arrow_batch()
+ if batch is None:
+ break
+ if batch.num_rows == 0:
+ continue
- all_batches = []
+ take = min(batch.num_rows, self._batch_size - num_rows)
+ piece = batch.slice(0, take)
+ piece_usage = _batch_offset_usage(piece)
+ if batches and any(
+ offset_usage.get(path, 0) + value > _MAX_ARROW_OFFSET
+ for path, value in piece_usage.items()):
+ self._remainder = batch
+ break
- # Read all batches from all reader suppliers
- for supplier in self.reader_suppliers:
- reader = supplier()
- if reader is None:
- continue
- try:
- while True:
- batch = reader.read_arrow_batch()
- if batch is None:
- break
- all_batches.append(batch)
- finally:
- reader.close()
-
- # Concatenate all batches into one big batch
- if all_batches:
- # For PyArrow < 17.0.0, use Table.concat_tables approach
- # Convert batches to tables and concatenate
- tables = [pa.Table.from_batches([batch]) for batch in all_batches]
- if len(tables) == 1:
- # Single table, just get the first batch
- self.merged_batch = tables[0].to_batches()[0]
- else:
- # Multiple tables, concatenate them
- concatenated_table = pa.concat_tables(tables)
- # Convert back to a single batch by taking all batches and
combining
- all_concatenated_batches = concatenated_table.to_batches()
- if len(all_concatenated_batches) == 1:
- self.merged_batch = all_concatenated_batches[0]
- else:
- # If still multiple batches, we need to manually combine
them
- # This shouldn't happen with concat_tables, but just in
case
- combined_arrays = []
- for i in range(len(all_concatenated_batches[0].columns)):
- column_arrays = [batch.column(i) for batch in
all_concatenated_batches]
- combined_arrays.append(pa.concat_arrays(column_arrays))
- self.merged_batch = pa.RecordBatch.from_arrays(
- combined_arrays,
- schema=all_concatenated_batches[0].schema
- )
- else:
- self.merged_batch = None
+ batches.append(piece)
+ num_rows += take
+ for path, value in piece_usage.items():
+ offset_usage[path] = offset_usage.get(path, 0) + value
+ if take < batch.num_rows:
+ self._remainder = batch.slice(take)
+ if num_rows == self._batch_size or any(
+ value >= _MAX_ARROW_OFFSET
+ for value in offset_usage.values()):
+ break
+
+ if not batches:
return None
- dataset = ds.InMemoryDataset(self.merged_batch)
- self.reader = dataset.scanner(batch_size=self._batch_size).to_reader()
- return self.reader.read_next_batch()
+ if len(batches) == 1:
+ return batches[0]
+
+ columns = [
+ pa.concat_arrays([batch.column(i) for batch in batches])
+ for i in range(batches[0].num_columns)
+ ]
+ return pa.RecordBatch.from_arrays(columns, schema=batches[0].schema)
def close(self) -> None:
- self.merged_batch = None
- self.reader = None
+ self._remainder = None
+ self._reader.close()
class DataEvolutionMergeReader(RecordBatchReader):
@@ -198,22 +245,8 @@ class DataEvolutionMergeReader(RecordBatchReader):
for i, reader in enumerate(self.readers):
if reader is not None:
if self._buffers[i] is not None:
- remainder = self._buffers[i]
+ batches[i] = self._buffers[i]
self._buffers[i] = None
- if remainder.num_rows >= _MIN_BATCH_SIZE_TO_REFILL:
- batches[i] = remainder
- else:
- new_batch = reader.read_arrow_batch()
- if new_batch is not None and new_batch.num_rows > 0:
- combined_arrays = [
- pa.concat_arrays([remainder.column(j),
new_batch.column(j)])
- for j in range(remainder.num_columns)
- ]
- batches[i] = pa.RecordBatch.from_arrays(
- combined_arrays, schema=remainder.schema
- )
- else:
- batches[i] = remainder
else:
batch = reader.read_arrow_batch()
if batch is None:
diff --git a/paimon-python/pypaimon/tests/concat_batch_reader_test.py
b/paimon-python/pypaimon/tests/concat_batch_reader_test.py
new file mode 100644
index 0000000000..1504d55530
--- /dev/null
+++ b/paimon-python/pypaimon/tests/concat_batch_reader_test.py
@@ -0,0 +1,218 @@
+# 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 unittest
+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_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()