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 f99d00837d [spark] Support parallel sorted index building across 
Paimon partitions (#9491)
f99d00837d is described below

commit f99d00837d840325d5f9091cbcec0118a173cfef
Author: liangjie <[email protected]>
AuthorDate: Mon Aug 31 17:25:45 2026 +0800

    [spark] Support parallel sorted index building across Paimon partitions 
(#9491)
---
 .../globalindex/sorted/SortedIndexTopoBuilder.java | 200 +++++++++++++++------
 .../sorted/SortedIndexTopoBuilderTest.java         | 118 ++++++++++++
 .../procedure/CreateGlobalIndexProcedureTest.scala |  33 +++-
 3 files changed, 293 insertions(+), 58 deletions(-)

diff --git 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/globalindex/sorted/SortedIndexTopoBuilder.java
 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/globalindex/sorted/SortedIndexTopoBuilder.java
index 62bfb157d3..0ee778de3e 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/globalindex/sorted/SortedIndexTopoBuilder.java
+++ 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/globalindex/sorted/SortedIndexTopoBuilder.java
@@ -61,6 +61,7 @@ import 
org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation;
 import org.apache.spark.sql.functions;
 
 import java.io.IOException;
+import java.io.Serializable;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -144,22 +145,23 @@ public class SortedIndexTopoBuilder implements 
GlobalIndexTopologyBuilder {
         SortedGlobalIndexWriter indexWriter =
                 new SortedGlobalIndexWriter(table, indexType, options)
                         .withIndexField(indexField.name());
-        for (Map.Entry<BinaryRow, Map<Range, List<Split>>> partitionEntry :
-                partitionRangeSplits.entrySet()) {
-            for (Map.Entry<Range, List<Split>> entry : 
partitionEntry.getValue().entrySet()) {
-                Range range = entry.getKey();
-                List<Split> rangeSplits = entry.getValue();
-                if (rangeSplits.isEmpty()) {
-                    continue;
-                }
-
-                final byte[] serializedWriter = 
InstantiationUtil.serializeObject(indexWriter);
-                final byte[] partitionBytes =
+        final byte[] serializedWriter = 
InstantiationUtil.serializeObject(indexWriter);
+        if (keyExtractor.isIdentity()) {
+            List<SortedBuildTask> buildTasks = new ArrayList<>();
+            List<Dataset<Row>> taskInputs = new ArrayList<>();
+            for (Map.Entry<BinaryRow, Map<Range, List<Split>>> partitionEntry :
+                    partitionRangeSplits.entrySet()) {
+                byte[] partitionBytes =
                         
binaryRowSerializer.serializeToBytes(partitionEntry.getKey());
-                if (keyExtractor.isIdentity()) {
-                    int partitionNum = Math.max((int) (range.count() / 
recordsPerRange), 1);
-                    partitionNum = Math.min(partitionNum, maxParallelism);
+                for (Map.Entry<Range, List<Split>> entry : 
partitionEntry.getValue().entrySet()) {
+                    Range range = entry.getKey();
+                    List<Split> rangeSplits = entry.getValue();
+                    if (rangeSplits.isEmpty()) {
+                        continue;
+                    }
 
+                    long taskId = buildTasks.size();
+                    buildTasks.add(new SortedBuildTask(taskId, range, 
partitionBytes));
                     Dataset<Row> source =
                             PaimonUtils.createDataset(
                                     spark,
@@ -167,35 +169,59 @@ public class SortedIndexTopoBuilder implements 
GlobalIndexTopologyBuilder {
                                             rangeSplits.toArray(new Split[0]), 
relation));
                     Dataset<Row> selected =
                             source.select(
-                                    readType.getFieldNames().stream()
-                                            .map(functions::col)
-                                            .toArray(Column[]::new));
-                    Column[] sortFields =
-                            new Column[] {
-                                functions.col(indexField.name()),
-                                functions.col(SpecialFields.ROW_ID.name())
-                            };
-                    Dataset<Row> partitioned =
-                            selected.repartitionByRange(partitionNum, 
sortFields)
-                                    .sortWithinPartitions(sortFields);
-                    JavaRDD<byte[]> written =
-                            partitioned
-                                    .javaRDD()
-                                    .map(row -> (InternalRow) (new 
SparkRow(readType, row)))
-                                    .mapPartitions(
-                                            
(FlatMapFunction<Iterator<InternalRow>, byte[]>)
-                                                    iter ->
-                                                            buildSortedIndex(
-                                                                    iter,
-                                                                    
serializedWriter,
-                                                                    range,
-                                                                    
partitionKeyNum,
-                                                                    
partitionBytes,
-                                                                    
scanSnapshotId));
-                    
allMessages.addAll(CommitMessageSerializer.deserializeAll(written.collect()));
+                                            readType.getFieldNames().stream()
+                                                    .map(functions::col)
+                                                    .toArray(Column[]::new))
+                                    .withColumn(taskIdField, 
functions.lit(taskId).cast("long"));
+                    taskInputs.add(
+                            selected.select(
+                                    functions.col(taskIdField),
+                                    functions.col(indexField.name()),
+                                    
functions.col(SpecialFields.ROW_ID.name())));
+                }
+            }
+
+            if (!buildTasks.isEmpty()) {
+                int partitionNum =
+                        calculateParallelism(buildTasks, recordsPerRange, 
maxParallelism);
+                Dataset<Row> partitioned =
+                        combineAndSortBuildTaskInputs(
+                                taskInputs, partitionNum, taskIdField, 
indexField.name());
+                Map<Long, SortedBuildTask> buildTasksById = new HashMap<>();
+                for (SortedBuildTask task : buildTasks) {
+                    buildTasksById.put(task.taskId, task);
+                }
+                JavaRDD<byte[]> written =
+                        partitioned
+                                .javaRDD()
+                                .map(row -> (InternalRow) (new 
SparkRow(normalizedReadType, row)))
+                                .mapPartitions(
+                                        
(FlatMapFunction<Iterator<InternalRow>, byte[]>)
+                                                iter ->
+                                                        buildSortedIndexes(
+                                                                iter,
+                                                                
serializedWriter,
+                                                                buildTasksById,
+                                                                
partitionKeyNum,
+                                                                
scanSnapshotId));
+                
allMessages.addAll(CommitMessageSerializer.deserializeAll(written.collect()));
+            }
+            addDeletedIndexMessages(allMessages, 
scanResult.deletedIndexEntries());
+            return allMessages;
+        }
+
+        for (Map.Entry<BinaryRow, Map<Range, List<Split>>> partitionEntry :
+                partitionRangeSplits.entrySet()) {
+            for (Map.Entry<Range, List<Split>> entry : 
partitionEntry.getValue().entrySet()) {
+                Range range = entry.getKey();
+                List<Split> rangeSplits = entry.getValue();
+                if (rangeSplits.isEmpty()) {
                     continue;
                 }
 
+                final byte[] partitionBytes =
+                        
binaryRowSerializer.serializeToBytes(partitionEntry.getKey());
+
                 Map<Range, List<Split>> shardedSplits =
                         shardSplitsByRowRange(
                                 Collections.singletonMap(range, rangeSplits), 
recordsPerRange);
@@ -256,7 +282,7 @@ public class SortedIndexTopoBuilder implements 
GlobalIndexTopologyBuilder {
                                 .mapPartitions(
                                         
(FlatMapFunction<Iterator<InternalRow>, byte[]>)
                                                 iter ->
-                                                        buildSortedIndexes(
+                                                        
buildShardedSortedIndexes(
                                                                 iter,
                                                                 
serializedWriter,
                                                                 taskRanges,
@@ -267,8 +293,34 @@ public class SortedIndexTopoBuilder implements 
GlobalIndexTopologyBuilder {
                 
allMessages.addAll(CommitMessageSerializer.deserializeAll(commitBytes));
             }
         }
-        for (IndexManifestEntry entry : scanResult.deletedIndexEntries()) {
-            allMessages.add(
+        addDeletedIndexMessages(allMessages, scanResult.deletedIndexEntries());
+        return allMessages;
+    }
+
+    static Dataset<Row> combineAndSortBuildTaskInputs(
+            List<Dataset<Row>> taskInputs,
+            int partitionNum,
+            String taskIdField,
+            String indexField) {
+        Dataset<Row> combined = taskInputs.get(0);
+        for (int i = 1; i < taskInputs.size(); i++) {
+            combined = combined.union(taskInputs.get(i));
+        }
+
+        Column[] sortFields =
+                new Column[] {
+                    functions.col(taskIdField),
+                    functions.col(indexField),
+                    functions.col(SpecialFields.ROW_ID.name())
+                };
+        return combined.repartitionByRange(partitionNum, sortFields)
+                .sortWithinPartitions(sortFields);
+    }
+
+    private static void addDeletedIndexMessages(
+            List<CommitMessage> messages, List<IndexManifestEntry> 
deletedIndexEntries) {
+        for (IndexManifestEntry entry : deletedIndexEntries) {
+            messages.add(
                     new CommitMessageImpl(
                             entry.partition(),
                             entry.bucket(),
@@ -277,28 +329,40 @@ public class SortedIndexTopoBuilder implements 
GlobalIndexTopologyBuilder {
                                     
Collections.singletonList(entry.indexFile())),
                             CompactIncrement.emptyIncrement()));
         }
-        return allMessages;
     }
 
-    private static Iterator<byte[]> buildSortedIndex(
+    private static Iterator<byte[]> buildSortedIndexes(
             Iterator<InternalRow> input,
             byte[] serializedWriter,
-            Range range,
+            Map<Long, SortedBuildTask> buildTasksById,
             int partitionKeyNum,
-            byte[] partitionBytes,
             long scanSnapshotId)
             throws IOException, ClassNotFoundException {
         final BinaryRowSerializer binaryRowSerializer = new 
BinaryRowSerializer(partitionKeyNum);
-        BinaryRow partition = 
binaryRowSerializer.deserializeFromBytes(partitionBytes);
         SortedGlobalIndexWriter writer =
                 InstantiationUtil.deserializeObject(
                         serializedWriter, 
SortedGlobalIndexWriter.class.getClassLoader());
-        return CommitMessageSerializer.serializeAll(
-                        writer.buildForSinglePartition(range, partition, 
input, scanSnapshotId))
-                .iterator();
+        SortedTaskInput taskInput = new SortedTaskInput(input, 
writer.keyExtractor().keyType());
+        List<byte[]> results = new ArrayList<>();
+        while (taskInput.hasTask()) {
+            long taskId = taskInput.taskId();
+            SortedBuildTask task = buildTasksById.get(taskId);
+            if (task == null) {
+                throw new IllegalArgumentException("Unknown sorted index build 
task id: " + taskId);
+            }
+            BinaryRow partition = 
binaryRowSerializer.deserializeFromBytes(task.partition);
+            results.addAll(
+                    CommitMessageSerializer.serializeAll(
+                            writer.buildForSinglePartition(
+                                    task.rowRange,
+                                    partition,
+                                    taskInput.consumeTask(taskId),
+                                    scanSnapshotId)));
+        }
+        return results.iterator();
     }
 
-    private static Iterator<byte[]> buildSortedIndexes(
+    private static Iterator<byte[]> buildShardedSortedIndexes(
             Iterator<InternalRow> input,
             byte[] serializedWriter,
             Map<Long, Range> taskRanges,
@@ -330,6 +394,22 @@ public class SortedIndexTopoBuilder implements 
GlobalIndexTopologyBuilder {
         return results.iterator();
     }
 
+    static int calculateParallelism(
+            List<SortedBuildTask> buildTasks, long recordsPerRange, int 
maxParallelism) {
+        long totalRecords = 0;
+        for (SortedBuildTask task : buildTasks) {
+            long count = task.rowRange.count();
+            if (Long.MAX_VALUE - totalRecords < count) {
+                totalRecords = Long.MAX_VALUE;
+            } else {
+                totalRecords += count;
+            }
+        }
+
+        long parallelism = Math.max(totalRecords / recordsPerRange, 1);
+        return (int) Math.min(parallelism, maxParallelism);
+    }
+
     private static String buildTaskIdFieldName(RowType readType) {
         String fieldName = BUILD_TASK_ID_FIELD;
         while (readType.containsField(fieldName)) {
@@ -349,6 +429,22 @@ public class SortedIndexTopoBuilder implements 
GlobalIndexTopologyBuilder {
                 readType.getField(SpecialFields.ROW_ID.name()));
     }
 
+    /** Metadata for one sorted index build range. */
+    static class SortedBuildTask implements Serializable {
+
+        private static final long serialVersionUID = 1L;
+
+        private final long taskId;
+        private final Range rowRange;
+        private final byte[] partition;
+
+        SortedBuildTask(long taskId, Range rowRange, byte[] partition) {
+            this.taskId = taskId;
+            this.rowRange = rowRange;
+            this.partition = partition;
+        }
+    }
+
     /** Groups a partition already sorted by task id, normalized key and row 
id. */
     private static class SortedTaskInput {
 
diff --git 
a/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/globalindex/sorted/SortedIndexTopoBuilderTest.java
 
b/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/globalindex/sorted/SortedIndexTopoBuilderTest.java
new file mode 100644
index 0000000000..bddd63bee2
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/globalindex/sorted/SortedIndexTopoBuilderTest.java
@@ -0,0 +1,118 @@
+/*
+ * 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.spark.globalindex.sorted;
+
+import 
org.apache.paimon.spark.globalindex.sorted.SortedIndexTopoBuilder.SortedBuildTask;
+import org.apache.paimon.table.SpecialFields;
+import org.apache.paimon.utils.Range;
+
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.SparkSession;
+import org.apache.spark.sql.catalyst.plans.logical.RepartitionByExpression;
+import org.apache.spark.sql.catalyst.plans.logical.Sort;
+import org.apache.spark.sql.catalyst.plans.logical.Union;
+import org.apache.spark.sql.catalyst.plans.physical.RangePartitioning;
+import org.apache.spark.sql.functions;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link SortedIndexTopoBuilder}. */
+public class SortedIndexTopoBuilderTest {
+
+    private static final String TASK_ID = "task_id";
+    private static final String INDEX_KEY = "index_key";
+
+    @Test
+    void testBuildTopologyAcrossPartitions() {
+        SparkSession spark =
+                SparkSession.builder()
+                        .master("local[1]")
+                        .appName("sorted-index-topology-test")
+                        .config("spark.ui.enabled", "false")
+                        .getOrCreate();
+        try {
+            List<Dataset<Row>> partitionInputs =
+                    Arrays.asList(taskInput(spark, 0), taskInput(spark, 1), 
taskInput(spark, 2));
+
+            Dataset<Row> topology =
+                    SortedIndexTopoBuilder.combineAndSortBuildTaskInputs(
+                            partitionInputs, 3, TASK_ID, INDEX_KEY);
+
+            
assertThat(topology.queryExecution().logical()).isInstanceOf(Sort.class);
+            Sort sort = (Sort) topology.queryExecution().logical();
+            assertThat(sort.global()).isFalse();
+            assertThat(sort.order().size()).isEqualTo(3);
+            
assertThat(sort.child()).isInstanceOf(RepartitionByExpression.class);
+
+            RepartitionByExpression repartition = (RepartitionByExpression) 
sort.child();
+            assertThat(repartition.numPartitions()).isEqualTo(3);
+            assertThat(repartition.shuffle()).isTrue();
+            
assertThat(repartition.partitioning()).isInstanceOf(RangePartitioning.class);
+            assertThat(repartition.partitionExpressions().size()).isEqualTo(3);
+            assertThat(repartition.child()).isInstanceOf(Union.class);
+            assertThat(((Union) 
repartition.child()).children().size()).isEqualTo(3);
+        } finally {
+            spark.stop();
+            SparkSession.clearActiveSession();
+            SparkSession.clearDefaultSession();
+        }
+    }
+
+    @Test
+    void testCalculateParallelismByTotalRowsInsteadOfRangeCount() {
+        List<SortedBuildTask> tasks = new ArrayList<>();
+        for (int i = 0; i < 100; i++) {
+            tasks.add(new SortedBuildTask(i, new Range(i * 10L, i * 10L + 9), 
new byte[0]));
+        }
+
+        assertThat(SortedIndexTopoBuilder.calculateParallelism(tasks, 1000L, 
4096)).isEqualTo(1);
+    }
+
+    @Test
+    void testCalculateParallelismHonorsMaxParallelism() {
+        List<SortedBuildTask> tasks = new ArrayList<>();
+        for (int i = 0; i < 100; i++) {
+            tasks.add(new SortedBuildTask(i, new Range(i * 1000L, i * 1000L + 
999), new byte[0]));
+        }
+
+        assertThat(SortedIndexTopoBuilder.calculateParallelism(tasks, 1000L, 
16)).isEqualTo(16);
+    }
+
+    @Test
+    void testCalculateParallelismKeepsSingleRangeBehavior() {
+        List<SortedBuildTask> tasks = new ArrayList<>();
+        tasks.add(new SortedBuildTask(0, new Range(0, 1499), new byte[0]));
+
+        assertThat(SortedIndexTopoBuilder.calculateParallelism(tasks, 1000L, 
16)).isEqualTo(1);
+    }
+
+    private static Dataset<Row> taskInput(SparkSession spark, long taskId) {
+        return spark.range(1)
+                .select(
+                        functions.lit(taskId).cast("long").alias(TASK_ID),
+                        functions.lit(taskId).alias(INDEX_KEY),
+                        
functions.col("id").alias(SpecialFields.ROW_ID.name()));
+    }
+}
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/CreateGlobalIndexProcedureTest.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/CreateGlobalIndexProcedureTest.scala
index 94d836a613..90c1eda5cb 100644
--- 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/CreateGlobalIndexProcedureTest.scala
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/CreateGlobalIndexProcedureTest.scala
@@ -26,6 +26,7 @@ import org.apache.paimon.spark.PaimonSparkTestBase
 import org.apache.paimon.types.VarCharType
 import org.apache.paimon.utils.Range
 
+import org.apache.spark.scheduler.{SparkListener, SparkListenerStageSubmitted}
 import org.apache.spark.sql.Row
 import org.apache.spark.sql.paimon.Utils
 import org.apache.spark.sql.streaming.StreamTest
@@ -34,6 +35,7 @@ import java.io.File
 
 import scala.collection.JavaConverters._
 import scala.collection.immutable
+import scala.collection.mutable
 
 class CreateGlobalIndexProcedureTest extends PaimonSparkTestBase with 
StreamTest {
 
@@ -322,16 +324,35 @@ class CreateGlobalIndexProcedureTest extends 
PaimonSparkTestBase with StreamTest
       values = (0 until 33333).map(i => s"($i, 'name_$i', 'p2')").mkString(",")
       spark.sql(s"INSERT INTO T VALUES $values")
 
+      val submittedStageTasks = mutable.ListBuffer.empty[Int]
+      val listener = new SparkListener {
+        override def onStageSubmitted(stageSubmitted: 
SparkListenerStageSubmitted): Unit = {
+          submittedStageTasks += stageSubmitted.stageInfo.numTasks
+        }
+      }
       val output =
-        spark
-          .sql(
-            "CALL sys.create_global_index(table => 'test.T', index_column => 
'name', index_type => 'btree'," +
-              " options => 'btree-index.records-per-range=1000')")
-          .collect()
-          .head
+        try {
+          spark.sparkContext.addSparkListener(listener)
+          spark
+            .sql(
+              "CALL sys.create_global_index(table => 'test.T', index_column => 
'name', index_type => 'btree'," +
+                " options => 'btree-index.records-per-range=1000')")
+            .collect()
+            .head
+        } finally {
+          Utils.waitUntilEventEmpty(spark)
+          spark.sparkContext.removeSparkListener(listener)
+        }
 
       assert(output.getBoolean(0))
 
+      val expectedBuildParallelism = (189088L / 1000).toInt
+      assert(
+        submittedStageTasks.count(_ == expectedBuildParallelism) == 1,
+        s"Expected one global build stage with $expectedBuildParallelism 
tasks, " +
+          s"but observed stages with ${submittedStageTasks.mkString(", ")} 
tasks"
+      )
+
       assertMultiplePartitionsResult("T", 189088L, 3)
     }
   }

Reply via email to