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 c29ddcb7f0 [format][python] Coalesce map blob metadata reads (#10121)
c29ddcb7f0 is described below

commit c29ddcb7f0a81a45a2c870b539623f263444d21a
Author: XiaoHongbo <[email protected]>
AuthorDate: Wed Sep 23 21:58:30 2026 +0800

    [format][python] Coalesce map blob metadata reads (#10121)
---
 .../format/blob/MapBlobElementSerializer.java      | 22 +++++---
 .../paimon/format/blob/BlobFileFormatTest.java     | 60 ++++++++++++++++++++++
 .../pypaimon/read/reader/format_blob_reader.py     |  8 +--
 paimon-python/pypaimon/tests/blob_test.py          | 43 ++++++++++++++++
 paimon-python/pypaimon/tests/native_commit_test.py | 17 ++++++
 paimon-python/pypaimon/write/native_commit.py      | 13 +++--
 6 files changed, 148 insertions(+), 15 deletions(-)

diff --git 
a/paimon-format/src/main/java/org/apache/paimon/format/blob/MapBlobElementSerializer.java
 
b/paimon-format/src/main/java/org/apache/paimon/format/blob/MapBlobElementSerializer.java
index 9d45b02ad9..0640acbc76 100644
--- 
a/paimon-format/src/main/java/org/apache/paimon/format/blob/MapBlobElementSerializer.java
+++ 
b/paimon-format/src/main/java/org/apache/paimon/format/blob/MapBlobElementSerializer.java
@@ -39,9 +39,13 @@ import org.apache.paimon.utils.DeltaVarintCompressor;
 import org.apache.paimon.utils.IOUtils;
 import org.apache.paimon.utils.Preconditions;
 
+import org.apache.paimon.shade.guava30.com.google.common.io.ByteStreams;
+
 import javax.annotation.Nullable;
 
+import java.io.BufferedInputStream;
 import java.io.IOException;
+import java.io.InputStream;
 import java.nio.ByteBuffer;
 import java.nio.ByteOrder;
 import java.util.HashSet;
@@ -320,13 +324,15 @@ final class MapBlobElementSerializer implements 
BlobElementSerializer {
                 long valueIndexStart = indexLengthsPosition - valueIndexLength;
                 long keyIndexStart = valueIndexStart - keyIndexLength;
 
-                byte[] keyIndexBytes = new byte[keyIndexLength];
                 in.seek(keyIndexStart);
-                IOUtils.readFully(in, keyIndexBytes);
+                InputStream indexes =
+                        new BufferedInputStream(
+                                ByteStreams.limit(in, (long) keyIndexLength + 
valueIndexLength));
+                byte[] keyIndexBytes = new byte[keyIndexLength];
+                IOUtils.readFully(indexes, keyIndexBytes);
 
                 byte[] valueIndexBytes = new byte[valueIndexLength];
-                in.seek(valueIndexStart);
-                IOUtils.readFully(in, valueIndexBytes);
+                IOUtils.readFully(indexes, valueIndexBytes);
 
                 long[] keyLengths;
                 try {
@@ -347,7 +353,9 @@ final class MapBlobElementSerializer implements 
BlobElementSerializer {
 
                 // 2. deserialize keys
                 Object[] keys = new Object[entryCount];
-                long keyOffset = dataStart;
+                in.seek(dataStart);
+                // Limit read-ahead to keys so descriptor reads never fetch 
BLOB values.
+                InputStream keyData = new 
BufferedInputStream(ByteStreams.limit(in, keyDataLength));
                 for (int i = 0; i < entryCount; i++) {
                     long keyLength = keyLengths[i];
                     Object key;
@@ -355,14 +363,12 @@ final class MapBlobElementSerializer implements 
BlobElementSerializer {
                         key = null;
                     } else {
                         byte[] keyBytes = new byte[(int) keyLength];
-                        in.seek(keyOffset);
-                        IOUtils.readFully(in, keyBytes);
+                        IOUtils.readFully(keyData, keyBytes);
                         try {
                             key = keySerializer.deserialize(keyBytes);
                         } catch (RuntimeException e) {
                             throw new IllegalArgumentException("Invalid MAP<X, 
BLOB> key.", e);
                         }
-                        keyOffset += keyLength;
                     }
                     keys[i] = key;
                 }
diff --git 
a/paimon-format/src/test/java/org/apache/paimon/format/blob/BlobFileFormatTest.java
 
b/paimon-format/src/test/java/org/apache/paimon/format/blob/BlobFileFormatTest.java
index 4431f0758a..afcf6ca98a 100644
--- 
a/paimon-format/src/test/java/org/apache/paimon/format/blob/BlobFileFormatTest.java
+++ 
b/paimon-format/src/test/java/org/apache/paimon/format/blob/BlobFileFormatTest.java
@@ -667,6 +667,63 @@ public class BlobFileFormatTest {
                 .hasMessage("Invalid MAP<X, BLOB> payload: duplicate key.");
     }
 
+    @Test
+    public void testMapDescriptorReadsCoalesceMetadata() throws IOException {
+        for (int entryCount : new int[] {32, 4097}) {
+            TrackingLocalFileIO trackingIO = new TrackingLocalFileIO();
+            RowType rowType = RowType.of(DataTypes.MAP(DataTypes.INT(), 
DataTypes.BLOB()));
+            Map<Object, Object> entries = new LinkedHashMap<>();
+            entries.put(null, null);
+            for (int i = 0; i < entryCount; i++) {
+                entries.put(i, new BlobData(i == 0 ? new byte[0] : new byte[] 
{1, 2, 3}));
+            }
+            Path mapFile = new Path(parent, UUID.randomUUID().toString());
+            BlobFileFormat format =
+                    new BlobFileFormat(true, 
BlobFormatWriter.DEFAULT_COPY_BUFFER_SIZE);
+            try (PositionOutputStream out = 
trackingIO.newOutputStream(mapFile, false)) {
+                FormatWriter writer = 
format.createWriterFactory(rowType).create(out, null);
+                writer.addElement(GenericRow.of(new GenericMap(entries)));
+                writer.close();
+            }
+
+            FormatReaderContext context =
+                    new FormatReaderContext(
+                            trackingIO, mapFile, 
trackingIO.getFileSize(mapFile), null, null);
+            List<InternalRow> rows = new ArrayList<>();
+            try (FileRecordReader<InternalRow> reader =
+                    format.createReaderFactory(null, rowType, 
null).createReader(context)) {
+                reader.forEachRemaining(rows::add);
+            }
+            GenericMap result = (GenericMap) rows.get(0).getMap(0);
+            assertThat(result.size()).isEqualTo(entryCount + 1);
+            assertThat(result.get(null)).isNull();
+            long valueStart = 4 + 9 + (long) entryCount * Integer.BYTES;
+            long valueEnd = valueStart + (entryCount - 1L) * 3;
+            for (int i = 0; i < entryCount; i++) {
+                Blob blob = (Blob) result.get(i);
+                assertThat(blob).isInstanceOf(BlobRef.class);
+                assertThat(blob.toDescriptor().offset())
+                        .isEqualTo(valueStart + Math.max(0, i - 1L) * 3);
+                assertThat(blob.toDescriptor().length()).isEqualTo(i == 0 ? 0 
: 3);
+            }
+            List<long[]> ranges = trackingIO.lastInputStream.readRanges;
+            if (entryCount == 32) {
+                // File footer/index plus map header, lengths, combined 
indexes and keys.
+                assertThat(ranges).hasSize(6);
+            } else {
+                // Metadata larger than the buffer is read in bounded chunks, 
not per key.
+                assertThat(ranges.size()).isLessThan(20);
+            }
+            for (long[] range : ranges) {
+                assertThat(range[0] >= valueEnd || range[0] + range[1] <= 
valueStart)
+                        .as(
+                                "metadata range [%s, %s) must not read values",
+                                range[0], range[0] + range[1])
+                        .isTrue();
+            }
+        }
+    }
+
     @Test
     public void testMapBlobSupportedKeyTypes() throws IOException {
         DataType[] keyTypes =
@@ -1185,6 +1242,7 @@ public class BlobFileFormatTest {
         private int closeCount;
         private int readCount;
         private int seekCount;
+        private final List<long[]> readRanges = new ArrayList<>();
 
         private TrackingSeekableInputStream(SeekableInputStream delegate) {
             this.delegate = delegate;
@@ -1212,8 +1270,10 @@ public class BlobFileFormatTest {
 
         @Override
         public int read(byte[] bytes, int offset, int length) throws 
IOException {
+            long position = delegate.getPos();
             int read = delegate.read(bytes, offset, length);
             if (read > 0) {
+                readRanges.add(new long[] {position, read});
                 readCount += read;
             }
             return read;
diff --git a/paimon-python/pypaimon/read/reader/format_blob_reader.py 
b/paimon-python/pypaimon/read/reader/format_blob_reader.py
index a469102c72..e39e08b7b2 100644
--- a/paimon-python/pypaimon/read/reader/format_blob_reader.py
+++ b/paimon-python/pypaimon/read/reader/format_blob_reader.py
@@ -676,11 +676,13 @@ class BlobRecordIterator:
             value_index_start = index_lengths_position - value_index_length
             key_index_start = value_index_start - key_index_length
             stream.seek(key_index_start)
-            key_index_bytes = self._read_fully_from(stream, key_index_length)
+            # The two indexes are adjacent; read both without touching BLOB 
values.
+            index_bytes = self._read_fully_from(
+                stream, key_index_length + value_index_length)
+            key_index_bytes = index_bytes[:key_index_length]
             if len(key_index_bytes) != key_index_length:
                 raise IOError("Invalid MAP<X, BLOB> payload: cannot read key 
index")
-            stream.seek(value_index_start)
-            value_index_bytes = self._read_fully_from(stream, 
value_index_length)
+            value_index_bytes = index_bytes[key_index_length:]
             if len(value_index_bytes) != value_index_length:
                 raise IOError("Invalid MAP<X, BLOB> payload: cannot read value 
index")
 
diff --git a/paimon-python/pypaimon/tests/blob_test.py 
b/paimon-python/pypaimon/tests/blob_test.py
index 7e25b720a3..21f2dc14e7 100644
--- a/paimon-python/pypaimon/tests/blob_test.py
+++ b/paimon-python/pypaimon/tests/blob_test.py
@@ -5029,6 +5029,49 @@ class BlobEndToEndTest(unittest.TestCase):
         self.assertEqual(calls[0][1], 4)
         parallel_reader.close()
 
+    def 
test_map_blob_descriptor_coalesces_indexes_without_reading_values(self):
+        field = DataField(
+            0, "blob_map", MapType(True, AtomicType("STRING"), 
AtomicType("BLOB")))
+        value_data = b"payload" * 1024
+        key_lengths = [1, 1, 1]
+        value_lengths = [len(value_data), -1, 0]
+        payload = self._map_blob_payload(
+            b"abc", value_data, key_lengths, value_lengths)
+        prefix = b"preceding row"
+        value_start = len(prefix) + BlobRecordIterator.MAP_HEADER_SIZE + 3
+        index_start = value_start + len(value_data)
+        index_length = (len(DeltaVarintCompressor.compress(key_lengths))
+                        + len(DeltaVarintCompressor.compress(value_lengths)))
+        reads = []
+
+        class TrackingStream(io.BytesIO):
+            def read(self, size=-1):
+                start = self.tell()
+                reads.append((start, size))
+                if start < index_start and start + size > value_start:
+                    raise AssertionError("Descriptor read touched BLOB value 
data")
+                return super().read(size)
+
+        with TrackingStream(prefix + payload) as stream:
+            iterator = BlobRecordIterator(
+                None, "test.blob", [], [], field,
+                input_stream=stream, blob_as_descriptor=True)
+            result = iterator._read_blob_map(len(prefix), len(payload))
+            self.assertEqual(list(result), ['a', 'b', 'c'])
+            descriptor = result['a'].to_descriptor()
+            self.assertEqual((descriptor.offset, descriptor.length),
+                             (value_start, len(value_data)))
+            self.assertIsNone(result['b'])
+            self.assertEqual(result['c'].to_descriptor().length, 0)
+
+        self.assertEqual(reads, [
+            (len(prefix), BlobRecordIterator.MAP_HEADER_SIZE),
+            (len(prefix) + len(payload) - 
BlobRecordIterator.MAP_INDEX_LENGTHS_SIZE,
+             BlobRecordIterator.MAP_INDEX_LENGTHS_SIZE),
+            (index_start, index_length),
+            (len(prefix) + BlobRecordIterator.MAP_HEADER_SIZE, 3),
+        ])
+
     def test_map_blob_consumer_descriptors_and_flush(self):
         from pypaimon.write.blob_format_writer import BlobFormatWriter
 
diff --git a/paimon-python/pypaimon/tests/native_commit_test.py 
b/paimon-python/pypaimon/tests/native_commit_test.py
index 67c9656c39..e4936ae447 100644
--- a/paimon-python/pypaimon/tests/native_commit_test.py
+++ b/paimon-python/pypaimon/tests/native_commit_test.py
@@ -492,6 +492,23 @@ def 
test_incompatible_publication_environment_is_not_reconstructed(tmp_path, kin
         resolve.assert_not_called()
 
 
[email protected]('missing_type,missing_method', [
+    ('Table', 'from_resolved_schema'),
+    ('CommitMessage', 'deserialize'),
+    ('StreamWriteBuilder', 'with_commit_user'),
+    ('BatchWriteBuilder', '_with_commit_user'),
+    ('BatchWriteBuilder', 'with_overwrite'),
+])
+def test_incomplete_runtime_falls_back_without_reconstructing_table(
+        tmp_path, missing_type, missing_method):
+    table = _table(tmp_path)
+    with patch('pypaimon.write.native_commit.native_method_available',
+               side_effect=lambda cls, method: (cls, method) != (missing_type, 
missing_method)), \
+            
patch('pypaimon.write.native_commit._resolved_schema_file_io_options') as 
resolve:
+        assert create_native_commit(table, 'job') is None
+        resolve.assert_not_called()
+
+
 def test_missing_runtime_falls_back_without_reconstructing_table(tmp_path):
     table = _table(tmp_path)
     with patch('pypaimon.write.native_commit.native_commit_available', 
return_value=False), \
diff --git a/paimon-python/pypaimon/write/native_commit.py 
b/paimon-python/pypaimon/write/native_commit.py
index 86fc008942..97d5316582 100644
--- a/paimon-python/pypaimon/write/native_commit.py
+++ b/paimon-python/pypaimon/write/native_commit.py
@@ -19,14 +19,19 @@
 
 from pypaimon.common.json_util import JSON
 from pypaimon.read.native_plan import (
-    _option_value_to_string, _resolved_schema_file_io_options)
+    _option_value_to_string, _resolved_schema_file_io_options, 
native_method_available)
 from pypaimon.write.commit_message_serializer import serialize_commit_message
 
 
 def native_commit_available() -> bool:
-    """Whether the optional Rust runtime is installed."""
-    from importlib.util import find_spec
-    return find_spec('pypaimon_rust') is not None
+    """Whether the Rust runtime provides the required commit APIs."""
+    return all(native_method_available(type_name, method) for type_name, 
method in (
+        ('Table', 'from_resolved_schema'),
+        ('CommitMessage', 'deserialize'),
+        ('StreamWriteBuilder', 'with_commit_user'),
+        ('BatchWriteBuilder', '_with_commit_user'),
+        ('BatchWriteBuilder', 'with_overwrite'),
+    ))
 
 
 def native_messages_supported(table, messages) -> bool:

Reply via email to