This is an automated email from the ASF dual-hosted git repository.
danny0405 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git
The following commit(s) were added to refs/heads/master by this push:
new 287ea4f0c980 feat(spark): support standard writes and compaction for
LSM tables (#19576)
287ea4f0c980 is described below
commit 287ea4f0c980f0b215b09bca1e7f78e816545bb1
Author: Shuo Cheng <[email protected]>
AuthorDate: Fri Aug 21 10:33:25 2026 +0800
feat(spark): support standard writes and compaction for LSM tables (#19576)
* feat(spark): support standard writes and compaction for LSM tables
---
.../org/apache/hudi/config/HoodieWriteConfig.java | 9 +
.../java/org/apache/hudi/io/BaseCreateHandle.java | 4 +-
.../apache/hudi/config/TestHoodieWriteConfig.java | 35 ++
.../MultipleSparkJobExecutionStrategy.java | 4 +-
.../BulkInsertInternalPartitionerFactory.java | 21 +-
...lkInsertInternalPartitionerWithRowsFactory.java | 31 +-
.../bulkinsert/LSMBulkInsertRecordSorter.java | 98 +++++
.../bulkinsert/LSMGlobalSortPartitioner.java | 67 ++++
...PartitionPathRepartitionAndSortPartitioner.java | 88 ++++
...nPathRepartitionAndSortPartitionerWithRows.java | 82 ++++
.../bulkinsert/LSMPartitionSortPartitioner.java | 64 +++
.../commit/BaseSparkCommitActionExecutor.java | 4 +-
.../table/action/commit/SparkBulkInsertHelper.java | 11 +-
.../BaseDatasetBulkInsertCommitActionExecutor.java | 16 +-
.../functional/TestHoodieClientOnLsmStorage.java | 364 +++++++++++++++++
.../bulkinsert/TestLSMBulkInsertPartitioner.java | 310 +++++++++++++++
.../apache/hudi/functional/TestLSMDataSource.scala | 441 +++++++++++++++++++++
.../apache/hudi/functional/TestMORDataSource.scala | 122 +-----
.../hudi/dml/insert/TestInsertWithLSMLayout.scala | 238 +++++++++++
19 files changed, 1891 insertions(+), 118 deletions(-)
diff --git
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieWriteConfig.java
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieWriteConfig.java
index 73bbffb749d9..46a09baebc71 100644
---
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieWriteConfig.java
+++
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieWriteConfig.java
@@ -1775,6 +1775,12 @@ public class HoodieWriteConfig extends HoodieConfig {
return BulkInsertSortMode.valueOf(sortMode.toUpperCase());
}
+ public boolean isLSMTreeStorageLayout() {
+ return HoodieTableConfig.TableStorageLayout.fromConfigValue(
+ getStringOrDefault(HoodieTableConfig.TABLE_STORAGE_LAYOUT))
+ == HoodieTableConfig.TableStorageLayout.LSM_TREE;
+ }
+
public boolean isMergeDataValidationCheckEnabled() {
return getBoolean(MERGE_DATA_VALIDATION_CHECK_ENABLE);
}
@@ -3802,6 +3808,9 @@ public class HoodieWriteConfig extends HoodieConfig {
protected void setDefaults() {
writeConfig.setDefaultValue(MARKERS_TYPE,
getDefaultMarkersType(engineType));
+ if (writeConfig.isLSMTreeStorageLayout()) {
+ writeConfig.setDefaultValue(BULK_INSERT_SORT_MODE,
BulkInsertSortMode.PARTITION_SORT.name());
+ }
// Check for mandatory properties
writeConfig.setDefaults(HoodieWriteConfig.class.getName());
// Make sure the props is propagated
diff --git
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/BaseCreateHandle.java
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/BaseCreateHandle.java
index 2decb5f2ecdf..6eaba960606b 100644
---
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/BaseCreateHandle.java
+++
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/BaseCreateHandle.java
@@ -141,8 +141,8 @@ public abstract class BaseCreateHandle<T, I, K, O> extends
HoodieWriteHandle<T,
Iterator<String> keyIterator;
if (hoodieTable.requireSortedRecords()) {
// Sorting the keys limits the amount of extra memory required for
writing sorted records.
- // requireSortedRecords() is true only for HFile base files, which order
keys by UTF-8 bytes,
- // not String (UTF-16) order, so sort with the matching comparator.
+ // HFile base files and LSM tables order keys by UTF-8 bytes, not String
(UTF-16) order,
+ // so sort with the matching comparator.
keyIterator =
recordMap.keySet().stream().sorted(StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR).iterator();
} else {
keyIterator = recordMap.keySet().stream().iterator();
diff --git
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/config/TestHoodieWriteConfig.java
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/config/TestHoodieWriteConfig.java
index 8d50ec397ccc..80e0e84d4f52 100644
---
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/config/TestHoodieWriteConfig.java
+++
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/config/TestHoodieWriteConfig.java
@@ -38,6 +38,7 @@ import org.apache.hudi.config.HoodieWriteConfig.Builder;
import org.apache.hudi.core.transaction.lock.InProcessLockProvider;
import org.apache.hudi.core.transaction.lock.NoopLockProvider;
import org.apache.hudi.exception.HoodieIndexException;
+import org.apache.hudi.execution.bulkinsert.BulkInsertSortMode;
import org.apache.hudi.index.HoodieIndex;
import org.apache.hudi.keygen.constant.KeyGeneratorOptions;
@@ -155,6 +156,40 @@ public class TestHoodieWriteConfig {
EngineType.JAVA, HoodieIndex.IndexType.SIMPLE));
}
+ @Test
+ public void testDefaultBulkInsertSortModeForLsmLayout() {
+ HoodieWriteConfig defaultLayoutConfig = HoodieWriteConfig.newBuilder()
+ .withPath("/tmp/default-layout")
+ .build();
+ assertFalse(defaultLayoutConfig.isLSMTreeStorageLayout());
+ assertEquals(BulkInsertSortMode.NONE,
defaultLayoutConfig.getBulkInsertSortMode());
+
+ Properties lsmLayoutProps = new Properties();
+ lsmLayoutProps.setProperty(
+ HoodieTableConfig.TABLE_STORAGE_LAYOUT.key(),
+ HoodieTableConfig.TableStorageLayout.LSM_TREE.configValue());
+ HoodieWriteConfig lsmLayoutConfig = HoodieWriteConfig.newBuilder()
+ .withPath("/tmp/lsm-layout")
+ .withProperties(lsmLayoutProps)
+ .build();
+ assertTrue(lsmLayoutConfig.isLSMTreeStorageLayout());
+ assertEquals(BulkInsertSortMode.PARTITION_SORT,
lsmLayoutConfig.getBulkInsertSortMode());
+
+ HoodieWriteConfig javaLsmLayoutConfig = HoodieWriteConfig.newBuilder()
+ .withPath("/tmp/java-lsm-layout")
+ .withEngineType(EngineType.JAVA)
+ .withProperties(lsmLayoutProps)
+ .build();
+ assertEquals(BulkInsertSortMode.PARTITION_SORT,
javaLsmLayoutConfig.getBulkInsertSortMode());
+
+ HoodieWriteConfig explicitlyUnsortedLsmConfig =
HoodieWriteConfig.newBuilder()
+ .withPath("/tmp/explicitly-unsorted-lsm-layout")
+ .withProperties(lsmLayoutProps)
+ .withBulkInsertSortMode(BulkInsertSortMode.NONE.name())
+ .build();
+ assertEquals(BulkInsertSortMode.NONE,
explicitlyUnsortedLsmConfig.getBulkInsertSortMode());
+ }
+
@Test
public void testDefaultClusteringPlanStrategyClassAccordingToEngineType() {
testEngineSpecificConfig(HoodieWriteConfig::getClusteringPlanStrategyClass,
diff --git
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/run/strategy/MultipleSparkJobExecutionStrategy.java
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/run/strategy/MultipleSparkJobExecutionStrategy.java
index befa4b925e8d..d085f2b29c1b 100644
---
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/run/strategy/MultipleSparkJobExecutionStrategy.java
+++
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/run/strategy/MultipleSparkJobExecutionStrategy.java
@@ -215,7 +215,9 @@ public abstract class MultipleSparkJobExecutionStrategy<T>
throw new UnsupportedOperationException(String.format("Layout
optimization strategy '%s' is not supported", layoutOptStrategy));
}
}).orElseGet(() -> isRowPartitioner
- ? BulkInsertInternalPartitionerWithRowsFactory.get(getWriteConfig(),
getHoodieTable().isPartitioned(), true)
+ ? BulkInsertInternalPartitionerWithRowsFactory.get(
+ getHoodieTable().getMetaClient().getTableConfig(),
getWriteConfig(),
+ getHoodieTable().isPartitioned(), true)
: BulkInsertInternalPartitionerFactory.get(getHoodieTable(),
getWriteConfig(), true));
}
diff --git
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/BulkInsertInternalPartitionerFactory.java
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/BulkInsertInternalPartitionerFactory.java
index aa2d26f67c7c..74160a0a0efc 100644
---
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/BulkInsertInternalPartitionerFactory.java
+++
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/BulkInsertInternalPartitionerFactory.java
@@ -45,12 +45,27 @@ public abstract class BulkInsertInternalPartitionerFactory {
return new RDDSimpleBucketBulkInsertPartitioner(table);
}
}
+ if (table.getMetaClient().getTableConfig().isLSMTreeStorageLayout()) {
+ switch (config.getBulkInsertSortMode()) {
+ case GLOBAL_SORT:
+ return new LSMGlobalSortPartitioner<>(config);
+ case PARTITION_SORT:
+ return new LSMPartitionSortPartitioner<>(config);
+ case PARTITION_PATH_REPARTITION_AND_SORT:
+ return new LSMPartitionPathRepartitionAndSortPartitioner<>(
+ table.isPartitioned(), config);
+ default:
+ throw new HoodieException(
+ "The bulk insert sort mode \"" +
config.getBulkInsertSortMode().name()
+ + "\" does not guarantee record ordering and is not
supported for LSM tables.");
+ }
+ }
return get(config, table.isPartitioned(), enforceNumOutputPartitions);
}
- public static BulkInsertPartitioner get(HoodieWriteConfig config,
- boolean isTablePartitioned,
- boolean enforceNumOutputPartitions) {
+ static BulkInsertPartitioner get(HoodieWriteConfig config,
+ boolean isTablePartitioned,
+ boolean enforceNumOutputPartitions) {
BulkInsertSortMode sortMode = config.getBulkInsertSortMode();
switch (sortMode) {
diff --git
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/BulkInsertInternalPartitionerWithRowsFactory.java
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/BulkInsertInternalPartitionerWithRowsFactory.java
index 07995e50d6a3..804cc1e3f3be 100644
---
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/BulkInsertInternalPartitionerWithRowsFactory.java
+++
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/BulkInsertInternalPartitionerWithRowsFactory.java
@@ -18,7 +18,9 @@
package org.apache.hudi.execution.bulkinsert;
+import org.apache.hudi.common.table.HoodieTableConfig;
import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.exception.HoodieException;
import org.apache.hudi.table.BulkInsertPartitioner;
import org.apache.spark.sql.Dataset;
@@ -30,14 +32,37 @@ import org.apache.spark.sql.Row;
*/
public abstract class BulkInsertInternalPartitionerWithRowsFactory {
- public static BulkInsertPartitioner<Dataset<Row>> get(HoodieWriteConfig
config,
+ public static BulkInsertPartitioner<Dataset<Row>> get(HoodieTableConfig
tableConfig,
+ HoodieWriteConfig
config,
boolean
isTablePartitioned) {
- return get(config, isTablePartitioned, false);
+ return get(tableConfig, config, isTablePartitioned, false);
}
- public static BulkInsertPartitioner<Dataset<Row>> get(HoodieWriteConfig
config,
+ public static BulkInsertPartitioner<Dataset<Row>> get(HoodieTableConfig
tableConfig,
+ HoodieWriteConfig
config,
boolean
isTablePartitioned,
boolean
enforceNumOutputPartitions) {
+ if (tableConfig.isLSMTreeStorageLayout()) {
+ switch (config.getBulkInsertSortMode()) {
+ case GLOBAL_SORT:
+ return new GlobalSortPartitionerWithRows(config);
+ case PARTITION_SORT:
+ return new PartitionSortPartitionerWithRows(config);
+ case PARTITION_PATH_REPARTITION_AND_SORT:
+ return new LSMPartitionPathRepartitionAndSortPartitionerWithRows(
+ isTablePartitioned, config);
+ default:
+ throw new HoodieException(
+ "The bulk insert sort mode \"" +
config.getBulkInsertSortMode().name()
+ + "\" does not guarantee record ordering and is not
supported for LSM tables.");
+ }
+ }
+ return get(config, isTablePartitioned, enforceNumOutputPartitions);
+ }
+
+ static BulkInsertPartitioner<Dataset<Row>> get(HoodieWriteConfig config,
+ boolean isTablePartitioned,
+ boolean
enforceNumOutputPartitions) {
BulkInsertSortMode sortMode = config.getBulkInsertSortMode();
switch (sortMode) {
case NONE:
diff --git
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/LSMBulkInsertRecordSorter.java
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/LSMBulkInsertRecordSorter.java
new file mode 100644
index 000000000000..7e4cd4571abe
--- /dev/null
+++
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/LSMBulkInsertRecordSorter.java
@@ -0,0 +1,98 @@
+/*
+ * 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.execution.bulkinsert;
+
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.util.StringUtils;
+
+import org.apache.spark.api.java.JavaPairRDD;
+import org.apache.spark.api.java.JavaRDD;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+import scala.Tuple2;
+
+/**
+ * Shared sorting operations for LSM RDD bulk-insert partitioners.
+ *
+ * <p>LSM base files must be ordered by partition path and record key using
their UTF-8 byte
+ * representation. This helper exposes two variants of that ordering:
+ *
+ * <ul>
+ * <li>{@link #keyByPartitionAndRecordKey(JavaRDD)} attaches the LSM sort
key to records that
+ * will subsequently be repartitioned and sorted by a caller.</li>
+ * <li>{@link #sortWithinPartitions(JavaRDD)} sorts records without changing
which Spark
+ * partition they belong to. This is used when a bulk-insert partitioner
has already
+ * established the desired record distribution.</li>
+ * </ul>
+ */
+final class LSMBulkInsertRecordSorter {
+
+ /** Comparator for the externally visible (partition path, record key) LSM
sort key. */
+ static final Comparator<Tuple2<String, String>> KEY_COMPARATOR =
+ (Comparator<Tuple2<String, String>> & Serializable) (left, right) ->
+ comparePartitionAndRecordKeys(left._1, left._2, right._1, right._2);
+
+ private LSMBulkInsertRecordSorter() {
+ }
+
+ /**
+ * Keys records by the LSM physical ordering columns.
+ *
+ * <p>The returned pair RDD is intended for callers that need to choose
their own Spark
+ * partitioner and invoke {@code repartitionAndSortWithinPartitions}.
+ */
+ static <T> JavaPairRDD<Tuple2<String, String>, HoodieRecord<T>>
keyByPartitionAndRecordKey(
+ JavaRDD<HoodieRecord<T>> records) {
+ return records.mapToPair(record -> new Tuple2<>(
+ new Tuple2<>(record.getPartitionPath(), record.getRecordKey()),
record));
+ }
+
+ /**
+ * Sorts each existing Spark partition by the LSM physical ordering without
changing record
+ * distribution between partitions.
+ *
+ * <p>This follows the existing {@link RDDPartitionSortPartitioner}
execution model: each Spark
+ * partition is materialized into a list and sorted locally without
introducing another shuffle.
+ * Memory usage is therefore proportional to the largest input partition.
+ */
+ static <T> JavaRDD<HoodieRecord<T>> sortWithinPartitions(
+ JavaRDD<HoodieRecord<T>> records) {
+ return records.mapPartitions(iterator -> {
+ List<HoodieRecord<T>> recordList = new ArrayList<>();
+ iterator.forEachRemaining(recordList::add);
+ recordList.sort((left, right) -> comparePartitionAndRecordKeys(
+ left.getPartitionPath(), left.getRecordKey(),
+ right.getPartitionPath(), right.getRecordKey()));
+ return recordList.iterator();
+ });
+ }
+
+ private static int comparePartitionAndRecordKeys(
+ String leftPartitionPath, String leftRecordKey,
+ String rightPartitionPath, String rightRecordKey) {
+ int partitionComparison = StringUtils.compareUtf8Bytes(leftPartitionPath,
rightPartitionPath);
+ return partitionComparison != 0
+ ? partitionComparison
+ : StringUtils.compareUtf8Bytes(leftRecordKey, rightRecordKey);
+ }
+}
diff --git
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/LSMGlobalSortPartitioner.java
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/LSMGlobalSortPartitioner.java
new file mode 100644
index 000000000000..416e23127627
--- /dev/null
+++
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/LSMGlobalSortPartitioner.java
@@ -0,0 +1,67 @@
+/*
+ * 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.execution.bulkinsert;
+
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.exception.HoodieException;
+import org.apache.hudi.table.BulkInsertPartitioner;
+
+import org.apache.spark.api.java.JavaRDD;
+
+import static
org.apache.hudi.execution.bulkinsert.BulkInsertSortMode.GLOBAL_SORT;
+import static
org.apache.hudi.execution.bulkinsert.LSMBulkInsertRecordSorter.KEY_COMPARATOR;
+import static
org.apache.hudi.execution.bulkinsert.LSMBulkInsertRecordSorter.keyByPartitionAndRecordKey;
+
+/**
+ * LSM RDD bulk-insert partitioner for {@link BulkInsertSortMode#GLOBAL_SORT}.
+ *
+ * <p>Like {@link GlobalSortPartitioner}, this partitioner globally sorts the
input and range
+ * partitions it across the requested number of Spark partitions. The sort key
and comparator are
+ * intentionally different: {@code GlobalSortPartitioner} concatenates the
partition path and
+ * record key and relies on the natural Java String ordering, while this
implementation keeps the
+ * two key components separate and compares each component by its UTF-8 bytes.
This preserves key
+ * boundaries and produces the physical ordering required by LSM base files.
+ */
+public class LSMGlobalSortPartitioner<T>
+ implements BulkInsertPartitioner<JavaRDD<HoodieRecord<T>>> {
+
+ private final boolean shouldPopulateMetaFields;
+
+ public LSMGlobalSortPartitioner(HoodieWriteConfig config) {
+ this.shouldPopulateMetaFields = config.populateMetaFields();
+ }
+
+ @Override
+ public JavaRDD<HoodieRecord<T>> repartitionRecords(JavaRDD<HoodieRecord<T>>
records,
+ int
outputSparkPartitions) {
+ if (!shouldPopulateMetaFields) {
+ throw new HoodieException(GLOBAL_SORT.name() + " mode requires
meta-fields to be enabled");
+ }
+
+ return keyByPartitionAndRecordKey(records)
+ .sortByKey(KEY_COMPARATOR, true, outputSparkPartitions)
+ .values();
+ }
+
+ @Override
+ public boolean arePartitionRecordsSorted() {
+ return true;
+ }
+}
diff --git
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/LSMPartitionPathRepartitionAndSortPartitioner.java
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/LSMPartitionPathRepartitionAndSortPartitioner.java
new file mode 100644
index 000000000000..1654bd1a62f0
--- /dev/null
+++
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/LSMPartitionPathRepartitionAndSortPartitioner.java
@@ -0,0 +1,88 @@
+/*
+ * 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.execution.bulkinsert;
+
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.exception.HoodieException;
+import org.apache.hudi.table.BulkInsertPartitioner;
+
+import org.apache.spark.api.java.JavaRDD;
+
+import scala.Tuple2;
+
+import static
org.apache.hudi.execution.bulkinsert.BulkInsertSortMode.PARTITION_PATH_REPARTITION_AND_SORT;
+import static
org.apache.hudi.execution.bulkinsert.LSMBulkInsertRecordSorter.KEY_COMPARATOR;
+import static
org.apache.hudi.execution.bulkinsert.LSMBulkInsertRecordSorter.keyByPartitionAndRecordKey;
+import static
org.apache.hudi.execution.bulkinsert.LSMBulkInsertRecordSorter.sortWithinPartitions;
+
+/**
+ * LSM RDD bulk-insert partitioner for
+ * {@link BulkInsertSortMode#PARTITION_PATH_REPARTITION_AND_SORT}.
+ *
+ * <p>Unlike {@link PartitionPathRepartitionAndSortPartitioner}, which orders
partitioned input
+ * only by partition path and leaves non-partitioned input unsorted, this
implementation orders
+ * every output Spark partition by {@code (partition path, record key)} using
UTF-8 byte ordering.
+ * This stronger ordering is required for records written to LSM base files.
+ *
+ * <p>For a physically partitioned table, {@link PartitionPathRDDPartitioner}
distributes records
+ * using only the partition-path component, keeping all records for the same
table partition
+ * together. {@code repartitionAndSortWithinPartitions} then sorts the
composite key with the LSM
+ * comparator, so records within each table partition are ordered by record
key as well.
+ *
+ * <p>For a physically non-partitioned table, the input is coalesced to the
requested parallelism
+ * and each resulting Spark partition is sorted locally with the same LSM
comparator. Therefore,
+ * both branches produce sorted output and {@link
#arePartitionRecordsSorted()} always returns
+ * {@code true}.
+ */
+public class LSMPartitionPathRepartitionAndSortPartitioner<T>
+ implements BulkInsertPartitioner<JavaRDD<HoodieRecord<T>>> {
+
+ private final boolean isTablePartitioned;
+ private final boolean shouldPopulateMetaFields;
+
+ public LSMPartitionPathRepartitionAndSortPartitioner(boolean
isTablePartitioned,
+ HoodieWriteConfig
config) {
+ this.isTablePartitioned = isTablePartitioned;
+ this.shouldPopulateMetaFields = config.populateMetaFields();
+ }
+
+ @Override
+ public JavaRDD<HoodieRecord<T>> repartitionRecords(JavaRDD<HoodieRecord<T>>
records,
+ int
outputSparkPartitions) {
+ if (!shouldPopulateMetaFields) {
+ throw new HoodieException(
+ PARTITION_PATH_REPARTITION_AND_SORT.name() + " mode requires
meta-fields to be enabled");
+ }
+
+ if (isTablePartitioned) {
+ PartitionPathRDDPartitioner partitioner = new
PartitionPathRDDPartitioner(
+ key -> ((Tuple2<String, String>) key)._1, outputSparkPartitions);
+ return keyByPartitionAndRecordKey(records)
+ .repartitionAndSortWithinPartitions(partitioner, KEY_COMPARATOR)
+ .values();
+ }
+ return sortWithinPartitions(records.coalesce(outputSparkPartitions));
+ }
+
+ @Override
+ public boolean arePartitionRecordsSorted() {
+ return true;
+ }
+}
diff --git
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/LSMPartitionPathRepartitionAndSortPartitionerWithRows.java
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/LSMPartitionPathRepartitionAndSortPartitionerWithRows.java
new file mode 100644
index 000000000000..84f916f35367
--- /dev/null
+++
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/LSMPartitionPathRepartitionAndSortPartitionerWithRows.java
@@ -0,0 +1,82 @@
+/*
+ * 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.execution.bulkinsert;
+
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.exception.HoodieException;
+import org.apache.hudi.table.BulkInsertPartitioner;
+
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.functions;
+
+import static
org.apache.hudi.execution.bulkinsert.BulkInsertSortMode.PARTITION_PATH_REPARTITION_AND_SORT;
+
+/**
+ * LSM Dataset Row bulk-insert partitioner for
+ * {@link BulkInsertSortMode#PARTITION_PATH_REPARTITION_AND_SORT}.
+ *
+ * <p>Unlike {@link PartitionPathRepartitionAndSortPartitionerWithRows}, which
orders partitioned
+ * input only by partition path and leaves non-partitioned input unsorted,
this implementation
+ * orders every output Spark partition by the {@link
HoodieRecord#PARTITION_PATH_METADATA_FIELD}
+ * and {@link HoodieRecord#RECORD_KEY_METADATA_FIELD} columns. Spark SQL
stores these columns as
+ * UTF-8 strings, giving LSM base files their required UTF-8 physical ordering
without adding
+ * temporary sort columns or changing the input schema.
+ *
+ * <p>For a physically partitioned table, rows are first repartitioned by the
partition-path
+ * metadata column so that one table partition is not split by the
distribution key, and then
+ * sorted within each resulting Spark partition by partition path and record
key. For a physically
+ * non-partitioned table, rows are coalesced before applying the same local
sort. Both branches
+ * require populated meta fields and produce sorted output, so
+ * {@link #arePartitionRecordsSorted()} always returns {@code true}.
+ */
+public class LSMPartitionPathRepartitionAndSortPartitionerWithRows
+ implements BulkInsertPartitioner<Dataset<Row>> {
+
+ private final boolean isTablePartitioned;
+ private final boolean shouldPopulateMetaFields;
+
+ public LSMPartitionPathRepartitionAndSortPartitionerWithRows(boolean
isTablePartitioned,
+
HoodieWriteConfig config) {
+ this.isTablePartitioned = isTablePartitioned;
+ this.shouldPopulateMetaFields = config.populateMetaFields();
+ }
+
+ @Override
+ public Dataset<Row> repartitionRecords(Dataset<Row> rows, int
outputSparkPartitions) {
+ if (!shouldPopulateMetaFields) {
+ throw new HoodieException(
+ PARTITION_PATH_REPARTITION_AND_SORT.name() + " mode requires
meta-fields to be enabled");
+ }
+
+ Dataset<Row> repartitionedRows = isTablePartitioned
+ ? rows.repartition(
+ outputSparkPartitions,
functions.col(HoodieRecord.PARTITION_PATH_METADATA_FIELD))
+ : rows.coalesce(outputSparkPartitions);
+ return repartitionedRows.sortWithinPartitions(
+ functions.col(HoodieRecord.PARTITION_PATH_METADATA_FIELD),
+ functions.col(HoodieRecord.RECORD_KEY_METADATA_FIELD));
+ }
+
+ @Override
+ public boolean arePartitionRecordsSorted() {
+ return true;
+ }
+}
diff --git
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/LSMPartitionSortPartitioner.java
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/LSMPartitionSortPartitioner.java
new file mode 100644
index 000000000000..8069fc7ba05f
--- /dev/null
+++
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/LSMPartitionSortPartitioner.java
@@ -0,0 +1,64 @@
+/*
+ * 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.execution.bulkinsert;
+
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.exception.HoodieException;
+import org.apache.hudi.table.BulkInsertPartitioner;
+
+import org.apache.spark.api.java.JavaRDD;
+
+import static
org.apache.hudi.execution.bulkinsert.BulkInsertSortMode.PARTITION_SORT;
+import static
org.apache.hudi.execution.bulkinsert.LSMBulkInsertRecordSorter.sortWithinPartitions;
+
+/**
+ * LSM RDD bulk-insert partitioner for {@link
BulkInsertSortMode#PARTITION_SORT}.
+ *
+ * <p>Like {@link RDDPartitionSortPartitioner}, this partitioner first
coalesces the input to the
+ * requested parallelism and then materializes and sorts the records within
each Spark partition.
+ * The ordering is intentionally different: {@code
RDDPartitionSortPartitioner} compares its
+ * combined partition-path and record-key string with Java {@link
String#compareTo(String)}, while
+ * an LSM table must compare the partition path and record key separately in
UTF-8 byte order to
+ * preserve the LSM base-file ordering invariant.
+ */
+public class LSMPartitionSortPartitioner<T>
+ implements BulkInsertPartitioner<JavaRDD<HoodieRecord<T>>> {
+
+ private final boolean shouldPopulateMetaFields;
+
+ public LSMPartitionSortPartitioner(HoodieWriteConfig config) {
+ this.shouldPopulateMetaFields = config.populateMetaFields();
+ }
+
+ @Override
+ public JavaRDD<HoodieRecord<T>> repartitionRecords(JavaRDD<HoodieRecord<T>>
records,
+ int
outputSparkPartitions) {
+ if (!shouldPopulateMetaFields) {
+ throw new HoodieException(PARTITION_SORT.name() + " mode requires
meta-fields to be enabled");
+ }
+
+ return sortWithinPartitions(records.coalesce(outputSparkPartitions));
+ }
+
+ @Override
+ public boolean arePartitionRecordsSorted() {
+ return true;
+ }
+}
diff --git
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/BaseSparkCommitActionExecutor.java
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/BaseSparkCommitActionExecutor.java
index add4d33c4ed7..bc8a3b3c4dc6 100644
---
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/BaseSparkCommitActionExecutor.java
+++
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/BaseSparkCommitActionExecutor.java
@@ -328,8 +328,8 @@ public abstract class BaseSparkCommitActionExecutor<T>
extends
if (table.requireSortedRecords()) {
// Partition and sort within each partition as a single step. This is
faster than partitioning first and then
// applying a sort.
- // requireSortedRecords() is true only for HFile base files, which order
keys by UTF-8 bytes,
- // not String (UTF-16) order, so sort with the matching comparator.
+ // HFile base files and LSM tables order keys by UTF-8 bytes, not String
(UTF-16) order,
+ // so sort with the matching comparator.
Comparator<Tuple2<HoodieKey, Option<HoodieRecordLocation>>> comparator =
(Comparator<Tuple2<HoodieKey, Option<HoodieRecordLocation>>> & Serializable)
(t1, t2) -> {
HoodieKey key1 = t1._1;
HoodieKey key2 = t2._1;
diff --git
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkBulkInsertHelper.java
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkBulkInsertHelper.java
index b1729baa6b53..c1ba3b43af24 100644
---
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkBulkInsertHelper.java
+++
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkBulkInsertHelper.java
@@ -38,6 +38,8 @@ import org.apache.spark.api.java.JavaRDD;
import java.util.List;
+import static org.apache.hudi.common.util.ValidationUtils.checkArgument;
+
/**
* A spark implementation of {@link BaseBulkInsertHelper}.
*
@@ -69,13 +71,18 @@ public class SparkBulkInsertHelper<T, R> extends
BaseBulkInsertHelper<T, HoodieD
final
Option<BulkInsertPartitioner> userDefinedBulkInsertPartitioner) {
HoodieWriteMetadata result = new HoodieWriteMetadata();
+ boolean isLsmTable =
table.getMetaClient().getTableConfig().isLSMTreeStorageLayout();
+ checkArgument(!isLsmTable || userDefinedBulkInsertPartitioner.isEmpty(),
+ "User-defined bulk insert partitioners are not supported for LSM
tables because "
+ + "their record-key ordering cannot be verified");
+ BulkInsertPartitioner partitioner = userDefinedBulkInsertPartitioner
+ .orElseGet(() -> BulkInsertInternalPartitionerFactory.get(table,
config));
+
// Transition bulk_insert state to inflight
table.getActiveTimeline().transitionRequestedToInflight(table.getInstantGenerator().createNewInstant(HoodieInstant.State.REQUESTED,
executor.getCommitActionType(), instantTime), Option.empty(),
config.shouldAllowMultiWriteOnSameInstant());
- BulkInsertPartitioner partitioner =
userDefinedBulkInsertPartitioner.orElseGet(() ->
BulkInsertInternalPartitionerFactory.get(table, config));
-
// Write new files
HoodieData<WriteStatus> writeStatuses =
bulkInsert(inputRecords, instantTime, table, config, performDedupe,
partitioner, false,
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/commit/BaseDatasetBulkInsertCommitActionExecutor.java
b/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/commit/BaseDatasetBulkInsertCommitActionExecutor.java
index 8b6466d5117d..e591eb272768 100644
---
a/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/commit/BaseDatasetBulkInsertCommitActionExecutor.java
+++
b/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/commit/BaseDatasetBulkInsertCommitActionExecutor.java
@@ -146,11 +146,21 @@ public abstract class
BaseDatasetBulkInsertCommitActionExecutor implements Seria
return new ConsistentBucketIndexBulkInsertPartitionerWithRows(table,
Collections.emptyMap(), true);
}
} else {
- return DataSourceUtils
- .createUserDefinedBulkInsertPartitionerWithRows(writeConfig)
- .orElseGet(() ->
BulkInsertInternalPartitionerWithRowsFactory.get(writeConfig,
isTablePartitioned));
+ Option<BulkInsertPartitioner<Dataset<Row>>> userDefinedPartitioner =
+
DataSourceUtils.createUserDefinedBulkInsertPartitionerWithRows(writeConfig);
+ if (userDefinedPartitioner.isPresent() &&
table.getMetaClient().getTableConfig().isLSMTreeStorageLayout()) {
+ throw new HoodieException(
+ "User-defined bulk insert partitioners are not supported for LSM
tables because "
+ + "their record-key ordering cannot be verified");
+ }
+ return userDefinedPartitioner.orElseGet(
+ () -> BulkInsertInternalPartitionerWithRowsFactory.get(
+ table.getMetaClient().getTableConfig(), writeConfig,
isTablePartitioned));
}
} else {
+ if (table.getMetaClient().getTableConfig().isLSMTreeStorageLayout()) {
+ throw new HoodieException("The Dataset Row writer requires
hoodie.populate.meta.fields=true for LSM tables.");
+ }
// Sort modes are not yet supported when meta fields are disabled
return new NonSortPartitionerWithRows();
}
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/client/functional/TestHoodieClientOnLsmStorage.java
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/client/functional/TestHoodieClientOnLsmStorage.java
new file mode 100644
index 000000000000..5fa1eb002464
--- /dev/null
+++
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/client/functional/TestHoodieClientOnLsmStorage.java
@@ -0,0 +1,364 @@
+/*
+ * 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.client.functional;
+
+import org.apache.hudi.client.HoodieWriteResult;
+import org.apache.hudi.client.SparkRDDWriteClient;
+import org.apache.hudi.client.WriteClientTestUtils;
+import org.apache.hudi.client.WriteStatus;
+import org.apache.hudi.common.config.HoodieStorageConfig;
+import org.apache.hudi.common.model.HoodieCommitMetadata;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.model.HoodieReplaceCommitMetadata;
+import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.model.WriteOperationType;
+import org.apache.hudi.common.table.HoodieTableConfig;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.common.table.timeline.HoodieTimeline;
+import org.apache.hudi.common.testutils.HoodieTestDataGenerator;
+import org.apache.hudi.common.testutils.HoodieTestUtils;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.exception.HoodieInsertException;
+import org.apache.hudi.execution.bulkinsert.NonSortPartitioner;
+import org.apache.hudi.testutils.HoodieClientTestBase;
+
+import org.apache.spark.api.java.JavaRDD;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+
+import static
org.apache.hudi.common.testutils.HoodieTestDataGenerator.getCommitTimeAtUTC;
+import static org.apache.hudi.testutils.Assertions.assertNoWriteErrors;
+import static
org.apache.hudi.testutils.HoodieClientTestBase.wrapRecordsGenFunctionForPreppedCalls;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@Tag("functional")
+public class TestHoodieClientOnLsmStorage extends HoodieClientTestBase {
+
+ @ParameterizedTest
+ @EnumSource(value = HoodieTableType.class)
+ void testInsert(HoodieTableType tableType) throws IOException {
+ LsmTableTestContext testContext = createTestContext(tableType);
+ try (SparkRDDWriteClient client =
getHoodieWriteClient(testContext.writeConfig)) {
+ String instantTime = getCommitTimeAtUTC(1);
+ List<HoodieRecord> records = generateInserts(testContext.dataGenerator,
instantTime);
+ WriteClientTestUtils.startCommitWithTime(client, instantTime);
+ commitWrite(client, instantTime, client.insert(jsc.parallelize(records,
2), instantTime));
+ assertCompletedOperation(testContext.metaClient, instantTime,
WriteOperationType.INSERT);
+ }
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = HoodieTableType.class)
+ void testInsertPrepped(HoodieTableType tableType) throws IOException {
+ LsmTableTestContext testContext = createTestContext(tableType);
+ try (SparkRDDWriteClient client =
getHoodieWriteClient(testContext.writeConfig)) {
+ String instantTime = getCommitTimeAtUTC(1);
+ List<HoodieRecord> records = wrapRecordsGenFunctionForPreppedCalls(
+ testContext.tablePath, storageConf, context, testContext.writeConfig,
+ testContext.dataGenerator::generateInserts).apply(instantTime, 4);
+ WriteClientTestUtils.startCommitWithTime(client, instantTime);
+ commitWrite(client, instantTime,
client.insertPreppedRecords(jsc.parallelize(records, 2), instantTime));
+ assertCompletedOperation(testContext.metaClient, instantTime,
WriteOperationType.INSERT_PREPPED);
+ }
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = HoodieTableType.class)
+ void testBulkInsert(HoodieTableType tableType) throws IOException {
+ LsmTableTestContext testContext = createTestContext(tableType);
+ try (SparkRDDWriteClient client =
getHoodieWriteClient(testContext.writeConfig)) {
+ String instantTime = getCommitTimeAtUTC(1);
+ List<HoodieRecord> records = generateInserts(testContext.dataGenerator,
instantTime);
+ WriteClientTestUtils.startCommitWithTime(client, instantTime);
+ commitWrite(client, instantTime,
client.bulkInsert(jsc.parallelize(records, 2), instantTime));
+ assertCompletedOperation(testContext.metaClient, instantTime,
WriteOperationType.BULK_INSERT);
+ }
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = HoodieTableType.class)
+ void testBulkInsertPrepped(HoodieTableType tableType) throws IOException {
+ LsmTableTestContext testContext = createTestContext(tableType);
+ try (SparkRDDWriteClient client =
getHoodieWriteClient(testContext.writeConfig)) {
+ String instantTime = getCommitTimeAtUTC(1);
+ List<HoodieRecord> records = wrapRecordsGenFunctionForPreppedCalls(
+ testContext.tablePath, storageConf, context, testContext.writeConfig,
+ testContext.dataGenerator::generateInserts).apply(instantTime, 4);
+ WriteClientTestUtils.startCommitWithTime(client, instantTime);
+ commitWrite(client, instantTime, client.bulkInsertPreppedRecords(
+ jsc.parallelize(records, 2), instantTime, Option.empty()));
+ assertCompletedOperation(
+ testContext.metaClient, instantTime,
WriteOperationType.BULK_INSERT_PREPPED);
+ }
+ }
+
+ @Test
+ void testRejectsCustomBulkInsertPartitionerBeforeInflight() throws
IOException {
+ LsmTableTestContext testContext =
createTestContext(HoodieTableType.COPY_ON_WRITE);
+ try (SparkRDDWriteClient client =
getHoodieWriteClient(testContext.writeConfig)) {
+ String instantTime = getCommitTimeAtUTC(1);
+ List<HoodieRecord> records = generateInserts(testContext.dataGenerator,
instantTime);
+ WriteClientTestUtils.startCommitWithTime(client, instantTime);
+
+ HoodieInsertException exception =
assertThrows(HoodieInsertException.class, () -> client.bulkInsert(
+ jsc.parallelize(records, 2), instantTime, Option.of(new
NonSortPartitioner<>())));
+ assertTrue(exception.getCause() instanceof IllegalArgumentException);
+ assertEquals(
+ "User-defined bulk insert partitioners are not supported for LSM
tables because their record-key ordering cannot be verified",
+ exception.getCause().getMessage());
+
+ HoodieInstant instant =
testContext.metaClient.reloadActiveTimeline().getInstants().stream()
+ .filter(candidate -> candidate.requestedTime().equals(instantTime))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("No instant " + instantTime));
+ assertEquals(HoodieInstant.State.REQUESTED, instant.getState());
+ }
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = HoodieTableType.class)
+ void testUpsert(HoodieTableType tableType) throws IOException {
+ LsmTableTestContext testContext = createTestContext(tableType);
+ try (SparkRDDWriteClient client =
getHoodieWriteClient(testContext.writeConfig)) {
+ bootstrapTable(testContext, client);
+ String instantTime = getCommitTimeAtUTC(2);
+ WriteClientTestUtils.startCommitWithTime(client, instantTime);
+ commitWrite(client, instantTime, client.upsert(jsc.parallelize(
+ testContext.dataGenerator.generateUniqueUpdates(instantTime, 4), 2),
instantTime));
+ assertCompletedOperation(testContext.metaClient, instantTime,
WriteOperationType.UPSERT);
+ }
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = HoodieTableType.class)
+ void testUpsertPrepped(HoodieTableType tableType) throws IOException {
+ LsmTableTestContext testContext = createTestContext(tableType);
+ try (SparkRDDWriteClient client =
getHoodieWriteClient(testContext.writeConfig)) {
+ bootstrapTable(testContext, client);
+ String instantTime = getCommitTimeAtUTC(2);
+ List<HoodieRecord> records = wrapRecordsGenFunctionForPreppedCalls(
+ testContext.tablePath, storageConf, context, testContext.writeConfig,
+ testContext.dataGenerator::generateUniqueUpdates).apply(instantTime,
4);
+ WriteClientTestUtils.startCommitWithTime(client, instantTime);
+ commitWrite(client, instantTime,
client.upsertPreppedRecords(jsc.parallelize(records, 2), instantTime));
+ assertCompletedOperation(testContext.metaClient, instantTime,
WriteOperationType.UPSERT_PREPPED);
+ }
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = HoodieTableType.class)
+ void testDelete(HoodieTableType tableType) throws IOException {
+ LsmTableTestContext testContext = createTestContext(tableType);
+ try (SparkRDDWriteClient client =
getHoodieWriteClient(testContext.writeConfig)) {
+ bootstrapTable(testContext, client);
+ String instantTime = getCommitTimeAtUTC(2);
+ WriteClientTestUtils.startCommitWithTime(client, instantTime);
+ commitWrite(client, instantTime, client.delete(
+ jsc.parallelize(testContext.dataGenerator.generateUniqueDeletes(2),
2), instantTime));
+ assertCompletedOperation(testContext.metaClient, instantTime,
WriteOperationType.DELETE);
+ }
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = HoodieTableType.class)
+ void testDeletePrepped(HoodieTableType tableType) throws IOException {
+ LsmTableTestContext testContext = createTestContext(tableType);
+ try (SparkRDDWriteClient client =
getHoodieWriteClient(testContext.writeConfig)) {
+ bootstrapTable(testContext, client);
+ String instantTime = getCommitTimeAtUTC(2);
+ List<HoodieRecord> records = wrapRecordsGenFunctionForPreppedCalls(
+ testContext.tablePath, storageConf, context, testContext.writeConfig,
+
testContext.dataGenerator::generateUniqueDeleteRecords).apply(instantTime, 2);
+ WriteClientTestUtils.startCommitWithTime(client, instantTime);
+ commitWrite(client, instantTime,
client.deletePrepped(jsc.parallelize(records, 2), instantTime));
+ assertCompletedOperation(testContext.metaClient, instantTime,
WriteOperationType.DELETE_PREPPED);
+ }
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = HoodieTableType.class)
+ void testInsertOverwrite(HoodieTableType tableType) throws IOException {
+ LsmTableTestContext testContext = createTestContext(tableType);
+ try (SparkRDDWriteClient client =
getHoodieWriteClient(testContext.writeConfig)) {
+ bootstrapTable(testContext, client);
+ String instantTime = getCommitTimeAtUTC(2);
+ List<HoodieRecord> records =
testContext.dataGenerator.generateInsertsForPartition(
+ instantTime, 3,
HoodieTestDataGenerator.DEFAULT_FIRST_PARTITION_PATH);
+ WriteClientTestUtils.startCommitWithTime(client, instantTime,
HoodieTimeline.REPLACE_COMMIT_ACTION);
+ HoodieWriteResult result =
client.insertOverwrite(jsc.parallelize(records, 1), instantTime);
+ commitReplace(client, instantTime, result);
+ assertReplaceCommit(
+ testContext.metaClient, instantTime,
WriteOperationType.INSERT_OVERWRITE,
+ result.getPartitionToReplaceFileIds(),
HoodieTestDataGenerator.DEFAULT_FIRST_PARTITION_PATH);
+ }
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = HoodieTableType.class)
+ void testInsertOverwriteTable(HoodieTableType tableType) throws IOException {
+ LsmTableTestContext testContext = createTestContext(tableType);
+ try (SparkRDDWriteClient client =
getHoodieWriteClient(testContext.writeConfig)) {
+ bootstrapTable(testContext, client);
+ String instantTime = getCommitTimeAtUTC(2);
+ List<HoodieRecord> records =
testContext.dataGenerator.generateInsertsForPartition(
+ instantTime, 3,
HoodieTestDataGenerator.DEFAULT_SECOND_PARTITION_PATH);
+ WriteClientTestUtils.startCommitWithTime(client, instantTime,
HoodieTimeline.REPLACE_COMMIT_ACTION);
+ HoodieWriteResult result =
client.insertOverwriteTable(jsc.parallelize(records, 1), instantTime);
+ commitReplace(client, instantTime, result);
+ assertReplaceCommit(
+ testContext.metaClient, instantTime,
WriteOperationType.INSERT_OVERWRITE_TABLE,
+ result.getPartitionToReplaceFileIds(),
HoodieTestDataGenerator.DEFAULT_SECOND_PARTITION_PATH);
+ }
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = HoodieTableType.class)
+ void testDeletePartition(HoodieTableType tableType) throws IOException {
+ LsmTableTestContext testContext = createTestContext(tableType);
+ try (SparkRDDWriteClient client =
getHoodieWriteClient(testContext.writeConfig)) {
+ bootstrapTable(testContext, client);
+ String instantTime = getCommitTimeAtUTC(2);
+ WriteClientTestUtils.startCommitWithTime(client, instantTime,
HoodieTimeline.REPLACE_COMMIT_ACTION);
+ HoodieWriteResult result = client.deletePartitions(
+
Collections.singletonList(HoodieTestDataGenerator.DEFAULT_SECOND_PARTITION_PATH),
instantTime);
+ commitReplace(client, instantTime, result);
+ assertReplaceCommit(
+ testContext.metaClient, instantTime,
WriteOperationType.DELETE_PARTITION,
+ result.getPartitionToReplaceFileIds(),
HoodieTestDataGenerator.DEFAULT_SECOND_PARTITION_PATH);
+ }
+ }
+
+ private LsmTableTestContext createTestContext(HoodieTableType tableType)
throws IOException {
+ String tablePath = basePath + "_" + tableType.name().toLowerCase() +
"_lsm";
+ Properties tableProperties = getPropertiesForKeyGen(true);
+ tableProperties.setProperty(
+ HoodieTableConfig.TABLE_STORAGE_LAYOUT.key(),
+ HoodieTableConfig.TableStorageLayout.LSM_TREE.configValue());
+ HoodieTableMetaClient lsmMetaClient = HoodieTestUtils.init(storageConf,
tablePath, tableType, tableProperties);
+ assertEquals(
+ HoodieTableConfig.TableStorageLayout.LSM_TREE,
+ lsmMetaClient.getTableConfig().getTableStorageLayout());
+
+ Properties writeProperties = new Properties();
+ writeProperties.setProperty(
+ HoodieTableConfig.TABLE_STORAGE_LAYOUT.key(),
+ HoodieTableConfig.TableStorageLayout.LSM_TREE.configValue());
+
writeProperties.setProperty(HoodieStorageConfig.LOGFILE_DATA_BLOCK_FORMAT.key(),
"parquet");
+ HoodieWriteConfig writeConfig = getConfigBuilder()
+ .withPath(tablePath)
+ .withEmbeddedTimelineServerEnabled(false)
+ .withProperties(writeProperties)
+ .build();
+ return new LsmTableTestContext(
+ tablePath, lsmMetaClient, writeConfig, new
HoodieTestDataGenerator(0x19437));
+ }
+
+ private void bootstrapTable(LsmTableTestContext testContext,
SparkRDDWriteClient client) {
+ String instantTime = getCommitTimeAtUTC(1);
+ List<HoodieRecord> records = generateInserts(testContext.dataGenerator,
instantTime);
+ WriteClientTestUtils.startCommitWithTime(client, instantTime);
+ commitWrite(client, instantTime, client.insert(jsc.parallelize(records,
2), instantTime));
+ }
+
+ private List<HoodieRecord> generateInserts(HoodieTestDataGenerator
dataGenerator, String instantTime) {
+ List<HoodieRecord> records = new ArrayList<>();
+ records.addAll(dataGenerator.generateInsertsForPartition(
+ instantTime, 6, HoodieTestDataGenerator.DEFAULT_FIRST_PARTITION_PATH));
+ records.addAll(dataGenerator.generateInsertsForPartition(
+ instantTime, 6,
HoodieTestDataGenerator.DEFAULT_SECOND_PARTITION_PATH));
+ return records;
+ }
+
+ private void commitWrite(SparkRDDWriteClient client, String instantTime,
JavaRDD<WriteStatus> writeStatuses) {
+ assertNoWriteErrors(writeStatuses.collect());
+ assertTrue(client.commit(instantTime, writeStatuses));
+ }
+
+ private void commitReplace(SparkRDDWriteClient client, String instantTime,
HoodieWriteResult writeResult) {
+ assertNoWriteErrors(writeResult.getWriteStatuses().collect());
+ assertTrue(client.commit(
+ instantTime,
+ writeResult.getWriteStatuses(),
+ Option.empty(),
+ HoodieTimeline.REPLACE_COMMIT_ACTION,
+ writeResult.getPartitionToReplaceFileIds()));
+ }
+
+ private void assertCompletedOperation(
+ HoodieTableMetaClient metaClient, String instantTime, WriteOperationType
operationType) throws IOException {
+ HoodieInstant instant = findCompletedInstant(metaClient, instantTime);
+ HoodieCommitMetadata commitMetadata =
metaClient.getActiveTimeline().readCommitMetadata(instant);
+ assertEquals(operationType, commitMetadata.getOperationType());
+ }
+
+ private void assertReplaceCommit(
+ HoodieTableMetaClient metaClient,
+ String instantTime,
+ WriteOperationType operationType,
+ Map<String, List<String>> expectedReplacedFileIds,
+ String expectedPartition) throws IOException {
+ HoodieInstant instant = findCompletedInstant(metaClient, instantTime);
+ assertEquals(HoodieTimeline.REPLACE_COMMIT_ACTION, instant.getAction());
+ HoodieReplaceCommitMetadata commitMetadata =
metaClient.getActiveTimeline().readReplaceCommitMetadata(instant);
+ assertEquals(operationType, commitMetadata.getOperationType());
+ assertEquals(expectedReplacedFileIds,
commitMetadata.getPartitionToReplaceFileIds());
+
assertTrue(commitMetadata.getPartitionToReplaceFileIds().containsKey(expectedPartition));
+
assertFalse(commitMetadata.getPartitionToReplaceFileIds().get(expectedPartition).isEmpty());
+ }
+
+ private HoodieInstant findCompletedInstant(HoodieTableMetaClient metaClient,
String instantTime) {
+ return
metaClient.reloadActiveTimeline().filterCompletedInstants().getInstants().stream()
+ .filter(instant -> instant.requestedTime().equals(instantTime))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("No completed instant " +
instantTime));
+ }
+
+ private static class LsmTableTestContext {
+ private final String tablePath;
+ private final HoodieTableMetaClient metaClient;
+ private final HoodieWriteConfig writeConfig;
+ private final HoodieTestDataGenerator dataGenerator;
+
+ private LsmTableTestContext(
+ String tablePath,
+ HoodieTableMetaClient metaClient,
+ HoodieWriteConfig writeConfig,
+ HoodieTestDataGenerator dataGenerator) {
+ this.tablePath = tablePath;
+ this.metaClient = metaClient;
+ this.writeConfig = writeConfig;
+ this.dataGenerator = dataGenerator;
+ }
+ }
+}
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/execution/bulkinsert/TestLSMBulkInsertPartitioner.java
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/execution/bulkinsert/TestLSMBulkInsertPartitioner.java
new file mode 100644
index 000000000000..4c56005b4e31
--- /dev/null
+++
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/execution/bulkinsert/TestLSMBulkInsertPartitioner.java
@@ -0,0 +1,310 @@
+/*
+ * 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.execution.bulkinsert;
+
+import org.apache.hudi.common.model.HoodieEmptyRecord;
+import org.apache.hudi.common.model.HoodieKey;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.table.HoodieTableConfig;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.util.StringUtils;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.exception.HoodieException;
+import org.apache.hudi.table.BulkInsertPartitioner;
+import org.apache.hudi.table.HoodieTable;
+import org.apache.hudi.testutils.HoodieSparkClientTestHarness;
+
+import org.apache.spark.api.java.JavaRDD;
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.RowFactory;
+import org.apache.spark.sql.types.DataTypes;
+import org.apache.spark.sql.types.StructType;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import scala.Tuple2;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/** Tests LSM bulk-insert ordering without changing the configured built-in
sort mode. */
+public class TestLSMBulkInsertPartitioner extends HoodieSparkClientTestHarness
{
+
+ private HoodieTable lsmTable;
+
+ private static final Comparator<Tuple2<String, String>> KEY_COMPARATOR =
(left, right) -> {
+ int partitionComparison = StringUtils.compareUtf8Bytes(left._1, right._1);
+ return partitionComparison != 0
+ ? partitionComparison
+ : StringUtils.compareUtf8Bytes(left._2, right._2);
+ };
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ initSparkContexts("TestLSMBulkInsertPartitioner");
+ initPath();
+ initHoodieStorage();
+
+ lsmTable = mock(HoodieTable.class);
+ HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class);
+ HoodieTableConfig tableConfig = mock(HoodieTableConfig.class);
+ when(lsmTable.getMetaClient()).thenReturn(metaClient);
+ when(metaClient.getTableConfig()).thenReturn(tableConfig);
+ when(tableConfig.isLSMTreeStorageLayout()).thenReturn(true);
+ when(lsmTable.isPartitioned()).thenReturn(true);
+ }
+
+ @AfterEach
+ public void tearDown() throws Exception {
+ cleanupResources();
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = BulkInsertSortMode.class, names = {
+ "GLOBAL_SORT", "PARTITION_SORT", "PARTITION_PATH_REPARTITION_AND_SORT"})
+ void testHoodieRecordPartitionerSortsSupportedModes(BulkInsertSortMode
sortMode) {
+ JavaRDD<HoodieRecord<Object>> input = jsc.parallelize(createRecords(), 3);
+ BulkInsertPartitioner<JavaRDD<HoodieRecord<Object>>> partitioner =
+ BulkInsertInternalPartitionerFactory.get(
+ lsmTable, createWriteConfig(sortMode, true));
+
+ JavaRDD<HoodieRecord<Object>> actual =
partitioner.repartitionRecords(input, 4);
+
+ assertSortedSparkPartitions(actual.glom().collect(), record ->
+ new Tuple2<>(record.getPartitionPath(), record.getRecordKey()));
+ assertDistributionSemantics(sortMode, actual);
+ assertTrue(partitioner.arePartitionRecordsSorted());
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = BulkInsertSortMode.class, names = {
+ "GLOBAL_SORT", "PARTITION_SORT", "PARTITION_PATH_REPARTITION_AND_SORT"})
+ void
testRowPartitionerSortsSupportedModesWithoutChangingSchema(BulkInsertSortMode
sortMode) {
+ StructType schema = new StructType()
+ .add(HoodieRecord.PARTITION_PATH_METADATA_FIELD, DataTypes.StringType,
false)
+ .add(HoodieRecord.RECORD_KEY_METADATA_FIELD, DataTypes.StringType,
false)
+ .add("value", DataTypes.IntegerType, false);
+ Dataset<Row> input = sqlContext.createDataFrame(
+ jsc.parallelize(createRows(), 3), schema);
+ BulkInsertPartitioner<Dataset<Row>> partitioner =
+ BulkInsertInternalPartitionerWithRowsFactory.get(
+ lsmTable.getMetaClient().getTableConfig(),
createWriteConfig(sortMode, true), true);
+
+ Dataset<Row> actual = partitioner.repartitionRecords(input, 4);
+
+ assertEquals(schema, actual.schema(), "Sorting must not add temporary
columns");
+ assertSortedSparkPartitions(actual.javaRDD().glom().collect(), row -> new
Tuple2<>(
+ row.getAs(HoodieRecord.PARTITION_PATH_METADATA_FIELD),
+ row.getAs(HoodieRecord.RECORD_KEY_METADATA_FIELD)));
+ assertDistributionSemantics(sortMode, actual.javaRDD());
+ assertTrue(partitioner.arePartitionRecordsSorted());
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = BulkInsertSortMode.class, names = {
+ "GLOBAL_SORT", "PARTITION_SORT", "PARTITION_PATH_REPARTITION_AND_SORT"})
+ void testPartitionersRequireMetaFields(BulkInsertSortMode sortMode) {
+ HoodieWriteConfig config = createWriteConfig(sortMode, false);
+ BulkInsertPartitioner<JavaRDD<HoodieRecord<Object>>> recordPartitioner =
+ BulkInsertInternalPartitionerFactory.get(lsmTable, config);
+ BulkInsertPartitioner<Dataset<Row>> rowPartitioner =
+ BulkInsertInternalPartitionerWithRowsFactory.get(
+ lsmTable.getMetaClient().getTableConfig(), config, true);
+
+ HoodieException recordException = assertThrows(HoodieException.class,
+ () -> recordPartitioner.repartitionRecords(jsc.emptyRDD(), 1));
+ HoodieException rowException = assertThrows(HoodieException.class,
+ () -> rowPartitioner.repartitionRecords(sparkSession.emptyDataFrame(),
1));
+
+ String expectedMessage = sortMode.name() + " mode requires meta-fields to
be enabled";
+ assertEquals(expectedMessage, recordException.getMessage());
+ assertEquals(expectedMessage, rowException.getMessage());
+ }
+
+ @Test
+ void testRowPartitionerSelectionForLsmModes() {
+ assertRowPartitionerSelection(
+ BulkInsertSortMode.GLOBAL_SORT, GlobalSortPartitionerWithRows.class);
+ assertRowPartitionerSelection(
+ BulkInsertSortMode.PARTITION_SORT,
PartitionSortPartitionerWithRows.class);
+ assertRowPartitionerSelection(
+ BulkInsertSortMode.PARTITION_PATH_REPARTITION_AND_SORT,
+ LSMPartitionPathRepartitionAndSortPartitionerWithRows.class);
+ }
+
+ @Test
+ void testHoodieRecordPartitionerSelectionForLsmModes() {
+ assertHoodieRecordPartitionerSelection(
+ BulkInsertSortMode.GLOBAL_SORT, LSMGlobalSortPartitioner.class);
+ assertHoodieRecordPartitionerSelection(
+ BulkInsertSortMode.PARTITION_SORT, LSMPartitionSortPartitioner.class);
+ assertHoodieRecordPartitionerSelection(
+ BulkInsertSortMode.PARTITION_PATH_REPARTITION_AND_SORT,
+ LSMPartitionPathRepartitionAndSortPartitioner.class);
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = BulkInsertSortMode.class, names = {"NONE",
"PARTITION_PATH_REPARTITION"})
+ void testNonSortingModesAreRejected(BulkInsertSortMode sortMode) {
+ HoodieWriteConfig config = createWriteConfig(sortMode, true);
+ String expectedMessage = "The bulk insert sort mode \"" + sortMode.name()
+ + "\" does not guarantee record ordering and is not supported for LSM
tables.";
+
+ HoodieException recordException = assertThrows(HoodieException.class,
+ () -> BulkInsertInternalPartitionerFactory.get(lsmTable, config));
+ HoodieException rowException = assertThrows(HoodieException.class,
+ () -> BulkInsertInternalPartitionerWithRowsFactory.get(
+ lsmTable.getMetaClient().getTableConfig(), config, true));
+
+ assertEquals(expectedMessage, recordException.getMessage());
+ assertEquals(expectedMessage, rowException.getMessage());
+ }
+
+ private BulkInsertPartitioner<Dataset<Row>>
getRowPartitioner(BulkInsertSortMode sortMode) {
+ return BulkInsertInternalPartitionerWithRowsFactory.get(
+ lsmTable.getMetaClient().getTableConfig(), createWriteConfig(sortMode,
true), true);
+ }
+
+ private void assertRowPartitionerSelection(BulkInsertSortMode sortMode,
+ Class<?>
expectedPartitionerClass) {
+ HoodieWriteConfig config = createWriteConfig(sortMode, true);
+ assertEquals(expectedPartitionerClass,
+ BulkInsertInternalPartitionerWithRowsFactory.get(
+ lsmTable.getMetaClient().getTableConfig(), config,
true).getClass());
+ assertEquals(expectedPartitionerClass,
+ BulkInsertInternalPartitionerWithRowsFactory.get(
+ lsmTable.getMetaClient().getTableConfig(), config, true,
true).getClass());
+ }
+
+ private BulkInsertPartitioner<JavaRDD<HoodieRecord<Object>>>
getHoodieRecordPartitioner(
+ BulkInsertSortMode sortMode) {
+ return BulkInsertInternalPartitionerFactory.get(
+ lsmTable, createWriteConfig(sortMode, true));
+ }
+
+ private void assertHoodieRecordPartitionerSelection(BulkInsertSortMode
sortMode,
+ Class<?>
expectedPartitionerClass) {
+ HoodieWriteConfig config = createWriteConfig(sortMode, true);
+ assertEquals(expectedPartitionerClass,
+ BulkInsertInternalPartitionerFactory.get(lsmTable, config).getClass());
+ assertEquals(expectedPartitionerClass,
+ BulkInsertInternalPartitionerFactory.get(lsmTable, config,
true).getClass());
+ }
+
+ private HoodieWriteConfig createWriteConfig(BulkInsertSortMode sortMode,
boolean populateMetaFields) {
+ return HoodieWriteConfig.newBuilder()
+ .withPath(basePath)
+ .withBulkInsertSortMode(sortMode.name())
+ .withPopulateMetaFields(populateMetaFields)
+ .build();
+ }
+
+ private <T> void assertDistributionSemantics(BulkInsertSortMode sortMode,
+ JavaRDD<T> actual) {
+ if (sortMode == BulkInsertSortMode.PARTITION_PATH_REPARTITION_AND_SORT) {
+ assertEquals(4, actual.getNumPartitions());
+ assertEachTablePartitionRoutesToOneSparkPartition(actual);
+ }
+ }
+
+ private <T> void
assertEachTablePartitionRoutesToOneSparkPartition(JavaRDD<T> records) {
+ List<Tuple2<String, Integer>> partitionLocations =
records.mapPartitionsWithIndex(
+ (sparkPartition, iterator) -> {
+ Set<String> tablePartitions = new HashSet<>();
+ while (iterator.hasNext()) {
+ Object record = iterator.next();
+ tablePartitions.add(record instanceof HoodieRecord
+ ? ((HoodieRecord<?>) record).getPartitionPath()
+ : ((Row)
record).getAs(HoodieRecord.PARTITION_PATH_METADATA_FIELD));
+ }
+ List<Tuple2<String, Integer>> locations = new ArrayList<>();
+ tablePartitions.forEach(path -> locations.add(new Tuple2<>(path,
sparkPartition)));
+ return locations.iterator();
+ }, true).collect();
+
+ Map<String, Set<Integer>> sparkPartitionsByTablePartition = new
HashMap<>();
+ partitionLocations.forEach(location -> sparkPartitionsByTablePartition
+ .computeIfAbsent(location._1, ignored -> new HashSet<>())
+ .add(location._2));
+ sparkPartitionsByTablePartition.values().forEach(
+ sparkPartitions -> assertEquals(1, sparkPartitions.size()));
+ }
+
+ private <T> void assertSortedSparkPartitions(
+ List<List<T>> sparkPartitions,
+ java.util.function.Function<T, Tuple2<String, String>> keyExtractor) {
+ for (List<T> sparkPartition : sparkPartitions) {
+ Tuple2<String, String> previous = null;
+ for (T record : sparkPartition) {
+ Tuple2<String, String> current = keyExtractor.apply(record);
+ assertTrue(previous == null || KEY_COMPARATOR.compare(previous,
current) <= 0,
+ "Spark partition is not UTF-8 sorted: " + previous + " > " +
current);
+ previous = current;
+ }
+ }
+ }
+
+ private List<HoodieRecord<Object>> createRecords() {
+ List<HoodieRecord<Object>> records = new ArrayList<>();
+ for (Tuple2<String, String> key : createKeys()) {
+ records.add(new HoodieEmptyRecord<>(
+ new HoodieKey(key._2, key._1), HoodieRecord.HoodieRecordType.AVRO));
+ }
+ return records;
+ }
+
+ private List<Row> createRows() {
+ List<Row> rows = new ArrayList<>();
+ int value = 0;
+ for (Tuple2<String, String> key : createKeys()) {
+ rows.add(RowFactory.create(key._1, key._2, value++));
+ }
+ return rows;
+ }
+
+ private List<Tuple2<String, String>> createKeys() {
+ String bmpPrivateUse = new String(Character.toChars(0xE000));
+ String supplementary = new String(Character.toChars(0x20000));
+ return Arrays.asList(
+ new Tuple2<>("p1", supplementary + "-a"),
+ new Tuple2<>("p1", bmpPrivateUse + "-b"),
+ new Tuple2<>("p2", supplementary + "-b"),
+ new Tuple2<>("p2", bmpPrivateUse + "-a"),
+ new Tuple2<>("p2", "ascii"),
+ new Tuple2<>("p3", supplementary + "-c"),
+ new Tuple2<>("p3", bmpPrivateUse + "-c"),
+ new Tuple2<>("p1", "ascii"));
+ }
+}
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestLSMDataSource.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestLSMDataSource.scala
new file mode 100644
index 000000000000..cac27b42c61f
--- /dev/null
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestLSMDataSource.scala
@@ -0,0 +1,441 @@
+/*
+ * 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.functional
+
+import org.apache.hudi.{DataSourceUtils, DataSourceWriteOptions}
+import org.apache.hudi.client.SparkRDDWriteClient
+import org.apache.hudi.common.config.HoodieStorageConfig
+import org.apache.hudi.common.model.{HoodieBaseFile, HoodieRecord,
HoodieRecordPayload, HoodieTableType, WriteOperationType}
+import org.apache.hudi.common.table.{HoodieTableConfig, HoodieTableMetaClient}
+import org.apache.hudi.common.testutils.HoodieTestUtils
+import org.apache.hudi.common.util.Option
+import org.apache.hudi.common.util.StringUtils
+import org.apache.hudi.config.{HoodieCompactionConfig, HoodieWriteConfig}
+import org.apache.hudi.exception.HoodieException
+import org.apache.hudi.execution.bulkinsert.{BulkInsertSortMode,
RowCustomColumnsSortPartitioner}
+import org.apache.hudi.testutils.{HoodieClientTestUtils,
SparkClientFunctionalTestHarness}
+import
org.apache.hudi.testutils.SparkClientFunctionalTestHarness.getSparkSqlConf
+
+import org.apache.spark.SparkConf
+import org.apache.spark.sql.{DataFrame, SaveMode}
+import org.junit.jupiter.api.{Tag, Test}
+import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse,
assertThrows, assertTrue}
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.{Arguments, EnumSource, MethodSource}
+
+import scala.collection.JavaConverters._
+
+@Tag("functional")
+class TestLSMDataSource extends SparkClientFunctionalTestHarness {
+
+ private val FirstPartition = "p1"
+ private val SecondPartition = "p2"
+
+ override def conf: SparkConf = conf(getSparkSqlConf)
+
+ @ParameterizedTest
+ @EnumSource(value = classOf[HoodieTableType], names = Array("COPY_ON_WRITE",
"MERGE_ON_READ"))
+ def testStandardWriteOperations(tableType: HoodieTableType): Unit = {
+ val tablePath = s"${basePath}_${tableType.name.toLowerCase}_lsm_dataframe"
+ val options = baseOptions(tableType) +
+ (DataSourceWriteOptions.ENABLE_ROW_WRITER.key -> "false")
+
+ val inserts = rows(Seq(
+ ("😀-insert-p1", "v1", 1L, FirstPartition),
+ ("A-insert-p1", "v1", 1L, FirstPartition),
+ ("middle-insert-p1", "v1", 1L, FirstPartition),
+ ("😀-insert-p2", "v1", 1L, SecondPartition),
+ ("A-insert-p2", "v1", 1L, SecondPartition)))
+ write(inserts, tablePath, options, WriteOperationType.INSERT,
SaveMode.Overwrite)
+ assertLatestBaseFilesSorted(tablePath, WriteOperationType.INSERT)
+ assertSnapshot(tablePath, Map(
+ "😀-insert-p1" -> "v1",
+ "A-insert-p1" -> "v1",
+ "middle-insert-p1" -> "v1",
+ "😀-insert-p2" -> "v1",
+ "A-insert-p2" -> "v1"))
+
+ val updates = rows(Seq(
+ ("😀-insert-p1", "v2", 2L, FirstPartition),
+ ("A-insert-p1", "v2", 2L, FirstPartition)))
+ write(updates, tablePath, options, WriteOperationType.UPSERT)
+ assertChangedFilesSorted(tablePath, tableType, WriteOperationType.UPSERT,
"log")
+ assertSnapshot(tablePath, Map(
+ "😀-insert-p1" -> "v2",
+ "A-insert-p1" -> "v2",
+ "middle-insert-p1" -> "v1",
+ "😀-insert-p2" -> "v1",
+ "A-insert-p2" -> "v1"))
+
+ val deletes = rows(Seq(
+ ("😀-insert-p1", "v2", 3L, FirstPartition),
+ ("A-insert-p1", "v2", 3L, FirstPartition)))
+ write(deletes, tablePath, options, WriteOperationType.DELETE)
+ assertChangedFilesSorted(tablePath, tableType, WriteOperationType.DELETE,
"deletes")
+ assertSnapshot(tablePath, Map(
+ "middle-insert-p1" -> "v1",
+ "😀-insert-p2" -> "v1",
+ "A-insert-p2" -> "v1"))
+
+ val partitionOverwrite = rows(Seq(
+ ("😀-overwrite-p2", "overwrite", 4L, SecondPartition),
+ ("A-overwrite-p2", "overwrite", 4L, SecondPartition)))
+ write(partitionOverwrite, tablePath, options,
WriteOperationType.INSERT_OVERWRITE)
+ assertLatestBaseFilesSorted(tablePath, WriteOperationType.INSERT_OVERWRITE)
+ assertSnapshot(tablePath, Map(
+ "middle-insert-p1" -> "v1",
+ "😀-overwrite-p2" -> "overwrite",
+ "A-overwrite-p2" -> "overwrite"))
+
+ val tableOverwrite = rows(Seq(
+ ("😀-overwrite-table", "table-overwrite", 5L, FirstPartition),
+ ("A-overwrite-table", "table-overwrite", 5L, FirstPartition)))
+ write(tableOverwrite, tablePath, options,
WriteOperationType.INSERT_OVERWRITE_TABLE)
+ assertLatestBaseFilesSorted(tablePath,
WriteOperationType.INSERT_OVERWRITE_TABLE)
+ assertSnapshot(tablePath, Map(
+ "😀-overwrite-table" -> "table-overwrite",
+ "A-overwrite-table" -> "table-overwrite"))
+
+ write(
+ tableOverwrite.limit(0),
+ tablePath,
+ options + (DataSourceWriteOptions.PARTITIONS_TO_DELETE.key ->
FirstPartition),
+ WriteOperationType.DELETE_PARTITION)
+ assertTrue(latestBaseFiles(tablePath).isEmpty)
+ assertSnapshot(tablePath, Map.empty)
+ }
+
+ @ParameterizedTest
+ @MethodSource(Array("bulkInsertWithHoodieRecordPathParams"))
+ def testBulkInsertWithHoodieRecordPath(
+ tableType: HoodieTableType,
+ sortMode: BulkInsertSortMode): Unit = {
+ val tablePath =
s"${basePath}_${tableType.name.toLowerCase}_lsm_bulk_insert_${sortMode.name.toLowerCase}"
+ val options = baseOptions(tableType) ++ Map(
+ DataSourceWriteOptions.ENABLE_ROW_WRITER.key -> "false",
+ HoodieWriteConfig.BULK_INSERT_SORT_MODE.key -> sortMode.name,
+ HoodieWriteConfig.BULKINSERT_PARALLELISM_VALUE.key -> "2")
+ val inserts = rows(Seq(
+ ("😀-bulk-p1", "v1", 1L, FirstPartition),
+ ("A-bulk-p1", "v1", 1L, FirstPartition),
+ ("middle-bulk-p1", "v1", 1L, FirstPartition),
+ ("😀-bulk-p2", "v1", 1L, SecondPartition),
+ ("A-bulk-p2", "v1", 1L, SecondPartition)))
+
+ write(inserts, tablePath, options, WriteOperationType.BULK_INSERT,
SaveMode.Overwrite)
+
+ assertLatestBaseFilesSorted(tablePath, WriteOperationType.BULK_INSERT)
+ assertSnapshot(tablePath, Map(
+ "😀-bulk-p1" -> "v1",
+ "A-bulk-p1" -> "v1",
+ "middle-bulk-p1" -> "v1",
+ "😀-bulk-p2" -> "v1",
+ "A-bulk-p2" -> "v1"))
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = classOf[BulkInsertSortMode], names = Array(
+ "GLOBAL_SORT", "PARTITION_SORT", "PARTITION_PATH_REPARTITION_AND_SORT"))
+ def testBulkInsertWithRowWriter(sortMode: BulkInsertSortMode): Unit = {
+ val tablePath =
s"${basePath}_cow_lsm_row_bulk_insert_${sortMode.name.toLowerCase}"
+ val options = baseOptions(HoodieTableType.COPY_ON_WRITE) ++ Map(
+ DataSourceWriteOptions.ENABLE_ROW_WRITER.key -> "true",
+ HoodieWriteConfig.BULK_INSERT_SORT_MODE.key -> sortMode.name,
+ HoodieWriteConfig.BULKINSERT_PARALLELISM_VALUE.key -> "2")
+ val inserts = rows(Seq(
+ ("😀-row-p1", "v1", 1L, FirstPartition),
+ ("A-row-p1", "v1", 1L, FirstPartition),
+ ("middle-row-p1", "v1", 1L, FirstPartition),
+ ("😀-row-p2", "v1", 1L, SecondPartition),
+ ("A-row-p2", "v1", 1L, SecondPartition)))
+
+ write(inserts, tablePath, options, WriteOperationType.BULK_INSERT,
SaveMode.Overwrite)
+
+ assertLatestBaseFilesSorted(tablePath, WriteOperationType.BULK_INSERT)
+ assertSnapshot(tablePath, Map(
+ "😀-row-p1" -> "v1",
+ "A-row-p1" -> "v1",
+ "middle-row-p1" -> "v1",
+ "😀-row-p2" -> "v1",
+ "A-row-p2" -> "v1"))
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = classOf[BulkInsertSortMode], names = Array(
+ "NONE", "PARTITION_PATH_REPARTITION"))
+ def testBulkInsertRejectsNonSortingModes(sortMode: BulkInsertSortMode): Unit
= {
+ Seq(false, true).foreach { enableRowWriter =>
+ val tablePath =
s"${basePath}_cow_lsm_reject_${sortMode.name.toLowerCase}_$enableRowWriter"
+ val options = baseOptions(HoodieTableType.COPY_ON_WRITE) ++ Map(
+ DataSourceWriteOptions.ENABLE_ROW_WRITER.key ->
enableRowWriter.toString,
+ HoodieWriteConfig.BULK_INSERT_SORT_MODE.key -> sortMode.name)
+ val inserts = rows(Seq(("key-1", "v1", 1L, FirstPartition)))
+
+ val exception = assertThrows(classOf[HoodieException], () =>
+ write(inserts, tablePath, options, WriteOperationType.BULK_INSERT,
SaveMode.Overwrite))
+
+ assertExceptionChainContains(exception,
+ s"""The bulk insert sort mode "${sortMode.name}" does not guarantee
record ordering""")
+ }
+ }
+
+ private def assertExceptionChainContains(exception: Throwable,
expectedMessage: String): Unit = {
+ var current = exception
+ var found = false
+ while (current != null && !found) {
+ found = current.getMessage != null &&
current.getMessage.contains(expectedMessage)
+ current = current.getCause
+ }
+ assertTrue(found,
+ s"Expected exception chain to contain '$expectedMessage', but caught:
$exception")
+ }
+
+ @Test
+ def testLsmRowWriterRejectsDisabledMetaFields(): Unit = {
+ val tablePath = s"${basePath}_cow_lsm_row_without_meta_fields"
+ val options = baseOptions(HoodieTableType.COPY_ON_WRITE) ++ Map(
+ DataSourceWriteOptions.ENABLE_ROW_WRITER.key -> "true",
+ HoodieTableConfig.POPULATE_META_FIELDS.key -> "false")
+ val inserts = rows(Seq(
+ ("key-2", "v2", 1L, FirstPartition),
+ ("key-1", "v1", 1L, FirstPartition)))
+
+ val exception = assertThrows(classOf[HoodieException], () =>
+ write(inserts, tablePath, options, WriteOperationType.BULK_INSERT,
SaveMode.Overwrite))
+
+ assertTrue(exception.getMessage.contains(
+ "The Dataset Row writer requires hoodie.populate.meta.fields=true for
LSM tables"))
+ val instants =
createMetaClient(tablePath).reloadActiveTimeline().getInstants.asScala
+ assertEquals(1, instants.size)
+
assertEquals(org.apache.hudi.common.table.timeline.HoodieInstant.State.REQUESTED,
instants.head.getState)
+ }
+
+ @Test
+ def testLsmRowWriterRejectsCustomPartitionerBeforeInflight(): Unit = {
+ val tablePath = s"${basePath}_cow_lsm_row_custom_partitioner"
+ val options = baseOptions(HoodieTableType.COPY_ON_WRITE) ++ Map(
+ DataSourceWriteOptions.ENABLE_ROW_WRITER.key -> "true",
+ HoodieWriteConfig.BULKINSERT_USER_DEFINED_PARTITIONER_CLASS_NAME.key ->
+ classOf[RowCustomColumnsSortPartitioner].getName,
+ HoodieWriteConfig.BULKINSERT_USER_DEFINED_PARTITIONER_SORT_COLUMNS.key
-> "value")
+ val inserts = rows(Seq(("key-1", "v1", 1L, FirstPartition)))
+
+ assertThrows(classOf[HoodieException], () =>
+ write(inserts, tablePath, options, WriteOperationType.BULK_INSERT,
SaveMode.Overwrite))
+
+ val instants =
createMetaClient(tablePath).reloadActiveTimeline().getInstants.asScala
+ assertEquals(1, instants.size)
+
assertEquals(org.apache.hudi.common.table.timeline.HoodieInstant.State.REQUESTED,
instants.head.getState)
+ }
+
+ @Test
+ def testCompactionProducesSortedBaseFile(): Unit = {
+ val tablePath = s"${basePath}_mor_lsm_compaction"
+ val options = baseOptions(HoodieTableType.MERGE_ON_READ) ++ Map(
+ HoodieCompactionConfig.INLINE_COMPACT_NUM_DELTA_COMMITS.key -> "1")
+
+ write(rows(Seq(
+ ("😀-compact", "v1", 1L, FirstPartition),
+ ("A-compact", "v1", 1L, FirstPartition))),
+ tablePath, options, WriteOperationType.INSERT, SaveMode.Overwrite)
+ write(rows(Seq(
+ ("A-compact", "v2", 2L, FirstPartition))),
+ tablePath, options, WriteOperationType.UPSERT)
+
+ val compactionInstant = withWriteClient(tablePath, options) { client =>
+ val instant = client.scheduleCompaction(Option.empty()).get()
+ val statuses = client.compact(instant, true).getWriteStatuses.collect()
+ assertFalse(statuses.isEmpty)
+ assertTrue(statuses.asScala.forall(status => !status.hasErrors))
+ instant
+ }
+
+ val metaClient = createMetaClient(tablePath)
+
assertTrue(metaClient.reloadActiveTimeline().filterCompletedInstants.containsInstant(compactionInstant))
+ val compactedBaseFiles = latestBaseFiles(tablePath)
+ assertFalse(compactedBaseFiles.isEmpty)
+ assertTrue(compactedBaseFiles.forall(_.getCommitTime == compactionInstant))
+ compactedBaseFiles.foreach(baseFile =>
assertParquetFileSorted(baseFile.getPath, WriteOperationType.COMPACT))
+ assertSnapshot(tablePath, Map(
+ "😀-compact" -> "v1",
+ "A-compact" -> "v2"))
+ }
+
+ @Test
+ def testLogCompactionProducesSortedNativeRun(): Unit = {
+ val tablePath = s"${basePath}_mor_lsm_log_compaction"
+ val options = baseOptions(HoodieTableType.MERGE_ON_READ) ++ Map(
+ HoodieCompactionConfig.ENABLE_LOG_COMPACTION.key -> "true",
+ HoodieCompactionConfig.LOG_COMPACTION_BLOCKS_THRESHOLD.key -> "1")
+
+ write(rows(Seq(
+ ("😀-log-compact", "v1", 1L, FirstPartition),
+ ("A-log-compact", "v1", 1L, FirstPartition))),
+ tablePath, options, WriteOperationType.INSERT, SaveMode.Overwrite)
+ write(rows(Seq(
+ ("A-log-compact", "v2", 2L, FirstPartition))),
+ tablePath, options, WriteOperationType.UPSERT)
+ write(rows(Seq(
+ ("😀-log-compact", "v2", 3L, FirstPartition))),
+ tablePath, options, WriteOperationType.UPSERT)
+
+ val expectedSnapshot = Map(
+ "😀-log-compact" -> "v2",
+ "A-log-compact" -> "v2")
+ assertSnapshot(tablePath, expectedSnapshot)
+
+ val logCompactionInstant = withWriteClient(tablePath, options) { client =>
+ val instant = client.scheduleLogCompaction(Option.empty()).get()
+ client.logCompact(instant, true)
+ instant
+ }
+
+ val metaClient = createMetaClient(tablePath)
+
assertTrue(metaClient.reloadActiveTimeline().filterCompletedInstants.containsInstant(logCompactionInstant))
+ val compactedLogFiles =
HoodieTestUtils.listNativeLogFiles(metaClient.getStorage, tablePath).asScala
+ .filter(path => path.getName.contains(logCompactionInstant) &&
path.getName.endsWith(".log.parquet"))
+ assertEquals(1, compactedLogFiles.size)
+ assertParquetFileSorted(compactedLogFiles.head.toString,
WriteOperationType.LOG_COMPACT)
+ assertSnapshot(tablePath, expectedSnapshot)
+ }
+
+ private def baseOptions(tableType: HoodieTableType): Map[String, String] =
Map(
+ DataSourceWriteOptions.TABLE_TYPE.key -> tableType.name,
+ DataSourceWriteOptions.RECORDKEY_FIELD.key -> "id",
+ DataSourceWriteOptions.PARTITIONPATH_FIELD.key -> "partition",
+ DataSourceWriteOptions.KEYGENERATOR_CLASS_NAME.key ->
"org.apache.hudi.keygen.SimpleKeyGenerator",
+ HoodieTableConfig.ORDERING_FIELDS.key -> "ts",
+ HoodieTableConfig.TABLE_STORAGE_LAYOUT.key ->
HoodieTableConfig.TableStorageLayout.LSM_TREE.configValue,
+ HoodieWriteConfig.TBL_NAME.key ->
s"hoodie_lsm_${tableType.name.toLowerCase}",
+ HoodieCompactionConfig.INLINE_COMPACT.key -> "false",
+ HoodieStorageConfig.LOGFILE_DATA_BLOCK_FORMAT.key -> "parquet",
+ "hoodie.insert.shuffle.parallelism" -> "1",
+ "hoodie.upsert.shuffle.parallelism" -> "1",
+ "hoodie.delete.shuffle.parallelism" -> "1")
+
+ private def rows(values: Seq[(String, String, Long, String)]): DataFrame = {
+ val _spark = spark
+ import _spark.implicits._
+ values.toDF("id", "value", "ts", "partition").repartition(1)
+ }
+
+ private def write(
+ input: DataFrame,
+ tablePath: String,
+ options: Map[String, String],
+ operationType: WriteOperationType,
+ saveMode: SaveMode = SaveMode.Append): Unit = {
+ input.write.format("hudi")
+ .options(options)
+ .option(DataSourceWriteOptions.OPERATION.key, operationType.value)
+ .mode(saveMode)
+ .save(tablePath)
+ }
+
+ private def withWriteClient[T](
+ tablePath: String,
+ options: Map[String, String])(
+ operation: SparkRDDWriteClient[HoodieRecordPayload[Nothing]] => T): T = {
+ val client = DataSourceUtils.createHoodieClient(
+ spark.sparkContext,
+ "",
+ tablePath,
+ options(HoodieWriteConfig.TBL_NAME.key),
+ options.asJava)
+ .asInstanceOf[SparkRDDWriteClient[HoodieRecordPayload[Nothing]]]
+ try {
+ operation(client)
+ } finally {
+ client.close()
+ }
+ }
+
+ private def assertChangedFilesSorted(
+ tablePath: String,
+ tableType: HoodieTableType,
+ operationType: WriteOperationType,
+ nativeLogExtension: String): Unit = {
+ if (tableType == HoodieTableType.COPY_ON_WRITE) {
+ assertLatestBaseFilesSorted(tablePath, operationType)
+ } else {
+ val metaClient = createMetaClient(tablePath)
+ val instantTime =
metaClient.getActiveTimeline.filterCompletedInstants.lastInstant.get.requestedTime
+ val nativeLogSuffix = s".$nativeLogExtension.parquet"
+ val logFiles = HoodieTestUtils.listNativeLogFiles(metaClient.getStorage,
tablePath).asScala
+ .filter(path => path.getName.contains(instantTime) &&
path.getName.endsWith(nativeLogSuffix))
+ assertFalse(logFiles.isEmpty, s"$operationType should produce an LSM
native log run")
+ val runSizes = logFiles.map(path =>
assertParquetFileSorted(path.toString, operationType))
+ assertTrue(runSizes.exists(_ > 1), s"$operationType should produce a
non-trivial sorted native run")
+ }
+ }
+
+ private def assertLatestBaseFilesSorted(tablePath: String, operationType:
WriteOperationType): Unit = {
+ val baseFiles = latestBaseFiles(tablePath)
+ assertFalse(baseFiles.isEmpty, s"$operationType should produce an LSM
base-file run")
+ val runSizes = baseFiles.map(baseFile =>
assertParquetFileSorted(baseFile.getPath, operationType))
+ assertTrue(runSizes.exists(_ > 1), s"$operationType should produce a
non-trivial sorted base-file run")
+ }
+
+ private def assertParquetFileSorted(path: String, operationType:
WriteOperationType): Int = {
+ val actualRecordKeys = spark.read.parquet(path)
+ .select(HoodieRecord.RECORD_KEY_METADATA_FIELD)
+ .collect()
+ .map(_.getString(0))
+ .toSeq
+ val expectedRecordKeys = actualRecordKeys.sortWith((left, right) =>
StringUtils.compareUtf8Bytes(left, right) < 0)
+ assertEquals(expectedRecordKeys, actualRecordKeys, s"$operationType output
is not sorted: $path")
+ actualRecordKeys.size
+ }
+
+ private def latestBaseFiles(tablePath: String): Seq[HoodieBaseFile] = {
+ val metaClient = createMetaClient(tablePath)
+ Seq(FirstPartition, SecondPartition).flatMap { partitionPath =>
+ HoodieClientTestUtils.getLatestBaseFiles(
+ tablePath,
+ metaClient.getStorage,
+ s"$tablePath/$partitionPath/*").asScala
+ }
+ }
+
+ private def assertSnapshot(tablePath: String, expected: Map[String,
String]): Unit = {
+ val actual = spark.read.format("hudi").load(tablePath)
+ .select("id", "value")
+ .collect()
+ .map(row => row.getString(0) -> row.getString(1))
+ .toMap
+ assertEquals(expected, actual)
+ }
+
+ private def createMetaClient(tablePath: String): HoodieTableMetaClient =
+ HoodieTableMetaClient.builder()
+ .setBasePath(tablePath)
+ .setConf(storageConf.newInstance())
+ .build()
+}
+
+object TestLSMDataSource {
+
+ def bulkInsertWithHoodieRecordPathParams():
java.util.stream.Stream[Arguments] =
+ java.util.stream.Stream.of(
+ Arguments.of(HoodieTableType.COPY_ON_WRITE,
BulkInsertSortMode.GLOBAL_SORT),
+ Arguments.of(HoodieTableType.COPY_ON_WRITE,
BulkInsertSortMode.PARTITION_SORT),
+ Arguments.of(HoodieTableType.COPY_ON_WRITE,
BulkInsertSortMode.PARTITION_PATH_REPARTITION_AND_SORT),
+ Arguments.of(HoodieTableType.MERGE_ON_READ,
BulkInsertSortMode.GLOBAL_SORT),
+ Arguments.of(HoodieTableType.MERGE_ON_READ,
BulkInsertSortMode.PARTITION_SORT),
+ Arguments.of(HoodieTableType.MERGE_ON_READ,
BulkInsertSortMode.PARTITION_PATH_REPARTITION_AND_SORT))
+}
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestMORDataSource.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestMORDataSource.scala
index 0316423c10e8..c422693cde09 100644
---
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestMORDataSource.scala
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestMORDataSource.scala
@@ -38,7 +38,7 @@ import
org.apache.hudi.metadata.HoodieTableMetadataUtil.{metadataPartitionExists
import org.apache.hudi.storage.{StoragePath, StoragePathInfo}
import org.apache.hudi.table.action.compact.CompactionTriggerStrategy
import org.apache.hudi.table.upgrade.TestUpgradeDowngrade.getFixtureName
-import org.apache.hudi.testutils.{DataSourceTestUtils, HoodieClientTestUtils,
HoodieSparkClientTestBase}
+import org.apache.hudi.testutils.{DataSourceTestUtils,
HoodieSparkClientTestBase}
import org.apache.hudi.util.JFunction
import org.apache.commons.io.FileUtils
@@ -191,6 +191,7 @@ class TestMORDataSource extends HoodieSparkClientTestBase
with SparkDatasetMixin
DataSourceWriteOptions.PARTITIONPATH_FIELD.key -> "",
DataSourceWriteOptions.KEYGENERATOR_CLASS_NAME.key ->
"org.apache.hudi.keygen.NonpartitionedKeyGenerator",
HoodieTableConfig.ORDERING_FIELDS.key -> "ts",
+ HoodieTableConfig.TABLE_STORAGE_LAYOUT.key ->
HoodieTableConfig.TableStorageLayout.LSM_TREE.configValue,
HoodieWriteConfig.COMBINE_BEFORE_INSERT.key -> "false",
HoodieWriteConfig.TBL_NAME.key ->
"hoodie_mor_lsm_base_file_only_duplicates",
HoodieMetadataConfig.ENABLE.key -> "false",
@@ -219,19 +220,10 @@ class TestMORDataSource extends HoodieSparkClientTestBase
with SparkDatasetMixin
assertEquals(1,
dataFiles.count(_.getPath.getName.endsWith(HoodieFileFormat.PARQUET.getFileExtension)))
assertFalse(dataFiles.exists(pathInfo =>
org.apache.hudi.common.fs.FSUtils.isLogFile(pathInfo.getPath)))
- // Build the duplicate-bearing base file with the supported default-layout
insert path, then
- // switch only the test fixture to LSM so this test does not imply LSM
insert support.
val metaClient = HoodieTableMetaClient.builder()
.setBasePath(tablePath)
.setConf(storageConf.newInstance())
.build()
- assertFalse(metaClient.getTableConfig.isLSMTreeStorageLayout)
- val tableProps = metaClient.getTableConfig.getProps
- tableProps.setProperty(
- HoodieTableConfig.TABLE_STORAGE_LAYOUT.key,
- HoodieTableConfig.TableStorageLayout.LSM_TREE.configValue)
- HoodieTableConfig.update(metaClient.getStorage, metaClient.getMetaPath,
tableProps)
- metaClient.reloadTableConfig()
assertTrue(metaClient.getTableConfig.isLSMTreeStorageLayout)
// With no log records to merge, the LSM reader delegates directly to the
base-file iterator and
@@ -244,6 +236,24 @@ class TestMORDataSource extends HoodieSparkClientTestBase
with SparkDatasetMixin
assertEquals(2, snapshotRows.length)
assertTrue(snapshotRows.forall(_.getString(0) == duplicateKey))
assertEquals(Set("first", "second"),
snapshotRows.map(_.getString(1)).toSet)
+
+ Seq((duplicateKey, "latest", 3L))
+ .toDF("id", "value", "ts")
+ .repartition(1)
+ .write.format("hudi")
+ .options(writeOpts + (DataSourceWriteOptions.OPERATION.key ->
UPSERT_OPERATION_OPT_VAL))
+ .mode(SaveMode.Append)
+ .save(tablePath)
+
+ // Once an update adds a sorted run, the LSM merge treats equal physical
keys as one logical key.
+ val mergedRows = spark.read.format("hudi")
+ .options(readOpts)
+ .load(tablePath)
+ .select("id", "value")
+ .collect()
+ assertEquals(1, mergedRows.length)
+ assertEquals(duplicateKey, mergedRows.head.getString(0))
+ assertEquals("latest", mergedRows.head.getString(1))
}
@Test
@@ -336,98 +346,6 @@ class TestMORDataSource extends HoodieSparkClientTestBase
with SparkDatasetMixin
assertEquals(Set("emoji-v1"), skipMergeVersions(emojiFaceKey))
}
- @Test
- def testLsmCompactionUsesUtf8Ordering(): Unit = {
- val fullWidthAKey = "A-key"
- val emojiFaceKey = "😀-key"
- val tableName = "hoodie_mor_lsm_compaction"
- val tablePath = s"${basePath}_mor_lsm_compaction"
- val _spark = spark
- import _spark.implicits._
-
- val options = Map[String, String](
- DataSourceWriteOptions.TABLE_TYPE.key ->
HoodieTableType.MERGE_ON_READ.name,
- DataSourceWriteOptions.OPERATION.key -> UPSERT_OPERATION_OPT_VAL,
- DataSourceWriteOptions.RECORDKEY_FIELD.key -> "id",
- DataSourceWriteOptions.PARTITIONPATH_FIELD.key -> "",
- DataSourceWriteOptions.KEYGENERATOR_CLASS_NAME.key ->
"org.apache.hudi.keygen.NonpartitionedKeyGenerator",
- HoodieTableConfig.ORDERING_FIELDS.key -> "ts",
- HoodieTableConfig.TABLE_STORAGE_LAYOUT.key ->
HoodieTableConfig.TableStorageLayout.LSM_TREE.configValue,
- HoodieWriteConfig.TBL_NAME.key -> tableName,
- HoodieMetadataConfig.ENABLE.key -> "false",
- HoodieCompactionConfig.INLINE_COMPACT.key -> "false",
- HoodieCompactionConfig.INLINE_COMPACT_NUM_DELTA_COMMITS.key -> "1",
- HoodieStorageConfig.LOGFILE_DATA_BLOCK_FORMAT.key -> "parquet",
- "hoodie.insert.shuffle.parallelism" -> "1",
- "hoodie.upsert.shuffle.parallelism" -> "1")
- val (writeOpts, readOpts) = getWriterReaderOpts(HoodieRecordType.AVRO,
options)
-
- Seq(
- (fullWidthAKey, "full-width-a-v1", 1L),
- (emojiFaceKey, "emoji-v1", 1L))
- .toDF("id", "value", "ts")
- .repartition(1)
- .write.format("hudi")
- .options(writeOpts)
- .mode(SaveMode.Append)
- .save(tablePath)
-
- // Update only the full-width A key so the base and log sorted runs have
different key sets. This
- // exposes an ordering mismatch during the LSM merge instead of merging
two identical runs.
- Seq((fullWidthAKey, "full-width-a-v2", 2L))
- .toDF("id", "value", "ts")
- .repartition(1)
- .write.format("hudi")
- .options(writeOpts)
- .mode(SaveMode.Append)
- .save(tablePath)
-
- val metaClient = HoodieTableMetaClient.builder()
- .setBasePath(tablePath)
- .setConf(storageConf.newInstance())
- .build()
- assertTrue(metaClient.getTableConfig.isLSMTreeStorageLayout)
-
- val client = DataSourceUtils.createHoodieClient(
- spark.sparkContext, "", tablePath, tableName, writeOpts.asJava)
- .asInstanceOf[SparkRDDWriteClient[HoodieRecordPayload[Nothing]]]
- val compactionInstant = try {
- val instant = client.scheduleCompaction(Option.empty()).get()
- val statuses = client.compact(instant, true).getWriteStatuses.collect()
- assertFalse(statuses.isEmpty)
- assertTrue(statuses.asScala.forall(status => !status.hasErrors))
- instant
- } finally {
- client.close()
- }
-
-
assertTrue(metaClient.reloadActiveTimeline().filterCompletedInstants.containsInstant(compactionInstant))
-
- val latestBaseFiles = HoodieClientTestUtils.getLatestBaseFiles(
- tablePath, metaClient.getStorage, s"$tablePath/*")
- assertEquals(1, latestBaseFiles.size())
- assertEquals(compactionInstant, latestBaseFiles.get(0).getCommitTime)
-
- // Compacted LSM base files retain the table-level UTF-8 record-key
ordering contract.
- val physicalBaseFileKeys =
spark.read.parquet(latestBaseFiles.get(0).getPath)
- .select(HoodieRecord.RECORD_KEY_METADATA_FIELD)
- .collect()
- .map(_.getString(0))
- .toList
- assertEquals(Seq(fullWidthAKey, emojiFaceKey), physicalBaseFileKeys)
-
- val actual = spark.read.format("hudi")
- .options(readOpts)
- .load(tablePath)
- .select("id", "value")
- .collect()
- .map(row => row.getString(0) -> row.getString(1))
- .toMap
- assertEquals(Map(
- fullWidthAKey -> "full-width-a-v2",
- emojiFaceKey -> "emoji-v1"), actual)
- }
-
@ParameterizedTest
@CsvSource(Array(
// Inferred as COMMIT_TIME_ORDERING
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/insert/TestInsertWithLSMLayout.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/insert/TestInsertWithLSMLayout.scala
new file mode 100644
index 000000000000..c3fa35ddec8e
--- /dev/null
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/insert/TestInsertWithLSMLayout.scala
@@ -0,0 +1,238 @@
+/*
+ * 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.spark.sql.hudi.dml.insert
+
+import org.apache.hudi.DataSourceWriteOptions.{ENABLE_ROW_WRITER,
SPARK_SQL_INSERT_INTO_OPERATION}
+import org.apache.hudi.common.config.HoodieStorageConfig
+import org.apache.hudi.common.model.{HoodieRecord, WriteOperationType}
+import org.apache.hudi.common.table.HoodieTableConfig
+import org.apache.hudi.common.util.StringUtils
+import org.apache.hudi.config.HoodieWriteConfig
+import org.apache.hudi.execution.bulkinsert.BulkInsertSortMode
+import org.apache.hudi.testutils.HoodieClientTestUtils.createMetaClient
+
+import org.apache.spark.sql.hudi.common.HoodieSparkSqlTestBase
+import
org.apache.spark.sql.hudi.common.HoodieSparkSqlTestBase.getLastCommitMetadata
+import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse}
+
+import java.nio.file.{Files, Paths}
+
+import scala.collection.JavaConverters._
+
+class TestInsertWithLSMLayout extends HoodieSparkSqlTestBase {
+
+ test("Test INSERT INTO with LSM layout") {
+ Seq("cow", "mor").foreach { tableType =>
+ Seq(WriteOperationType.INSERT, WriteOperationType.UPSERT).foreach {
operation =>
+ withSQLConf(SPARK_SQL_INSERT_INTO_OPERATION.key -> operation.value()) {
+ withTempDir { tmp =>
+ val tableName = generateTableName
+ val tablePath = s"${tmp.getCanonicalPath}/$tableName"
+ spark.sql(
+ s"""
+ |create table $tableName (
+ | id int,
+ | name string,
+ | price double,
+ | ts long
+ |) using hudi
+ |location '$tablePath'
+ |tblproperties (
+ | type = '$tableType',
+ | primaryKey = 'id',
+ | preCombineField = 'ts',
+ | '${HoodieTableConfig.TABLE_STORAGE_LAYOUT.key}' =
'${HoodieTableConfig.TableStorageLayout.LSM_TREE.configValue}',
+ | '${HoodieStorageConfig.LOGFILE_DATA_BLOCK_FORMAT.key}' =
'parquet'
+ |)
+ |""".stripMargin)
+
+ spark.sql(
+ s"""
+ |insert into $tableName values
+ | (3, 'name-3', 30.0, 1000),
+ | (1, 'name-1', 10.0, 1000),
+ | (2, 'name-2', 20.0, 1000)
+ |""".stripMargin)
+
+ checkAnswer(s"select id, name, price, ts from $tableName order by
id")(
+ Seq(1, "name-1", 10.0, 1000),
+ Seq(2, "name-2", 20.0, 1000),
+ Seq(3, "name-3", 30.0, 1000))
+ assertResult(operation) {
+ getLastCommitMetadata(spark, tablePath).getOperationType
+ }
+ assertEquals(
+ HoodieTableConfig.TableStorageLayout.LSM_TREE,
+ createMetaClient(spark,
tablePath).getTableConfig.getTableStorageLayout)
+ }
+ }
+ }
+ }
+ }
+
+ test("Test bulk insert overwrite with LSM row writer") {
+ withSQLConf(SPARK_SQL_INSERT_INTO_OPERATION.key ->
WriteOperationType.BULK_INSERT.value()) {
+ Seq("cow", "mor").foreach { tableType =>
+ withTempDir { tmp =>
+ val tableName = generateTableName
+ val tablePath = s"${tmp.getCanonicalPath}/$tableName"
+ spark.sql(
+ s"""
+ |create table $tableName (
+ | id string,
+ | name string,
+ | ts long,
+ | dt string
+ |) using hudi
+ |location '$tablePath'
+ |partitioned by (dt)
+ |tblproperties (
+ | type = '$tableType',
+ | primaryKey = 'id',
+ | preCombineField = 'ts',
+ | '${ENABLE_ROW_WRITER.key}' = 'true',
+ | '${HoodieTableConfig.TABLE_STORAGE_LAYOUT.key}' =
'${HoodieTableConfig.TableStorageLayout.LSM_TREE.configValue}',
+ | '${HoodieStorageConfig.LOGFILE_DATA_BLOCK_FORMAT.key}' =
'parquet'
+ |)
+ |""".stripMargin)
+
+ spark.sql(
+ s"""
+ |insert into $tableName values
+ | ('😀-old-p1', 'old', 1, 'p1'),
+ | ('A-old-p1', 'old', 1, 'p1'),
+ | ('middle-old-p2', 'keep', 1, 'p2')
+ |""".stripMargin)
+
+ spark.sql(
+ s"""
+ |insert overwrite table $tableName partition(dt='p1') values
+ | ('😀-partition-overwrite', 'new', 2),
+ | ('A-partition-overwrite', 'new', 2),
+ | ('middle-partition-overwrite', 'new', 2)
+ |""".stripMargin)
+ checkAnswer(s"select id, name, ts, dt from $tableName order by id")(
+ Seq("middle-old-p2", "keep", 1, "p2"),
+ Seq("middle-partition-overwrite", "new", 2, "p1"),
+ Seq("A-partition-overwrite", "new", 2, "p1"),
+ Seq("😀-partition-overwrite", "new", 2, "p1"))
+ assertResult(WriteOperationType.INSERT_OVERWRITE) {
+ getLastCommitMetadata(spark, tablePath).getOperationType
+ }
+ assertBaseFilesSorted(tablePath, WriteOperationType.INSERT_OVERWRITE)
+
+ spark.sql(
+ s"""
+ |insert overwrite table $tableName values
+ | ('😀-table-overwrite', 'table', 3, 'p3'),
+ | ('A-table-overwrite', 'table', 3, 'p3'),
+ | ('middle-table-overwrite', 'table', 3, 'p3')
+ |""".stripMargin)
+ checkAnswer(s"select id, name, ts, dt from $tableName order by id")(
+ Seq("middle-table-overwrite", "table", 3, "p3"),
+ Seq("A-table-overwrite", "table", 3, "p3"),
+ Seq("😀-table-overwrite", "table", 3, "p3"))
+ assertResult(WriteOperationType.INSERT_OVERWRITE_TABLE) {
+ getLastCommitMetadata(spark, tablePath).getOperationType
+ }
+ assertBaseFilesSorted(tablePath,
WriteOperationType.INSERT_OVERWRITE_TABLE)
+ }
+ }
+ }
+ }
+
+ test("Test bulk insert with LSM HoodieRecord writer") {
+ withSQLConf(SPARK_SQL_INSERT_INTO_OPERATION.key ->
WriteOperationType.BULK_INSERT.value()) {
+ withTempDir { tmp =>
+ val tableName = generateTableName
+ val tablePath = s"${tmp.getCanonicalPath}/$tableName"
+ spark.sql(
+ s"""
+ |create table $tableName (
+ | id string,
+ | name string,
+ | ts long,
+ | dt string
+ |) using hudi
+ |location '$tablePath'
+ |partitioned by (dt)
+ |tblproperties (
+ | type = 'cow',
+ | primaryKey = 'id',
+ | preCombineField = 'ts',
+ | '${ENABLE_ROW_WRITER.key}' = 'false',
+ | '${HoodieWriteConfig.BULK_INSERT_SORT_MODE.key}' =
+ |
'${BulkInsertSortMode.PARTITION_PATH_REPARTITION_AND_SORT.name}',
+ | '${HoodieWriteConfig.BULKINSERT_PARALLELISM_VALUE.key}' = '2',
+ | '${HoodieTableConfig.TABLE_STORAGE_LAYOUT.key}' =
+ |
'${HoodieTableConfig.TableStorageLayout.LSM_TREE.configValue}'
+ |)
+ |""".stripMargin)
+
+ spark.sql(
+ s"""
+ |insert into $tableName values
+ | ('😀-p1', 'emoji', 1, 'p1'),
+ | ('A-p1', 'full-width', 1, 'p1'),
+ | ('middle-p1', 'ascii', 1, 'p1'),
+ | ('😀-p2', 'emoji', 1, 'p2'),
+ | ('A-p2', 'full-width', 1, 'p2')
+ |""".stripMargin)
+
+ checkAnswer(s"select id, name, ts, dt from $tableName order by id")(
+ Seq("middle-p1", "ascii", 1, "p1"),
+ Seq("A-p1", "full-width", 1, "p1"),
+ Seq("A-p2", "full-width", 1, "p2"),
+ Seq("😀-p1", "emoji", 1, "p1"),
+ Seq("😀-p2", "emoji", 1, "p2"))
+ assertResult(WriteOperationType.BULK_INSERT) {
+ getLastCommitMetadata(spark, tablePath).getOperationType
+ }
+ assertEquals(
+ HoodieTableConfig.TableStorageLayout.LSM_TREE,
+ createMetaClient(spark,
tablePath).getTableConfig.getTableStorageLayout)
+ assertBaseFilesSorted(tablePath, WriteOperationType.BULK_INSERT)
+ }
+ }
+ }
+
+ private def assertBaseFilesSorted(tablePath: String, operationType:
WriteOperationType): Unit = {
+ val pathStream = Files.walk(Paths.get(tablePath))
+ val baseFiles = try {
+ pathStream.iterator().asScala
+ .filter(Files.isRegularFile(_))
+ .filter(path => path.getFileName.toString.endsWith(".parquet"))
+ .filterNot(path => path.toString.contains("/.hoodie/"))
+ .map(_.toString)
+ .toList
+ } finally {
+ pathStream.close()
+ }
+ assertFalse(baseFiles.isEmpty)
+ baseFiles.foreach { baseFile =>
+ val actualKeys = spark.read.parquet(baseFile)
+ .select(HoodieRecord.RECORD_KEY_METADATA_FIELD)
+ .collect()
+ .map(_.getString(0))
+ .toSeq
+ val expectedKeys = actualKeys.sortWith(
+ (left, right) => StringUtils.compareUtf8Bytes(left, right) < 0)
+ assertEquals(expectedKeys, actualKeys, s"$operationType output is not
sorted: $baseFile")
+ }
+ }
+}