diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/OptionsResolver.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/OptionsResolver.java
index eb891b0a51fa1..07f31daa86162 100644
--- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/OptionsResolver.java
+++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/OptionsResolver.java
@@ -166,13 +166,13 @@ public static boolean isCowTable(Configuration conf) {
/**
* Returns the configured table storage layout.
*
- *
Insert and bulk insert operations preserve duplicate record keys and therefore default to
- * the regular storage layout. Other operations use Flink's LSM tree default.
+ *
Insert operations preserve duplicate record keys and therefore default to the regular
+ * storage layout. Other operations use Flink's LSM tree default.
*/
public static HoodieTableConfig.TableStorageLayout getTableStorageLayout(Configuration conf) {
return HoodieTableConfig.TableStorageLayout.fromConfigValue(conf.getString(
HoodieTableConfig.TABLE_STORAGE_LAYOUT.key(),
- (isInsertOperation(conf) || isBulkInsertOperation(conf))
+ isInsertOperation(conf)
? HoodieTableConfig.TableStorageLayout.DEFAULT.configValue()
: HoodieTableConfig.TableStorageLayout.LSM_TREE.configValue()));
}
diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bucket/BucketBulkInsertWriterHelper.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bucket/BucketBulkInsertWriterHelper.java
index 358686f9f6ff8..4dc233bb0ed17 100644
--- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bucket/BucketBulkInsertWriterHelper.java
+++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bucket/BucketBulkInsertWriterHelper.java
@@ -47,7 +47,7 @@
public class BucketBulkInsertWriterHelper extends BulkInsertWriterHelper {
public static final String FILE_GROUP_META_FIELD = "_fg";
- private final int recordArity;
+ protected final int recordArity;
private String lastFileId; // for efficient code path
@@ -63,12 +63,7 @@ public void write(RowData tuple) throws IOException {
String recordKey = keyGen.getRecordKey(record);
String partitionPath = keyGen.getPartitionPath(record);
String fileId = tuple.getString(0).toString();
- if ((lastFileId == null) || !lastFileId.equals(fileId)) {
- log.info("Creating new file for partition path {}", partitionPath);
- handle = getRowCreateHandle(partitionPath, fileId);
- lastFileId = fileId;
- }
- handle.write(recordKey, partitionPath, record);
+ writeRecord(recordKey, partitionPath, fileId, record);
} catch (Throwable throwable) {
IOException ioException = new IOException("Exception happened when bulk insert.", throwable);
log.error("Global error thrown while trying to write records in HoodieRowDataCreateHandle", ioException);
@@ -76,6 +71,19 @@ public void write(RowData tuple) throws IOException {
}
}
+ protected void writeRecord(
+ String recordKey,
+ String partitionPath,
+ String fileId,
+ RowData record) throws IOException {
+ if ((lastFileId == null) || !lastFileId.equals(fileId)) {
+ log.info("Creating new file for partition path {}", partitionPath);
+ handle = getRowCreateHandle(partitionPath, fileId);
+ lastFileId = fileId;
+ }
+ handle.write(recordKey, partitionPath, record);
+ }
+
private HoodieRowDataCreateHandle getRowCreateHandle(String partitionPath, String fileId) throws IOException {
if (!handles.containsKey(fileId)) { // if there is no handle corresponding to the fileId
if (this.isInputSorted) {
@@ -93,19 +101,30 @@ public static SortOperatorGen getFileIdSorterGen(RowType rowType) {
return new SortOperatorGen(rowType, new String[] {FILE_GROUP_META_FIELD});
}
- private static String getFileId(Map bucketIdToFileId, RowDataKeyGen keyGen, RowData record, List indexKeyFields,
- NumBucketsFunction numBucketsFunction, boolean needFixedFileIdSuffix) {
- String recordKey = keyGen.getRecordKey(record);
- String partition = keyGen.getPartitionPath(record);
- final int numBuckets = numBucketsFunction.getNumBuckets(partition);
+ static String getFileId(
+ Map bucketIdToFileId,
+ String recordKey,
+ String partitionPath,
+ List indexKeyFields,
+ NumBucketsFunction numBucketsFunction,
+ boolean needFixedFileIdSuffix) {
+ final int numBuckets = numBucketsFunction.getNumBuckets(partitionPath);
final int bucketNum = BucketIdentifier.getBucketId(recordKey, indexKeyFields, numBuckets);
- String bucketId = partition + bucketNum;
+ String bucketId = partitionPath + bucketNum;
return bucketIdToFileId.computeIfAbsent(bucketId, k -> needFixedFileIdSuffix ? BucketIdentifier.newBucketFileIdForNBCC(bucketNum) : BucketIdentifier.newBucketFileIdPrefix(bucketNum));
}
public static RowData rowWithFileId(Map bucketIdToFileId, RowDataKeyGen keyGen, RowData record, List indexKeyFields,
NumBucketsFunction numBucketsFunction, boolean needFixedFileIdSuffix) {
- final String fileId = getFileId(bucketIdToFileId, keyGen, record, indexKeyFields, numBucketsFunction, needFixedFileIdSuffix);
+ String recordKey = keyGen.getRecordKey(record);
+ String partitionPath = keyGen.getPartitionPath(record);
+ final String fileId = getFileId(
+ bucketIdToFileId,
+ recordKey,
+ partitionPath,
+ indexKeyFields,
+ numBucketsFunction,
+ needFixedFileIdSuffix);
return GenericRowData.of(StringData.fromString(fileId), record);
}
diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bucket/LsmBucketBulkInsertWriterHelper.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bucket/LsmBucketBulkInsertWriterHelper.java
new file mode 100644
index 0000000000000..23e0b60137ef4
--- /dev/null
+++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bucket/LsmBucketBulkInsertWriterHelper.java
@@ -0,0 +1,119 @@
+/*
+ * 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.hudi.sink.bucket;
+
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.index.bucket.partition.NumBucketsFunction;
+import org.apache.hudi.sink.bulk.RowDataKeyGen;
+import org.apache.hudi.sink.bulk.sort.SortOperatorGen;
+import org.apache.hudi.table.HoodieTable;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.data.GenericRowData;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.StringData;
+import org.apache.flink.table.types.logical.LogicalType;
+import org.apache.flink.table.types.logical.RowType;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Bucket-index bulk-insert writer helper for LSM input rows.
+ *
+ * The input row contains file ID, encoded record key, and the original table row.
+ */
+public class LsmBucketBulkInsertWriterHelper extends BucketBulkInsertWriterHelper {
+
+ private static final String RECORD_KEY_FIELD = "_record_key";
+ private static final String RECORD_FIELD = "record";
+
+ public LsmBucketBulkInsertWriterHelper(
+ Configuration conf,
+ HoodieTable, ?, ?, ?> hoodieTable,
+ HoodieWriteConfig writeConfig,
+ String instantTime,
+ int taskPartitionId,
+ long taskId,
+ long taskEpochId,
+ RowType rowType) {
+ super(conf, hoodieTable, writeConfig, instantTime, taskPartitionId, taskId, taskEpochId, rowType);
+ }
+
+ @Override
+ public void write(RowData sortRow) throws IOException {
+ String fileId = sortRow.getString(0).toString();
+ String recordKey = sortRow.getString(1).toString();
+ RowData record = sortRow.getRow(2, recordArity);
+ String partitionPath = keyGen.getPartitionPath(record);
+ writeRecord(recordKey, partitionPath, fileId, record);
+ }
+
+ public static RowData rowWithFileIdAndKey(
+ Map bucketIdToFileId,
+ RowDataKeyGen keyGen,
+ RowData record,
+ List indexKeyFields,
+ NumBucketsFunction numBucketsFunction,
+ boolean needFixedFileIdSuffix) {
+ String recordKey = keyGen.getRecordKey(record);
+ String partitionPath = keyGen.getPartitionPath(record);
+ String fileId = getFileId(
+ bucketIdToFileId,
+ recordKey,
+ partitionPath,
+ indexKeyFields,
+ numBucketsFunction,
+ needFixedFileIdSuffix);
+ return GenericRowData.of(
+ StringData.fromString(fileId),
+ StringData.fromString(recordKey),
+ record);
+ }
+
+ /**
+ * Returns the internal row type used to sort LSM bucket bulk-insert records by file ID and
+ * record key.
+ *
+ * The fields are ordered as file ID, encoded record key, and original table row.
+ */
+ public static RowType rowTypeWithFileIdAndKey(RowType rowType) {
+ LogicalType[] types = new LogicalType[] {
+ DataTypes.STRING().getLogicalType(),
+ DataTypes.STRING().getLogicalType(),
+ rowType
+ };
+ String[] names =
+ new String[] {FILE_GROUP_META_FIELD, RECORD_KEY_FIELD, RECORD_FIELD};
+ return RowType.of(types, names);
+ }
+
+ /**
+ * Creates an external sorter ordered by file ID and encoded record key.
+ *
+ *
The nested payload is deliberately excluded from the sort keys, so duplicate record keys
+ * are retained without comparing or aggregating their payloads.
+ */
+ public static SortOperatorGen getFileIdAndKeySorterGen(RowType rowType) {
+ return new SortOperatorGen(
+ rowType, new String[] {FILE_GROUP_META_FIELD, RECORD_KEY_FIELD});
+ }
+}
diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bulk/BulkInsertWriterHelper.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bulk/BulkInsertWriterHelper.java
index 30a19e1423aa6..123dd191a4c43 100644
--- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bulk/BulkInsertWriterHelper.java
+++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bulk/BulkInsertWriterHelper.java
@@ -115,7 +115,9 @@ public BulkInsertWriterHelper(Configuration conf, HoodieTable hoodieTable, Hoodi
? schema
: HoodieSchemaUtils.addMetadataFields(schema, writeConfig.allowOperationMetadataField());
this.preserveHoodieMetadata = preserveHoodieMetadata;
- this.isInputSorted = OptionsResolver.isBulkInsertOperation(conf) && conf.get(FlinkOptions.WRITE_BULK_INSERT_SORT_INPUT);
+ this.isInputSorted = OptionsResolver.isBulkInsertOperation(conf)
+ && (conf.get(FlinkOptions.WRITE_BULK_INSERT_SORT_INPUT)
+ || OptionsResolver.isLsmTreeStorageLayout(conf));
this.fileIdPrefix = UUID.randomUUID().toString();
this.keyGen = preserveHoodieMetadata ? null : RowDataKeyGens.instance(conf, rowType, taskPartitionId, instantTime);
this.writeMetrics = writeMetrics;
@@ -129,14 +131,7 @@ public void write(RowData record) throws IOException {
String partitionPath = preserveHoodieMetadata
? record.getString(HoodieRecord.PARTITION_PATH_META_FIELD_ORD).toString()
: keyGen.getPartitionPath(record);
-
- if ((lastKnownPartitionPath == null) || !lastKnownPartitionPath.equals(partitionPath) || !handle.canWrite()) {
- handle = getRowCreateHandle(partitionPath);
- lastKnownPartitionPath = partitionPath;
- writeMetrics.ifPresent(FlinkStreamWriteMetrics::markHandleSwitch);
- }
- handle.write(recordKey, partitionPath, record);
- writeMetrics.ifPresent(FlinkStreamWriteMetrics::markRecordIn);
+ writeRecord(recordKey, partitionPath, record);
} catch (Throwable t) {
IOException ioException = new IOException("Exception happened when bulk insert.", t);
log.error("Global error thrown while trying to write records in HoodieRowCreateHandle ", ioException);
@@ -144,6 +139,18 @@ public void write(RowData record) throws IOException {
}
}
+ protected void writeRecord(String recordKey, String partitionPath, RowData record) throws IOException {
+ if ((lastKnownPartitionPath == null)
+ || !lastKnownPartitionPath.equals(partitionPath)
+ || !handle.canWrite()) {
+ handle = getRowCreateHandle(partitionPath);
+ lastKnownPartitionPath = partitionPath;
+ writeMetrics.ifPresent(FlinkStreamWriteMetrics::markHandleSwitch);
+ }
+ handle.write(recordKey, partitionPath, record);
+ writeMetrics.ifPresent(FlinkStreamWriteMetrics::markRecordIn);
+ }
+
private HoodieRowDataCreateHandle getRowCreateHandle(String partitionPath) throws IOException {
if (!handles.containsKey(partitionPath)) { // if there is no handle corresponding to the partition path
// if records are sorted, we can close all existing handles
@@ -228,4 +235,3 @@ private WriteStatus closeWriteHandle(HoodieRowDataCreateHandle rowCreateHandle)
}
}
-
diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bulk/LsmBulkInsertWriterHelper.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bulk/LsmBulkInsertWriterHelper.java
new file mode 100644
index 0000000000000..e20aa9688c28c
--- /dev/null
+++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bulk/LsmBulkInsertWriterHelper.java
@@ -0,0 +1,115 @@
+/*
+ * 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.hudi.sink.bulk;
+
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.sink.bulk.sort.SortOperatorGen;
+import org.apache.hudi.table.HoodieTable;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.data.GenericRowData;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.StringData;
+import org.apache.flink.table.types.logical.LogicalType;
+import org.apache.flink.table.types.logical.RowType;
+
+import java.io.IOException;
+
+/**
+ * Bulk-insert writer helper for LSM input rows.
+ *
+ *
The input row contains partition path, encoded record key, and the original table row. The
+ * routing and record keys are retained after sorting so the writer does not generate them again.
+ */
+public class LsmBulkInsertWriterHelper extends BulkInsertWriterHelper {
+
+ private static final String PARTITION_META_FIELD = "_partition";
+ private static final String RECORD_KEY_FIELD = "_record_key";
+ private static final String RECORD_FIELD = "record";
+
+ private final int recordArity;
+
+ public LsmBulkInsertWriterHelper(
+ Configuration conf,
+ HoodieTable, ?, ?, ?> hoodieTable,
+ HoodieWriteConfig writeConfig,
+ String instantTime,
+ int taskPartitionId,
+ long taskId,
+ long taskEpochId,
+ RowType rowType) {
+ super(conf, hoodieTable, writeConfig, instantTime, taskPartitionId, taskId, taskEpochId, rowType);
+ this.recordArity = rowType.getFieldCount();
+ }
+
+ @Override
+ public void write(RowData sortRow) throws IOException {
+ String partitionPath = sortRow.getString(0).toString();
+ String recordKey = sortRow.getString(1).toString();
+ RowData record = sortRow.getRow(2, recordArity);
+ writeRecord(recordKey, partitionPath, record);
+ }
+
+ /**
+ * Decorates a table row with the partition path and encoded record key used by the LSM
+ * sorter.
+ *
+ * @param partitionPath partition path
+ * @param record original table row used to generate the record key
+ * @param keyGen RowData key generator
+ * @return internal LSM sort row containing partition path, record key, and original table row
+ */
+ public static RowData rowWithPartitionAndKey(
+ String partitionPath,
+ RowData record,
+ RowDataKeyGen keyGen) {
+ return GenericRowData.of(
+ StringData.fromString(partitionPath),
+ StringData.fromString(keyGen.getRecordKey(record)),
+ record);
+ }
+
+ /**
+ * Returns the internal row type used to sort LSM bulk-insert records by partition and record key.
+ *
+ *
The fields are ordered as partition path, encoded record key, and original table row.
+ */
+ public static RowType rowTypeWithPartitionAndKey(RowType rowType) {
+ LogicalType[] types = new LogicalType[] {
+ DataTypes.STRING().getLogicalType(),
+ DataTypes.STRING().getLogicalType(),
+ rowType
+ };
+ String[] names =
+ new String[] {PARTITION_META_FIELD, RECORD_KEY_FIELD, RECORD_FIELD};
+ return RowType.of(types, names);
+ }
+
+ /**
+ * Creates an external sorter ordered by partition path and encoded record key.
+ *
+ *
The nested payload is deliberately excluded from the sort keys, so duplicate record keys
+ * are retained without comparing or aggregating their payloads.
+ */
+ public static SortOperatorGen getPartitionAndKeySorterGen(RowType rowType) {
+ return new SortOperatorGen(
+ rowType, new String[] {PARTITION_META_FIELD, RECORD_KEY_FIELD});
+ }
+}
diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bulk/WriterHelpers.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bulk/WriterHelpers.java
index 99a9ae114cd8e..0595830be8c77 100644
--- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bulk/WriterHelpers.java
+++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bulk/WriterHelpers.java
@@ -21,6 +21,7 @@
import org.apache.hudi.config.HoodieWriteConfig;
import org.apache.hudi.configuration.OptionsResolver;
import org.apache.hudi.sink.bucket.BucketBulkInsertWriterHelper;
+import org.apache.hudi.sink.bucket.LsmBucketBulkInsertWriterHelper;
import org.apache.hudi.table.HoodieTable;
import org.apache.flink.configuration.Configuration;
@@ -32,8 +33,18 @@
public class WriterHelpers {
public static BulkInsertWriterHelper getWriterHelper(Configuration conf, HoodieTable, ?, ?, ?> hoodieTable, HoodieWriteConfig writeConfig,
String instantTime, int taskPartitionId, long taskId, long taskEpochId, RowType rowType) {
- return OptionsResolver.isBucketIndexType(conf)
- ? new BucketBulkInsertWriterHelper(conf, hoodieTable, writeConfig, instantTime, taskPartitionId, taskId, taskEpochId, rowType)
- : new BulkInsertWriterHelper(conf, hoodieTable, writeConfig, instantTime, taskPartitionId, taskId, taskEpochId, rowType);
+ if (OptionsResolver.isLsmTreeStorageLayout(conf)) {
+ return OptionsResolver.isBucketIndexType(conf)
+ ? new LsmBucketBulkInsertWriterHelper(
+ conf, hoodieTable, writeConfig, instantTime, taskPartitionId, taskId, taskEpochId, rowType)
+ : new LsmBulkInsertWriterHelper(
+ conf, hoodieTable, writeConfig, instantTime, taskPartitionId, taskId, taskEpochId, rowType);
+ } else {
+ return OptionsResolver.isBucketIndexType(conf)
+ ? new BucketBulkInsertWriterHelper(
+ conf, hoodieTable, writeConfig, instantTime, taskPartitionId, taskId, taskEpochId, rowType)
+ : new BulkInsertWriterHelper(
+ conf, hoodieTable, writeConfig, instantTime, taskPartitionId, taskId, taskEpochId, rowType);
+ }
}
}
diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/Pipelines.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/Pipelines.java
index 59612323b7db2..ea2bf868c19df 100644
--- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/Pipelines.java
+++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/Pipelines.java
@@ -39,8 +39,10 @@
import org.apache.hudi.sink.bucket.BucketBulkInsertWriterHelper;
import org.apache.hudi.sink.bucket.BucketStreamWriteOperator;
import org.apache.hudi.sink.bucket.ConsistentBucketAssignFunction;
+import org.apache.hudi.sink.bucket.LsmBucketBulkInsertWriterHelper;
import org.apache.hudi.sink.buffer.BufferType;
import org.apache.hudi.sink.bulk.BulkInsertWriteOperator;
+import org.apache.hudi.sink.bulk.LsmBulkInsertWriterHelper;
import org.apache.hudi.sink.bulk.RowDataKeyGen;
import org.apache.hudi.sink.bulk.RowDataKeyGens;
import org.apache.hudi.sink.bulk.sort.SortOperatorGen;
@@ -125,82 +127,209 @@ public class Pipelines {
* @return the bulk insert data stream sink
*/
public static DataStream bulkInsert(Configuration conf, RowType rowType, DataStream dataStream) {
+ if (OptionsResolver.isRecordLevelIndex(conf)) {
+ throw new HoodieException(
+ "Record level index does not work with bulk insert using FLINK engine.");
+ }
+ // TODO support bulk insert for consistent bucket index
+ if (OptionsResolver.isConsistentHashingBucketIndexType(conf)) {
+ throw new HoodieException(
+ "Consistent hashing bucket index does not work with bulk insert using FLINK engine. Use simple bucket index or Spark engine.");
+ }
+
// we need same parallelism for all operators,
// which is equal to write tasks number, to avoid shuffles
- final int PARALLELISM_VALUE = conf.get(FlinkOptions.WRITE_TASKS);
+ final int writeTasks = conf.get(FlinkOptions.WRITE_TASKS);
final boolean isBucketIndexType = OptionsResolver.isBucketIndexType(conf);
+ final boolean isLsmTreeStorageLayout = OptionsResolver.isLsmTreeStorageLayout(conf);
+
+ DataStream preparedDataStream = isBucketIndexType
+ ? bucketShuffleAndSort(
+ conf, rowType, dataStream, writeTasks, isLsmTreeStorageLayout)
+ : shuffleAndSort(
+ conf, rowType, dataStream, writeTasks, isLsmTreeStorageLayout);
+
+ String operatorName =
+ isBucketIndexType ? "bucket_bulk_insert" : "hoodie_bulk_insert_write";
+ return preparedDataStream
+ .transform(opName(operatorName, conf),
+ TypeInformation.of(RowData.class), BulkInsertWriteOperator.getFactory(conf, rowType))
+ .uid(opUID(operatorName, conf))
+ .setParallelism(writeTasks);
+ }
- if (OptionsResolver.isRecordLevelIndex(conf)) {
- throw new HoodieException(
- "Record level index does not work with bulk insert using FLINK engine.");
+ /**
+ * Shuffles and sorts the input stream for a bucket bulk insert writer.
+ *
+ * Records are first routed to the write task that owns the target bucket. For the default
+ * layout, the file ID is appended and the stream is optionally sorted by file ID. For the LSM
+ * layout, the file ID and record key are appended in the same transform, then the stream is
+ * sorted by file ID and record key.
+ */
+ private static DataStream bucketShuffleAndSort(
+ Configuration conf,
+ RowType rowType,
+ DataStream dataStream,
+ int writeTasks,
+ boolean isLsmTreeStorageLayout) {
+ List indexKeyFieldList = OptionsResolver.getIndexKeyFields(conf);
+ // Built once and captured by the per-record map closure (NumBucketsFunction is Serializable),
+ // avoiding a per-record rebuild from conf inside BucketBulkInsertWriterHelper.
+ NumBucketsFunction numBucketsFunction = new NumBucketsFunction(
+ conf.get(FlinkOptions.BUCKET_INDEX_PARTITION_EXPRESSIONS),
+ conf.get(FlinkOptions.BUCKET_INDEX_PARTITION_RULE),
+ conf.get(FlinkOptions.BUCKET_INDEX_NUM_BUCKETS));
+ Partitioner partitioner =
+ BucketIndexPartitionerFactory.create(conf, indexKeyFieldList);
+ RowDataKeyGen keyGen = RowDataKeyGens.instance(conf, rowType);
+ boolean needFixedFileIdSuffix =
+ OptionsResolver.isNonBlockingConcurrencyControl(conf);
+
+ Map bucketIdToFileId = new HashMap<>();
+ DataStream routedDataStream =
+ dataStream.partitionCustom(partitioner, keyGen::getHoodieKey);
+
+ if (isLsmTreeStorageLayout) {
+ RowType sortRowType =
+ LsmBucketBulkInsertWriterHelper.rowTypeWithFileIdAndKey(rowType);
+ InternalTypeInfo sortTypeInfo = InternalTypeInfo.of(sortRowType);
+ DataStream sortInput = routedDataStream
+ .map(record -> LsmBucketBulkInsertWriterHelper.rowWithFileIdAndKey(
+ bucketIdToFileId,
+ keyGen,
+ record,
+ indexKeyFieldList,
+ numBucketsFunction,
+ needFixedFileIdSuffix), sortTypeInfo)
+ .name("lsm_bulk_insert_sort_keys")
+ .setParallelism(writeTasks);
+ return addBulkInsertSorter(
+ conf,
+ sortInput,
+ sortTypeInfo,
+ LsmBucketBulkInsertWriterHelper.getFileIdAndKeySorterGen(sortRowType),
+ "lsm_sorter:(file_group, record_key)",
+ writeTasks);
}
- if (isBucketIndexType) {
- // TODO support bulk insert for consistent bucket index
- if (OptionsResolver.isConsistentHashingBucketIndexType(conf)) {
- throw new HoodieException(
- "Consistent hashing bucket index does not work with bulk insert using FLINK engine. Use simple bucket index or Spark engine.");
- }
- List indexKeyFieldList = OptionsResolver.getIndexKeyFields(conf);
- // built once and captured by the per-record map closure (NumBucketsFunction is Serializable),
- // avoiding a per-record rebuild from conf inside BucketBulkInsertWriterHelper
- NumBucketsFunction numBucketsFunction = new NumBucketsFunction(conf.get(FlinkOptions.BUCKET_INDEX_PARTITION_EXPRESSIONS),
- conf.get(FlinkOptions.BUCKET_INDEX_PARTITION_RULE), conf.get(FlinkOptions.BUCKET_INDEX_NUM_BUCKETS));
- Partitioner partitioner = BucketIndexPartitionerFactory.create(conf, indexKeyFieldList);
- RowDataKeyGen keyGen = RowDataKeyGens.instance(conf, rowType);
- RowType rowTypeWithFileId = BucketBulkInsertWriterHelper.rowTypeWithFileId(rowType);
- InternalTypeInfo typeInfo = InternalTypeInfo.of(rowTypeWithFileId);
- boolean needFixedFileIdSuffix = OptionsResolver.isNonBlockingConcurrencyControl(conf);
-
- Map bucketIdToFileId = new HashMap<>();
- dataStream = dataStream.partitionCustom(partitioner, keyGen::getHoodieKey)
- .map(record -> BucketBulkInsertWriterHelper.rowWithFileId(bucketIdToFileId, keyGen, record, indexKeyFieldList, numBucketsFunction, needFixedFileIdSuffix), typeInfo)
- .setParallelism(PARALLELISM_VALUE);
- if (conf.get(FlinkOptions.WRITE_BULK_INSERT_SORT_INPUT)) {
- SortOperatorGen sortOperatorGen = BucketBulkInsertWriterHelper.getFileIdSorterGen(rowTypeWithFileId);
- dataStream = dataStream.transform("file_sorter", typeInfo, sortOperatorGen.createSortOperator(conf))
- .setParallelism(PARALLELISM_VALUE);
- FlinkTransformationUtils.setManagedMemoryWeight(dataStream.getTransformation(),
- conf.get(FlinkOptions.WRITE_SORT_MEMORY) * 1024L * 1024L);
- }
- } else if (!FlinkOptions.isDefaultValueDefined(conf, FlinkOptions.PARTITION_PATH_FIELD)) {
- // if table is not partitioned then we don't need any shuffles,
- // and could add main write operator only
- if (conf.get(FlinkOptions.WRITE_BULK_INSERT_SHUFFLE_INPUT)) {
- // shuffle by partition keys
- // use #partitionCustom instead of #keyBy to avoid duplicate sort operations,
- // see BatchExecutionUtils#applyBatchExecutionSettings for details.
- Partitioner partitioner = (key, channels) -> KeyGroupRangeAssignment.assignKeyToParallelOperator(key,
- KeyGroupRangeAssignment.computeDefaultMaxParallelism(PARALLELISM_VALUE), channels);
- RowDataKeyGen rowDataKeyGen = RowDataKeyGens.instance(conf, rowType);
- dataStream = dataStream.partitionCustom(partitioner, rowDataKeyGen::getPartitionPath);
- }
- if (conf.get(FlinkOptions.WRITE_BULK_INSERT_SORT_INPUT)) {
- final boolean isNeededSortInput = conf.get(FlinkOptions.WRITE_BULK_INSERT_SORT_INPUT_BY_RECORD_KEY);
- final String[] partitionFields = FilePathUtils.extractPartitionKeys(conf);
- final String[] recordKeyFields = OptionsResolver.getRecordKeys(conf);
-
- // if sort input by record key is needed then add record keys to partition keys
- String[] sortFields = isNeededSortInput
- ? Stream.concat(Arrays.stream(partitionFields), Arrays.stream(recordKeyFields)).toArray(String[]::new)
- : partitionFields;
- SortOperatorGen sortOperatorGen = new SortOperatorGen(rowType, sortFields);
- dataStream = dataStream
- .transform(isNeededSortInput ? "sorter:(partition_key, record_key)" : "sorter:(partition_key)",
- InternalTypeInfo.of(rowType), sortOperatorGen.createSortOperator(conf))
- .setParallelism(PARALLELISM_VALUE);
- FlinkTransformationUtils.setManagedMemoryWeight(dataStream.getTransformation(),
- conf.get(FlinkOptions.WRITE_SORT_MEMORY) * 1024L * 1024L);
- }
+ RowType rowTypeWithFileId = BucketBulkInsertWriterHelper.rowTypeWithFileId(rowType);
+ InternalTypeInfo typeInfo = InternalTypeInfo.of(rowTypeWithFileId);
+ DataStream rowsWithFileId = routedDataStream
+ .map(record -> BucketBulkInsertWriterHelper.rowWithFileId(
+ bucketIdToFileId,
+ keyGen,
+ record,
+ indexKeyFieldList,
+ numBucketsFunction,
+ needFixedFileIdSuffix), typeInfo)
+ .setParallelism(writeTasks);
+
+ if (!conf.get(FlinkOptions.WRITE_BULK_INSERT_SORT_INPUT)) {
+ return rowsWithFileId;
}
- // main write operator with following dummy sink in the end
- String opName = isBucketIndexType ? "bucket_bulk_insert" : "hoodie_bulk_insert_write";
- return dataStream
- .transform(opName(opName, conf),
- TypeInformation.of(RowData.class), BulkInsertWriteOperator.getFactory(conf, rowType))
- .uid(opUID(opName, conf))
- .setParallelism(PARALLELISM_VALUE);
+ return addBulkInsertSorter(
+ conf,
+ rowsWithFileId,
+ typeInfo,
+ BucketBulkInsertWriterHelper.getFileIdSorterGen(rowTypeWithFileId),
+ "file_sorter",
+ writeTasks);
+ }
+
+ /**
+ * Shuffles and sorts the input stream for a non-bucket bulk insert writer.
+ *
+ * Partitioned input is optionally shuffled by partition path. The LSM layout then appends
+ * the partition path and record key and sorts by both fields; for a non-partitioned table the
+ * partition path is empty, so the effective ordering is by record key. The default layout keeps
+ * the existing behavior: non-partitioned input is passed through without shuffle or sort, while
+ * partitioned input is sorted only when bulk-insert input sorting is enabled.
+ */
+ private static DataStream shuffleAndSort(
+ Configuration conf,
+ RowType rowType,
+ DataStream dataStream,
+ int writeTasks,
+ boolean isLsmTreeStorageLayout) {
+ final boolean isPartitioned =
+ !FlinkOptions.isDefaultValueDefined(conf, FlinkOptions.PARTITION_PATH_FIELD);
+ final boolean shouldShuffle =
+ isPartitioned && conf.get(FlinkOptions.WRITE_BULK_INSERT_SHUFFLE_INPUT);
+ final RowDataKeyGen rowDataKeyGen = RowDataKeyGens.instance(conf, rowType);
+
+ DataStream routedDataStream = dataStream;
+ if (shouldShuffle) {
+ // Use #partitionCustom instead of #keyBy to avoid duplicate sort operations,
+ // see BatchExecutionUtils#applyBatchExecutionSettings for details.
+ Partitioner partitioner =
+ (key, channels) -> KeyGroupRangeAssignment.assignKeyToParallelOperator(
+ key,
+ KeyGroupRangeAssignment.computeDefaultMaxParallelism(writeTasks),
+ channels);
+ routedDataStream =
+ dataStream.partitionCustom(partitioner, rowDataKeyGen::getPartitionPath);
+ }
+
+ if (isLsmTreeStorageLayout) {
+ // LSM sorted runs are ordered by partition path and the encoded record key strings.
+ RowType sortRowType = LsmBulkInsertWriterHelper.rowTypeWithPartitionAndKey(rowType);
+ InternalTypeInfo sortTypeInfo = InternalTypeInfo.of(sortRowType);
+ DataStream sortInput = routedDataStream
+ .map(record -> LsmBulkInsertWriterHelper.rowWithPartitionAndKey(
+ rowDataKeyGen.getPartitionPath(record), record, rowDataKeyGen), sortTypeInfo)
+ .name("lsm_bulk_insert_sort_keys")
+ .setParallelism(writeTasks);
+ return addBulkInsertSorter(
+ conf,
+ sortInput,
+ sortTypeInfo,
+ LsmBulkInsertWriterHelper.getPartitionAndKeySorterGen(sortRowType),
+ "lsm_sorter:(partition_path, record_key)",
+ writeTasks);
+ }
+
+ if (!isPartitioned || !conf.get(FlinkOptions.WRITE_BULK_INSERT_SORT_INPUT)) {
+ return routedDataStream;
+ }
+
+ // Unlike the LSM path, the default-layout sorter orders the original record-key fields by
+ // their Flink logical types. The resulting order can differ from encoded record-key String
+ // ordering; for example, numeric keys are ordered as 2, 10 here but as "10", "2" for LSM.
+ final boolean sortByRecordKey =
+ conf.get(FlinkOptions.WRITE_BULK_INSERT_SORT_INPUT_BY_RECORD_KEY);
+ final String[] partitionFields = FilePathUtils.extractPartitionKeys(conf);
+ final String[] recordKeyFields = OptionsResolver.getRecordKeys(conf);
+ String[] sortFields = sortByRecordKey
+ ? Stream.concat(Arrays.stream(partitionFields), Arrays.stream(recordKeyFields))
+ .toArray(String[]::new)
+ : partitionFields;
+
+ return addBulkInsertSorter(
+ conf,
+ routedDataStream,
+ InternalTypeInfo.of(rowType),
+ new SortOperatorGen(rowType, sortFields),
+ sortByRecordKey
+ ? "sorter:(partition_key, record_key)"
+ : "sorter:(partition_key)",
+ writeTasks);
+ }
+
+ private static DataStream addBulkInsertSorter(
+ Configuration conf,
+ DataStream dataStream,
+ TypeInformation typeInfo,
+ SortOperatorGen sortOperatorGen,
+ String operatorName,
+ int writeTasks) {
+ DataStream sortedDataStream = dataStream
+ .transform(operatorName, typeInfo, sortOperatorGen.createSortOperator(conf))
+ .setParallelism(writeTasks);
+ FlinkTransformationUtils.setManagedMemoryWeight(
+ sortedDataStream.getTransformation(),
+ conf.get(FlinkOptions.WRITE_SORT_MEMORY) * 1024L * 1024L);
+ return sortedDataStream;
}
/**
diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableFactory.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableFactory.java
index 92912fd46a17f..0d1437c0216c5 100644
--- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableFactory.java
+++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableFactory.java
@@ -193,9 +193,9 @@ private void checkStorageLayout(
Configuration conf) {
HoodieTableConfig.TableStorageLayout storageLayout = OptionsResolver.getTableStorageLayout(conf);
ValidationUtils.checkArgument(
- !(OptionsResolver.isInsertOperation(conf) || OptionsResolver.isBulkInsertOperation(conf))
+ !OptionsResolver.isInsertOperation(conf)
|| storageLayout != HoodieTableConfig.TableStorageLayout.LSM_TREE,
- "The LSM tree storage layout does not support insert or bulk insert operations because they allow duplicate record keys.");
+ "The LSM tree storage layout does not support insert operations because they allow duplicate record keys.");
}
/**
@@ -506,6 +506,13 @@ private static void setupWriteOptions(Configuration conf) {
* Sets up the table exec sort options.
*/
private void setupSortOptions(Configuration conf, ReadableConfig contextConfig) {
+ if (OptionsResolver.isBulkInsertOperation(conf)
+ && OptionsResolver.isLsmTreeStorageLayout(conf)) {
+ // An LSM run must always be ordered by its encoded record key. These options are mandatory
+ // for LSM bulk insert even when the user explicitly disables the regular bulk-insert sort.
+ conf.set(FlinkOptions.WRITE_BULK_INSERT_SORT_INPUT, true);
+ conf.set(FlinkOptions.WRITE_BULK_INSERT_SORT_INPUT_BY_RECORD_KEY, true);
+ }
if (contextConfig.getOptional(TABLE_EXEC_SORT_MAX_NUM_FILE_HANDLES).isPresent()) {
conf.set(TABLE_EXEC_SORT_MAX_NUM_FILE_HANDLES,
contextConfig.get(TABLE_EXEC_SORT_MAX_NUM_FILE_HANDLES));
diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/configuration/TestOptionsResolver.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/configuration/TestOptionsResolver.java
index bd22f66415843..1ce45c4e8d6d0 100644
--- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/configuration/TestOptionsResolver.java
+++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/configuration/TestOptionsResolver.java
@@ -56,7 +56,7 @@ void testTableStorageLayoutDefaultsByOperation() {
assertFalse(OptionsResolver.isLsmTreeStorageLayout(conf));
conf.set(FlinkOptions.OPERATION, WriteOperationType.BULK_INSERT.value());
- assertFalse(OptionsResolver.isLsmTreeStorageLayout(conf));
+ assertTrue(OptionsResolver.isLsmTreeStorageLayout(conf));
conf.setString(HoodieTableConfig.TABLE_STORAGE_LAYOUT.key(),
HoodieTableConfig.TableStorageLayout.LSM_TREE.configValue());
diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/bulk/TestLsmBulkInsertWriterHelper.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/bulk/TestLsmBulkInsertWriterHelper.java
new file mode 100644
index 0000000000000..7e1fe68b1738e
--- /dev/null
+++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/bulk/TestLsmBulkInsertWriterHelper.java
@@ -0,0 +1,141 @@
+/*
+ * 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.hudi.sink.bulk;
+
+import org.apache.hudi.configuration.FlinkOptions;
+import org.apache.hudi.sink.bulk.sort.SortOperatorGen;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.table.data.GenericRowData;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.StringData;
+import org.apache.flink.table.runtime.generated.RecordComparator;
+import org.apache.flink.table.types.logical.BigIntType;
+import org.apache.flink.table.types.logical.LogicalType;
+import org.apache.flink.table.types.logical.RowType;
+import org.apache.flink.table.types.logical.VarCharType;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** Tests for LSM sort-row construction and {@link LsmBulkInsertWriterHelper}. */
+class TestLsmBulkInsertWriterHelper {
+
+ @Test
+ void testActualCompositeRecordKeyAndPayloadAreRetained() {
+ RowType rowType = RowType.of(
+ new LogicalType[] {
+ new VarCharType(), new VarCharType(), new VarCharType(), new VarCharType()
+ },
+ new String[] {"id1", "id2", "name", "partition"});
+ Configuration conf = new Configuration();
+ conf.set(FlinkOptions.RECORD_KEY_FIELD, "id1,id2");
+ conf.set(FlinkOptions.PARTITION_PATH_FIELD, "partition");
+ RowDataKeyGen keyGen = RowDataKeyGens.instance(conf, rowType);
+ List rows = Arrays.asList(
+ compositeRow("a", "10"),
+ compositeRow("a", "2"),
+ compositeRow("a,", "2"),
+ compositeRow("a", String.valueOf((char) 0xE000)),
+ compositeRow("a", new String(Character.toChars(0x1F600))));
+
+ for (RowData row : rows) {
+ RowData sortRow = LsmBulkInsertWriterHelper.rowWithPartitionAndKey("p1", row, keyGen);
+ assertEquals("p1", sortRow.getString(0).toString());
+ assertEquals(keyGen.getRecordKey(row), sortRow.getString(1).toString());
+ assertSame(row, sortRow.getRow(2, rowType.getFieldCount()));
+ }
+ }
+
+ @Test
+ void testDecoratedRowsSortByShuffleAndEncodedRecordKey() {
+ RowType rowType = RowType.of(
+ new LogicalType[] {new BigIntType(), new VarCharType(), new VarCharType()},
+ new String[] {"id", "name", "partition"});
+ Configuration conf = new Configuration();
+ conf.set(FlinkOptions.RECORD_KEY_FIELD, "id");
+ conf.set(FlinkOptions.PARTITION_PATH_FIELD, "partition");
+ RowDataKeyGen keyGen = RowDataKeyGens.instance(conf, rowType);
+ RowType sortRowType = LsmBulkInsertWriterHelper.rowTypeWithPartitionAndKey(rowType);
+ assertEquals(
+ Arrays.asList("_partition", "_record_key", "record"),
+ sortRowType.getFieldNames());
+
+ RowData row10 = row(10L, "ten", "p1");
+ RowData row2 = row(2L, "two", "p1");
+ RowData rowOtherPartition = row(1L, "one", "p2");
+ RowData sortRow10 = LsmBulkInsertWriterHelper.rowWithPartitionAndKey("p1", row10, keyGen);
+ RowData sortRow2 = LsmBulkInsertWriterHelper.rowWithPartitionAndKey("p1", row2, keyGen);
+ RowData sortRowOtherPartition =
+ LsmBulkInsertWriterHelper.rowWithPartitionAndKey("p2", rowOtherPartition, keyGen);
+
+ SortOperatorGen sortOperatorGen =
+ LsmBulkInsertWriterHelper.getPartitionAndKeySorterGen(sortRowType);
+ RecordComparator comparator = sortOperatorGen.generateRecordComparator("TestLsmBulkInsertComparator")
+ .newInstance(Thread.currentThread().getContextClassLoader());
+
+ assertTrue(comparator.compare(sortRow10, sortRow2) < 0);
+ assertTrue(comparator.compare(sortRow2, sortRowOtherPartition) < 0);
+ assertEquals("10", sortRow10.getString(1).toString());
+ assertSame(row10, sortRow10.getRow(2, rowType.getFieldCount()));
+ }
+
+ @Test
+ void testDuplicateKeysRemainDistinctPayloads() {
+ RowType rowType = RowType.of(
+ new LogicalType[] {new BigIntType(), new VarCharType(), new VarCharType()},
+ new String[] {"id", "name", "partition"});
+ Configuration conf = new Configuration();
+ conf.set(FlinkOptions.RECORD_KEY_FIELD, "id");
+ conf.set(FlinkOptions.PARTITION_PATH_FIELD, "partition");
+ RowDataKeyGen keyGen = RowDataKeyGens.instance(conf, rowType);
+
+ RowData first = row(1L, "first", "p1");
+ RowData second = row(1L, "second", "p1");
+ RowData firstSortRow =
+ LsmBulkInsertWriterHelper.rowWithPartitionAndKey("p1", first, keyGen);
+ RowData secondSortRow =
+ LsmBulkInsertWriterHelper.rowWithPartitionAndKey("p1", second, keyGen);
+
+ assertEquals(firstSortRow.getString(1), secondSortRow.getString(1));
+ assertSame(first, firstSortRow.getRow(2, rowType.getFieldCount()));
+ assertSame(second, secondSortRow.getRow(2, rowType.getFieldCount()));
+ }
+
+ private static RowData row(long id, String name, String partition) {
+ return GenericRowData.of(
+ id,
+ StringData.fromString(name),
+ StringData.fromString(partition));
+ }
+
+ private static RowData compositeRow(String id1, String id2) {
+ return GenericRowData.of(
+ StringData.fromString(id1),
+ StringData.fromString(id2),
+ StringData.fromString("payload"),
+ StringData.fromString("p1"));
+ }
+
+}
diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/utils/BulkInsertFunctionWrapper.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/utils/BulkInsertFunctionWrapper.java
index 1d5cc2fc9a97e..1ad8ccdeec4f2 100644
--- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/utils/BulkInsertFunctionWrapper.java
+++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/utils/BulkInsertFunctionWrapper.java
@@ -27,6 +27,7 @@
import org.apache.hudi.index.bucket.partition.NumBucketsFunction;
import org.apache.hudi.sink.StreamWriteOperatorCoordinator;
import org.apache.hudi.sink.bucket.BucketBulkInsertWriterHelper;
+import org.apache.hudi.sink.bucket.LsmBucketBulkInsertWriterHelper;
import org.apache.hudi.sink.bulk.BulkInsertWriteFunction;
import org.apache.hudi.sink.bulk.RowDataKeyGen;
import org.apache.hudi.sink.bulk.RowDataKeyGens;
@@ -73,6 +74,7 @@ public class BulkInsertFunctionWrapper implements TestFunctionWrapper {
private final Configuration conf;
private final RowType rowType;
private final RowType rowTypeWithFileId;
+ private final RowType sortInputRowType;
private final IOManager ioManager;
private final MockStreamingRuntimeContext runtimeContext;
@@ -82,6 +84,7 @@ public class BulkInsertFunctionWrapper implements TestFunctionWrapper {
@Getter
private StreamWriteOperatorCoordinator coordinator;
private final boolean needSortInput;
+ private final boolean lsmSortInput;
private BulkInsertWriteFunction writeFunction;
private MapFunction mapFunction;
@@ -101,9 +104,14 @@ public BulkInsertFunctionWrapper(String tablePath, Configuration conf) throws Ex
this.conf = conf;
this.rowType = (RowType) HoodieSchemaConverter.convertToDataType(StreamerUtil.getSourceSchema(conf)).getLogicalType();
this.rowTypeWithFileId = BucketBulkInsertWriterHelper.rowTypeWithFileId(rowType);
+ this.lsmSortInput = OptionsResolver.isLsmTreeStorageLayout(conf);
+ this.sortInputRowType = lsmSortInput
+ ? LsmBucketBulkInsertWriterHelper.rowTypeWithFileIdAndKey(rowType)
+ : rowTypeWithFileId;
this.coordinatorContext = new MockOperatorCoordinatorContext(new OperatorID(), 1);
this.coordinator = new StreamWriteOperatorCoordinator(conf, this.coordinatorContext);
- this.needSortInput = conf.get(FlinkOptions.WRITE_BULK_INSERT_SORT_INPUT);
+ this.needSortInput =
+ lsmSortInput || conf.get(FlinkOptions.WRITE_BULK_INSERT_SORT_INPUT);
}
public void openFunction() throws Exception {
@@ -117,12 +125,12 @@ public void openFunction() throws Exception {
}
public void invoke(I record) throws Exception {
- RowData recordWithFileId = mapFunction.map((RowData) record);
+ RowData routedRecord = mapFunction.map((RowData) record);
if (needSortInput) {
// Sort input first, trigger writeFunction at the #endInput
- sortOperator.processElement(new StreamRecord(recordWithFileId));
+ sortOperator.processElement(new StreamRecord(routedRecord));
} else {
- writeFunction.processElement(recordWithFileId, null, null);
+ writeFunction.processElement(routedRecord, null, null);
}
}
@@ -219,7 +227,11 @@ private void setupMapFunction() {
conf.get(FlinkOptions.BUCKET_INDEX_PARTITION_RULE), conf.get(FlinkOptions.BUCKET_INDEX_NUM_BUCKETS));
boolean needFixedFileIdSuffix = OptionsResolver.isNonBlockingConcurrencyControl(conf);
this.bucketIdToFileId = new HashMap<>();
- this.mapFunction = r -> BucketBulkInsertWriterHelper.rowWithFileId(bucketIdToFileId, keyGen, r, indexKeyFieldList, numBucketsFunction, needFixedFileIdSuffix);
+ this.mapFunction = lsmSortInput
+ ? r -> LsmBucketBulkInsertWriterHelper.rowWithFileIdAndKey(
+ bucketIdToFileId, keyGen, r, indexKeyFieldList, numBucketsFunction, needFixedFileIdSuffix)
+ : r -> BucketBulkInsertWriterHelper.rowWithFileId(
+ bucketIdToFileId, keyGen, r, indexKeyFieldList, numBucketsFunction, needFixedFileIdSuffix);
}
private void setupSortOperator() throws Exception {
@@ -232,13 +244,15 @@ private void setupSortOperator() throws Exception {
.setConfig(new StreamConfig(conf))
.setExecutionConfig(new ExecutionConfig().enableObjectReuse())
.build();
- SortOperatorGen sortOperatorGen = BucketBulkInsertWriterHelper.getFileIdSorterGen(rowTypeWithFileId);
+ SortOperatorGen sortOperatorGen = lsmSortInput
+ ? LsmBucketBulkInsertWriterHelper.getFileIdAndKeySorterGen(sortInputRowType)
+ : BucketBulkInsertWriterHelper.getFileIdSorterGen(rowTypeWithFileId);
this.sortOperator = (SortOperator) sortOperatorGen.createSortOperator(conf);
this.sortOperator.setProcessingTimeService(new TestProcessingTimeService());
this.output = new CollectOutputAdapter<>();
StreamConfig streamConfig = new StreamConfig(conf);
streamConfig.setOperatorID(new OperatorID());
- RowDataSerializer inputSerializer = new RowDataSerializer(rowTypeWithFileId);
+ RowDataSerializer inputSerializer = new RowDataSerializer(sortInputRowType);
TestStreamConfigs.setupNetworkInputs(streamConfig, inputSerializer);
streamConfig.setManagedMemoryFractionOperatorOfUseCase(ManagedMemoryUseCase.OPERATOR, .99);
this.sortOperator.setup(streamTask, streamConfig, output);
diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestHoodieDataSource.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestHoodieDataSource.java
index f1990de488205..ef017d7ed0880 100644
--- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestHoodieDataSource.java
+++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestHoodieDataSource.java
@@ -21,6 +21,7 @@
import org.apache.hudi.common.config.HoodieMetadataConfig;
import org.apache.hudi.common.config.HoodieStorageConfig;
import org.apache.hudi.common.model.DefaultHoodieRecordPayload;
+import org.apache.hudi.common.model.HoodieRecord;
import org.apache.hudi.common.model.HoodieTableType;
import org.apache.hudi.common.model.WriteOperationType;
import org.apache.hudi.common.table.HoodieTableConfig;
@@ -55,6 +56,7 @@
import org.apache.hudi.utils.factory.CollectSinkTableFactory;
import lombok.extern.slf4j.Slf4j;
+import org.apache.avro.generic.GenericRecord;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.configuration.JobManagerOptions;
import org.apache.flink.core.execution.JobClient;
@@ -70,6 +72,8 @@
import org.apache.flink.table.data.RowData;
import org.apache.flink.types.Row;
import org.apache.flink.util.CollectionUtil;
+import org.apache.parquet.avro.AvroParquetReader;
+import org.apache.parquet.hadoop.ParquetReader;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
@@ -83,6 +87,8 @@
import java.io.File;
import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
@@ -2004,6 +2010,10 @@ void testBulkInsertWithPartitionBucketIndex(String operationType, String tableTy
expected.add("partition=par4" + "00000000");
assertEquals(expected.stream().sorted().collect(Collectors.toList()), actual.stream().sorted().collect(Collectors.toList()));
+ if ("bulk_insert".equals(operationType)) {
+ assertEquals(TestData.DATA_SET_SOURCE_INSERT.size(),
+ assertBaseFilesAreSortedAndCountRecords(new File(basePath)));
+ }
}
@Test
@@ -2076,6 +2086,80 @@ void testBulkInsertWithSortByRecordKey() {
+ "+I[id2, Stephen, 33, 1970-01-01T00:00:02, par1]]", 4);
}
+ @ParameterizedTest
+ @MethodSource("tableTypeAndBooleanTrueFalseParams")
+ void testLsmBulkInsertSortsEncodedRecordKeysAndSupportsUpdates(
+ HoodieTableType tableType, boolean partitioned) throws IOException {
+ TestConfigurations.Sql bulkInsertTable = sql("t1")
+ .field("id INT NOT NULL")
+ .field("name STRING")
+ .field("ts BIGINT")
+ .field("pt STRING")
+ .pkField("id")
+ .partitionField("pt")
+ .option(FlinkOptions.PATH, tempFile.getAbsolutePath())
+ .option(FlinkOptions.TABLE_TYPE, tableType)
+ .option(FlinkOptions.OPERATION, "bulk_insert")
+ .option(FlinkOptions.ORDERING_FIELDS, "ts")
+ .option(FlinkOptions.WRITE_TASKS, 1)
+ // LSM bulk insert must enforce record-key sorting even when the user explicitly disables
+ // the legacy bulk-insert sorting options.
+ .option(FlinkOptions.WRITE_BULK_INSERT_SORT_INPUT, false)
+ .option(FlinkOptions.WRITE_BULK_INSERT_SORT_INPUT_BY_RECORD_KEY, false);
+ batchTableEnv.executeSql(partitioned
+ ? bulkInsertTable.end()
+ : bulkInsertTable.noPartition().end());
+
+ // The encoded record keys use String ordering, where "10" < "2" and "11" < "3".
+ // Keeping the input in numeric order verifies that sorting uses the encoded Hudi record key
+ // instead of the INT field ordering.
+ execInsertSql(batchTableEnv, "insert into t1 values "
+ + "(2, 'old-2', 1, 'p1'), "
+ + "(10, 'old-10', 1, 'p1'), "
+ + "(3, 'old-3', 1, 'p2'), "
+ + "(11, 'old-11', 1, 'p2')");
+
+ // Validate both the persisted default layout and the physical record-key ordering in every
+ // base file, rather than relying on query output that may be reordered during the read.
+ HoodieTableMetaClient metaClient = HoodieTestUtils.createMetaClient(tempFile.getAbsolutePath());
+ assertEquals(HoodieTableConfig.TableStorageLayout.LSM_TREE,
+ metaClient.getTableConfig().getTableStorageLayout());
+ assertEquals(4, assertBaseFilesAreSortedAndCountRecords(tempFile));
+
+ // Reopen the LSM table with upsert to verify that files created by bulk insert remain usable
+ // by the update path for both COW and MOR tables.
+ batchTableEnv.executeSql("drop table t1");
+ TestConfigurations.Sql upsertTable = sql("t1")
+ .field("id INT NOT NULL")
+ .field("name STRING")
+ .field("ts BIGINT")
+ .field("pt STRING")
+ .pkField("id")
+ .partitionField("pt")
+ .option(FlinkOptions.PATH, tempFile.getAbsolutePath())
+ .option(FlinkOptions.TABLE_TYPE, tableType)
+ .option(FlinkOptions.OPERATION, "upsert")
+ .option(FlinkOptions.ORDERING_FIELDS, "ts")
+ .option(FlinkOptions.WRITE_TASKS, 1);
+ batchTableEnv.executeSql(partitioned
+ ? upsertTable.end()
+ : upsertTable.noPartition().end());
+
+ execInsertSql(batchTableEnv, "insert into t1 values "
+ + "(2, 'new-2', 2, 'p1'), "
+ + "(10, 'new-10', 2, 'p1'), "
+ + "(3, 'new-3', 2, 'p2'), "
+ + "(11, 'new-11', 2, 'p2')");
+
+ // The snapshot must expose the newer payload for every key after the update.
+ List result = execSelectSql(batchTableEnv, "select * from t1");
+ assertRowsEquals(result, "["
+ + "+I[10, new-10, 2, p1], "
+ + "+I[11, new-11, 2, p2], "
+ + "+I[2, new-2, 2, p1], "
+ + "+I[3, new-3, 2, p2]]");
+ }
+
@Test
void testBulkInsertNonPartitionedTable() {
TableEnvironment tableEnv = batchTableEnv;
@@ -3951,6 +4035,38 @@ public static Map getDefaultKeys() {
return conf.toMap();
}
+ private int assertBaseFilesAreSortedAndCountRecords(File tableBasePath) throws IOException {
+ List baseFiles;
+ try (Stream paths = Files.walk(tableBasePath.toPath())) {
+ baseFiles = paths
+ .filter(Files::isRegularFile)
+ .filter(path -> path.getFileName().toString().endsWith(".parquet"))
+ .filter(path -> !path.toString().contains(
+ File.separator + HoodieTableMetaClient.METAFOLDER_NAME + File.separator))
+ .collect(Collectors.toList());
+ }
+ assertFalse(baseFiles.isEmpty());
+
+ int totalRecords = 0;
+ for (Path baseFile : baseFiles) {
+ try (ParquetReader reader = AvroParquetReader
+ .builder(new org.apache.hadoop.fs.Path(baseFile.toUri()))
+ .build()) {
+ String previousKey = null;
+ GenericRecord record;
+ while ((record = reader.read()) != null) {
+ String currentKey = record.get(HoodieRecord.RECORD_KEY_METADATA_FIELD).toString();
+ String lastKey = previousKey;
+ assertTrue(previousKey == null || previousKey.compareTo(currentKey) <= 0,
+ () -> "Base file " + baseFile + " is not sorted: " + lastKey + " > " + currentKey);
+ previousKey = currentKey;
+ totalRecords++;
+ }
+ }
+ }
+ return totalRecords;
+ }
+
/**
* Return test params => (execution mode, table type).
*/
diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/TestHoodieTableFactory.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/TestHoodieTableFactory.java
index c9a5f1aa4ee2a..1bbfbb65418fe 100644
--- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/TestHoodieTableFactory.java
+++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/TestHoodieTableFactory.java
@@ -27,6 +27,7 @@
import org.apache.hudi.common.table.HoodieTableConfig;
import org.apache.hudi.config.HoodieWriteConfig;
import org.apache.hudi.configuration.FlinkOptions;
+import org.apache.hudi.configuration.OptionsResolver;
import org.apache.hudi.exception.HoodieValidationException;
import org.apache.hudi.hive.MultiPartKeysValueExtractor;
import org.apache.hudi.index.HoodieIndex;
@@ -56,7 +57,6 @@
import java.io.File;
import java.io.IOException;
-import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
@@ -898,19 +898,42 @@ void testLanceFormatSupportedForFlinkTables() {
}
@Test
- void testInsertOperationsDoNotSupportLsmTreeStorageLayout() throws IOException {
- for (String operation : Arrays.asList("insert", "bulk_insert")) {
- Configuration newTableConf = new Configuration();
- newTableConf.set(FlinkOptions.PATH, new File(tempFile, operation).getAbsolutePath());
- newTableConf.set(FlinkOptions.TABLE_NAME, "t_" + operation);
- newTableConf.set(FlinkOptions.RECORD_KEY_FIELD, "uuid");
- newTableConf.set(FlinkOptions.OPERATION, operation);
- newTableConf.setString(HoodieTableConfig.TABLE_STORAGE_LAYOUT.key(),
- HoodieTableConfig.TableStorageLayout.LSM_TREE.configValue());
-
- assertThrows(IllegalArgumentException.class,
- () -> new HoodieTableFactory().createDynamicTableSink(MockContext.getInstance(newTableConf)));
- }
+ void testStorageLayoutValidationAndBulkInsertSort() throws IOException {
+ Configuration insertConf = new Configuration();
+ insertConf.set(FlinkOptions.PATH, new File(tempFile, "insert").getAbsolutePath());
+ insertConf.set(FlinkOptions.TABLE_NAME, "t_insert");
+ insertConf.set(FlinkOptions.RECORD_KEY_FIELD, "uuid");
+ insertConf.set(FlinkOptions.OPERATION, "insert");
+ insertConf.setString(HoodieTableConfig.TABLE_STORAGE_LAYOUT.key(),
+ HoodieTableConfig.TableStorageLayout.LSM_TREE.configValue());
+ assertThrows(IllegalArgumentException.class,
+ () -> new HoodieTableFactory().createDynamicTableSink(MockContext.getInstance(insertConf)));
+
+ Configuration bulkInsertConf = new Configuration();
+ bulkInsertConf.set(FlinkOptions.PATH, new File(tempFile, "bulk_insert").getAbsolutePath());
+ bulkInsertConf.set(FlinkOptions.TABLE_NAME, "t_bulk_insert");
+ bulkInsertConf.set(FlinkOptions.RECORD_KEY_FIELD, "uuid");
+ bulkInsertConf.set(FlinkOptions.OPERATION, "bulk_insert");
+ bulkInsertConf.set(FlinkOptions.WRITE_BULK_INSERT_SORT_INPUT, false);
+ bulkInsertConf.set(FlinkOptions.WRITE_BULK_INSERT_SORT_INPUT_BY_RECORD_KEY, false);
+ HoodieTableSink lsmSink = (HoodieTableSink) new HoodieTableFactory()
+ .createDynamicTableSink(MockContext.getInstance(bulkInsertConf));
+ assertThat(OptionsResolver.isLsmTreeStorageLayout(lsmSink.getConf()), is(true));
+ assertThat(lsmSink.getConf().get(FlinkOptions.WRITE_BULK_INSERT_SORT_INPUT), is(true));
+ assertThat(lsmSink.getConf().get(FlinkOptions.WRITE_BULK_INSERT_SORT_INPUT_BY_RECORD_KEY), is(true));
+
+ Configuration defaultBulkInsertConf = new Configuration(bulkInsertConf);
+ defaultBulkInsertConf.set(FlinkOptions.PATH,
+ new File(tempFile, "default_bulk_insert").getAbsolutePath());
+ defaultBulkInsertConf.setString(HoodieTableConfig.TABLE_STORAGE_LAYOUT.key(),
+ HoodieTableConfig.TableStorageLayout.DEFAULT.configValue());
+ defaultBulkInsertConf.set(FlinkOptions.WRITE_BULK_INSERT_SORT_INPUT, false);
+ defaultBulkInsertConf.set(FlinkOptions.WRITE_BULK_INSERT_SORT_INPUT_BY_RECORD_KEY, false);
+ HoodieTableSink defaultSink = (HoodieTableSink) new HoodieTableFactory()
+ .createDynamicTableSink(MockContext.getInstance(defaultBulkInsertConf));
+ assertThat(OptionsResolver.isLsmTreeStorageLayout(defaultSink.getConf()), is(false));
+ assertThat(defaultSink.getConf().get(FlinkOptions.WRITE_BULK_INSERT_SORT_INPUT), is(false));
+ assertThat(defaultSink.getConf().get(FlinkOptions.WRITE_BULK_INSERT_SORT_INPUT_BY_RECORD_KEY), is(false));
Configuration tableConf = new Configuration();
tableConf.set(FlinkOptions.PATH, new File(tempFile, "existing_lsm").getAbsolutePath());
@@ -926,6 +949,15 @@ void testInsertOperationsDoNotSupportLsmTreeStorageLayout() throws IOException {
writeConf.set(FlinkOptions.OPERATION, "insert");
assertThrows(IllegalArgumentException.class,
() -> new HoodieTableFactory().createDynamicTableSink(MockContext.getInstance(writeConf)));
+
+ writeConf.set(FlinkOptions.OPERATION, "bulk_insert");
+ writeConf.set(FlinkOptions.WRITE_BULK_INSERT_SORT_INPUT, false);
+ writeConf.set(FlinkOptions.WRITE_BULK_INSERT_SORT_INPUT_BY_RECORD_KEY, false);
+ HoodieTableSink existingLsmSink = (HoodieTableSink) new HoodieTableFactory()
+ .createDynamicTableSink(MockContext.getInstance(writeConf));
+ assertThat(OptionsResolver.isLsmTreeStorageLayout(existingLsmSink.getConf()), is(true));
+ assertThat(existingLsmSink.getConf().get(FlinkOptions.WRITE_BULK_INSERT_SORT_INPUT), is(true));
+ assertThat(existingLsmSink.getConf().get(FlinkOptions.WRITE_BULK_INSERT_SORT_INPUT_BY_RECORD_KEY), is(true));
}
// -------------------------------------------------------------------------
diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestStreamerUtil.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestStreamerUtil.java
index 831637fb7750d..ca9c29ae7ff6d 100644
--- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestStreamerUtil.java
+++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestStreamerUtil.java
@@ -192,7 +192,7 @@ void testInitInsertTableStorageLayout() throws IOException {
conf.set(FlinkOptions.OPERATION, WriteOperationType.BULK_INSERT.value());
metaClient = StreamerUtil.initTableIfNotExists(conf);
- assertFalse(metaClient.getTableConfig().isLSMTreeStorageLayout());
+ assertTrue(metaClient.getTableConfig().isLSMTreeStorageLayout());
}
@Test