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 088d4880ff [core] Add public binary serialization for 
BucketVectorSearchSplit (#9386)
088d4880ff is described below

commit 088d4880ffa3e46566dd6c94d46a2bd1a03f87b0
Author: Junrui Lee <[email protected]>
AuthorDate: Wed Aug 26 17:29:30 2026 +0800

    [core] Add public binary serialization for BucketVectorSearchSplit (#9386)
---
 .../table/source/BucketVectorSearchSplit.java      |  91 +++++--
 .../table/source/BucketVectorSearchSplitTest.java  | 272 +++++++++++++++++++++
 2 files changed, 345 insertions(+), 18 deletions(-)

diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/BucketVectorSearchSplit.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/BucketVectorSearchSplit.java
index 31c2f7b0cb..75ca1f58a1 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/BucketVectorSearchSplit.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/BucketVectorSearchSplit.java
@@ -20,7 +20,9 @@ package org.apache.paimon.table.source;
 
 import org.apache.paimon.index.IndexFileMeta;
 import org.apache.paimon.index.IndexFileMetaSerializer;
+import org.apache.paimon.io.DataInputView;
 import org.apache.paimon.io.DataInputViewStreamWrapper;
+import org.apache.paimon.io.DataOutputView;
 import org.apache.paimon.io.DataOutputViewStreamWrapper;
 import org.apache.paimon.utils.Range;
 
@@ -33,6 +35,7 @@ import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.TreeMap;
 
 import static org.apache.paimon.utils.Preconditions.checkArgument;
 
@@ -40,10 +43,12 @@ import static 
org.apache.paimon.utils.Preconditions.checkArgument;
 public class BucketVectorSearchSplit extends VectorSearchSplit {
 
     private static final long serialVersionUID = 1L;
+
+    private static final long MAGIC = 0x504B5653504C4954L;
     private static final int VERSION = 1;
 
     private DataSplit dataSplit;
-    private transient List<IndexFileMeta> payloadFiles;
+    private List<IndexFileMeta> payloadFiles;
     private Map<String, List<Range>> rowRangesByFile;
 
     public BucketVectorSearchSplit(DataSplit dataSplit, List<IndexFileMeta> 
payloadFiles) {
@@ -84,32 +89,70 @@ public class BucketVectorSearchSplit extends 
VectorSearchSplit {
         return rowRangesByFile;
     }
 
-    private void writeObject(ObjectOutputStream out) throws IOException {
-        out.defaultWriteObject();
+    /**
+     * Serialize to the byte form a reader outside the JVM consumes, following 
{@code IndexedSplit}:
+     * magic and version, then the nested {@link DataSplit}, then this split's 
own state.
+     *
+     * <p>Row-range entries are written sorted by file name, so two splits 
that compare equal
+     * serialize to the same bytes even though the map's iteration order is 
its construction order.
+     *
+     * <p>{@link #VERSION} pins this envelope and the layout of what it nests, 
not the nested bytes
+     * themselves: {@link DataSplit#serialize} carries its own version, while 
{@link
+     * IndexFileMetaSerializer} carries none, so a change to {@code 
IndexFileMeta.SCHEMA} has to
+     * bump this version too.
+     */
+    public void serialize(DataOutputView out) throws IOException {
+        out.writeLong(MAGIC);
         out.writeInt(VERSION);
-        out.writeInt(payloadFiles.size());
-        IndexFileMetaSerializer serializer = new IndexFileMetaSerializer();
-        for (IndexFileMeta payloadFile : payloadFiles) {
-            serializer.serialize(payloadFile, new 
DataOutputViewStreamWrapper(out));
+        dataSplit.serialize(out);
+        new IndexFileMetaSerializer().serializeList(payloadFiles, out);
+        out.writeInt(rowRangesByFile.size());
+        for (Map.Entry<String, List<Range>> entry : new 
TreeMap<>(rowRangesByFile).entrySet()) {
+            out.writeUTF(entry.getKey());
+            out.writeInt(entry.getValue().size());
+            for (Range range : entry.getValue()) {
+                out.writeLong(range.from);
+                out.writeLong(range.to);
+            }
         }
     }
 
-    private void readObject(ObjectInputStream in) throws IOException, 
ClassNotFoundException {
-        in.defaultReadObject();
+    /** Reverse of {@link #serialize(DataOutputView)}. */
+    public static BucketVectorSearchSplit deserialize(DataInputView in) throws 
IOException {
+        long magic = in.readLong();
+        if (magic != MAGIC) {
+            throw new IOException("Corrupted BucketVectorSearchSplit: wrong 
magic number " + magic);
+        }
         int version = in.readInt();
         if (version != VERSION) {
             throw new IOException("Unsupported BucketVectorSearchSplit 
version: " + version);
         }
-        int payloadFileCount = in.readInt();
-        if (payloadFileCount < 0) {
-            throw new IOException("Negative primary-key vector payload file 
count.");
+        DataSplit dataSplit = DataSplit.deserialize(in);
+        List<IndexFileMeta> payloadFiles = new 
IndexFileMetaSerializer().deserializeList(in);
+        int rangeFileCount = in.readInt();
+        Map<String, List<Range>> rowRangesByFile = new LinkedHashMap<>();
+        for (int i = 0; i < rangeFileCount; i++) {
+            String fileName = in.readUTF();
+            int rangeCount = in.readInt();
+            List<Range> ranges = new ArrayList<>(rangeCount);
+            for (int j = 0; j < rangeCount; j++) {
+                ranges.add(new Range(in.readLong(), in.readLong()));
+            }
+            rowRangesByFile.put(fileName, ranges);
         }
-        List<IndexFileMeta> payloadFiles = new ArrayList<>(payloadFileCount);
-        IndexFileMetaSerializer serializer = new IndexFileMetaSerializer();
-        for (int i = 0; i < payloadFileCount; i++) {
-            payloadFiles.add(serializer.deserialize(new 
DataInputViewStreamWrapper(in)));
-        }
-        this.payloadFiles = Collections.unmodifiableList(payloadFiles);
+        return new BucketVectorSearchSplit(dataSplit, payloadFiles, 
rowRangesByFile);
+    }
+
+    private void writeObject(ObjectOutputStream out) throws IOException {
+        serialize(new DataOutputViewStreamWrapper(out));
+    }
+
+    private void readObject(ObjectInputStream in) throws IOException, 
ClassNotFoundException {
+        BucketVectorSearchSplit other = deserialize(new 
DataInputViewStreamWrapper(in));
+
+        this.dataSplit = other.dataSplit;
+        this.payloadFiles = other.payloadFiles;
+        this.rowRangesByFile = other.rowRangesByFile;
     }
 
     @Override
@@ -130,4 +173,16 @@ public class BucketVectorSearchSplit extends 
VectorSearchSplit {
     public int hashCode() {
         return Objects.hash(dataSplit, payloadFiles, rowRangesByFile);
     }
+
+    @Override
+    public String toString() {
+        return "BucketVectorSearchSplit{"
+                + "dataSplit="
+                + dataSplit
+                + ", payloadFiles="
+                + payloadFiles
+                + ", rowRangesByFile="
+                + rowRangesByFile
+                + '}';
+    }
 }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/source/BucketVectorSearchSplitTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/source/BucketVectorSearchSplitTest.java
new file mode 100644
index 0000000000..c2f5c78261
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/source/BucketVectorSearchSplitTest.java
@@ -0,0 +1,272 @@
+/*
+ * 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.
+ */
+
+package org.apache.paimon.table.source;
+
+import org.apache.paimon.data.BinaryArray;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.Timestamp;
+import org.apache.paimon.index.DeletionVectorMeta;
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceFile;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceMeta;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.DataInputDeserializer;
+import org.apache.paimon.io.DataOutputSerializer;
+import org.apache.paimon.io.PojoDataFileMeta;
+import org.apache.paimon.manifest.FileSource;
+import org.apache.paimon.stats.SimpleStats;
+import org.apache.paimon.utils.InstantiationUtil;
+import org.apache.paimon.utils.Range;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests the byte form of {@link BucketVectorSearchSplit}, which a reader 
outside the JVM reads. */
+class BucketVectorSearchSplitTest {
+
+    @Test
+    void testRoundTrip() throws Exception {
+        BucketVectorSearchSplit expected = split();
+
+        // Compared whole rather than field by field: only that catches a 
field the format forgets
+        // to carry.
+        BucketVectorSearchSplit actual = deserialize(serialize(expected));
+        assertThat(actual).isEqualTo(expected);
+        assertPayload(actual.payloadFiles().get(0));
+    }
+
+    /** Java engines ship this split through object serialization, which uses 
the same form. */
+    @Test
+    void testJavaSerializationRoundTrip() throws Exception {
+        BucketVectorSearchSplit expected = split();
+        byte[] bytes = InstantiationUtil.serializeObject(expected);
+        BucketVectorSearchSplit actual =
+                InstantiationUtil.deserializeObject(
+                        bytes, BucketVectorSearchSplit.class.getClassLoader());
+        assertThat(actual).isEqualTo(expected);
+        assertPayload(actual.payloadFiles().get(0));
+    }
+
+    /** Payloads go through {@code IndexFileMetaSerializer}, so every field it 
carries survives. */
+    @Test
+    void testRoundTripKeepsDeletionVectorRanges() throws Exception {
+        LinkedHashMap<String, DeletionVectorMeta> dvRanges = new 
LinkedHashMap<>();
+        dvRanges.put("data-1.orc", new DeletionVectorMeta("data-1.orc", 0, 8, 
2L));
+        BucketVectorSearchSplit expected = withDvRanges(dvRanges);
+
+        assertThat(deserialize(serialize(expected))).isEqualTo(expected);
+        assertThat(
+                        
InstantiationUtil.<BucketVectorSearchSplit>deserializeObject(
+                                InstantiationUtil.serializeObject(expected),
+                                
BucketVectorSearchSplit.class.getClassLoader()))
+                .isEqualTo(expected);
+    }
+
+    /** Two splits that compare equal have to serialize to the same bytes. */
+    @Test
+    void testSerializationIsCanonical() throws Exception {
+        DataSplit dataSplit =
+                dataSplit(Arrays.asList(dataFile("data-1.orc"), 
dataFile("data-2.orc")));
+        List<Range> ranges = Collections.singletonList(new Range(0, 1));
+        Map<String, List<Range>> ascending = new LinkedHashMap<>();
+        ascending.put("data-1.orc", ranges);
+        ascending.put("data-2.orc", ranges);
+        Map<String, List<Range>> descending = new LinkedHashMap<>();
+        descending.put("data-2.orc", ranges);
+        descending.put("data-1.orc", ranges);
+
+        List<IndexFileMeta> payloadFiles = split().payloadFiles();
+        BucketVectorSearchSplit first =
+                new BucketVectorSearchSplit(dataSplit, payloadFiles, 
ascending);
+        BucketVectorSearchSplit second =
+                new BucketVectorSearchSplit(dataSplit, payloadFiles, 
descending);
+        assertThat(first).isEqualTo(second);
+        assertThat(serialize(first)).isEqualTo(serialize(second));
+    }
+
+    /** A bucket with no vector payload still plans a split, so both 
collections can be empty. */
+    @Test
+    void testRoundTripWithoutPayloadsOrRowRanges() throws Exception {
+        BucketVectorSearchSplit expected =
+                new BucketVectorSearchSplit(
+                        split().dataSplit(), Collections.emptyList(), 
Collections.emptyMap());
+        assertThat(deserialize(serialize(expected))).isEqualTo(expected);
+    }
+
+    /** Names carry more than ASCII, so they have to survive unchanged. */
+    @Test
+    void testRoundTripNonAsciiNames() throws Exception {
+        String indexType = "向量-ivf🚀";
+        BucketVectorSearchSplit actual = 
deserialize(serialize(withPayloadIndexType(indexType)));
+        
assertThat(actual.payloadFiles().get(0).indexType()).isEqualTo(indexType);
+    }
+
+    @Test
+    void testRejectsForeignBytes() throws Exception {
+        byte[] wrongMagic = serialize(split());
+        ByteBuffer.wrap(wrongMagic).putLong(0, 1);
+        assertThatThrownBy(() -> deserialize(wrongMagic))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("wrong magic number");
+
+        byte[] unsupportedVersion = serialize(split());
+        ByteBuffer.wrap(unsupportedVersion).putInt(Long.BYTES, 2);
+        assertThatThrownBy(() -> deserialize(unsupportedVersion))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("Unsupported BucketVectorSearchSplit 
version: 2");
+    }
+
+    private static byte[] serialize(BucketVectorSearchSplit split) throws 
IOException {
+        DataOutputSerializer out = new DataOutputSerializer(256);
+        split.serialize(out);
+        return out.getCopyOfBuffer();
+    }
+
+    private static BucketVectorSearchSplit deserialize(byte[] bytes) throws 
IOException {
+        return BucketVectorSearchSplit.deserialize(new 
DataInputDeserializer(bytes));
+    }
+
+    /**
+     * Non-empty rows and stats on purpose: the nested BinaryRow layout is the 
part of this message
+     * a reader is most likely to get wrong, and an empty row exercises none 
of it.
+     */
+    private static DataFileMeta dataFile(String fileName) {
+        SimpleStats stats =
+                new SimpleStats(
+                        BinaryRow.singleColumn("min_value"),
+                        BinaryRow.singleColumn("max_value"),
+                        BinaryArray.fromLongArray(new Long[] {0L}));
+        return new PojoDataFileMeta(
+                fileName,
+                1_234,
+                6,
+                BinaryRow.singleColumn("min_key"),
+                BinaryRow.singleColumn("max_key"),
+                stats,
+                stats,
+                3,
+                9,
+                7,
+                1,
+                Collections.emptyList(),
+                Timestamp.fromEpochMillis(1_700_000_000_000L),
+                0L,
+                null,
+                FileSource.COMPACT,
+                null,
+                null,
+                40L,
+                Arrays.asList("k", "v"),
+                new long[] {3, 9});
+    }
+
+    private static DataSplit dataSplit(List<DataFileMeta> dataFiles) {
+        return DataSplit.builder()
+                .withSnapshot(11)
+                .withPartition(BinaryRow.singleColumn(20250826))
+                .withBucket(2)
+                .withBucketPath("bucket-2")
+                .withTotalBuckets(8)
+                .withDataFiles(dataFiles)
+                .build();
+    }
+
+    private static BucketVectorSearchSplit split() {
+        DataSplit dataSplit = 
dataSplit(Collections.singletonList(dataFile("data-1.orc")));
+
+        byte[] sourceMeta =
+                new PrimaryKeyIndexSourceMeta(
+                                1,
+                                Collections.singletonList(
+                                        new 
PrimaryKeyIndexSourceFile("data-1.orc", 6)))
+                        .serialize();
+        IndexFileMeta payload =
+                new IndexFileMeta(
+                        "ivf-pq",
+                        "ann-0.idx",
+                        5_000_000_000L,
+                        6,
+                        new GlobalIndexMeta(
+                                40, 45, 7, new int[] {3, 5}, new byte[] {1, 2, 
3}, sourceMeta),
+                        "s3://vector-bucket/ann-0.idx");
+
+        Map<String, List<Range>> ranges = new LinkedHashMap<>();
+        ranges.put("data-1.orc", Arrays.asList(new Range(0, 1), new Range(4, 
5)));
+        return new BucketVectorSearchSplit(dataSplit, 
Collections.singletonList(payload), ranges);
+    }
+
+    private static BucketVectorSearchSplit withPayloadIndexType(String 
indexType) {
+        BucketVectorSearchSplit split = split();
+        IndexFileMeta payload = split.payloadFiles().get(0);
+        IndexFileMeta renamed =
+                new IndexFileMeta(
+                        indexType,
+                        payload.fileName(),
+                        payload.fileSize(),
+                        payload.rowCount(),
+                        payload.globalIndexMeta(),
+                        payload.externalPath());
+        return new BucketVectorSearchSplit(
+                split.dataSplit(), Collections.singletonList(renamed), 
split.rowRangesByFile());
+    }
+
+    private static BucketVectorSearchSplit withDvRanges(
+            LinkedHashMap<String, DeletionVectorMeta> dvRanges) {
+        BucketVectorSearchSplit base = split();
+        IndexFileMeta payload = base.payloadFiles().get(0);
+        IndexFileMeta withDvRanges =
+                new IndexFileMeta(
+                        payload.indexType(),
+                        payload.fileName(),
+                        payload.fileSize(),
+                        payload.rowCount(),
+                        dvRanges,
+                        payload.externalPath(),
+                        payload.globalIndexMeta());
+        return new BucketVectorSearchSplit(
+                base.dataSplit(), Collections.singletonList(withDvRanges), 
base.rowRangesByFile());
+    }
+
+    private static void assertPayload(IndexFileMeta payload) {
+        assertThat(payload.indexType()).isEqualTo("ivf-pq");
+        assertThat(payload.fileName()).isEqualTo("ann-0.idx");
+        assertThat(payload.fileSize()).isEqualTo(5_000_000_000L);
+        assertThat(payload.rowCount()).isEqualTo(6);
+        
assertThat(payload.externalPath()).isEqualTo("s3://vector-bucket/ann-0.idx");
+        assertThat(payload.globalIndexMeta()).isNotNull();
+        assertThat(payload.globalIndexMeta().rowRangeStart()).isEqualTo(40);
+        assertThat(payload.globalIndexMeta().rowRangeEnd()).isEqualTo(45);
+        assertThat(payload.globalIndexMeta().indexFieldId()).isEqualTo(7);
+        
assertThat(payload.globalIndexMeta().extraFieldIds()).containsExactly(3, 5);
+        assertThat(payload.globalIndexMeta().indexMeta()).containsExactly(1, 
2, 3);
+        
assertThat(PrimaryKeyIndexSourceMeta.fromIndexFile(payload).sourceFile().fileName())
+                .isEqualTo("data-1.orc");
+    }
+}

Reply via email to