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 8e6813713c [python] Read VARIANT pages with Parquet OffsetIndex (#9962)
8e6813713c is described below

commit 8e6813713cef363767a7bdcd1a0da0735c4be279
Author: XiaoHongbo <[email protected]>
AuthorDate: Tue Sep 22 11:36:59 2026 +0800

    [python] Read VARIANT pages with Parquet OffsetIndex (#9962)
---
 paimon-python/README.md                            |   5 +-
 .../pypaimon/read/reader/format_pyarrow_reader.py  |   3 +-
 .../pypaimon/tests/parquet_page_index_test.py      | 115 +++++++++++++++++++++
 3 files changed, 119 insertions(+), 4 deletions(-)

diff --git a/paimon-python/README.md b/paimon-python/README.md
index cb1836fc57..a711f71345 100644
--- a/paimon-python/README.md
+++ b/paimon-python/README.md
@@ -108,8 +108,9 @@ The command will install the package and core dependencies 
to your local Python
 # Parquet page-index reads
 
 For row-tracking tables with a Parquet OffsetIndex, PyPaimon can read a
-contiguous `_ROW_ID` range without decoding the full row group. This is enabled
-by default and can be disabled with the table option:
+contiguous `_ROW_ID` range, including standard VARIANT columns, without 
decoding
+the full row group. This is enabled by default and can be disabled with the 
table
+option:
 
 ```python
 table = table.copy({"parquet.filter.columnindex.enabled": "false"})
diff --git a/paimon-python/pypaimon/read/reader/format_pyarrow_reader.py 
b/paimon-python/pypaimon/read/reader/format_pyarrow_reader.py
index df9e3e7002..ed6dd37215 100644
--- a/paimon-python/pypaimon/read/reader/format_pyarrow_reader.py
+++ b/paimon-python/pypaimon/read/reader/format_pyarrow_reader.py
@@ -542,8 +542,7 @@ class FormatPyArrowReader(RecordBatchReader):
                 if (self._selected_parquet_row_groups is not None
                         and options is not None
                         and options.parquet_column_index_enabled()
-                        and self._row_group_cache is None
-                        and not self._bounded_variant_read):
+                        and self._row_group_cache is None):
                     from pypaimon.read.reader.parquet_page_index_reader import 
(
                         ParquetPageIndexReader,
                     )
diff --git a/paimon-python/pypaimon/tests/parquet_page_index_test.py 
b/paimon-python/pypaimon/tests/parquet_page_index_test.py
index 5af33654d4..f9caf3c2e0 100644
--- a/paimon-python/pypaimon/tests/parquet_page_index_test.py
+++ b/paimon-python/pypaimon/tests/parquet_page_index_test.py
@@ -42,6 +42,10 @@ RUNS = [(0, 2), (125, 132), (4500, 4540), (N - 2, N - 1)]
 FIELDS = [DataField(0, 'id', AtomicType('BIGINT')),
           DataField(1, 'payload', AtomicType('STRING'))]
 PAGE_INDEX_OPTIONS = 
CoreOptions(Options({'parquet.filter.columnindex.enabled': 'true'}))
+VARIANT_TYPE = pa.struct([
+    pa.field('value', pa.binary(), nullable=False),
+    pa.field('metadata', pa.binary(), nullable=False),
+])
 
 
 @pytest.fixture
@@ -177,6 +181,117 @@ def test_sparse_row_indices_are_normalized(fixture):
     assert _read(fixture, row_ranges=[(N, N + 10)])[0] is None
 
 
[email protected]('selected_row', [0, 3009, 3011, 4095])
[email protected]('page_version', ['1.0', '2.0'])
+def test_standard_variant_reads_selected_pages(
+        tmp_path, selected_row, page_version):
+    count = 4096
+    table = pa.table({
+        'record_value': pa.array([
+            None if index % 17 == 0 else {
+                'value': hashlib.shake_256(str(index).encode()).digest(512),
+                'metadata': hashlib.shake_256(
+                    ('metadata-%d' % index).encode()).digest(512),
+            }
+            for index in range(count)
+        ], type=VARIANT_TYPE),
+    })
+    path = str(tmp_path / 'variant.parquet')
+    pq.write_table(
+        table, path, write_page_index=True, data_page_size=4096,
+        write_batch_size=32, row_group_size=count,
+        use_dictionary=False, compression='zstd',
+        data_page_version=page_version)
+    counter = _CountingLocalFileSystem(skip_instance_cache=True)
+    file_io = LocalFileIO(str(tmp_path), Options({}))
+    file_io.filesystem = pafs.PyFileSystem(pafs.FSSpecHandler(counter))
+    fields = [DataField(0, 'record_value', AtomicType('VARIANT'))]
+
+    def read(options):
+        counter.reset_counts()
+        reader = reader_module.FormatPyArrowReader(
+            file_io, 'parquet', path, fields, None,
+            row_ranges=[(selected_row, selected_row)],
+            batch_size=31, options=options)
+        try:
+            if options is not None:
+                assert reader._page_index_reader is not None
+            batches = []
+            while True:
+                batch = reader.read_arrow_batch()
+                if batch is None:
+                    break
+                batches.append(batch)
+            return pa.Table.from_batches(batches), sum(
+                size for _, size in counter.reads)
+        finally:
+            reader.close()
+
+    baseline, baseline_bytes = read(None)
+    with patch.object(
+            page_module.ParquetPageIndexReader, '_column_payload',
+            autospec=True,
+            side_effect=page_module.ParquetPageIndexReader._column_payload,
+    ) as selected_pages:
+        optimized, optimized_bytes = read(PAGE_INDEX_OPTIONS)
+    assert optimized.equals(baseline)
+    assert optimized.equals(table.slice(selected_row, 1))
+    assert [len(call.args[1][4]) for call in selected_pages.call_args_list] == 
[1, 1]
+    assert optimized_bytes < baseline_bytes
+
+
+def test_unprofitable_variant_page_selection_uses_ordinary_reader(tmp_path):
+    count = 4096
+    table = pa.table({
+        'record_value': pa.array([
+            {
+                'value': hashlib.shake_256(str(index).encode()).digest(512),
+                'metadata': b'\x01',
+            }
+            for index in range(count)
+        ], type=VARIANT_TYPE),
+    })
+    path = str(tmp_path / 'unprofitable-variant.parquet')
+    pq.write_table(
+        table, path, write_page_index=True, data_page_size=4096,
+        write_batch_size=32, row_group_size=count,
+        use_dictionary=True, compression='zstd')
+    reader = reader_module.FormatPyArrowReader(
+        LocalFileIO(str(tmp_path), Options({})), 'parquet', path,
+        [DataField(0, 'record_value', AtomicType('VARIANT'))], None,
+        row_ranges=[(3009, 3009)], options=PAGE_INDEX_OPTIONS)
+    try:
+        assert reader._page_index_reader is not None
+        with patch.object(
+                page_module.ParquetPageIndexReader, '_column_payload',
+                side_effect=AssertionError('must use the ordinary reader')):
+            result = reader.read_arrow_batch()
+        assert pa.Table.from_batches([result]).equals(table.slice(3009, 1))
+    finally:
+        reader.close()
+
+
+def test_variant_without_offset_index_uses_ordinary_reader(tmp_path):
+    table = pa.table({
+        'record_value': pa.array([
+            {'value': b'one', 'metadata': b'\x01'},
+            {'value': b'two', 'metadata': b'\x01'},
+        ], type=VARIANT_TYPE),
+    })
+    path = str(tmp_path / 'unindexed-variant.parquet')
+    pq.write_table(table, path, write_page_index=False)
+    reader = reader_module.FormatPyArrowReader(
+        LocalFileIO(str(tmp_path), Options({})), 'parquet', path,
+        [DataField(0, 'record_value', AtomicType('VARIANT'))], None,
+        row_ranges=[(1, 1)], options=PAGE_INDEX_OPTIONS)
+    try:
+        assert reader._page_index_reader is None
+        assert pa.Table.from_batches([reader.read_arrow_batch()]).equals(
+            table.slice(1, 1))
+    finally:
+        reader.close()
+
+
 def test_concurrent_readers_and_early_close(fixture):
     path, table, file_io, _ = fixture
 

Reply via email to