wombatu-kun commented on code in PR #19354:
URL: https://github.com/apache/hudi/pull/19354#discussion_r3636116750


##########
hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/table/action/commit/FlinkDeleteHelper.java:
##########
@@ -65,16 +66,16 @@ public static FlinkDeleteHelper newInstance() {
   public List<HoodieKey> deduplicateKeys(List<HoodieKey> keys, 
HoodieTable<EmptyHoodieRecordPayload, 
List<HoodieRecord<EmptyHoodieRecordPayload>>, List<HoodieKey>, 
List<WriteStatus>> table, int parallelism) {
     boolean isIndexingGlobal = table.getIndex().isGlobal();
     if (isIndexingGlobal) {
-      HashSet<String> recordKeys = 
keys.stream().map(HoodieKey::getRecordKey).collect(Collectors.toCollection(HashSet::new));
+      HashSet<String> recordKeys = new HashSet<>();
       List<HoodieKey> deduplicatedKeys = new LinkedList<>();
       keys.forEach(x -> {
-        if (recordKeys.contains(x.getRecordKey())) {
+        if (recordKeys.add(x.getRecordKey())) {

Review Comment:
   `JavaDeleteHelper.deduplicateKeys` still carries the identical global-index 
bug this fixes - it pre-fills the key set and tests `contains`, so it never 
drops duplicates for the Java engine. Is limiting the fix to Flink intentional, 
or should `JavaDeleteHelper` get the same `contains` -> `add` change (follow-up 
is fine)?



##########
hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/TestFlinkWriteClientFunctional.java:
##########
@@ -0,0 +1,446 @@
+/*
+ * 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;
+
+import org.apache.hudi.avro.model.HoodieClusteringPlan;
+import 
org.apache.hudi.client.clustering.plan.strategy.FlinkSizeBasedClusteringPlanStrategyRecently;
+import org.apache.hudi.client.common.HoodieFlinkEngineContext;
+import org.apache.hudi.client.model.HoodieFlinkRecord;
+import org.apache.hudi.common.config.HoodieMetadataConfig;
+import org.apache.hudi.common.engine.EngineType;
+import org.apache.hudi.common.fs.FSUtils;
+import org.apache.hudi.common.model.HoodieKey;
+import org.apache.hudi.common.model.HoodieOperation;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.model.HoodieRecordLocation;
+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.cdc.HoodieCDCSupplementalLoggingMode;
+import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.common.util.ClusteringUtils;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.config.HoodieClusteringConfig;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.io.FlinkCreateHandle;
+import org.apache.hudi.io.FlinkMergeHandle;
+import org.apache.hudi.io.FlinkWriteHandleFactory;
+import org.apache.hudi.io.HoodieWriteMergeHandle;
+import org.apache.hudi.storage.StoragePath;
+import org.apache.hudi.table.HoodieFlinkTable;
+import org.apache.hudi.table.action.HoodieWriteMetadata;
+import org.apache.hudi.table.action.commit.BucketInfo;
+import org.apache.hudi.table.action.commit.BucketType;
+import org.apache.hudi.testutils.HoodieFlinkClientTestHarness;
+
+import org.apache.flink.table.data.GenericRowData;
+import org.apache.flink.table.data.StringData;
+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.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Properties;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Functional coverage for the Flink client write boundary.
+ *
+ * <p>The datasource bucket assigner hands this client records that already 
carry a target file group.
+ * These tests construct that same input directly so client and handle 
behavior can be exercised without
+ * depending on the datasource module.
+ */
+class TestFlinkWriteClientFunctional extends HoodieFlinkClientTestHarness {
+
+  private static final String PARTITION_PATH = "2026/07/23";
+  private static final String FILE_ID = "f0";
+  private static final String SCHEMA = "{"
+      + "\"type\":\"record\","
+      + "\"name\":\"flink_write_test\","
+      + "\"fields\":["
+      + "{\"name\":\"id\",\"type\":\"string\"},"
+      + "{\"name\":\"name\",\"type\":\"string\"},"
+      + "{\"name\":\"ts\",\"type\":\"long\"}"
+      + "]}";
+
+  private HoodieWriteConfig writeConfig;
+
+  @BeforeEach
+  void setUp() {
+    initPath();
+    initFileSystem();
+  }
+
+  @AfterEach
+  void tearDown() throws IOException {
+    cleanupResources();
+  }
+
+  static Stream<Arguments> tableTypesAndCdc() {
+    return Stream.of(
+        Arguments.of(HoodieTableType.COPY_ON_WRITE, false),
+        Arguments.of(HoodieTableType.COPY_ON_WRITE, true),
+        Arguments.of(HoodieTableType.MERGE_ON_READ, false),
+        Arguments.of(HoodieTableType.MERGE_ON_READ, true));
+  }
+
+  @ParameterizedTest
+  @MethodSource("tableTypesAndCdc")
+  void testInsertAndUpsertWriteFilesAndCommitMetadata(HoodieTableType 
tableType, boolean cdcEnabled)
+      throws IOException {
+    if (tableType == HoodieTableType.COPY_ON_WRITE && !cdcEnabled) {
+      context = new HoodieFlinkEngineContext(
+          new HoodieFlinkEngineContext.DefaultTaskContextSupplier() {
+            @Override
+            public java.util.function.Supplier<Long> getAttemptIdSupplier() {
+              return () -> 1L;
+            }
+          });
+    }
+    initWriteClient(tableType, cdcEnabled, false);
+
+    String insertInstant = writeClient.startCommit();
+    transitionToInflight(insertInstant);
+    if (tableType == HoodieTableType.COPY_ON_WRITE && !cdcEnabled) {
+      createInvalidRetryFile(insertInstant);
+    }
+    List<HoodieRecord> firstBatch = new ArrayList<>(Arrays.asList(
+        insertRecord("id1", "one", 1L),
+        insertRecord("id2", "two", 2L)));
+    if (tableType == HoodieTableType.MERGE_ON_READ) {
+      firstBatch.add(insertRecord("id3", "three", 3L));
+    }
+    List<WriteStatus> firstInsertStatuses = writeClient.insert(firstBatch, 
insertInstant);
+    assertWriteStatuses(firstInsertStatuses, firstBatch.size());
+
+    // A second COW mini-batch for the same bucket exercises the 
incremental/replace handle.
+    List<WriteStatus> insertStatuses = tableType == 
HoodieTableType.COPY_ON_WRITE
+        ? writeClient.insert(Arrays.asList(insertRecord("id3", "three", 3L)), 
insertInstant)
+        : firstInsertStatuses;
+    if (tableType == HoodieTableType.COPY_ON_WRITE && cdcEnabled) {
+      insertStatuses = writeClient.upsert(
+          Arrays.asList(updateRecord("id1", "one-mini-batch-update", 4L, 
insertInstant)),
+          insertInstant);
+    }
+    assertWriteStatuses(insertStatuses, 3);
+    if (tableType == HoodieTableType.COPY_ON_WRITE && !cdcEnabled) {
+      HoodieFlinkTable table = writeClient.getHoodieTable();
+      FlinkCreateHandle rolloverHandle = new FlinkCreateHandle(
+          writeConfig, insertInstant, table, PARTITION_PATH, FILE_ID, 
table.getTaskContextSupplier());
+      assertTrue(rolloverHandle.canWrite(insertRecord("id4", "four", 4L)));
+      assertNotEquals(insertStatuses.get(0).getStat().getPath(), 
rolloverHandle.getWritePath().toString());
+      rolloverHandle.closeGracefully();
+      rolloverHandle.closeGracefully();
+
+      FlinkCreateHandle failingHandle = new FlinkCreateHandle(
+          writeConfig, insertInstant, table, PARTITION_PATH, FILE_ID, 
table.getTaskContextSupplier()) {
+        @Override
+        public List<WriteStatus> close() {
+          super.close();
+          throw new IllegalStateException("expected close failure");
+        }
+      };
+      StoragePath failedWritePath = failingHandle.getWritePath();
+      failingHandle.closeGracefully();
+      assertFalse(metaClient.getStorage().exists(failedWritePath));
+    }
+    assertTrue(writeClient.commit(insertInstant, insertStatuses));
+    assertCommitMetadata(insertInstant, tableType, 3);
+
+    writeClient.cleanHandles();
+    String updateInstant = writeClient.startCommit();
+    transitionToInflight(updateInstant);
+    if (tableType == HoodieTableType.COPY_ON_WRITE && !cdcEnabled) {
+      createInvalidRetryFile(updateInstant);
+    }
+    List<HoodieRecord> updates = Arrays.asList(
+        updateRecord("id1", "one-updated", 11L, insertInstant),
+        deleteRecord("id2", 12L, insertInstant));
+    List<WriteStatus> updateStatuses = writeClient.upsert(updates, 
updateInstant);
+    long expectedWrites = tableType == HoodieTableType.COPY_ON_WRITE ? 2 : 1;
+    assertWriteStatuses(updateStatuses, expectedWrites);
+    assertEquals(1,
+        updateStatuses.stream().map(WriteStatus::getStat).mapToLong(stat -> 
stat.getNumDeletes()).sum());
+    assertTrue(writeClient.commit(updateInstant, updateStatuses));
+    assertCommitMetadata(updateInstant, tableType, expectedWrites);
+
+    if (tableType == HoodieTableType.COPY_ON_WRITE && !cdcEnabled) {
+      String failedMergeInstant = writeClient.startCommit();
+      transitionToInflight(failedMergeInstant);
+      HoodieFlinkTable table = writeClient.getHoodieTable();
+      FlinkMergeHandle failingHandle = new FlinkMergeHandle(
+          writeConfig,
+          failedMergeInstant,
+          table,
+          Collections.<HoodieRecord>emptyList().iterator(),
+          PARTITION_PATH,
+          FILE_ID,
+          table.getTaskContextSupplier()) {
+        @Override
+        public List<WriteStatus> close() {
+          super.close();
+          throw new IllegalStateException("expected close failure");
+        }
+      };
+      StoragePath failedWritePath = failingHandle.getWritePath();
+      failingHandle.closeGracefully();
+      assertFalse(metaClient.getStorage().exists(failedWritePath));
+    }
+
+    if (tableType == HoodieTableType.COPY_ON_WRITE && cdcEnabled) {
+      assertTrue(updateStatuses.stream()
+          .map(WriteStatus::getStat)
+          .anyMatch(stat -> stat.getCdcStats() != null && 
!stat.getCdcStats().isEmpty()));
+    }
+  }
+
+  @Test
+  void testScheduleClusteringFromRecentlyWrittenPartition() throws IOException 
{
+    initWriteClient(HoodieTableType.COPY_ON_WRITE, false, true);
+
+    String insertInstant = writeClient.startCommit();
+    transitionToInflight(insertInstant);
+    List<WriteStatus> statuses = writeClient.insert(Arrays.asList(
+        insertRecord("id1", "one", 1L),
+        insertRecord("id2", "two", 2L)), insertInstant);
+    assertTrue(writeClient.commit(insertInstant, statuses));
+
+    Option<String> clusteringInstant = 
writeClient.scheduleClustering(Option.empty());
+    assertTrue(clusteringInstant.isPresent());
+    metaClient = HoodieTableMetaClient.reload(metaClient);
+    HoodieInstant requestedInstant = metaClient.getInstantGenerator()
+        .getClusteringCommitRequestedInstant(clusteringInstant.get());
+    HoodieClusteringPlan clusteringPlan = ClusteringUtils
+        .getClusteringPlan(metaClient, requestedInstant).get().getRight();
+    assertEquals(1, clusteringPlan.getInputGroups().size());
+    assertEquals(PARTITION_PATH,
+        
clusteringPlan.getInputGroups().get(0).getSlices().get(0).getPartitionPath());
+  }
+
+  @Test
+  void testPreppedWriteEntryPointsCommitMetadata() throws IOException {
+    initWriteClient(HoodieTableType.COPY_ON_WRITE, false, false);
+
+    String insertInstant = writeClient.startCommit();
+    transitionToInflight(insertInstant);
+    List<WriteStatus> insertStatuses = writeClient.insert(Arrays.asList(
+        insertRecord("id1", "one", 1L),
+        insertRecord("id2", "two", 2L)), insertInstant);
+    assertTrue(writeClient.commit(insertInstant, insertStatuses));
+
+    writeClient.cleanHandles();
+    String upsertInstant = writeClient.startCommit();
+    transitionToInflight(upsertInstant);
+    List<WriteStatus> upsertStatuses = writeClient.upsertPreppedRecords(
+        Collections.singletonList(updateRecord("id1", "one-prepped", 3L, 
insertInstant)),
+        upsertInstant);
+    assertWriteStatuses(upsertStatuses, 2);
+    assertTrue(writeClient.commit(upsertInstant, upsertStatuses));
+    assertCommitMetadata(upsertInstant, HoodieTableType.COPY_ON_WRITE, 2);
+
+    writeClient.cleanHandles();
+    String bulkInsertInstant = writeClient.startCommit();
+    transitionToInflight(bulkInsertInstant);
+    List<WriteStatus> bulkInsertStatuses = 
writeClient.bulkInsertPreppedRecords(
+        Collections.singletonList(insertRecord("id3", "three", 4L)),
+        bulkInsertInstant,
+        Option.empty());
+    assertWriteStatuses(bulkInsertStatuses, 1);
+    assertTrue(writeClient.commit(bulkInsertInstant, bulkInsertStatuses));
+    assertCommitMetadata(bulkInsertInstant, HoodieTableType.COPY_ON_WRITE, 1);
+  }
+
+  @Test
+  @SuppressWarnings({"rawtypes", "unchecked"})
+  void testClientRoutesOverwriteAndDeleteActionsAfterPriorCommit() throws 
IOException {
+    initWriteClient(HoodieTableType.COPY_ON_WRITE, false, false);
+    String insertInstant = writeClient.startCommit();
+    transitionToInflight(insertInstant);
+    List<WriteStatus> insertStatuses = writeClient.insert(
+        Collections.singletonList(insertRecord("id1", "one", 1L)), 
insertInstant);
+    assertTrue(writeClient.commit(insertInstant, insertStatuses));
+    java.util.Map<String, List<String>> replacedFileIds =
+        
writeClient.getPartitionToReplacedFileIds(WriteOperationType.INSERT_OVERWRITE, 
insertStatuses);
+    assertEquals(Collections.singleton(FILE_ID),
+        new java.util.HashSet<>(replacedFileIds.get(PARTITION_PATH)));
+
+    HoodieFlinkTable table = mock(HoodieFlinkTable.class);
+    when(table.getMetaClient()).thenReturn(metaClient);
+    HoodieWriteMetadata<List<WriteStatus>> metadata = new 
HoodieWriteMetadata<>();
+    metadata.setWriteStatuses(Collections.emptyList());
+    when(table.insertOverwrite(any(), any(), any(), anyString(), 
any())).thenReturn(metadata);
+    when(table.insertOverwriteTable(any(), any(), any(), anyString(), 
any())).thenReturn(metadata);
+    when(table.delete(any(), anyString(), any())).thenReturn(metadata);
+    when(table.deletePrepped(any(), anyString(), any())).thenReturn(metadata);
+    when(table.deletePartitions(any(), anyString(), 
any())).thenReturn(metadata);
+
+    HoodieFlinkWriteClient routingClient = new HoodieFlinkWriteClient(context, 
writeConfig) {
+      @Override
+      protected org.apache.hudi.table.HoodieTable createTable(
+          HoodieWriteConfig config, HoodieTableMetaClient ignoredMetaClient) {
+        return table;
+      }
+    };
+    FlinkWriteHandleFactory.Factory handleFactory = 
mock(FlinkWriteHandleFactory.Factory.class);
+    FlinkCreateHandle writeHandle = mock(FlinkCreateHandle.class);
+    when(handleFactory.create(any(), any(), any(), anyString(), any(), 
any())).thenReturn(writeHandle);
+    BucketInfo bucketInfo = new BucketInfo(BucketType.INSERT, FILE_ID, 
PARTITION_PATH);
+
+    try (MockedStatic<FlinkWriteHandleFactory> factory = 
Mockito.mockStatic(FlinkWriteHandleFactory.class)) {
+      factory.when(() -> FlinkWriteHandleFactory.getFactory(any(), any(), 
anyBoolean()))
+          .thenReturn(handleFactory);
+      assertTrue(routingClient.insertOverwrite(

Review Comment:
   These `.isEmpty()` checks assert on the stubbed return value, so they pass 
no matter which `table` method the client routes to and cannot catch a routing 
regression. Add a `verify(table).insertOverwrite(...)` / `delete(...)` per 
case, or return a per-method sentinel, so the assertion actually pins the route.



##########
hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/TestFlinkWriteClient.java:
##########
@@ -157,4 +163,41 @@ public void 
testCleanResourcesCleansMetadataTableHeartbeatForStreamingMetadataWr
     assertFalse(HoodieHeartbeatClient.heartbeatExists(
         metaClient.getStorage(), metadataTableBasePath, instantTime));
   }
+
+  @Test
+  void testUnsupportedWriteEntryPointsAndInvalidTableServiceFailFast() {
+    HoodieWriteConfig writeConfig = HoodieWriteConfig.newBuilder()
+        .withPath(metaClient.getBasePath())
+        .withEngineType(EngineType.FLINK)
+        .withEmbeddedTimelineServerEnabled(false)
+        .build();
+    writeClient = new HoodieFlinkWriteClient(context, writeConfig);
+
+    assertThrows(HoodieNotSupportedException.class, () -> 
writeClient.bootstrap(Option.empty()));
+    assertThrows(HoodieNotSupportedException.class,
+        () -> writeClient.insertPreppedRecords(Collections.emptyList(), 
"001"));
+    assertThrows(HoodieNotSupportedException.class,
+        () -> writeClient.bulkInsert(Collections.emptyList(), "001"));
+    assertThrows(HoodieNotSupportedException.class,
+        () -> writeClient.bulkInsert(Collections.emptyList(), "001", 
Option.empty()));
+    assertThrows(HoodieNotSupportedException.class,
+        () -> writeClient.cluster("001", false));
+    assertThrows(IndexOutOfBoundsException.class,

Review Comment:
   `insert` and `upsert` are supported operations, so these two 
`IndexOutOfBoundsException` assertions only fire because `records.get(0)` reads 
an empty list - an incidental artifact, not the "unsupported ... fail fast" 
behavior the test name describes. Drop the two assertions or point them at a 
real supported-path write, since any empty-input guard added later would break 
them.



-- 
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