diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/FlinkConnectorOptions.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/FlinkConnectorOptions.java index bc7529fe23c..fd4fd4d8022 100644 --- a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/FlinkConnectorOptions.java +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/FlinkConnectorOptions.java @@ -232,6 +232,7 @@ public class FlinkConnectorOptions { .defaultValue(DistributionMode.AUTO) .withDescription( "Defines the distribution mode for writing data to the sink. Available options are:\n" + + "- BUCKET_LOAD_BALANCE: Shuffle data by bucket key with even load distribution across all downstream subtasks using LCM-based logical slot assignment. Records with the same bucket key always route to the same subtask. Requires 'bucket.key' to be defined. Suitable for tables where intra-bucket ordering is not required but even load distribution matters. Not supported for tables with the aggregation merge engine, because Undo Recovery requires each bucket to be written by exactly one subtask.\n" + "- AUTO: Automatically chooses the best mode based on the table type. " + "Uses BUCKET mode for Primary Key Tables and Log table with bucket key to maximize throughput, " + "and NONE for Log Tables without bucket key.\n" diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/shuffle/BucketLoadBalanceRouter.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/shuffle/BucketLoadBalanceRouter.java new file mode 100644 index 00000000000..b11389ec232 --- /dev/null +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/shuffle/BucketLoadBalanceRouter.java @@ -0,0 +1,134 @@ +/* + * 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.fluss.flink.shuffle; + +import org.apache.fluss.annotation.Internal; +import org.apache.fluss.bucketing.BucketingFunction; +import org.apache.fluss.row.InternalRow; +import org.apache.fluss.row.encode.KeyEncoder; +import org.apache.fluss.utils.MathUtils; + +import javax.annotation.Nullable; + +import java.util.Arrays; + +/** + * Shuffle data by bucket key with even load distribution across all downstream subtasks. + * + *
Two callers share this router and must stay consistent: + * + *
Routing algorithm: first compute the bucket ID via {@code BucketingFunction}, then select the + * concrete subtask within the bucket's assigned slot range using the murmur hash of the hash key. + * When the bucket count is evenly divisible by the channel count the hash is skipped entirely and + * the result is simply {@code bucketId % numChannels}, matching the behaviour of the BUCKET + * distribution mode. + */ +@Internal +public final class BucketLoadBalanceRouter { + + private final KeyEncoder bucketKeyEncoder; + private final @Nullable KeyEncoder hashKeyEncoder; + private final BucketingFunction bucketingFunction; + private final int numBuckets; + + /** + * Creates a router for the BUCKET_LOAD_BALANCE distribution strategy. + * + * @param bucketKeyEncoder encoder for the bucket key (determines bucket ID) + * @param hashKeyEncoder encoder for the intra-bucket slot hash; may be {@code null} when the + * caller wants to use the bucket key itself as the hash key (e.g. sink shuffle) + * @param bucketingFunction the bucketing function for computing the bucket ID + * @param numBuckets total number of buckets + */ + public BucketLoadBalanceRouter( + KeyEncoder bucketKeyEncoder, + @Nullable KeyEncoder hashKeyEncoder, + BucketingFunction bucketingFunction, + int numBuckets) { + this.bucketKeyEncoder = bucketKeyEncoder; + this.hashKeyEncoder = hashKeyEncoder; + this.bucketingFunction = bucketingFunction; + this.numBuckets = numBuckets; + } + + /** + * Routes a record to a downstream channel using bucket-ID + LCM-based slot assignment. + * + *
The record is first encoded by {@code bucketKeyEncoder} to obtain the bucket key. When + * {@link #isEvenlyDivisible} holds, the result is simply {@code bucketId % numChannels} and the + * hash key is never encoded. Otherwise the intra-bucket slot hash is computed from the hash key + * (via {@code hashKeyEncoder} when present, otherwise the bucket key is reused). + * + * @param row the record to route + * @param numChannels number of downstream channels (subtask parallelism) + * @return channel index in {@code [0, numChannels)} + */ + public int route(InternalRow row, int numChannels) { + byte[] bucketKeyBytes = bucketKeyEncoder.encodeKey(row); + int bucketId = bucketingFunction.bucketing(bucketKeyBytes, numBuckets); + + // Fast-path: when bucket count is an exact multiple of channel count, every bucket + // maps to a single channel and no hash is needed. + if (isEvenlyDivisible(numBuckets, numChannels)) { + return bucketId % numChannels; + } + + byte[] hashKeyBytes = + hashKeyEncoder != null ? hashKeyEncoder.encodeKey(row) : bucketKeyBytes; + + int recordHash = MathUtils.murmurHash(Arrays.hashCode(hashKeyBytes)); + return selectSlot(bucketId, numBuckets, numChannels, recordHash); + } + + private static int selectSlot(int bucketId, int numBuckets, int numChannels, int recordHash) { + if (numChannels > numBuckets && numChannels % numBuckets == 0) { + int candidateCount = numChannels / numBuckets; + return (recordHash % candidateCount) * numBuckets + bucketId; + } + + int gcd = greatestCommonDivisor(numBuckets, numChannels); + int slotsPerBucket = numChannels / gcd; + int slotsPerSubtask = numBuckets / gcd; + int slotWithinBucket = recordHash % slotsPerBucket; + long logicalSlot = (long) bucketId * slotsPerBucket + slotWithinBucket; + return (int) (logicalSlot / slotsPerSubtask); + } + + private static int greatestCommonDivisor(int first, int second) { + while (second != 0) { + int remainder = first % second; + first = second; + second = remainder; + } + return first; + } + + private static boolean isEvenlyDivisible(int numBuckets, int numChannels) { + return numBuckets >= numChannels && numBuckets % numChannels == 0; + } +} diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/sink/BucketLoadBalanceChannelComputer.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/sink/BucketLoadBalanceChannelComputer.java new file mode 100644 index 00000000000..dc9386aab4c --- /dev/null +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/sink/BucketLoadBalanceChannelComputer.java @@ -0,0 +1,116 @@ +/* + * 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.fluss.flink.sink; + +import org.apache.fluss.annotation.Internal; +import org.apache.fluss.bucketing.BucketingFunction; +import org.apache.fluss.exception.FlussRuntimeException; +import org.apache.fluss.flink.row.RowWithOp; +import org.apache.fluss.flink.shuffle.BucketLoadBalanceRouter; +import org.apache.fluss.flink.sink.serializer.FlussSerializationSchema; +import org.apache.fluss.flink.sink.serializer.SerializerInitContextImpl; +import org.apache.fluss.metadata.DataLakeFormat; +import org.apache.fluss.row.InternalRow; +import org.apache.fluss.row.encode.KeyEncoder; +import org.apache.fluss.types.RowType; + +import javax.annotation.Nullable; + +import java.util.List; + +/** + * {@link ChannelComputer} for {@link + * org.apache.fluss.flink.sink.shuffle.DistributionMode#BUCKET_LOAD_BALANCE}. + * + *
Distributes records by bucket key with even load across all downstream channels. Unlike {@link + * FlinkRowDataChannelComputer} (BUCKET mode) which maps each bucket to a single channel, this + * computer uses an LCM-based logical slot assignment so that every channel receives traffic from + * every bucket. + * + *
The routing algorithm is shared with the source-side lookup-join partitioner ({@code + * FlussLookupInputPartitioner}); see {@link BucketLoadBalanceRouter}. + * + *
Records with the same bucket key always route to the same channel.
+ *
+ * @param Note: This mode has overhead costs including data statistics collection and additional
* shuffle operations.
*/
- PARTITION_DYNAMIC
+ PARTITION_DYNAMIC,
+
+ /**
+ * Shuffle data by bucket key with even load distribution across all downstream subtasks.
+ *
+ * Unlike {@link #BUCKET} which maps each bucket to exactly one subtask (potentially leaving
+ * some subtasks idle), this mode uses an LCM-based logical slot assignment to ensure every
+ * subtask receives data.
+ *
+ * Routing algorithm: first compute bucket ID via {@code BucketingFunction}, then select the
+ * concrete subtask within the bucket's assigned slot range using the bucket key's murmur hash.
+ * Records with the same bucket key always route to the same subtask, so merge semantics are
+ * preserved at runtime. However, one bucket may be written by several subtasks when the bucket
+ * count and the sink parallelism do not evenly divide each other, which breaks the
+ * one-writer-per-bucket assumption of Undo Recovery; this mode is therefore rejected for tables
+ * using the aggregation merge engine.
+ *
+ * Characteristics:
+ *
+ * Requires 'bucket.key' to be defined. Suitable for tables where intra-bucket ordering is
+ * not required but even load distribution matters.
+ */
+ BUCKET_LOAD_BALANCE
}
diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/lookup/FlussLookupInputPartitioner.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/lookup/FlussLookupInputPartitioner.java
index 7aef52476a2..a90d2f4ef6a 100644
--- a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/lookup/FlussLookupInputPartitioner.java
+++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/lookup/FlussLookupInputPartitioner.java
@@ -20,12 +20,12 @@
import org.apache.fluss.bucketing.BucketingFunction;
import org.apache.fluss.flink.adapter.SupportsLookupCustomShuffleAdapter.InputDataPartitionerAdapter;
import org.apache.fluss.flink.row.FlinkAsFlussRow;
+import org.apache.fluss.flink.shuffle.BucketLoadBalanceRouter;
import org.apache.fluss.flink.utils.FlinkConversions;
import org.apache.fluss.metadata.DataLakeFormat;
import org.apache.fluss.row.InternalRow;
import org.apache.fluss.row.encode.CompactedKeyEncoder;
import org.apache.fluss.row.encode.KeyEncoder;
-import org.apache.fluss.utils.MathUtils;
import org.apache.flink.table.data.RowData;
import org.apache.flink.table.types.logical.RowType;
@@ -33,7 +33,6 @@
import javax.annotation.Nullable;
import java.util.ArrayList;
-import java.util.Arrays;
import java.util.List;
import static org.apache.fluss.utils.Preconditions.checkArgument;
@@ -44,11 +43,9 @@
* bucketing used by {@code PrimaryKeyLookuper}/{@code PrefixKeyLookuper} (bucket-key encoding +
* {@link BucketingFunction}).
*
- * Partitioned and non-partitioned tables use the same strategy. When the bucket and subtask
- * counts do not evenly divide each other, weighted logical slots balance the expected load across
- * subtasks. This keeps every lookup key on a stable subtask while bounding each bucket's RPC
- * fan-out. Existing bucket-affinity mappings are preserved when either count is an exact multiple
- * of the other.
+ * The routing delegates to {@link BucketLoadBalanceRouter} which uses LCM-based logical slot
+ * assignment to balance load evenly across all subtasks while keeping every lookup key on a stable
+ * subtask.
*/
public class FlussLookupInputPartitioner implements InputDataPartitionerAdapter {
@@ -63,9 +60,7 @@ public class FlussLookupInputPartitioner implements InputDataPartitionerAdapter
@Nullable private final DataLakeFormat lakeFormat;
private final int numBuckets;
- private transient KeyEncoder bucketKeyEncoder;
- private transient KeyEncoder lookupKeyEncoder;
- private transient BucketingFunction bucketingFunction;
+ private transient BucketLoadBalanceRouter bucketLoadBalanceRouter;
private transient FlinkAsFlussRow reuseRow;
/**
@@ -99,16 +94,17 @@ public FlussLookupInputPartitioner(
}
private void ensureInitialized() {
- if (bucketKeyEncoder == null) {
+ if (bucketLoadBalanceRouter == null) {
org.apache.fluss.types.RowType flussKeyType =
FlinkConversions.toFlussRowType(keyFlinkRowType);
// bucketing uses the bucket-key encoder consistent with the client's bucket routing
- bucketKeyEncoder =
- KeyEncoder.ofBucketKeyEncoder(flussKeyType, bucketKeyNames, lakeFormat);
- lookupKeyEncoder =
- CompactedKeyEncoder.createKeyEncoder(
- flussKeyType, keyFlinkRowType.getFieldNames());
- bucketingFunction = BucketingFunction.of(lakeFormat);
+ bucketLoadBalanceRouter =
+ new BucketLoadBalanceRouter(
+ KeyEncoder.ofBucketKeyEncoder(flussKeyType, bucketKeyNames, lakeFormat),
+ CompactedKeyEncoder.createKeyEncoder(
+ flussKeyType, keyFlinkRowType.getFieldNames()),
+ BucketingFunction.of(lakeFormat),
+ numBuckets);
reuseRow = new FlinkAsFlussRow();
}
}
@@ -127,48 +123,6 @@ public int partition(RowData joinKeys, int numPartitions) {
// normalize the projected join keys into the Fluss key order
RowData normalizedKey = normalizer.normalizeLookupKey(joinKeys);
InternalRow flussKeyRow = reuseRow.replace(normalizedKey);
- byte[] bucketKeyBytes = bucketKeyEncoder.encodeKey(flussKeyRow);
- // BucketingFunction always returns a non-negative bucket id.
- int bucketId = bucketingFunction.bucketing(bucketKeyBytes, numBuckets);
- if (numBuckets >= numPartitions && numBuckets % numPartitions == 0) {
- return bucketId % numPartitions;
- }
-
- byte[] lookupKeyBytes = lookupKeyEncoder.encodeKey(flussKeyRow);
- // Do not derive this hash from the bucket hash. The low bits of that hash determine the
- // bucket id, so reusing it can make some logical slots unreachable.
- int lookupKeyHash = MathUtils.murmurHash(Arrays.hashCode(lookupKeyBytes));
- if (numPartitions > numBuckets && numPartitions % numBuckets == 0) {
- // Preserve the original disjoint round-robin assignment when every bucket owns the
- // same number of subtasks.
- int candidateCount = numPartitions / numBuckets;
- return (lookupKeyHash % candidateCount) * numBuckets + bucketId;
- }
-
- // Represent the assignment with LCM(numBuckets, numPartitions) logical slots without
- // materializing them. Each bucket owns numPartitions / gcd consecutive slots and each
- // subtask owns numBuckets / gcd consecutive slots. A uniform hash within a bucket therefore
- // gives every subtask the same expected number of slots, while a bucket can only fan out to
- // the subtasks whose slot ranges overlap its own range.
- int gcd = greatestCommonDivisor(numBuckets, numPartitions);
- int slotsPerBucket = numPartitions / gcd;
- int slotsPerSubtask = numBuckets / gcd;
- int slotWithinBucket = lookupKeyHash % slotsPerBucket;
- long logicalSlot = (long) bucketId * slotsPerBucket + slotWithinBucket;
- return (int) (logicalSlot / slotsPerSubtask);
- }
-
- @Override
- public boolean isDeterministic() {
- return true;
- }
-
- private static int greatestCommonDivisor(int first, int second) {
- while (second != 0) {
- int remainder = first % second;
- first = second;
- second = remainder;
- }
- return first;
+ return bucketLoadBalanceRouter.route(flussKeyRow, numPartitions);
}
}
diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/utils/FlinkConnectorOptionsUtils.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/utils/FlinkConnectorOptionsUtils.java
index 48c8a739c6b..520eeb03875 100644
--- a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/utils/FlinkConnectorOptionsUtils.java
+++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/utils/FlinkConnectorOptionsUtils.java
@@ -83,6 +83,10 @@ public static void validateTableSourceOptions(ReadableConfig tableOptions) {
* Additionally, the AGGREGATION merge engine enables Undo Recovery on the sink, whose
+ * one-writer-per-bucket assumption further excludes BUCKET_LOAD_BALANCE; see {@link
+ * #validateDistributionModeForUndoRecovery}.
+ *
* @param mergeEngineType the merge engine type (can be null for non-merge-engine tables)
* @param distributionMode the distribution mode configured for the sink
* @throws IllegalArgumentException if distribution mode is incompatible with merge engine
@@ -91,15 +95,54 @@ public static void validateDistributionModeForMergeEngine(
@Nullable MergeEngineType mergeEngineType, DistributionMode distributionMode) {
if (mergeEngineType != null
&& distributionMode != DistributionMode.BUCKET
+ && distributionMode != DistributionMode.BUCKET_LOAD_BALANCE
&& distributionMode != DistributionMode.AUTO) {
throw new IllegalArgumentException(
String.format(
"For primary key tables with merge engine ('%s'), "
- + "'sink.distribution-mode' must be 'bucket' or 'auto' (default). "
- + "Disabling shuffle breaks merge semantics because records with the same key "
+ + "'sink.distribution-mode' must be 'bucket', 'bucket_load_balance' or 'auto' (default). "
+ + "Disabling keyed shuffle breaks merge semantics because records with the same key "
+ "must be processed by the same task. Current mode: %s",
mergeEngineType, distributionMode));
}
+ // The AGGREGATION merge engine enables Undo Recovery on the sink; the
+ // one-writer-per-bucket constraint of Undo Recovery further excludes BUCKET_LOAD_BALANCE.
+ validateDistributionModeForUndoRecovery(
+ mergeEngineType == MergeEngineType.AGGREGATION, distributionMode);
+ }
+
+ /**
+ * Validates that the distribution mode is safe for the sink's Undo Recovery.
+ *
+ * Undo Recovery, which is enabled automatically for aggregation tables, assumes each bucket
+ * is written by exactly one sink subtask: the writer state tracks the last written offset per
+ * bucket and fails on conflicting offsets during recovery. BUCKET_LOAD_BALANCE and NONE may fan
+ * one bucket out to several subtasks and break that assumption.
+ *
+ * This is the single guard shared by the entry-point validation ({@link
+ * #validateDistributionModeForMergeEngine}) and the sink builder's defense-in-depth check, so
+ * the two can never drift apart.
+ *
+ * @param enableUndoRecovery whether Undo Recovery is enabled for the sink
+ * @param distributionMode the distribution mode configured for the sink
+ * @throws IllegalArgumentException if the distribution mode may fan one bucket out to several
+ * subtasks while Undo Recovery is enabled
+ */
+ public static void validateDistributionModeForUndoRecovery(
+ boolean enableUndoRecovery, DistributionMode distributionMode) {
+ if (enableUndoRecovery
+ && distributionMode != DistributionMode.AUTO
+ && distributionMode != DistributionMode.BUCKET) {
+ throw new IllegalArgumentException(
+ String.format(
+ "'sink.distribution-mode' = '%s' is not supported when Undo Recovery "
+ + "is enabled (automatic for the aggregation merge engine): "
+ + "Undo Recovery assumes each bucket is written by exactly one "
+ + "sink subtask, but this distribution mode may fan one bucket "
+ + "out to several subtasks. Please use 'bucket' or 'auto' "
+ + "(default) instead.",
+ distributionMode));
+ }
}
public static StartupOptions getStartupOptions(ReadableConfig tableOptions, ZoneId timeZone) {
diff --git a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/catalog/FlinkTableFactoryTest.java b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/catalog/FlinkTableFactoryTest.java
index 37575097c24..54d3fa49273 100644
--- a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/catalog/FlinkTableFactoryTest.java
+++ b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/catalog/FlinkTableFactoryTest.java
@@ -21,6 +21,7 @@
import org.apache.fluss.flink.FlinkConnectorOptions;
import org.apache.fluss.flink.adapter.CatalogTableAdapter;
import org.apache.fluss.flink.sink.FlinkTableSink;
+import org.apache.fluss.flink.sink.shuffle.DistributionMode;
import org.apache.fluss.flink.source.BinlogFlinkTableSource;
import org.apache.fluss.flink.source.ChangelogFlinkTableSource;
import org.apache.fluss.flink.source.FlinkTableSource;
@@ -321,6 +322,61 @@ void testSink() {
createTableSink(schema, properties);
}
+ @Test
+ void testSinkDistributionModeForMergeEngine() {
+ ResolvedSchema schema = createBasicSchema();
+
+ // keyed modes are accepted for every merge engine
+ for (String mergeEngine : new String[] {"first_row", "versioned", "aggregation"}) {
+ for (String mode : new String[] {"auto", "bucket"}) {
+ Map