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 d4a493d733 [flink] size aware partitioner for full compaction job 
(#8583)
d4a493d733 is described below

commit d4a493d7332b20f7a77556efe8f58e187e419d43
Author: dwangatt <[email protected]>
AuthorDate: Sat Jul 18 15:35:23 2026 +1000

    [flink] size aware partitioner for full compaction job (#8583)
---
 docs/generated/flink_connector_configuration.html  |   6 +
 .../apache/paimon/flink/FlinkConnectorOptions.java |  38 +++++
 .../apache/paimon/flink/action/CompactAction.java  |  24 +++-
 .../paimon/flink/action/CompactDatabaseAction.java |  11 +-
 .../paimon/flink/sink/CompactorSinkBuilder.java    |  29 +++-
 .../flink/source/CompactorSourceBuilder.java       | 103 +++++++++++++-
 .../paimon/flink/source/StaticFileStoreSource.java |  85 ++++++++++-
 .../source/assigners/PreAssignSplitAssigner.java   | 142 +++++++++++++++++--
 .../CompactionBucketDistributionStrategyTest.java  |  70 +++++++++
 .../flink/source/CompactorSourceBuilderTest.java   | 157 +++++++++++++++++++++
 .../paimon/flink/source/CompactorSourceITCase.java |  61 ++++++++
 .../paimon/flink/source/FairAssignModeTest.java    | 102 +++++++++++++
 12 files changed, 805 insertions(+), 23 deletions(-)

diff --git a/docs/generated/flink_connector_configuration.html 
b/docs/generated/flink_connector_configuration.html
index 83e9c0bb25..8f5b2eb9c3 100644
--- a/docs/generated/flink_connector_configuration.html
+++ b/docs/generated/flink_connector_configuration.html
@@ -26,6 +26,12 @@ under the License.
         </tr>
     </thead>
     <tbody>
+        <tr>
+            <td><h5>compaction.bucket-distribution-strategy</h5></td>
+            <td style="word-wrap: break-word;">linear</td>
+            <td><p>Enum</p></td>
+            <td>Defines how dedicated bucket compaction jobs distribute 
compact buckets to writers. 'linear' uses the existing stable 
partition-plus-bucket mapping. 'size-aware-batch' assigns bounded 
full-compaction bucket splits by total data file size and forwards them to 
writers to reduce compaction long tail.<br /><br />Possible 
values:<ul><li>"linear": Distribute compact buckets by the existing stable 
partition-plus-bucket channel mapping.</li><li>"size-aware-batch": For bounded 
ful [...]
+        </tr>
         <tr>
             <td><h5>changelog.precommit-compact.thread-num</h5></td>
             <td style="word-wrap: break-word;">(none)</td>
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/FlinkConnectorOptions.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/FlinkConnectorOptions.java
index bc61b8bbab..1f0bf32c09 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/FlinkConnectorOptions.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/FlinkConnectorOptions.java
@@ -84,6 +84,16 @@ public class FlinkConnectorOptions {
                                     + "This eliminates data transfer overhead 
when the source already "
                                     + "provides suitable data distribution 
(e.g., Kafka partitions).");
 
+    public static final ConfigOption<CompactionBucketDistributionStrategy>
+            COMPACTION_BUCKET_DISTRIBUTION_STRATEGY =
+                    
ConfigOptions.key("compaction.bucket-distribution-strategy")
+                            
.enumType(CompactionBucketDistributionStrategy.class)
+                            
.defaultValue(CompactionBucketDistributionStrategy.LINEAR)
+                            .withDescription(
+                                    "Defines how dedicated bucket compaction 
jobs distribute compact buckets to writers. "
+                                            + "'linear' uses the existing 
stable partition-plus-bucket mapping. "
+                                            + "'size-aware-batch' assigns 
bounded full-compaction bucket splits by total data file size and forwards them 
to writers to reduce compaction long tail.");
+
     public static final ConfigOption<Boolean> INFER_SCAN_PARALLELISM =
             ConfigOptions.key("scan.infer-parallelism")
                     .booleanType()
@@ -644,6 +654,34 @@ public class FlinkConnectorOptions {
         }
     }
 
+    /** Bucket distribution strategy for dedicated compaction jobs. */
+    public enum CompactionBucketDistributionStrategy implements DescribedEnum {
+        LINEAR(
+                "linear",
+                "Distribute compact buckets by the existing stable 
partition-plus-bucket channel mapping."),
+        SIZE_AWARE_BATCH(
+                "size-aware-batch",
+                "For bounded full compaction, assign compact bucket splits by 
total data file size and forward them to writers to reduce long-tail compaction 
tasks.");
+
+        private final String value;
+        private final String description;
+
+        CompactionBucketDistributionStrategy(String value, String description) 
{
+            this.value = value;
+            this.description = description;
+        }
+
+        @Override
+        public String toString() {
+            return value;
+        }
+
+        @Override
+        public InlineElement getDescription() {
+            return text(description);
+        }
+    }
+
     /**
      * Split assign mode for {@link 
org.apache.paimon.flink.source.StaticFileStoreSplitEnumerator}.
      */
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/CompactAction.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/CompactAction.java
index 146c7b7a62..501b6aed16 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/CompactAction.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/CompactAction.java
@@ -23,6 +23,7 @@ import org.apache.paimon.Snapshot;
 import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.data.InternalRow;
 import org.apache.paimon.flink.FlinkConnectorOptions;
+import 
org.apache.paimon.flink.FlinkConnectorOptions.CompactionBucketDistributionStrategy;
 import org.apache.paimon.flink.compact.AppendTableCompact;
 import org.apache.paimon.flink.compact.DataEvolutionTableCompact;
 import org.apache.paimon.flink.compact.IncrementalClusterCompact;
@@ -166,6 +167,20 @@ public class CompactAction extends TableActionBase {
         return true;
     }
 
+    static CompactionBucketDistributionStrategy 
compactionBucketDistributionStrategy(
+            FileStoreTable table, boolean fullCompaction, boolean isStreaming) 
{
+        return compactionBucketDistributionStrategy(
+                table.coreOptions().toConfiguration(), fullCompaction, 
isStreaming);
+    }
+
+    static CompactionBucketDistributionStrategy 
compactionBucketDistributionStrategy(
+            Options options, boolean fullCompaction, boolean isStreaming) {
+        if (!fullCompaction || isStreaming) {
+            return CompactionBucketDistributionStrategy.LINEAR;
+        }
+        return 
options.get(FlinkConnectorOptions.COMPACTION_BUCKET_DISTRIBUTION_STRATEGY);
+    }
+
     protected void buildForBucketedTableCompact(
             StreamExecutionEnvironment env, FileStoreTable table, boolean 
isStreaming)
             throws Exception {
@@ -192,9 +207,14 @@ public class CompactAction extends TableActionBase {
                     };
             table = table.copy(dynamicOptions);
         }
+        CompactionBucketDistributionStrategy bucketDistributionStrategy =
+                compactionBucketDistributionStrategy(table, fullCompaction, 
isStreaming);
         CompactorSourceBuilder sourceBuilder =
-                new CompactorSourceBuilder(identifier.getFullName(), table);
-        CompactorSinkBuilder sinkBuilder = new CompactorSinkBuilder(table, 
fullCompaction);
+                new CompactorSourceBuilder(identifier.getFullName(), table)
+                        
.withBucketDistributionStrategy(bucketDistributionStrategy);
+        CompactorSinkBuilder sinkBuilder =
+                new CompactorSinkBuilder(table, fullCompaction)
+                        
.withBucketDistributionStrategy(bucketDistributionStrategy);
 
         sourceBuilder.withPartitionPredicate(getPartitionPredicate());
         DataStreamSource<RowData> source =
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/CompactDatabaseAction.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/CompactDatabaseAction.java
index 1a65b8d1bd..3000c319b5 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/CompactDatabaseAction.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/CompactDatabaseAction.java
@@ -23,6 +23,7 @@ import org.apache.paimon.append.MultiTableAppendCompactTask;
 import org.apache.paimon.catalog.Catalog;
 import org.apache.paimon.catalog.Identifier;
 import org.apache.paimon.flink.FlinkConnectorOptions;
+import 
org.apache.paimon.flink.FlinkConnectorOptions.CompactionBucketDistributionStrategy;
 import org.apache.paimon.flink.compact.AppendTableCompact;
 import org.apache.paimon.flink.sink.BucketsRowChannelComputer;
 import org.apache.paimon.flink.sink.CombinedTableCompactorSink;
@@ -266,10 +267,16 @@ public class CompactDatabaseAction extends ActionBase {
             table = table.copy(dynamicOptions);
         }
 
+        CompactionBucketDistributionStrategy bucketDistributionStrategy =
+                CompactAction.compactionBucketDistributionStrategy(
+                        table, fullCompaction, isStreaming);
         CompactorSourceBuilder sourceBuilder =
                 new CompactorSourceBuilder(fullName, table)
-                        .withPartitionIdleTime(partitionIdleTime);
-        CompactorSinkBuilder sinkBuilder = new CompactorSinkBuilder(table, 
fullCompaction);
+                        .withPartitionIdleTime(partitionIdleTime)
+                        
.withBucketDistributionStrategy(bucketDistributionStrategy);
+        CompactorSinkBuilder sinkBuilder =
+                new CompactorSinkBuilder(table, fullCompaction)
+                        
.withBucketDistributionStrategy(bucketDistributionStrategy);
 
         DataStreamSource<RowData> source =
                 
sourceBuilder.withEnv(env).withContinuousMode(isStreaming).build();
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/CompactorSinkBuilder.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/CompactorSinkBuilder.java
index 8e1321a9f7..778b2fd551 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/CompactorSinkBuilder.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/CompactorSinkBuilder.java
@@ -19,15 +19,18 @@
 package org.apache.paimon.flink.sink;
 
 import org.apache.paimon.flink.FlinkConnectorOptions;
+import 
org.apache.paimon.flink.FlinkConnectorOptions.CompactionBucketDistributionStrategy;
 import org.apache.paimon.table.BucketMode;
 import org.apache.paimon.table.FileStoreTable;
 
 import org.apache.flink.streaming.api.datastream.DataStream;
 import org.apache.flink.streaming.api.datastream.DataStreamSink;
+import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
 import org.apache.flink.table.data.RowData;
 
 import java.util.Optional;
 
+import static org.apache.paimon.CoreOptions.createCommitUser;
 import static org.apache.paimon.flink.sink.FlinkStreamPartitioner.partition;
 
 /** Builder for {@link CompactorSink}. */
@@ -39,6 +42,9 @@ public class CompactorSinkBuilder {
 
     private final boolean fullCompaction;
 
+    private CompactionBucketDistributionStrategy bucketDistributionStrategy =
+            CompactionBucketDistributionStrategy.LINEAR;
+
     public CompactorSinkBuilder(FileStoreTable table, boolean fullCompaction) {
         this.table = table;
         this.fullCompaction = fullCompaction;
@@ -49,6 +55,12 @@ public class CompactorSinkBuilder {
         return this;
     }
 
+    public CompactorSinkBuilder withBucketDistributionStrategy(
+            CompactionBucketDistributionStrategy bucketDistributionStrategy) {
+        this.bucketDistributionStrategy = bucketDistributionStrategy;
+        return this;
+    }
+
     public DataStreamSink<?> build() {
         BucketMode bucketMode = table.bucketMode();
         switch (bucketMode) {
@@ -66,8 +78,19 @@ public class CompactorSinkBuilder {
                                 
table.options().get(FlinkConnectorOptions.SINK_PARALLELISM.key()))
                         .map(Integer::valueOf)
                         .orElse(null);
-        DataStream<RowData> partitioned =
-                partition(input, new BucketsRowChannelComputer(), parallelism);
-        return new CompactorSink(table, fullCompaction).sinkFrom(partitioned);
+        switch (bucketDistributionStrategy) {
+            case SIZE_AWARE_BATCH:
+                CompactorSink sink = new CompactorSink(table, fullCompaction);
+                String commitUser = 
createCommitUser(table.coreOptions().toConfiguration());
+                DataStream<Committable> written = sink.doWrite(input, 
commitUser, null);
+                return sink.doCommit(
+                        ((SingleOutputStreamOperator<Committable>) 
written).startNewChain(),
+                        commitUser);
+            case LINEAR:
+            default:
+                DataStream<RowData> partitioned =
+                        partition(input, new BucketsRowChannelComputer(), 
parallelism);
+                return new CompactorSink(table, 
fullCompaction).sinkFrom(partitioned);
+        }
     }
 }
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/CompactorSourceBuilder.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/CompactorSourceBuilder.java
index 83486a8624..96961272f1 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/CompactorSourceBuilder.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/CompactorSourceBuilder.java
@@ -21,12 +21,15 @@ package org.apache.paimon.flink.source;
 import org.apache.paimon.CoreOptions;
 import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.flink.FlinkConnectorOptions;
+import 
org.apache.paimon.flink.FlinkConnectorOptions.CompactionBucketDistributionStrategy;
+import org.apache.paimon.flink.FlinkConnectorOptions.SplitAssignMode;
 import org.apache.paimon.flink.LogicalTypeConversion;
 import org.apache.paimon.manifest.PartitionEntry;
 import org.apache.paimon.options.Options;
 import org.apache.paimon.partition.PartitionPredicate;
 import org.apache.paimon.partition.PartitionValuesTimeExpireStrategy;
 import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.source.DataSplit;
 import org.apache.paimon.table.source.ReadBuilder;
 import org.apache.paimon.table.system.CompactBucketsTable;
 import org.apache.paimon.types.RowType;
@@ -66,6 +69,9 @@ public class CompactorSourceBuilder {
     @Nullable private PartitionPredicate partitionPredicate = null;
     @Nullable private Duration partitionIdleTime = null;
 
+    private CompactionBucketDistributionStrategy bucketDistributionStrategy =
+            CompactionBucketDistributionStrategy.LINEAR;
+
     public CompactorSourceBuilder(String tableIdentifier, FileStoreTable 
table) {
         this.tableIdentifier = tableIdentifier;
         this.table = table;
@@ -101,11 +107,22 @@ public class CompactorSourceBuilder {
             return new ContinuousFileStoreSource(readBuilder, 
compactBucketsTable.options(), null);
         } else {
             Options options = 
compactBucketsTable.coreOptions().toConfiguration();
+            SplitAssignMode splitAssignMode =
+                    
options.get(FlinkConnectorOptions.SCAN_SPLIT_ENUMERATOR_ASSIGN_MODE);
+            validateBucketDistributionStrategy(bucketDistributionStrategy, 
splitAssignMode);
             return new StaticFileStoreSource(
                     readBuilder,
                     null,
                     
options.get(FlinkConnectorOptions.SCAN_SPLIT_ENUMERATOR_BATCH_SIZE),
-                    
options.get(FlinkConnectorOptions.SCAN_SPLIT_ENUMERATOR_ASSIGN_MODE),
+                    splitAssignMode,
+                    bucketDistributionStrategy
+                                    == 
CompactionBucketDistributionStrategy.SIZE_AWARE_BATCH
+                            ? split -> bucketFileSize((DataSplit) 
split.split())
+                            : null,
+                    bucketDistributionStrategy
+                                    == 
CompactionBucketDistributionStrategy.SIZE_AWARE_BATCH
+                            ? split -> bucketKey((DataSplit) split.split())
+                            : null,
                     options.get(CoreOptions.BLOB_AS_DESCRIPTOR));
         }
     }
@@ -117,12 +134,21 @@ public class CompactorSourceBuilder {
 
         CompactBucketsTable compactBucketsTable = new 
CompactBucketsTable(table, isContinuous);
         RowType produceType = compactBucketsTable.rowType();
+        Integer parallelism =
+                sourceParallelism(Options.fromMap(table.options()), 
bucketDistributionStrategy);
         DataStreamSource<RowData> dataStream =
                 env.fromSource(
                         buildSource(compactBucketsTable),
                         WatermarkStrategy.noWatermarks(),
                         tableIdentifier + "-compact-source",
                         
InternalTypeInfo.of(LogicalTypeConversion.toLogicalType(produceType)));
+        if (parallelism != null) {
+            // Size-aware assignment depends on source-reader to writer 
alignment. Set the
+            // parallelism on the original source before adding downstream 
filters; otherwise the
+            // configured parallelism may only apply to a filter and 
source-to-filter redistribution
+            // could break grouped bucket assignment.
+            dataStream.setParallelism(parallelism);
+        }
         if (isContinuous) {
             Preconditions.checkArgument(
                     partitionIdleTime == null, "Streaming mode does not 
support partitionIdleTime");
@@ -140,6 +166,9 @@ public class CompactorSourceBuilder {
                                 BinaryRow partition = 
deserializeBinaryRow(rowData.getBinary(1));
                                 return partitionInfo.get(partition) <= 
historyMilli;
                             });
+            if (parallelism != null) {
+                filterStream.setParallelism(parallelism);
+            }
             dataStream = new DataStreamSource<>(filterStream);
         }
         CoreOptions coreOptions = table.coreOptions();
@@ -160,13 +189,11 @@ public class CompactorSourceBuilder {
                                 BinaryRow partition = 
deserializeBinaryRow(rowData.getBinary(1));
                                 return 
!expireStrategy.isExpired(expireDateTime, partition);
                             });
+            if (parallelism != null) {
+                filterStream.setParallelism(parallelism);
+            }
             dataStream = new DataStreamSource<>(filterStream);
         }
-        Integer parallelism =
-                
Options.fromMap(table.options()).get(FlinkConnectorOptions.SCAN_PARALLELISM);
-        if (parallelism != null) {
-            dataStream.setParallelism(parallelism);
-        }
         return dataStream;
     }
 
@@ -204,6 +231,70 @@ public class CompactorSourceBuilder {
         return this;
     }
 
+    public CompactorSourceBuilder withBucketDistributionStrategy(
+            CompactionBucketDistributionStrategy bucketDistributionStrategy) {
+        this.bucketDistributionStrategy = bucketDistributionStrategy;
+        return this;
+    }
+
+    static void validateBucketDistributionStrategy(
+            CompactionBucketDistributionStrategy bucketDistributionStrategy,
+            SplitAssignMode splitAssignMode) {
+        if (bucketDistributionStrategy == 
CompactionBucketDistributionStrategy.SIZE_AWARE_BATCH
+                && splitAssignMode == SplitAssignMode.PREEMPTIVE) {
+            throw new IllegalArgumentException(
+                    "compaction.bucket-distribution-strategy=size-aware-batch 
requires "
+                            + "scan.split-enumerator.mode=fair because it 
relies on grouped "
+                            + "bucket assignment. Preemptive split assignment 
can split the "
+                            + "same bucket across different writers while the 
sink skips "
+                            + "bucket shuffle.");
+        }
+    }
+
+    static Integer sourceParallelism(
+            Options tableOptions, CompactionBucketDistributionStrategy 
bucketDistributionStrategy) {
+        Integer parallelism = 
tableOptions.get(FlinkConnectorOptions.SCAN_PARALLELISM);
+        if (bucketDistributionStrategy == 
CompactionBucketDistributionStrategy.SIZE_AWARE_BATCH) {
+            Integer sinkParallelism = 
tableOptions.get(FlinkConnectorOptions.SINK_PARALLELISM);
+            if (sinkParallelism != null) {
+                parallelism = sinkParallelism;
+            }
+        }
+        return parallelism;
+    }
+
+    static long bucketFileSize(DataSplit split) {
+        return split.dataFiles().stream().mapToLong(dataFile -> 
dataFile.fileSize()).sum();
+    }
+
+    static BucketKey bucketKey(DataSplit split) {
+        return new BucketKey(split.partition(), split.bucket());
+    }
+
+    static class BucketKey {
+        private final BinaryRow partition;
+        private final int bucket;
+
+        private BucketKey(BinaryRow partition, int bucket) {
+            this.partition = partition.copy();
+            this.bucket = bucket;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (!(o instanceof BucketKey)) {
+                return false;
+            }
+            BucketKey that = (BucketKey) o;
+            return bucket == that.bucket && partition.equals(that.partition);
+        }
+
+        @Override
+        public int hashCode() {
+            return 31 * partition.hashCode() + bucket;
+        }
+    }
+
     private Map<BinaryRow, Long> getPartitionInfo(CompactBucketsTable table) {
         List<PartitionEntry> partitions = 
table.newSnapshotReader().partitionEntries();
 
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/StaticFileStoreSource.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/StaticFileStoreSource.java
index 940feb9d31..04facbd49e 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/StaticFileStoreSource.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/StaticFileStoreSource.java
@@ -27,6 +27,7 @@ import org.apache.paimon.table.ChainGroupReadTable;
 import org.apache.paimon.table.source.InnerTableScan;
 import org.apache.paimon.table.source.ReadBuilder;
 import org.apache.paimon.table.source.TableScan;
+import org.apache.paimon.utils.SerializableFunction;
 
 import org.apache.flink.api.connector.source.Boundedness;
 import org.apache.flink.api.connector.source.SplitEnumerator;
@@ -50,6 +51,10 @@ public class StaticFileStoreSource extends FlinkSource {
 
     @Nullable private final DynamicPartitionFilteringInfo 
dynamicPartitionFilteringInfo;
 
+    @Nullable private final SerializableFunction<FileStoreSourceSplit, Long> 
splitWeightFunc;
+
+    @Nullable private final SerializableFunction<FileStoreSourceSplit, ?> 
splitGroupFunc;
+
     private final boolean skipPreloadTargetSnapshot;
 
     public StaticFileStoreSource(
@@ -65,6 +70,51 @@ public class StaticFileStoreSource extends FlinkSource {
                 splitAssignMode,
                 null,
                 null,
+                null,
+                null,
+                blobAsDescriptor,
+                false);
+    }
+
+    public StaticFileStoreSource(
+            ReadBuilder readBuilder,
+            @Nullable Long limit,
+            int splitBatchSize,
+            SplitAssignMode splitAssignMode,
+            @Nullable DynamicPartitionFilteringInfo 
dynamicPartitionFilteringInfo,
+            @Nullable NestedProjectedRowData rowData,
+            boolean blobAsDescriptor,
+            boolean skipPreloadTargetSnapshot) {
+        this(
+                readBuilder,
+                limit,
+                splitBatchSize,
+                splitAssignMode,
+                dynamicPartitionFilteringInfo,
+                rowData,
+                null,
+                null,
+                blobAsDescriptor,
+                skipPreloadTargetSnapshot);
+    }
+
+    public StaticFileStoreSource(
+            ReadBuilder readBuilder,
+            @Nullable Long limit,
+            int splitBatchSize,
+            SplitAssignMode splitAssignMode,
+            @Nullable SerializableFunction<FileStoreSourceSplit, Long> 
splitWeightFunc,
+            @Nullable SerializableFunction<FileStoreSourceSplit, ?> 
splitGroupFunc,
+            boolean blobAsDescriptor) {
+        this(
+                readBuilder,
+                limit,
+                splitBatchSize,
+                splitAssignMode,
+                null,
+                null,
+                splitWeightFunc,
+                splitGroupFunc,
                 blobAsDescriptor,
                 false);
     }
@@ -76,12 +126,16 @@ public class StaticFileStoreSource extends FlinkSource {
             SplitAssignMode splitAssignMode,
             @Nullable DynamicPartitionFilteringInfo 
dynamicPartitionFilteringInfo,
             @Nullable NestedProjectedRowData rowData,
+            @Nullable SerializableFunction<FileStoreSourceSplit, Long> 
splitWeightFunc,
+            @Nullable SerializableFunction<FileStoreSourceSplit, ?> 
splitGroupFunc,
             boolean blobAsDescriptor,
             boolean skipPreloadTargetSnapshot) {
         super(readBuilder, limit, rowData, blobAsDescriptor);
         this.splitBatchSize = splitBatchSize;
         this.splitAssignMode = splitAssignMode;
         this.dynamicPartitionFilteringInfo = dynamicPartitionFilteringInfo;
+        this.splitWeightFunc = splitWeightFunc;
+        this.splitGroupFunc = splitGroupFunc;
         this.skipPreloadTargetSnapshot = skipPreloadTargetSnapshot;
     }
 
@@ -97,7 +151,13 @@ public class StaticFileStoreSource extends FlinkSource {
         Collection<FileStoreSourceSplit> splits =
                 checkpoint == null ? getSplits(context) : checkpoint.splits();
         SplitAssigner splitAssigner =
-                createSplitAssigner(context, splitBatchSize, splitAssignMode, 
splits);
+                createSplitAssigner(
+                        context,
+                        splitBatchSize,
+                        splitAssignMode,
+                        splits,
+                        splitWeightFunc,
+                        splitGroupFunc);
         return new StaticFileStoreSplitEnumerator(
                 context, null, splitAssigner, dynamicPartitionFilteringInfo);
     }
@@ -121,9 +181,30 @@ public class StaticFileStoreSource extends FlinkSource {
             int splitBatchSize,
             SplitAssignMode splitAssignMode,
             Collection<FileStoreSourceSplit> splits) {
+        return createSplitAssigner(context, splitBatchSize, splitAssignMode, 
splits, null, null);
+    }
+
+    public static SplitAssigner createSplitAssigner(
+            SplitEnumeratorContext<FileStoreSourceSplit> context,
+            int splitBatchSize,
+            SplitAssignMode splitAssignMode,
+            Collection<FileStoreSourceSplit> splits,
+            @Nullable SerializableFunction<FileStoreSourceSplit, Long> 
splitWeightFunc) {
+        return createSplitAssigner(
+                context, splitBatchSize, splitAssignMode, splits, 
splitWeightFunc, null);
+    }
+
+    public static SplitAssigner createSplitAssigner(
+            SplitEnumeratorContext<FileStoreSourceSplit> context,
+            int splitBatchSize,
+            SplitAssignMode splitAssignMode,
+            Collection<FileStoreSourceSplit> splits,
+            @Nullable SerializableFunction<FileStoreSourceSplit, Long> 
splitWeightFunc,
+            @Nullable SerializableFunction<FileStoreSourceSplit, ?> 
splitGroupFunc) {
         switch (splitAssignMode) {
             case FAIR:
-                return new PreAssignSplitAssigner(splitBatchSize, context, 
splits);
+                return new PreAssignSplitAssigner(
+                        splitBatchSize, context, splits, splitWeightFunc, 
splitGroupFunc);
             case PREEMPTIVE:
                 return new FIFOSplitAssigner(splits);
             default:
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/assigners/PreAssignSplitAssigner.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/assigners/PreAssignSplitAssigner.java
index fbb31bd108..24ac4a2911 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/assigners/PreAssignSplitAssigner.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/assigners/PreAssignSplitAssigner.java
@@ -24,6 +24,7 @@ import org.apache.paimon.flink.FlinkRowData;
 import org.apache.paimon.flink.source.FileStoreSourceSplit;
 import org.apache.paimon.table.source.DataSplit;
 import org.apache.paimon.utils.BinPacking;
+import org.apache.paimon.utils.SerializableFunction;
 
 import org.apache.flink.api.connector.source.SplitEnumeratorContext;
 import org.apache.flink.table.connector.source.DynamicFilteringData;
@@ -37,6 +38,7 @@ import java.util.LinkedList;
 import java.util.List;
 import java.util.ListIterator;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.Queue;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -59,6 +61,8 @@ public class PreAssignSplitAssigner implements SplitAssigner {
 
     private final AtomicInteger numberOfPendingSplits;
     private final Collection<FileStoreSourceSplit> splits;
+    private final SerializableFunction<FileStoreSourceSplit, Long> weightFunc;
+    @Nullable private final SerializableFunction<FileStoreSourceSplit, ?> 
groupFunc;
 
     public PreAssignSplitAssigner(
             int splitBatchSize,
@@ -67,26 +71,80 @@ public class PreAssignSplitAssigner implements 
SplitAssigner {
         this(splitBatchSize, context.currentParallelism(), splits);
     }
 
+    public PreAssignSplitAssigner(
+            int splitBatchSize,
+            SplitEnumeratorContext<FileStoreSourceSplit> context,
+            Collection<FileStoreSourceSplit> splits,
+            SerializableFunction<FileStoreSourceSplit, Long> weightFunc) {
+        this(splitBatchSize, context.currentParallelism(), splits, weightFunc);
+    }
+
+    public PreAssignSplitAssigner(
+            int splitBatchSize,
+            SplitEnumeratorContext<FileStoreSourceSplit> context,
+            Collection<FileStoreSourceSplit> splits,
+            @Nullable SerializableFunction<FileStoreSourceSplit, Long> 
weightFunc,
+            @Nullable SerializableFunction<FileStoreSourceSplit, ?> groupFunc) 
{
+        this(splitBatchSize, context.currentParallelism(), splits, weightFunc, 
groupFunc);
+    }
+
     public PreAssignSplitAssigner(
             int splitBatchSize,
             int parallelism,
             Collection<FileStoreSourceSplit> splits,
             Projection partitionRowProjection,
             DynamicFilteringData dynamicFilteringData) {
+        this(
+                splitBatchSize,
+                parallelism,
+                splits,
+                partitionRowProjection,
+                dynamicFilteringData,
+                split -> split.split().rowCount());
+    }
+
+    public PreAssignSplitAssigner(
+            int splitBatchSize,
+            int parallelism,
+            Collection<FileStoreSourceSplit> splits,
+            Projection partitionRowProjection,
+            DynamicFilteringData dynamicFilteringData,
+            SerializableFunction<FileStoreSourceSplit, Long> weightFunc) {
         this(
                 splitBatchSize,
                 parallelism,
                 splits.stream()
                         .filter(s -> filter(partitionRowProjection, 
dynamicFilteringData, s))
-                        .collect(Collectors.toList()));
+                        .collect(Collectors.toList()),
+                weightFunc);
     }
 
     public PreAssignSplitAssigner(
             int splitBatchSize, int parallelism, 
Collection<FileStoreSourceSplit> splits) {
+        this(splitBatchSize, parallelism, splits, split -> 
split.split().rowCount());
+    }
+
+    public PreAssignSplitAssigner(
+            int splitBatchSize,
+            int parallelism,
+            Collection<FileStoreSourceSplit> splits,
+            SerializableFunction<FileStoreSourceSplit, Long> weightFunc) {
+        this(splitBatchSize, parallelism, splits, weightFunc, null);
+    }
+
+    public PreAssignSplitAssigner(
+            int splitBatchSize,
+            int parallelism,
+            Collection<FileStoreSourceSplit> splits,
+            SerializableFunction<FileStoreSourceSplit, Long> weightFunc,
+            @Nullable SerializableFunction<FileStoreSourceSplit, ?> groupFunc) 
{
         this.splitBatchSize = splitBatchSize;
         this.parallelism = parallelism;
         this.splits = splits;
-        this.pendingSplitAssignment = createBatchFairSplitAssignment(splits, 
parallelism);
+        this.weightFunc = weightFunc == null ? split -> 
split.split().rowCount() : weightFunc;
+        this.groupFunc = groupFunc;
+        this.pendingSplitAssignment =
+                createBatchFairSplitAssignment(splits, parallelism, 
this.weightFunc, groupFunc);
         this.numberOfPendingSplits = new AtomicInteger(splits.size());
     }
 
@@ -132,11 +190,37 @@ public class PreAssignSplitAssigner implements 
SplitAssigner {
      * this method only reload restore for batch execute, because in streaming 
mode, we need to
      * assign certain bucket to certain task.
      */
-    private static Map<Integer, LinkedList<FileStoreSourceSplit>> 
createBatchFairSplitAssignment(
-            Collection<FileStoreSourceSplit> splits, int numReaders) {
-        List<List<FileStoreSourceSplit>> assignmentList =
-                BinPacking.packForFixedBinNumber(
-                        splits, split -> split.split().rowCount(), numReaders);
+    public static Map<Integer, LinkedList<FileStoreSourceSplit>> 
createBatchFairSplitAssignment(
+            Collection<FileStoreSourceSplit> splits,
+            int numReaders,
+            SerializableFunction<FileStoreSourceSplit, Long> weightFunc) {
+        return createBatchFairSplitAssignment(splits, numReaders, weightFunc, 
null);
+    }
+
+    public static Map<Integer, LinkedList<FileStoreSourceSplit>> 
createBatchFairSplitAssignment(
+            Collection<FileStoreSourceSplit> splits,
+            int numReaders,
+            SerializableFunction<FileStoreSourceSplit, Long> weightFunc,
+            @Nullable SerializableFunction<FileStoreSourceSplit, ?> groupFunc) 
{
+        List<List<FileStoreSourceSplit>> assignmentList;
+        if (groupFunc == null) {
+            assignmentList = BinPacking.packForFixedBinNumber(splits, 
weightFunc, numReaders);
+        } else {
+            List<GroupedSplit> groupedSplits = groupSplits(splits, weightFunc, 
groupFunc);
+            List<List<GroupedSplit>> groupedAssignment =
+                    BinPacking.packForFixedBinNumber(
+                            groupedSplits, GroupedSplit::weight, numReaders);
+            assignmentList =
+                    groupedAssignment.stream()
+                            .map(
+                                    group ->
+                                            group.stream()
+                                                    .flatMap(
+                                                            groupedSplit ->
+                                                                    
groupedSplit.splits().stream())
+                                                    
.collect(Collectors.toList()))
+                            .collect(Collectors.toList());
+        }
         Map<Integer, LinkedList<FileStoreSourceSplit>> assignment = new 
HashMap<>();
         for (int i = 0; i < assignmentList.size(); i++) {
             assignment.put(i, new LinkedList<>(assignmentList.get(i)));
@@ -144,6 +228,43 @@ public class PreAssignSplitAssigner implements 
SplitAssigner {
         return assignment;
     }
 
+    private static List<GroupedSplit> groupSplits(
+            Collection<FileStoreSourceSplit> splits,
+            SerializableFunction<FileStoreSourceSplit, Long> weightFunc,
+            SerializableFunction<FileStoreSourceSplit, ?> groupFunc) {
+        Map<Object, List<FileStoreSourceSplit>> grouped = new HashMap<>();
+        for (FileStoreSourceSplit split : splits) {
+            grouped.computeIfAbsent(
+                            Objects.requireNonNull(groupFunc.apply(split)),
+                            ignored -> new ArrayList<>())
+                    .add(split);
+        }
+        return grouped.values().stream()
+                .map(
+                        group ->
+                                new GroupedSplit(
+                                        group, 
group.stream().mapToLong(weightFunc::apply).sum()))
+                .collect(Collectors.toList());
+    }
+
+    private static class GroupedSplit {
+        private final List<FileStoreSourceSplit> splits;
+        private final long weight;
+
+        private GroupedSplit(List<FileStoreSourceSplit> splits, long weight) {
+            this.splits = splits;
+            this.weight = weight;
+        }
+
+        private List<FileStoreSourceSplit> splits() {
+            return splits;
+        }
+
+        private long weight() {
+            return weight;
+        }
+    }
+
     @Override
     public Optional<Long> getNextSnapshotId(int subtask) {
         LinkedList<FileStoreSourceSplit> pendingSplits = 
pendingSplitAssignment.get(subtask);
@@ -160,7 +281,12 @@ public class PreAssignSplitAssigner implements 
SplitAssigner {
     public SplitAssigner ofDynamicPartitionPruning(
             Projection partitionRowProjection, DynamicFilteringData 
dynamicFilteringData) {
         return new PreAssignSplitAssigner(
-                splitBatchSize, parallelism, splits, partitionRowProjection, 
dynamicFilteringData);
+                splitBatchSize,
+                parallelism,
+                splits,
+                partitionRowProjection,
+                dynamicFilteringData,
+                weightFunc);
     }
 
     private static boolean filter(
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/CompactionBucketDistributionStrategyTest.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/CompactionBucketDistributionStrategyTest.java
new file mode 100644
index 0000000000..3ea093f345
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/CompactionBucketDistributionStrategyTest.java
@@ -0,0 +1,70 @@
+/*
+ * 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.flink.action;
+
+import org.apache.paimon.flink.FlinkConnectorOptions;
+import 
org.apache.paimon.flink.FlinkConnectorOptions.CompactionBucketDistributionStrategy;
+import org.apache.paimon.options.Options;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for compaction bucket distribution strategy resolution. */
+public class CompactionBucketDistributionStrategyTest {
+
+    @Test
+    public void testDefaultStrategyIsLinear() {
+        assertThat(
+                        Options.fromMap(java.util.Collections.emptyMap())
+                                
.get(FlinkConnectorOptions.COMPACTION_BUCKET_DISTRIBUTION_STRATEGY))
+                .isEqualTo(CompactionBucketDistributionStrategy.LINEAR);
+    }
+
+    @Test
+    public void testStrategyOptionParsing() {
+        
assertThat(strategy("linear")).isEqualTo(CompactionBucketDistributionStrategy.LINEAR);
+        assertThat(strategy("size-aware-batch"))
+                
.isEqualTo(CompactionBucketDistributionStrategy.SIZE_AWARE_BATCH);
+    }
+
+    @Test
+    public void testStrategyOnlyAppliesToBatchFullCompaction() {
+        Options options =
+                Options.fromMap(
+                        java.util.Collections.singletonMap(
+                                
FlinkConnectorOptions.COMPACTION_BUCKET_DISTRIBUTION_STRATEGY.key(),
+                                "size-aware-batch"));
+
+        assertThat(CompactAction.compactionBucketDistributionStrategy(options, 
true, false))
+                
.isEqualTo(CompactionBucketDistributionStrategy.SIZE_AWARE_BATCH);
+        assertThat(CompactAction.compactionBucketDistributionStrategy(options, 
false, false))
+                .isEqualTo(CompactionBucketDistributionStrategy.LINEAR);
+        assertThat(CompactAction.compactionBucketDistributionStrategy(options, 
true, true))
+                .isEqualTo(CompactionBucketDistributionStrategy.LINEAR);
+    }
+
+    private static CompactionBucketDistributionStrategy strategy(String value) 
{
+        return Options.fromMap(
+                        java.util.Collections.singletonMap(
+                                
FlinkConnectorOptions.COMPACTION_BUCKET_DISTRIBUTION_STRATEGY.key(),
+                                value))
+                
.get(FlinkConnectorOptions.COMPACTION_BUCKET_DISTRIBUTION_STRATEGY);
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/CompactorSourceBuilderTest.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/CompactorSourceBuilderTest.java
new file mode 100644
index 0000000000..4746db671b
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/CompactorSourceBuilderTest.java
@@ -0,0 +1,157 @@
+/*
+ * 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.flink.source;
+
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.flink.FlinkConnectorOptions;
+import 
org.apache.paimon.flink.FlinkConnectorOptions.CompactionBucketDistributionStrategy;
+import org.apache.paimon.flink.FlinkConnectorOptions.SplitAssignMode;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.table.source.DataSplit;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link CompactorSourceBuilder}. */
+public class CompactorSourceBuilderTest {
+
+    @Test
+    public void testSizeAwareBatchUsesSinkParallelismForSource() {
+        Options options = optionsWithParallelism(4, 16);
+
+        assertThat(
+                        CompactorSourceBuilder.sourceParallelism(
+                                options, 
CompactionBucketDistributionStrategy.SIZE_AWARE_BATCH))
+                .isEqualTo(16);
+    }
+
+    @Test
+    public void testNonSizeAwareBatchUsesScanParallelismForSource() {
+        Options options = optionsWithParallelism(4, 16);
+
+        assertThat(
+                        CompactorSourceBuilder.sourceParallelism(
+                                options, 
CompactionBucketDistributionStrategy.LINEAR))
+                .isEqualTo(4);
+    }
+
+    @Test
+    public void 
testSizeAwareBatchFallsBackToScanParallelismWithoutSinkParallelism() {
+        Map<String, String> map = new HashMap<>();
+        map.put(FlinkConnectorOptions.SCAN_PARALLELISM.key(), "4");
+        Options options = Options.fromMap(map);
+
+        assertThat(
+                        CompactorSourceBuilder.sourceParallelism(
+                                options, 
CompactionBucketDistributionStrategy.SIZE_AWARE_BATCH))
+                .isEqualTo(4);
+    }
+
+    @Test
+    public void testSizeAwareBatchRejectsPreemptiveSplitAssignment() {
+        assertThatThrownBy(
+                        () ->
+                                
CompactorSourceBuilder.validateBucketDistributionStrategy(
+                                        
CompactionBucketDistributionStrategy.SIZE_AWARE_BATCH,
+                                        SplitAssignMode.PREEMPTIVE))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("size-aware-batch requires")
+                .hasMessageContaining("scan.split-enumerator.mode=fair");
+    }
+
+    @Test
+    public void testSizeAwareBatchAllowsFairSplitAssignment() {
+        assertThatCode(
+                        () ->
+                                
CompactorSourceBuilder.validateBucketDistributionStrategy(
+                                        
CompactionBucketDistributionStrategy.SIZE_AWARE_BATCH,
+                                        SplitAssignMode.FAIR))
+                .doesNotThrowAnyException();
+    }
+
+    @Test
+    public void testBucketKeyUsesPartitionAndBucket() {
+        DataSplit split1 = dataSplit(1L, BinaryRow.EMPTY_ROW, 0, 
dataFile("file-1", 10L));
+        DataSplit split2 = dataSplit(2L, BinaryRow.EMPTY_ROW, 0, 
dataFile("file-2", 25L));
+        DataSplit split3 = dataSplit(3L, BinaryRow.EMPTY_ROW, 1, 
dataFile("file-3", 30L));
+
+        assertThat(CompactorSourceBuilder.bucketKey(split1))
+                .isEqualTo(CompactorSourceBuilder.bucketKey(split2));
+        assertThat(CompactorSourceBuilder.bucketKey(split1))
+                .isNotEqualTo(CompactorSourceBuilder.bucketKey(split3));
+    }
+
+    @Test
+    public void testBucketFileSizeUsesTotalDataFileSize() {
+        DataSplit split =
+                dataSplit(
+                        1L,
+                        BinaryRow.EMPTY_ROW,
+                        0,
+                        dataFile("file-1", 10L),
+                        dataFile("file-2", 25L));
+
+        
assertThat(CompactorSourceBuilder.bucketFileSize(split)).isEqualTo(35L);
+    }
+
+    private static Options optionsWithParallelism(int scanParallelism, int 
sinkParallelism) {
+        Map<String, String> map = new HashMap<>();
+        map.put(FlinkConnectorOptions.SCAN_PARALLELISM.key(), 
String.valueOf(scanParallelism));
+        map.put(FlinkConnectorOptions.SINK_PARALLELISM.key(), 
String.valueOf(sinkParallelism));
+        return Options.fromMap(map);
+    }
+
+    private static DataSplit dataSplit(
+            long snapshot, BinaryRow partition, int bucket, DataFileMeta... 
dataFiles) {
+        return DataSplit.builder()
+                .withSnapshot(snapshot)
+                .withPartition(partition)
+                .withBucket(bucket)
+                .withBucketPath("bucket-" + bucket)
+                .withDataFiles(Arrays.asList(dataFiles))
+                .build();
+    }
+
+    private static DataFileMeta dataFile(String fileName, long fileSize) {
+        return DataFileMeta.forAppend(
+                fileName,
+                fileSize,
+                1L,
+                null,
+                0L,
+                0L,
+                0L,
+                Collections.emptyList(),
+                null,
+                null,
+                null,
+                null,
+                null,
+                null);
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/CompactorSourceITCase.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/CompactorSourceITCase.java
index d5224670d4..8f7686dbff 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/CompactorSourceITCase.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/CompactorSourceITCase.java
@@ -23,6 +23,7 @@ import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.data.BinaryRowWriter;
 import org.apache.paimon.data.BinaryString;
 import org.apache.paimon.data.GenericRow;
+import 
org.apache.paimon.flink.FlinkConnectorOptions.CompactionBucketDistributionStrategy;
 import org.apache.paimon.flink.util.AbstractTestBase;
 import org.apache.paimon.fs.Path;
 import org.apache.paimon.fs.local.LocalFileIO;
@@ -42,6 +43,10 @@ import org.apache.paimon.types.RowType;
 
 import org.apache.flink.streaming.api.datastream.DataStreamSource;
 import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+import org.apache.flink.streaming.api.graph.StreamEdge;
+import org.apache.flink.streaming.api.graph.StreamGraph;
+import org.apache.flink.streaming.api.graph.StreamNode;
+import org.apache.flink.streaming.runtime.partitioner.ForwardPartitioner;
 import org.apache.flink.table.data.RowData;
 import org.apache.flink.util.CloseableIterator;
 import org.junit.jupiter.api.BeforeEach;
@@ -286,6 +291,54 @@ public class CompactorSourceITCase extends 
AbstractTestBase {
         it.close();
     }
 
+    @Test
+    public void 
testSizeAwareSourceAndFilterKeepSameParallelismWithoutShuffle() throws 
Exception {
+        FileStoreTable table =
+                createFileStoreTable()
+                        .copy(
+                                new HashMap<String, String>() {
+                                    {
+                                        put("sink.parallelism", "4");
+                                        put("scan.parallelism", "2");
+                                        put(
+                                                
"compaction.bucket-distribution-strategy",
+                                                "size-aware-batch");
+                                    }
+                                });
+        StreamWriteBuilder streamWriteBuilder =
+                table.newStreamWriteBuilder().withCommitUser(commitUser);
+        StreamTableWrite write = streamWriteBuilder.newWrite();
+        StreamTableCommit commit = streamWriteBuilder.newCommit();
+        write.write(rowData(1, 1510, BinaryString.fromString("20221208"), 15));
+        commit.commit(0, write.prepareCommit(true, 0));
+
+        StreamExecutionEnvironment env =
+                streamExecutionEnvironmentBuilder().streamingMode().build();
+        new CompactorSourceBuilder("test", table)
+                .withContinuousMode(false)
+                .withBucketDistributionStrategy(
+                        CompactionBucketDistributionStrategy.SIZE_AWARE_BATCH)
+                .withPartitionIdleTime(Duration.ofMillis(1))
+                .withEnv(env)
+                .build();
+
+        StreamGraph streamGraph = env.getStreamGraph(false);
+        StreamNode sourceNode = findStreamNode(streamGraph, 
"test-compact-source");
+        StreamNode filterNode = findStreamNode(streamGraph, "Filter");
+        assertThat(sourceNode.getParallelism()).isEqualTo(4);
+        assertThat(filterNode.getParallelism()).isEqualTo(4);
+
+        StreamEdge sourceToFilterEdge =
+                sourceNode.getOutEdges().stream()
+                        .filter(edge -> edge.getTargetId() == 
filterNode.getId())
+                        .findFirst()
+                        .orElseThrow(() -> new AssertionError("Source to 
filter edge not found"));
+        
assertThat(sourceToFilterEdge.getPartitioner()).isInstanceOf(ForwardPartitioner.class);
+
+        write.close();
+        commit.close();
+    }
+
     @ParameterizedTest(name = "defaultOptions = {0}")
     @ValueSource(booleans = {true, false})
     public void testHistoryPartitionRead(boolean defaultOptions) throws 
Exception {
@@ -334,6 +387,14 @@ public class CompactorSourceITCase extends 
AbstractTestBase {
         it.close();
     }
 
+    private StreamNode findStreamNode(StreamGraph streamGraph, String 
operatorNameContains) {
+        return streamGraph.getStreamNodes().stream()
+                .filter(node -> 
node.getOperatorName().contains(operatorNameContains))
+                .findFirst()
+                .orElseThrow(
+                        () -> new AssertionError("Stream node not found: " + 
operatorNameContains));
+    }
+
     private String toString(RowData rowData) {
         int numFiles;
         try {
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/FairAssignModeTest.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/FairAssignModeTest.java
index 8a7b36b27d..1f4fcf465d 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/FairAssignModeTest.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/FairAssignModeTest.java
@@ -18,6 +18,8 @@
 
 package org.apache.paimon.flink.source;
 
+import org.apache.paimon.table.source.DataSplit;
+
 import 
org.apache.flink.connector.testutils.source.reader.TestingSplitEnumeratorContext;
 import org.junit.jupiter.api.Test;
 
@@ -64,6 +66,90 @@ public class FairAssignModeTest extends 
StaticFileStoreSplitEnumeratorTestBase {
                 .containsExactly(splits.get(0), splits.get(2));
     }
 
+    @Test
+    public void testSplitAllocationWithCustomWeight() {
+        final TestingSplitEnumeratorContext<FileStoreSourceSplit> context =
+                getSplitEnumeratorContext(2);
+
+        List<FileStoreSourceSplit> splits = new ArrayList<>();
+        for (int i = 1; i <= 4; i++) {
+            splits.add(createSnapshotSplit(i, 0, Collections.emptyList()));
+        }
+        StaticFileStoreSplitEnumerator enumerator =
+                new StaticFileStoreSplitEnumerator(
+                        context,
+                        null,
+                        StaticFileStoreSource.createSplitAssigner(
+                                context,
+                                10,
+                                SplitAssignMode.FAIR,
+                                splits,
+                                split -> {
+                                    int snapshotId = (int) ((DataSplit) 
split.split()).snapshotId();
+                                    return snapshotId == 1 || snapshotId == 2 
? 100L : 1L;
+                                }));
+
+        enumerator.handleSplitRequest(0, "test-host");
+        enumerator.handleSplitRequest(1, "test-host");
+        Map<Integer, SplitAssignmentState<FileStoreSourceSplit>> assignments =
+                context.getSplitAssignments();
+
+        assertThat(assignments).containsOnlyKeys(0, 1);
+        
assertThat(totalWeight(assignments.get(0).getAssignedSplits())).isEqualTo(101L);
+        
assertThat(totalWeight(assignments.get(1).getAssignedSplits())).isEqualTo(101L);
+        assertThat(assignments.get(0).getAssignedSplits()).hasSize(2);
+        assertThat(assignments.get(1).getAssignedSplits()).hasSize(2);
+    }
+
+    @Test
+    public void testGroupedSplitAllocationBalancesByBucketTotalWeight() {
+        final TestingSplitEnumeratorContext<FileStoreSourceSplit> context =
+                getSplitEnumeratorContext(2);
+
+        List<FileStoreSourceSplit> splits = new ArrayList<>();
+        // Bucket 0 has two splits, each with weight 50. Bucket 1 has one 
split with weight 100.
+        // The assigner should first group by bucket, calculate bucket-level 
total weight, and then
+        // bin-pack the two bucket groups evenly across readers. This also 
guarantees all splits of
+        // the same bucket go to the same downstream writer.
+        splits.add(createSnapshotSplit(1, 0, Collections.emptyList()));
+        splits.add(createSnapshotSplit(2, 0, Collections.emptyList()));
+        splits.add(createSnapshotSplit(3, 1, Collections.emptyList()));
+
+        StaticFileStoreSplitEnumerator enumerator =
+                new StaticFileStoreSplitEnumerator(
+                        context,
+                        null,
+                        StaticFileStoreSource.createSplitAssigner(
+                                context,
+                                10,
+                                SplitAssignMode.FAIR,
+                                splits,
+                                split -> ((DataSplit) split.split()).bucket() 
== 0 ? 50L : 100L,
+                                split -> ((DataSplit) 
split.split()).bucket()));
+
+        enumerator.handleSplitRequest(0, "test-host");
+        enumerator.handleSplitRequest(1, "test-host");
+        Map<Integer, SplitAssignmentState<FileStoreSourceSplit>> assignments =
+                context.getSplitAssignments();
+
+        assertThat(assignments).containsOnlyKeys(0, 1);
+        assertThat(assignments.values())
+                .anySatisfy(
+                        assignment ->
+                                assertThat(assignment.getAssignedSplits())
+                                        
.containsExactlyInAnyOrder(splits.get(0), splits.get(1)));
+        assertThat(assignments.values())
+                .anySatisfy(
+                        assignment ->
+                                assertThat(assignment.getAssignedSplits())
+                                        .containsExactly(splits.get(2)));
+        assertThat(assignments.values())
+                .allSatisfy(
+                        assignment ->
+                                
assertThat(groupedTotalWeight(assignment.getAssignedSplits()))
+                                        .isEqualTo(100L));
+    }
+
     @Test
     public void testSplitBatch() {
         final TestingSplitEnumeratorContext<FileStoreSourceSplit> context =
@@ -146,6 +232,22 @@ public class FairAssignModeTest extends 
StaticFileStoreSplitEnumeratorTestBase {
         assertThat(assignments.get(2).getAssignedSplits()).isEmpty();
     }
 
+    private long totalWeight(List<FileStoreSourceSplit> splits) {
+        return splits.stream()
+                .mapToLong(
+                        split -> {
+                            int snapshotId = (int) ((DataSplit) 
split.split()).snapshotId();
+                            return snapshotId == 1 || snapshotId == 2 ? 100L : 
1L;
+                        })
+                .sum();
+    }
+
+    private long groupedTotalWeight(List<FileStoreSourceSplit> splits) {
+        return splits.stream()
+                .mapToLong(split -> ((DataSplit) split.split()).bucket() == 0 
? 50L : 100L)
+                .sum();
+    }
+
     @Override
     protected SplitAssignMode splitAssignMode() {
         return SplitAssignMode.FAIR;

Reply via email to