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 eed76ed708 [format][python] Reject duplicate MAP BLOB keys (#9107)
eed76ed708 is described below

commit eed76ed7085db83cafef51f4c0ce2b02e36069cb
Author: QuakeWang <[email protected]>
AuthorDate: Mon Aug 10 16:44:13 2026 +0800

    [format][python] Reject duplicate MAP BLOB keys (#9107)
---
 .../test/java/org/apache/paimon/JavaPyE2ETest.java |  3 -
 .../format/blob/MapBlobElementSerializer.java      | 48 ++++++++++----
 .../paimon/format/blob/BlobFileFormatTest.java     | 76 +++++++++++-----------
 .../pypaimon/read/reader/format_blob_reader.py     |  2 +
 paimon-python/pypaimon/tests/blob_table_test.py    |  6 +-
 paimon-python/pypaimon/tests/blob_test.py          | 45 +++++++++++--
 .../pypaimon/tests/e2e/java_py_read_write_test.py  |  1 -
 paimon-python/pypaimon/write/blob_format_writer.py | 14 ++--
 8 files changed, 128 insertions(+), 67 deletions(-)

diff --git a/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java 
b/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java
index 69de8c06f9..b6801b4a7b 100644
--- a/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java
@@ -1494,9 +1494,6 @@ public class JavaPyE2ETest {
         Map<Object, Object> timePayloads = new LinkedHashMap<>();
         timePayloads.put(45_296_789, new 
BlobData("java-time".getBytes(StandardCharsets.UTF_8)));
         Map<Object, Object> binaryPayloads = new LinkedHashMap<>();
-        binaryPayloads.put(
-                new byte[] {0, (byte) 0xff, 1, 2},
-                new 
BlobData("java-binary-first".getBytes(StandardCharsets.UTF_8)));
         binaryPayloads.put(
                 new byte[] {0, (byte) 0xff, 1, 2},
                 new BlobData("java-binary".getBytes(StandardCharsets.UTF_8)));
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 b9fb6a74b9..9d45b02ad9 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
@@ -44,8 +44,10 @@ import javax.annotation.Nullable;
 import java.io.IOException;
 import java.nio.ByteBuffer;
 import java.nio.ByteOrder;
+import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.Map;
+import java.util.Set;
 
 import static org.apache.paimon.utils.StreamUtils.intToLittleEndian;
 import static org.apache.paimon.utils.StreamUtils.longToLittleEndian;
@@ -176,25 +178,41 @@ final class MapBlobElementSerializer implements 
BlobElementSerializer {
                         "MAP<X, BLOB> key/value array size does not match map 
size.");
             }
 
-            long recordPosition = startRecord();
-            // 1. Write meta
-            write(MAGIC_NUMBER_BYTES);
-            write(new byte[] {VERSION});
-            write(intToLittleEndian(map.size()));
-
-            // 2. Write key array
+            // 1. Serialize and validate the key array before writing the 
record.
             long[] keyLengths = new long[map.size()];
+            byte[][] keyBytes = new byte[map.size()][];
+            Set<ByteBuffer> seenKeys = new HashSet<>();
             for (int i = 0; i < map.size(); i++) {
                 if (keys.isNullAt(i)) {
                     keyLengths[i] = NULL_KEY_LENGTH;
+                    if (!seenKeys.add(null)) {
+                        throw new IllegalArgumentException("MAP<X, BLOB> keys 
must be unique.");
+                    }
                 } else {
-                    byte[] keyBytes = 
keySerializer.serialize(keyGetter.getElementOrNull(keys, i));
-                    keyLengths[i] = keyBytes.length;
-                    write(keyBytes);
+                    byte[] serializedKey =
+                            
keySerializer.serialize(keyGetter.getElementOrNull(keys, i));
+                    keyLengths[i] = serializedKey.length;
+                    keyBytes[i] = serializedKey;
+                    if (!seenKeys.add(ByteBuffer.wrap(serializedKey))) {
+                        throw new IllegalArgumentException("MAP<X, BLOB> keys 
must be unique.");
+                    }
                 }
             }
 
-            // 3. Write values(blobs) array, same as ArrayBlobElementWriter
+            long recordPosition = startRecord();
+            // 2. Write meta
+            write(MAGIC_NUMBER_BYTES);
+            write(new byte[] {VERSION});
+            write(intToLittleEndian(map.size()));
+
+            // 3. Write key array
+            for (byte[] serializedKey : keyBytes) {
+                if (serializedKey != null) {
+                    write(serializedKey);
+                }
+            }
+
+            // 4. Write values(blobs) array, same as ArrayBlobElementWriter
             long[] valueLengths = new long[map.size()];
             boolean flush = false;
             for (int i = 0; i < map.size(); i++) {
@@ -364,7 +382,13 @@ final class MapBlobElementSerializer implements 
BlobElementSerializer {
                     }
                     map.put(keys[i], value);
                 }
-                return binaryKey ? GenericMap.fromBinaryKeyMap(map) : new 
GenericMap(map);
+                GenericMap result =
+                        binaryKey ? GenericMap.fromBinaryKeyMap(map) : new 
GenericMap(map);
+                if (result.size() != entryCount) {
+                    throw new IllegalArgumentException(
+                            "Invalid MAP<X, BLOB> payload: duplicate key.");
+                }
+                return result;
             } catch (IOException e) {
                 throw new RuntimeException(e);
             }
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 bd11580bb0..a9a2d2f99c 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
@@ -442,47 +442,54 @@ public class BlobFileFormatTest {
     }
 
     @Test
-    public void testDuplicateMapBlobKeyLastWinsInline() throws IOException {
-        assertDuplicateMapBlobKeyLastWins(
+    public void testRejectDuplicateBinaryMapBlobKeyOnWrite() throws 
IOException {
+        RowType rowType = RowType.of(DataTypes.MAP(DataTypes.BYTES(), 
DataTypes.BLOB()));
+        Map<Object, Object> entries = new LinkedHashMap<>();
+        entries.put(new byte[] {1}, new BlobData("first".getBytes()));
+        entries.put(new byte[] {1}, new BlobData("second".getBytes()));
+
+        try (PositionOutputStream out = fileIO.newOutputStream(file, false)) {
+            FormatWriter writer =
+                    new BlobFileFormat(false, 
BlobFormatWriter.DEFAULT_COPY_BUFFER_SIZE)
+                            .createWriterFactory(rowType)
+                            .create(out, null);
+            assertThatThrownBy(() -> writer.addElement(GenericRow.of(new 
GenericMap(entries))))
+                    .isInstanceOf(IllegalArgumentException.class)
+                    .hasMessage("MAP<X, BLOB> keys must be unique.");
+            writer.close();
+        }
+    }
+
+    @Test
+    public void testRejectDuplicateMapBlobKeyInline() throws IOException {
+        assertDuplicateMapBlobKeyRejected(
                 false,
                 DataTypes.STRING(),
                 BinaryString.fromString("a"),
                 BinaryString.fromString("b"),
-                BinaryString.fromString("a"),
                 (byte) 'a');
     }
 
     @Test
-    public void testDuplicateMapBlobKeyLastWinsAsDescriptor() throws 
IOException {
-        assertDuplicateMapBlobKeyLastWins(
+    public void testRejectDuplicateMapBlobKeyAsDescriptor() throws IOException 
{
+        assertDuplicateMapBlobKeyRejected(
                 true,
                 DataTypes.STRING(),
                 BinaryString.fromString("a"),
                 BinaryString.fromString("b"),
-                BinaryString.fromString("a"),
                 (byte) 'a');
     }
 
     @Test
-    public void testDuplicateBinaryMapBlobKeyLastWinsInline() throws 
IOException {
-        assertDuplicateMapBlobKeyLastWins(
-                false,
-                DataTypes.BINARY(1),
-                new byte[] {1},
-                new byte[] {2},
-                new byte[] {1},
-                (byte) 1);
+    public void testRejectDuplicateBinaryMapBlobKeyInline() throws IOException 
{
+        assertDuplicateMapBlobKeyRejected(
+                false, DataTypes.BINARY(1), new byte[] {1}, new byte[] {2}, 
(byte) 1);
     }
 
     @Test
-    public void testDuplicateBinaryMapBlobKeyLastWinsAsDescriptor() throws 
IOException {
-        assertDuplicateMapBlobKeyLastWins(
-                true,
-                DataTypes.BINARY(1),
-                new byte[] {1},
-                new byte[] {2},
-                new byte[] {1},
-                (byte) 1);
+    public void testRejectDuplicateBinaryMapBlobKeyAsDescriptor() throws 
IOException {
+        assertDuplicateMapBlobKeyRejected(
+                true, DataTypes.BINARY(1), new byte[] {1}, new byte[] {2}, 
(byte) 1);
     }
 
     @Test
@@ -564,12 +571,11 @@ public class BlobFileFormatTest {
         assertThat(genericMap.keyArray().getBinary(0)).isEqualTo(new byte[] 
{1});
     }
 
-    private void assertDuplicateMapBlobKeyLastWins(
+    private void assertDuplicateMapBlobKeyRejected(
             boolean blobAsDescriptor,
             DataType keyType,
             Object firstKey,
             Object secondKey,
-            Object lookupKey,
             byte duplicateKeyByte)
             throws IOException {
         Map<Object, Object> entries = new LinkedHashMap<>();
@@ -599,19 +605,15 @@ public class BlobFileFormatTest {
         FormatReaderFactory readerFactory = format.createReaderFactory(null, 
rowType, null);
         FormatReaderContext context =
                 new FormatReaderContext(fileIO, file, 
fileIO.getFileSize(file));
-        List<InternalRow> rows = new ArrayList<>();
-        try (FileRecordReader<InternalRow> reader = 
readerFactory.createReader(context)) {
-            reader.forEachRemaining(rows::add);
-        }
-
-        assertThat(rows).hasSize(1);
-        GenericMap result = (GenericMap) rows.get(0).getMap(0);
-        assertThat(result.size()).isOne();
-        assertThat(result.contains(lookupKey)).isTrue();
-        assertMapBlob(result.get(lookupKey), blobAsDescriptor, 
"second".getBytes());
-        if (lookupKey instanceof byte[]) {
-            assertThat(result.keyArray().getBinary(0)).isEqualTo(lookupKey);
-        }
+        assertThatThrownBy(
+                        () -> {
+                            try (FileRecordReader<InternalRow> reader =
+                                    readerFactory.createReader(context)) {
+                                reader.forEachRemaining(ignored -> {});
+                            }
+                        })
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessage("Invalid MAP<X, BLOB> payload: duplicate key.");
     }
 
     @Test
diff --git a/paimon-python/pypaimon/read/reader/format_blob_reader.py 
b/paimon-python/pypaimon/read/reader/format_blob_reader.py
index 897a81f67a..1e403f53a0 100644
--- a/paimon-python/pypaimon/read/reader/format_blob_reader.py
+++ b/paimon-python/pypaimon/read/reader/format_blob_reader.py
@@ -659,6 +659,8 @@ class BlobRecordIterator:
                     value_offset += value_length
                     value_data_offset += value_length
                 result[key] = value
+            if len(result) != entry_count:
+                raise ValueError("Invalid MAP<X, BLOB> payload: duplicate 
key.")
             return result
         finally:
             if close_stream:
diff --git a/paimon-python/pypaimon/tests/blob_table_test.py 
b/paimon-python/pypaimon/tests/blob_table_test.py
index 1559f0efd7..4b7e99a937 100755
--- a/paimon-python/pypaimon/tests/blob_table_test.py
+++ b/paimon-python/pypaimon/tests/blob_table_test.py
@@ -1471,7 +1471,7 @@ class DedicatedFormatWriterTest(unittest.TestCase):
                     [],
                     [('last', b'blob-4')],
                     [('descriptor', source_descriptor.serialize())],
-                    [('duplicate', b'first'), ('duplicate', b'last')],
+                    [('first', b'first'), ('last', b'last')],
                 ],
                 type=map_blob_type,
             ),
@@ -1497,7 +1497,7 @@ class DedicatedFormatWriterTest(unittest.TestCase):
                 3: {},
                 4: {'last': b'blob-4'},
                 5: {'descriptor': descriptor_body},
-                6: {'duplicate': b'last'},
+                6: {'first': b'first', 'last': b'last'},
             },
         )
 
@@ -1576,7 +1576,7 @@ class DedicatedFormatWriterTest(unittest.TestCase):
                 3: {},
                 4: {'last': b'blob-4'},
                 5: {'descriptor': descriptor_body},
-                6: {'duplicate': b'last'},
+                6: {'first': b'first', 'last': b'last'},
             },
         )
 
diff --git a/paimon-python/pypaimon/tests/blob_test.py 
b/paimon-python/pypaimon/tests/blob_test.py
index 19aacce9e0..e5a14677cc 100644
--- a/paimon-python/pypaimon/tests/blob_test.py
+++ b/paimon-python/pypaimon/tests/blob_test.py
@@ -3126,7 +3126,28 @@ class BlobEndToEndTest(unittest.TestCase):
             placeholder_reader.read_arrow_batch()
         placeholder_reader.close()
 
-    def test_duplicate_map_blob_key_last_wins(self):
+    def test_reject_duplicate_map_blob_key_on_write(self):
+        from pypaimon.write.blob_format_writer import BlobFormatWriter
+
+        fields = [DataField(
+            0,
+            "blob_map",
+            MapType(True, AtomicType("STRING"), AtomicType("BLOB")),
+        )]
+        output = io.BytesIO()
+        writer = BlobFormatWriter(output)
+        with self.assertRaisesRegex(ValueError, "MAP<X, BLOB> keys must be 
unique"):
+            writer.add_element(GenericRow(
+                [[
+                    ("duplicate", BlobData(b"first")),
+                    ("duplicate", BlobData(b"second")),
+                ]],
+                fields,
+                RowKind.INSERT,
+            ))
+        self.assertEqual(output.getvalue(), b"")
+
+    def test_reject_duplicate_map_blob_key_payload(self):
         from pypaimon.write.blob_format_writer import BlobFormatWriter
 
         file_io = LocalFileIO(self.temp_dir, Options({}))
@@ -3140,7 +3161,7 @@ class BlobEndToEndTest(unittest.TestCase):
         writer.add_element(GenericRow(
             [[
                 ("duplicate", BlobData(b"first")),
-                ("duplicate", BlobData(b"second")),
+                ("duplicatE", BlobData(b"second")),
                 ("tail", BlobData(b"third")),
             ]],
             fields,
@@ -3149,6 +3170,17 @@ class BlobEndToEndTest(unittest.TestCase):
         record_length = writer.lengths[0]
         writer.close()
 
+        with open(blob_file_path, 'r+b') as blob_file:
+            second_key_position = (
+                BlobRecordIterator.MAGIC_NUMBER_SIZE
+                + BlobRecordIterator.MAP_HEADER_SIZE
+                + len("duplicate")
+            )
+            bytes_data = bytearray(blob_file.read())
+            bytes_data[second_key_position + len("duplicatE") - 1] = ord("e")
+            blob_file.seek(0)
+            blob_file.write(bytes_data)
+
         for blob_as_descriptor in (False, True):
             iterator = BlobRecordIterator(
                 file_io,
@@ -3158,10 +3190,11 @@ class BlobEndToEndTest(unittest.TestCase):
                 fields[0],
                 blob_as_descriptor=blob_as_descriptor,
             )
-            result = next(iterator).values[0]
-            self.assertEqual(list(result), ["duplicate", "tail"])
-            self.assertEqual(result["duplicate"].to_data(), b"second")
-            self.assertEqual(result["tail"].to_data(), b"third")
+            with self.subTest(blob_as_descriptor=blob_as_descriptor):
+                with self.assertRaisesRegex(
+                    ValueError, "Invalid MAP<X, BLOB> payload: duplicate key"
+                ):
+                    next(iterator)
 
     def test_map_blob_key_types_and_rejections(self):
         from pypaimon.common.map_blob_key_serializer import 
create_map_blob_key_serializer
diff --git a/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py 
b/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py
index fbf619a6f7..d14e1f7459 100644
--- a/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py
+++ b/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py
@@ -1663,7 +1663,6 @@ class JavaPyReadWriteTest(unittest.TestCase):
             'binary_payloads': pa.array(
                 [
                     [
-                        (bytes([0, 255, 1, 2]), b'python-binary-first'),
                         (bytes([0, 255, 1, 2]), b'python-binary'),
                     ],
                     None,
diff --git a/paimon-python/pypaimon/write/blob_format_writer.py 
b/paimon-python/pypaimon/write/blob_format_writer.py
index b68600c129..8a7969b271 100644
--- a/paimon-python/pypaimon/write/blob_format_writer.py
+++ b/paimon-python/pypaimon/write/blob_format_writer.py
@@ -215,13 +215,17 @@ class BlobFormatWriter:
 
         key_serializer = create_map_blob_key_serializer(key_type)
         key_bytes = []
+        seen_keys = set()
         for key, _ in entries:
             if key is None:
-                key_bytes.append(None)
-                continue
-            serialized = key_serializer.serialize(key)
-            if len(serialized) > 0x7fffffff:
-                raise ValueError(f"MAP<X, BLOB> key is too large: 
{len(serialized)}")
+                serialized = None
+            else:
+                serialized = key_serializer.serialize(key)
+                if len(serialized) > 0x7fffffff:
+                    raise ValueError(f"MAP<X, BLOB> key is too large: 
{len(serialized)}")
+            if serialized in seen_keys:
+                raise ValueError("MAP<X, BLOB> keys must be unique.")
+            seen_keys.add(serialized)
             key_bytes.append(serialized)
 
         for _, blob_value in entries:

Reply via email to