hudi-agent commented on code in PR #19576:
URL: https://github.com/apache/hudi/pull/19576#discussion_r3756552426


##########
hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkBulkInsertHelper.java:
##########
@@ -69,13 +71,18 @@ public HoodieWriteMetadata<HoodieData<WriteStatus>> 
bulkInsert(final HoodieData<
                                                                  final 
Option<BulkInsertPartitioner> userDefinedBulkInsertPartitioner) {
     HoodieWriteMetadata result = new HoodieWriteMetadata();
 
+    boolean isLsmTable = 
table.getMetaClient().getTableConfig().isLSMTreeStorageLayout();
+    checkArgument(!isLsmTable || userDefinedBulkInsertPartitioner.isEmpty(),

Review Comment:
   🤖 This guards the partitioner choice for LSM, but not duplicate record keys. 
Flink's HoodieTableFactory.checkStorageLayout hard-rejects INSERT on LSM 
"because they allow duplicate record keys", whereas this PR enables 
INSERT/bulk_insert on Spark LSM with no equivalent guard. The updated 
TestMORDataSource shows dup keys written via INSERT survive only until the 
first merge, then silently collapse to one row. Is allowing INSERT here 
intentional, and if so how do we avoid that silent data loss? @danny0405
   
   <sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag 
quality.</i></sub>



##########
hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/BulkInsertInternalPartitionerFactory.java:
##########
@@ -45,12 +45,27 @@ public static BulkInsertPartitioner get(HoodieTable table,
         return new RDDSimpleBucketBulkInsertPartitioner(table);
       }
     }
+    if (table.getMetaClient().getTableConfig().isLSMTreeStorageLayout()) {

Review Comment:
   🤖 The BUCKET-index branch above returns before this LSM check, so an LSM 
table configured with hoodie.index.type=BUCKET would get the bucket partitioner 
and skip the LSM UTF-8 ordering entirely. Is that combination prevented at 
table creation, or could it silently produce base files that aren't record-key 
sorted and break the LSM reader? Same ordering applies in the WithRows factory.
   
   <sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag 
quality.</i></sub>



##########
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);

Review Comment:
   🤖 nit: the inner class name `LsmTableTestContext` uses mixed-case "Lsm" 
while everywhere else in this PR — and in the outer class name — the acronym is 
all-caps "LSM". Could you rename it to `LSMTableTestContext` for consistency?
   
   <sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag 
quality.</i></sub>



##########
hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/execution/bulkinsert/TestLSMBulkInsertPartitioner.java:
##########
@@ -0,0 +1,306 @@
+/*
+ * 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, 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, 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, 
config, true));
+
+    assertEquals(expectedMessage, recordException.getMessage());
+    assertEquals(expectedMessage, rowException.getMessage());
+  }
+
+  private BulkInsertPartitioner<Dataset<Row>> 
getRowPartitioner(BulkInsertSortMode sortMode) {
+    return BulkInsertInternalPartitionerWithRowsFactory.get(
+        lsmTable, createWriteConfig(sortMode, true), true);
+  }
+
+  private void assertRowPartitionerSelection(BulkInsertSortMode sortMode,
+                                             Class<?> 
expectedPartitionerClass) {
+    HoodieWriteConfig config = createWriteConfig(sortMode, true);
+    assertEquals(expectedPartitionerClass,
+        BulkInsertInternalPartitionerWithRowsFactory.get(lsmTable, config, 
true).getClass());
+    assertEquals(expectedPartitionerClass,
+        BulkInsertInternalPartitionerWithRowsFactory.get(lsmTable, 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,

Review Comment:
   🤖 nit: `getRowPartitioner` and `getHoodieRecordPartitioner` look unused — 
`assertRowPartitionerSelection` and `assertHoodieRecordPartitionerSelection` 
call the factories directly. Could you remove them to avoid dead code?
   
   <sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag 
quality.</i></sub>



##########
hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/LSMPartitionPathRepartitionAndSortPartitionerWithRows.java:
##########
@@ -0,0 +1,62 @@
+/*
+ * 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.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 row partitioner for {@link 
BulkInsertSortMode#PARTITION_PATH_REPARTITION_AND_SORT}.
+ *
+ * <p>The LSM ordering is stronger than the default mode's partition-path-only 
ordering, so this
+ * implementation reuses partition-path distribution and applies the LSM sort 
once afterward.
+ */
+public class LSMPartitionPathRepartitionAndSortPartitionerWithRows
+    extends PartitionPathRepartitionPartitionerWithRows {
+
+  private final boolean shouldPopulateMetaFields;
+
+  public LSMPartitionPathRepartitionAndSortPartitionerWithRows(boolean 
isTablePartitioned,
+                                                               
HoodieWriteConfig config) {
+    super(isTablePartitioned, config);
+    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");
+    }
+    return super.repartitionRecords(rows, outputSparkPartitions)
+        
.sortWithinPartitions(functions.col(HoodieRecord.PARTITION_PATH_METADATA_FIELD),
 functions.col(HoodieRecord.RECORD_KEY_METADATA_FIELD));
+  }

Review Comment:
   🤖 nit: this line is quite long — could you break the two 
`functions.col(...)` arguments onto separate lines to keep it within the usual 
column limit?
   
   <sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag 
quality.</i></sub>



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to