This is an automated email from the ASF dual-hosted git repository.
JingsongLi 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 25a3b5a96b [python] Support batch size for blob read (#8502)
25a3b5a96b is described below
commit 25a3b5a96b238ed938c6c82142d472b54f2ecdbc
Author: umi <[email protected]>
AuthorDate: Fri Jul 10 08:36:35 2026 +0800
[python] Support batch size for blob read (#8502)
---
.../pypaimon/read/reader/concat_batch_reader.py | 229 ++++++++++++++++-----
paimon-python/pypaimon/read/split_read.py | 1 +
paimon-python/pypaimon/tests/blob_test.py | 153 ++++++++++++++
.../tests/data_evolution_deletion_vector_test.py | 137 +++++++++++-
4 files changed, 459 insertions(+), 61 deletions(-)
diff --git a/paimon-python/pypaimon/read/reader/concat_batch_reader.py
b/paimon-python/pypaimon/read/reader/concat_batch_reader.py
index 51b6f573a6..f17092279c 100644
--- a/paimon-python/pypaimon/read/reader/concat_batch_reader.py
+++ b/paimon-python/pypaimon/read/reader/concat_batch_reader.py
@@ -31,6 +31,24 @@ from pypaimon.utils.range import Range
_MIN_BATCH_SIZE_TO_REFILL = 1024
+class _BlobFileState:
+
+ def __init__(
+ self,
+ file: DataFileMeta,
+ supplier: Callable,
+ selected_ranges: List[Range],
+ ):
+ self.file = file
+ self.supplier = supplier
+ self.selected_ranges = selected_ranges
+ self.selected_count = sum(row_range.count() for row_range in
selected_ranges)
+ self.reader = None
+ self.reader_initialized = False
+ self.selected_range_index = 0
+ self.selected_position_base = 0
+
+
class ConcatBatchReader(RecordBatchReader):
def __init__(self, reader_suppliers: List[Callable], file_io=None,
@@ -241,7 +259,7 @@ class BlobFallbackBatchReader(RecordBatchReader):
def __init__(self, file_reader_suppliers: List[Tuple[DataFileMeta,
Callable]],
field_name: str, output_type, row_ranges:
Optional[List[Range]] = None,
- blob_as_descriptor: bool = False, deletion_vector=None):
+ blob_as_descriptor: bool = False, deletion_vector=None,
batch_size: int = 1024):
self._file_reader_suppliers = file_reader_suppliers
self._field_name = field_name
self._output_type = output_type
@@ -253,28 +271,37 @@ class BlobFallbackBatchReader(RecordBatchReader):
else:
self._deletion_vector_range, self._deletion_vector =
deletion_vector
self._returned = False
- self._readers: List[RecordBatchReader] = []
+ self._batch_size = max(1, batch_size)
+ self._target_ranges = self._compute_target_ranges()
+ self._target_range_index = 0
+ self._next_row_id = (
+ self._target_ranges[0].from_
+ if self._target_ranges
+ else None
+ )
+ self._file_states = []
+ for file, supplier in self._file_reader_suppliers:
+ selected_ranges = self._selected_ranges(file)
+ if selected_ranges:
+ self._file_states.append(_BlobFileState(file, supplier,
selected_ranges))
def read_arrow_batch(self) -> Optional[RecordBatch]:
- if self._returned:
+ batch_row_ids = self._next_batch_row_ids()
+ if not batch_row_ids:
return None
- self._returned = True
groups: Dict[int, Dict[int, Tuple[object, bool]]] = {}
- target_row_ids = self._target_row_ids()
- for file, supplier in self._file_reader_suppliers:
- row_ids = self._selected_row_ids(file)
- blob_values = self._read_blob_values(file, supplier)
- if len(blob_values) != len(row_ids):
- raise ValueError(
- "Blob fallback reader returned an unexpected row count "
- f"for {file.file_name}: expect {len(row_ids)}, got
{len(blob_values)}."
- )
- if not row_ids:
+ batch_first = batch_row_ids[0]
+ batch_last = batch_row_ids[-1]
+ for state in self._file_states:
+ if not self._state_overlaps_batch(state, batch_first, batch_last):
+ continue
+ blob_values = self._read_blob_values(state, batch_row_ids)
+ if not blob_values:
continue
- group = groups.setdefault(file.max_sequence_number, {})
- for row_id, blob in zip(row_ids, blob_values):
+ group = groups.setdefault(state.file.max_sequence_number, {})
+ for row_id, blob in blob_values.items():
if row_id in group:
raise ValueError(
"Blob files within the same max sequence should not
overlap."
@@ -293,7 +320,7 @@ class BlobFallbackBatchReader(RecordBatchReader):
return None
result = []
- for row_id in target_row_ids:
+ for row_id in batch_row_ids:
found = False
for max_sequence_number in sorted(groups.keys(), reverse=True):
candidate = groups[max_sequence_number].get(row_id)
@@ -312,38 +339,49 @@ class BlobFallbackBatchReader(RecordBatchReader):
names=[self._field_name],
)
- def _target_row_ids(self) -> List[int]:
- file_ranges = [
+ def _compute_target_ranges(self) -> List[Range]:
+ ranges = Range.sort_and_merge_overlap([
file.row_id_range()
for file, _ in self._file_reader_suppliers
- ]
- ranges = [
- Range(
- min(row_range.from_ for row_range in file_ranges),
- max(row_range.to for row_range in file_ranges),
- )
- ]
+ ])
if self._row_ranges is not None:
ranges = Range.and_(ranges, self._row_ranges)
- return [
- row_id
- for row_id in self._expand_ranges(ranges)
- if not self._is_deleted(row_id)
- ]
+ return ranges
- def _selected_row_ids(self, file: DataFileMeta) -> List[int]:
+ def _selected_ranges(self, file: DataFileMeta) -> List[Range]:
ranges = [file.row_id_range()]
if self._row_ranges is not None:
ranges = Range.and_(ranges, self._row_ranges)
- return self._expand_ranges(ranges)
+ return ranges
+
+ def _next_batch_row_ids(self) -> List[int]:
+ batch_row_ids = []
+ while (
+ len(batch_row_ids) < self._batch_size
+ and self._target_range_index < len(self._target_ranges)
+ ):
+ target_range = self._target_ranges[self._target_range_index]
+ if self._next_row_id is None or self._next_row_id <
target_range.from_:
+ self._next_row_id = target_range.from_
+
+ while (
+ self._next_row_id <= target_range.to
+ and len(batch_row_ids) < self._batch_size
+ ):
+ row_id = self._next_row_id
+ self._next_row_id += 1
+ if not self._is_deleted(row_id):
+ batch_row_ids.append(row_id)
+
+ if self._next_row_id > target_range.to:
+ self._target_range_index += 1
+ self._next_row_id = (
+ self._target_ranges[self._target_range_index].from_
+ if self._target_range_index < len(self._target_ranges)
+ else None
+ )
- @staticmethod
- def _expand_ranges(ranges: List[Range]) -> List[int]:
- return [
- row_id
- for row_range in ranges
- for row_id in range(row_range.from_, row_range.to + 1)
- ]
+ return batch_row_ids
def _is_deleted(self, row_id: int) -> bool:
if self._deletion_vector is None:
@@ -357,25 +395,116 @@ class BlobFallbackBatchReader(RecordBatchReader):
row_id - self._deletion_vector_range.from_
)
- def _read_blob_values(self, file: DataFileMeta, supplier: Callable) ->
List[object]:
- reader = supplier()
+ @staticmethod
+ def _state_overlaps_batch(
+ state: _BlobFileState, batch_first: int, batch_last: int
+ ) -> bool:
+ selected_ranges = state.selected_ranges
+ while (
+ state.selected_range_index < len(selected_ranges)
+ and selected_ranges[state.selected_range_index].to < batch_first
+ ):
+ state.selected_position_base += (
+ selected_ranges[state.selected_range_index].count()
+ )
+ state.selected_range_index += 1
+ return (
+ state.selected_range_index < len(selected_ranges)
+ and selected_ranges[state.selected_range_index].from_ <= batch_last
+ )
+
+ def _read_blob_values(
+ self, state: _BlobFileState, batch_row_ids: List[int]
+ ) -> Dict[int, object]:
+ positions_and_row_ids = self._selected_positions_and_row_ids(
+ state, batch_row_ids
+ )
+ if not positions_and_row_ids:
+ return {}
+
+ reader = self._reader_for_state(state)
if reader is None:
- return []
- self._readers.append(reader)
+ return {}
+
try:
+ blob_lengths = [reader.blob_lengths[pos] for pos, _ in
positions_and_row_ids]
+ blob_offsets = [reader.blob_offsets[pos] for pos, _ in
positions_and_row_ids]
iterator = BlobRecordIterator(
reader._file_io,
reader.file_path,
- reader.blob_lengths,
- reader.blob_offsets,
+ blob_lengths,
+ blob_offsets,
self._field_name,
reader._input_stream,
)
- return [row.values[0] for row in iterator]
+
+ blobs = []
+ for row in iterator:
+ blobs.append(row.values[0])
+ return {
+ row_id: blob
+ for (_, row_id), blob in zip(positions_and_row_ids, blobs)
+ }
except AttributeError as e:
raise TypeError("Blob fallback reader expects FormatBlobReader
suppliers.") from e
- def close(self) -> None:
- for reader in self._readers:
+ @staticmethod
+ def _selected_positions_and_row_ids(
+ state: _BlobFileState, batch_row_ids: List[int]
+ ) -> List[Tuple[int, int]]:
+ selected_ranges = state.selected_ranges
+ positions_and_row_ids = []
+ for row_id in batch_row_ids:
+ while (
+ state.selected_range_index < len(selected_ranges)
+ and selected_ranges[state.selected_range_index].to < row_id
+ ):
+ state.selected_position_base += (
+ selected_ranges[state.selected_range_index].count()
+ )
+ state.selected_range_index += 1
+ if state.selected_range_index >= len(selected_ranges):
+ break
+ row_range = selected_ranges[state.selected_range_index]
+ if row_range.from_ <= row_id <= row_range.to:
+ positions_and_row_ids.append(
+ (
+ state.selected_position_base + row_id -
row_range.from_,
+ row_id,
+ )
+ )
+ return positions_and_row_ids
+
+ def _reader_for_state(self, state: _BlobFileState):
+ if state.reader_initialized:
+ return state.reader
+
+ reader = state.supplier()
+ if reader is None:
+ state.reader_initialized = True
+ return None
+ actual_rows = len(reader.blob_lengths)
+ expected_rows = state.selected_count
+ if actual_rows != expected_rows:
+ reader.close()
+ raise ValueError(
+ "Blob fallback reader returned an unexpected row count "
+ f"for {state.file.file_name}: expect {expected_rows}, got "
+ f"{actual_rows}."
+ )
+
+ state.reader = reader
+ state.reader_initialized = True
+ return reader
+
+ @staticmethod
+ def _close_state_reader(state: _BlobFileState) -> None:
+ reader = state.reader
+ state.reader = None
+ state.reader_initialized = False
+ if reader is not None:
reader.close()
- self._readers = []
+
+ def close(self) -> None:
+ for state in self._file_states:
+ self._close_state_reader(state)
diff --git a/paimon-python/pypaimon/read/split_read.py
b/paimon-python/pypaimon/read/split_read.py
index d230975d5c..0b01ae4507 100644
--- a/paimon-python/pypaimon/read/split_read.py
+++ b/paimon-python/pypaimon/read/split_read.py
@@ -1283,6 +1283,7 @@ class DataEvolutionSplitRead(SplitRead):
self.row_ranges,
CoreOptions.blob_as_descriptor(self.table.options),
deletion_vector=deletion_vector,
+ batch_size=batch_size,
)
else:
# Create concatenated reader for multiple files
diff --git a/paimon-python/pypaimon/tests/blob_test.py
b/paimon-python/pypaimon/tests/blob_test.py
index 14508d1e10..cd79d5d432 100644
--- a/paimon-python/pypaimon/tests/blob_test.py
+++ b/paimon-python/pypaimon/tests/blob_test.py
@@ -28,7 +28,9 @@ import pyarrow as pa
from pypaimon import CatalogFactory, Schema
from pypaimon.common.file_io import FileIO
from pypaimon.filesystem.local_file_io import LocalFileIO
+from pypaimon.manifest.schema.data_file_meta import DataFileMeta
from pypaimon.common.options import Options
+from pypaimon.read.reader.concat_batch_reader import BlobFallbackBatchReader
from pypaimon.read.reader.format_blob_reader import BlobRecordIterator,
FormatBlobReader
from pypaimon.schema.data_types import AtomicType, DataField
from pypaimon.table.row.blob import Blob, BlobData, BlobRef, BlobDescriptor,
BlobViewStruct, BlobView
@@ -246,6 +248,157 @@ class BlobTest(unittest.TestCase):
self.assertFalse(blob.is_resolved())
self.assertEqual(blob.view_struct, view_struct)
+ def test_blob_fallback_batch_reader_respects_batch_size(self):
+ created_readers = []
+
+ class FakeBlobReader:
+ def __init__(self):
+ self._file_io = None
+ self.file_path = "fake.blob"
+ self.blob_lengths = [20, 20, 20, 20, 20]
+ self.blob_offsets = [0, 100, 200, 300, 400]
+ self._input_stream = None
+ self.closed = False
+
+ def close(self):
+ self.closed = True
+
+ def supplier():
+ reader = FakeBlobReader()
+ created_readers.append(reader)
+ return reader
+
+ data_file = DataFileMeta(
+ file_name="fake.blob",
+ file_size=0,
+ row_count=5,
+ min_key=None,
+ max_key=None,
+ key_stats=None,
+ value_stats=None,
+ min_sequence_number=0,
+ max_sequence_number=0,
+ schema_id=0,
+ level=0,
+ extra_files=[],
+ first_row_id=10,
+ file_path="fake.blob",
+ )
+ reader = BlobFallbackBatchReader(
+ [(data_file, supplier)],
+ "picture",
+ pa.large_binary(),
+ blob_as_descriptor=True,
+ batch_size=2,
+ )
+
+ first = reader.read_arrow_batch()
+ second = reader.read_arrow_batch()
+ third = reader.read_arrow_batch()
+ self.assertIsNone(reader.read_arrow_batch())
+
+ self.assertEqual(first.num_rows, 2)
+ self.assertEqual(second.num_rows, 2)
+ self.assertEqual(third.num_rows, 1)
+ offsets = []
+ for batch in (first, second, third):
+ offsets.extend(
+ BlobDescriptor.deserialize(value.as_py()).offset
+ for value in batch.column("picture")
+ )
+ self.assertEqual(offsets, [4, 104, 204, 304, 404])
+ self.assertEqual(1, len(created_readers))
+ self.assertFalse(created_readers[0].closed)
+
+ reader.close()
+ self.assertTrue(created_readers[0].closed)
+
+ def test_blob_fallback_batch_reader_reuses_version_readers(self):
+ created_by_file = {}
+
+ class FakeBlobReader:
+ def __init__(self, file_path, blob_lengths, blob_offsets):
+ self._file_io = None
+ self.file_path = file_path
+ self.blob_lengths = blob_lengths
+ self.blob_offsets = blob_offsets
+ self._input_stream = None
+ self.closed = False
+
+ def close(self):
+ self.closed = True
+
+ def data_file(name, max_sequence_number):
+ return DataFileMeta(
+ file_name=name,
+ file_size=0,
+ row_count=5,
+ min_key=None,
+ max_key=None,
+ key_stats=None,
+ value_stats=None,
+ min_sequence_number=max_sequence_number,
+ max_sequence_number=max_sequence_number,
+ schema_id=0,
+ level=0,
+ extra_files=[],
+ first_row_id=0,
+ file_path=name,
+ )
+
+ def supplier(file_path, blob_lengths, blob_offsets):
+ def create_reader():
+ reader = FakeBlobReader(file_path, blob_lengths, blob_offsets)
+ created_by_file.setdefault(file_path, []).append(reader)
+ return reader
+ return create_reader
+
+ old_file = data_file("old.blob", 1)
+ new_file = data_file("new.blob", 2)
+ reader = BlobFallbackBatchReader(
+ [
+ (
+ old_file,
+ supplier(
+ "old.blob",
+ [20, 20, 20, 20, 20],
+ [0, 100, 200, 300, 400],
+ ),
+ ),
+ (
+ new_file,
+ supplier(
+ "new.blob",
+ [-2, 20, -2, 20, -2],
+ [-1, 1000, -1, 3000, -1],
+ ),
+ ),
+ ],
+ "picture",
+ pa.large_binary(),
+ blob_as_descriptor=True,
+ batch_size=2,
+ )
+
+ offsets = []
+ batch = reader.read_arrow_batch()
+ while batch is not None:
+ offsets.extend(
+ BlobDescriptor.deserialize(value.as_py()).offset
+ for value in batch.column("picture")
+ )
+ batch = reader.read_arrow_batch()
+
+ self.assertEqual([4, 1004, 204, 3004, 404], offsets)
+ self.assertEqual(1, len(created_by_file["old.blob"]))
+ self.assertEqual(1, len(created_by_file["new.blob"]))
+ self.assertFalse(created_by_file["old.blob"][0].closed)
+ self.assertFalse(created_by_file["new.blob"][0].closed)
+
+ reader.close()
+ self.assertTrue(created_by_file["old.blob"][0].closed)
+ self.assertTrue(created_by_file["new.blob"][0].closed)
+
def test_blob_data_interface_compliance(self):
"""Test that BlobData properly implements Blob interface."""
test_data = b"interface test data"
diff --git
a/paimon-python/pypaimon/tests/data_evolution_deletion_vector_test.py
b/paimon-python/pypaimon/tests/data_evolution_deletion_vector_test.py
index 650adb95f2..448a8ab1b0 100644
--- a/paimon-python/pypaimon/tests/data_evolution_deletion_vector_test.py
+++ b/paimon-python/pypaimon/tests/data_evolution_deletion_vector_test.py
@@ -57,7 +57,8 @@ class _OneBatchReader(RecordBatchReader):
class _BlobFallbackBatchReaderForTest(BlobFallbackBatchReader):
def __init__(
- self, files, values_by_file_name, row_ranges=None, deletion_vector=None
+ self, files, values_by_file_name, row_ranges=None,
deletion_vector=None,
+ batch_size=1024
):
super().__init__(
[(file, lambda: None) for file in files],
@@ -66,15 +67,18 @@ class
_BlobFallbackBatchReaderForTest(BlobFallbackBatchReader):
row_ranges=row_ranges,
blob_as_descriptor=False,
deletion_vector=deletion_vector,
+ batch_size=batch_size,
)
self._values_by_file_name = values_by_file_name
- def _read_blob_values(self, file, supplier):
- values = self._values_by_file_name[file.file_name]
- return [
- values[row_id - file.first_row_id]
- for row_id in self._selected_row_ids(file)
- ]
+ def _read_blob_values(self, state, batch_row_ids):
+ values = self._values_by_file_name[state.file.file_name]
+ return {
+ row_id: values[pos]
+ for pos, row_id in self._selected_positions_and_row_ids(
+ state, batch_row_ids
+ )
+ }
def _file(name, first_row_id, row_count, max_sequence_number):
@@ -213,6 +217,121 @@ class DataEvolutionDeletionVectorTest(unittest.TestCase):
)
self.assertIsNone(reader.read_arrow_batch())
+ def test_blob_fallback_batch_reader_does_not_eof_on_row_id_gap(self):
+ reader = _BlobFallbackBatchReaderForTest(
+ [
+ _file("blob-left.blob", 0, 1, 1),
+ _file("blob-right.blob", 2, 1, 1),
+ ],
+ {
+ "blob-left.blob": [BlobData(b"left")],
+ "blob-right.blob": [BlobData(b"right")],
+ },
+ row_ranges=[Range(0, 2)],
+ batch_size=1,
+ )
+
+ values = []
+ for batch in iter(reader.read_arrow_batch, None):
+ values.extend(batch.column(0).to_pylist())
+
+ self.assertEqual([b"left", b"right"], values)
+
+ def test_blob_fallback_batch_reader_skips_states_outside_batch(self):
+ class
_CountingBlobFallbackBatchReader(_BlobFallbackBatchReaderForTest):
+ def __init__(self, files, values_by_file_name, batch_size):
+ super().__init__(files, values_by_file_name,
batch_size=batch_size)
+ self.selected_calls = []
+
+ def _selected_positions_and_row_ids(self, state, batch_row_ids):
+ self.selected_calls.append(
+ (state.file.file_name, list(batch_row_ids))
+ )
+ return BlobFallbackBatchReader._selected_positions_and_row_ids(
+ state, batch_row_ids
+ )
+
+ reader = _CountingBlobFallbackBatchReader(
+ [
+ _file("blob-0.blob", 0, 1, 1),
+ _file("blob-10.blob", 10, 1, 1),
+ _file("blob-20.blob", 20, 1, 1),
+ ],
+ {
+ "blob-0.blob": [BlobData(b"0")],
+ "blob-10.blob": [BlobData(b"10")],
+ "blob-20.blob": [BlobData(b"20")],
+ },
+ batch_size=1,
+ )
+ try:
+ self.assertEqual([b"0"],
reader.read_arrow_batch().column(0).to_pylist())
+ self.assertEqual([("blob-0.blob", [0])], reader.selected_calls)
+
+ self.assertEqual([b"10"],
reader.read_arrow_batch().column(0).to_pylist())
+ self.assertEqual(
+ [("blob-0.blob", [0]), ("blob-10.blob", [10])],
+ reader.selected_calls,
+ )
+ finally:
+ reader.close()
+
+ def
test_blob_fallback_batch_reader_avoids_full_row_id_materialization(self):
+ class _LazyBlobFallbackBatchReaderForTest(BlobFallbackBatchReader):
+ def _read_blob_values(self, state, batch_row_ids):
+ return {
+ row_id: BlobData(str(row_id).encode("utf-8"))
+ for row_id in batch_row_ids
+ }
+
+ reader = _LazyBlobFallbackBatchReaderForTest(
+ [(_file("large.blob", 0, 1_000_000, 1), lambda: None)],
+ "blob_col",
+ pa.binary(),
+ batch_size=2,
+ )
+ try:
+ self.assertNotIn("_target_row_ids", reader.__dict__)
+ for state in reader._file_states:
+ self.assertFalse(hasattr(state, "selected_row_ids"))
+ self.assertFalse(hasattr(state, "row_id_to_pos"))
+ self.assertFalse(hasattr(state,
"reader_uses_selected_positions"))
+
+ batch = reader.read_arrow_batch()
+ self.assertEqual([b"0", b"1"], batch.column(0).to_pylist())
+ finally:
+ reader.close()
+
+ def test_blob_fallback_batch_reader_advances_sparse_range_cursor(self):
+ reader = _BlobFallbackBatchReaderForTest(
+ [_file("sparse.blob", 0, 100, 1)],
+ {
+ "sparse.blob": [
+ BlobData(b"0"),
+ BlobData(b"10"),
+ BlobData(b"20"),
+ BlobData(b"30"),
+ ],
+ },
+ row_ranges=[Range(0, 0), Range(10, 10), Range(20, 20), Range(30,
30)],
+ batch_size=2,
+ )
+ try:
+ state = reader._file_states[0]
+
+ first = reader.read_arrow_batch()
+ self.assertEqual([b"0", b"10"], first.column(0).to_pylist())
+ self.assertEqual(1, state.selected_range_index)
+ self.assertEqual(1, state.selected_position_base)
+
+ second = reader.read_arrow_batch()
+ self.assertEqual([b"20", b"30"], second.column(0).to_pylist())
+ self.assertEqual(3, state.selected_range_index)
+ self.assertEqual(3, state.selected_position_base)
+ self.assertIsNone(reader.read_arrow_batch())
+ finally:
+ reader.close()
+
def
test_data_evolution_merge_reader_aligns_blob_with_row_ranges_and_dv(self):
row_ranges = [Range(1, 4)]
deletion_vector = BitmapDeletionVector()
@@ -234,20 +353,16 @@ class DataEvolutionDeletionVectorTest(unittest.TestCase):
],
{
"blob-old.blob": [
- BlobData(b"old-0"),
BlobData(b"old-1"),
BlobData(b"old-2"),
BlobData(b"old-3"),
BlobData(b"old-4"),
- BlobData(b"old-5"),
],
"blob-new.blob": [
- Blob.PLACE_HOLDER,
Blob.PLACE_HOLDER,
BlobData(b"new-2"),
BlobData(b"new-3"),
BlobData(b"new-4"),
- Blob.PLACE_HOLDER,
],
},
row_ranges=row_ranges,