voonhous commented on code in PR #19354:
URL: https://github.com/apache/hudi/pull/19354#discussion_r3636271766


##########
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` has the exact same bug (pre-built set + 
`contains`, so every key is kept), plus the same non-deterministic `HashSet` in 
the else branch. Since it is a copy of this method, can we apply the same fix 
there in this PR? A follow-up issue referencing this one works too.



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

Review Comment:
   There are five `tableType == COPY_ON_WRITE && !cdcEnabled` blocks in this 
method, so each of the four parameter combinations effectively runs a different 
test and a failure is hard to trace back. Suggest pulling the COW-only handle 
scenarios (retry-file cleanup, rollover handle, failing create/merge handles) 
into their own test methods and keeping this one to the shared 
insert/upsert/commit flow.



##########
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,
+        () -> writeClient.insert(Collections.emptyList(), "001"));
+    assertThrows(IndexOutOfBoundsException.class,
+        () -> writeClient.upsert(Collections.emptyList(), "001"));
+    assertThrows(HoodieException.class,
+        () -> writeClient.delete(Collections.singletonList(new HoodieKey("id", 
"partition")), "001"));
+    assertThrows(HoodieException.class,
+        () -> writeClient.deletePrepped(Collections.emptyList(), "001"));
+    assertThrows(IllegalArgumentException.class,
+        () -> writeClient.completeTableService(TableServiceType.CLEAN, null, 
null, "001"));
+    assertThrows(AssertionError.class,
+        () -> 
writeClient.getPartitionToReplacedFileIds(WriteOperationType.UPSERT, 
Collections.emptyList()));
+
+    assertFalse(writeClient.loadActiveTimelineOnTableInit());
+    writeClient.waitForCleaningFinish();
+    writeClient.cleanHandles();
+    assertTrue(writeClient.getHoodieTable(false) != null);

Review Comment:
   nit: `assertNotNull(writeClient.getHoodieTable(false))`



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

Review Comment:
   If the double `closeGracefully()` is an idempotency check, add a one-line 
comment saying so; right now it reads like a copy-paste slip.



##########
hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/table/TestHoodieFlinkTableActionRouting.java:
##########
@@ -0,0 +1,250 @@
+/*
+ * 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.table;
+
+import org.apache.hudi.client.WriteStatus;
+import org.apache.hudi.common.data.HoodieListData;
+import org.apache.hudi.common.engine.EngineType;
+import org.apache.hudi.common.model.HoodieKey;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.table.HoodieTableVersion;
+import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.exception.HoodieNotSupportedException;
+import org.apache.hudi.io.HoodieAppendHandle;
+import org.apache.hudi.io.HoodieCreateHandle;
+import org.apache.hudi.io.HoodieInlineLogAppendHandle;
+import org.apache.hudi.io.HoodieWriteHandle;
+import org.apache.hudi.table.action.HoodieWriteMetadata;
+import org.apache.hudi.table.action.clean.CleanPlanActionExecutor;
+import org.apache.hudi.table.action.cluster.ClusteringPlanActionExecutor;
+import org.apache.hudi.table.action.commit.BucketInfo;
+import org.apache.hudi.table.action.commit.BucketType;
+import 
org.apache.hudi.table.action.commit.FlinkDeletePreppedCommitActionExecutor;
+import org.apache.hudi.table.action.commit.FlinkInsertCommitActionExecutor;
+import 
org.apache.hudi.table.action.commit.FlinkInsertOverwriteCommitActionExecutor;
+import 
org.apache.hudi.table.action.commit.FlinkInsertOverwriteTableCommitActionExecutor;
+import 
org.apache.hudi.table.action.commit.FlinkInsertPreppedCommitActionExecutor;
+import org.apache.hudi.table.action.commit.FlinkPartitionTTLActionExecutor;
+import org.apache.hudi.table.action.commit.FlinkUpsertCommitActionExecutor;
+import 
org.apache.hudi.table.action.commit.FlinkUpsertPreppedCommitActionExecutor;
+import 
org.apache.hudi.table.action.commit.delta.FlinkUpsertDeltaCommitActionExecutor;
+import 
org.apache.hudi.table.action.commit.delta.FlinkUpsertPreppedDeltaCommitActionExecutor;
+import org.apache.hudi.table.action.compact.RunCompactionActionExecutor;
+import org.apache.hudi.table.action.compact.ScheduleCompactionActionExecutor;
+import org.apache.hudi.table.action.rollback.BaseRollbackPlanActionExecutor;
+import org.apache.hudi.testutils.HoodieFlinkClientTestHarness;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedConstruction;
+import org.mockito.Mockito;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.function.Supplier;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/** Tests Flink table action routing and explicitly unsupported engine APIs. */
+@SuppressWarnings({"rawtypes", "unchecked"})
+class TestHoodieFlinkTableActionRouting extends HoodieFlinkClientTestHarness {
+
+  @BeforeEach
+  void setUp() {
+    initPath();
+    initFileSystem();
+  }
+
+  @AfterEach
+  void tearDown() throws IOException {
+    cleanupResources();
+  }
+
+  @Test
+  void testCopyOnWriteUnsupportedActionsFailFast() throws IOException {
+    initMetaClient(HoodieTableType.COPY_ON_WRITE);
+    HoodieFlinkCopyOnWriteTable table = new 
HoodieFlinkCopyOnWriteTable(config(), context, metaClient);
+
+    assertUnsupported(() -> table.upsert(context, "001", 
Collections.emptyList()));
+    assertUnsupported(() -> table.insert(context, "001", 
Collections.emptyList()));
+    assertUnsupported(() -> table.bulkInsert(context, "001", 
Collections.emptyList(), Option.empty()));
+    assertUnsupported(() -> table.delete(context, "001", 
Collections.<HoodieKey>emptyList()));
+    assertUnsupported(() -> table.deletePrepped(context, "001", 
Collections.<HoodieRecord>emptyList()));
+    assertUnsupported(() -> table.upsertPrepped(context, "001", 
Collections.<HoodieRecord>emptyList()));
+    assertUnsupported(() -> table.insertPrepped(context, "001", 
Collections.<HoodieRecord>emptyList()));
+    assertUnsupported(() -> table.bulkInsertPrepped(
+        context, "001", Collections.<HoodieRecord>emptyList(), 
Option.empty()));
+    assertUnsupported(() -> table.insertOverwrite(context, "001", 
Collections.<HoodieRecord>emptyList()));
+    assertUnsupported(() -> table.insertOverwriteTable(context, "001", 
Collections.<HoodieRecord>emptyList()));
+    assertUnsupported(() -> table.scheduleCompaction(context, "001", 
Option.empty()));
+    assertUnsupported(() -> table.compact(context, "001"));
+    assertUnsupported(() -> table.cluster(context, "001"));
+    assertUnsupported(() -> table.bootstrap(context, Option.empty()));
+    assertUnsupported(() -> table.rollbackBootstrap(context, "001"));
+    assertUnsupported(() -> table.scheduleIndexing(context, "001", 
Collections.emptyList(), Collections.emptyList()));
+    assertUnsupported(() -> table.index(context, "001"));
+    assertUnsupported(() -> table.savepoint(context, "001", "user", 
"comment"));
+    assertUnsupported(() -> table.scheduleRestore(context, "002", "001"));
+    assertUnsupported(() -> table.restore(context, "002", "001"));
+  }
+
+  @Test
+  void testCopyOnWriteRoutesSupportedPlanningActions() throws IOException {
+    initMetaClient(HoodieTableType.COPY_ON_WRITE);
+    HoodieFlinkCopyOnWriteTable table = new 
HoodieFlinkCopyOnWriteTable(config(), context, metaClient);
+
+    try (MockedConstruction<ClusteringPlanActionExecutor> ignored = 
Mockito.mockConstruction(
+        ClusteringPlanActionExecutor.class,
+        (executor, constructionContext) -> 
when(executor.execute()).thenReturn(Option.empty()))) {
+      assertFalse(table.scheduleClustering(context, "001", 
Option.empty()).isPresent());
+    }
+    try (MockedConstruction<CleanPlanActionExecutor> ignored = 
Mockito.mockConstruction(
+        CleanPlanActionExecutor.class,
+        (executor, constructionContext) -> 
when(executor.execute()).thenReturn(Option.empty()))) {
+      assertFalse(table.createCleanerPlan(context, 
Option.empty()).isPresent());
+    }
+    try (MockedConstruction<FlinkPartitionTTLActionExecutor> ignored = 
Mockito.mockConstruction(
+        FlinkPartitionTTLActionExecutor.class,
+        (executor, constructionContext) -> {
+          HoodieWriteMetadata<java.util.List<WriteStatus>> metadata = new 
HoodieWriteMetadata<>();
+          metadata.setWriteStatuses(Collections.emptyList());
+          when(executor.execute()).thenReturn(metadata);
+        })) {
+      assertEquals(Collections.emptyList(), table.managePartitionTTL(context, 
"002").getWriteStatuses());
+    }
+    assertConstructed(BaseRollbackPlanActionExecutor.class,
+        () -> table.scheduleRollback(
+            context, "003", mock(HoodieInstant.class), false, false, false));
+  }
+
+  @Test
+  void testCopyOnWriteRoutesWriteActionsAndCompactionInsert() throws 
IOException {
+    initMetaClient(HoodieTableType.COPY_ON_WRITE);
+    HoodieFlinkCopyOnWriteTable table = new 
HoodieFlinkCopyOnWriteTable(config(), context, metaClient);
+    HoodieWriteHandle writeHandle = mock(HoodieWriteHandle.class);
+    BucketInfo bucketInfo = new BucketInfo(BucketType.INSERT, "file-1", 
"partition");
+
+    assertConstructed(FlinkUpsertCommitActionExecutor.class,
+        () -> table.upsert(context, writeHandle, bucketInfo, "001", 
Collections.emptyIterator()));
+    assertConstructed(FlinkInsertCommitActionExecutor.class,
+        () -> table.insert(context, writeHandle, bucketInfo, "001", 
Collections.emptyIterator()));
+    assertConstructed(FlinkDeletePreppedCommitActionExecutor.class,
+        () -> table.deletePrepped(context, writeHandle, bucketInfo, "001", 
Collections.emptyList()));
+    assertConstructed(FlinkUpsertPreppedCommitActionExecutor.class,
+        () -> table.upsertPrepped(context, writeHandle, bucketInfo, "001", 
Collections.emptyList()));
+    assertConstructed(FlinkInsertPreppedCommitActionExecutor.class,
+        () -> table.insertPrepped(context, writeHandle, bucketInfo, "001", 
Collections.emptyList()));
+    assertConstructed(FlinkInsertOverwriteCommitActionExecutor.class,
+        () -> table.insertOverwrite(context, writeHandle, bucketInfo, "001", 
Collections.emptyIterator()));
+    assertConstructed(FlinkInsertOverwriteTableCommitActionExecutor.class,
+        () -> table.insertOverwriteTable(context, writeHandle, bucketInfo, 
"001", Collections.emptyIterator()));
+
+    try (MockedConstruction<HoodieCreateHandle> ignored = 
Mockito.mockConstruction(HoodieCreateHandle.class)) {
+      assertEquals(Collections.emptyList(),
+          table.handleInsert("001", "partition", "file-1", 
Collections.emptyMap()).next());
+    }
+  }
+
+  @Test
+  void testMergeOnReadValidatesHandlesAndRoutesScheduling() throws IOException 
{
+    initMetaClient(HoodieTableType.MERGE_ON_READ);
+    HoodieFlinkMergeOnReadTable table = new 
HoodieFlinkMergeOnReadTable(config(), context, metaClient);
+    HoodieWriteHandle writeHandle = mock(HoodieWriteHandle.class);
+    BucketInfo bucketInfo = new BucketInfo(BucketType.UPDATE, "file-1", 
"partition");
+
+    assertThrows(IllegalArgumentException.class,
+        () -> table.upsert(context, writeHandle, bucketInfo, "001", 
Collections.emptyIterator()));
+    assertThrows(IllegalArgumentException.class,
+        () -> table.upsertPrepped(context, writeHandle, bucketInfo, "001", 
Collections.emptyList()));
+
+    try (MockedConstruction<ScheduleCompactionActionExecutor> mocked = 
Mockito.mockConstruction(
+        ScheduleCompactionActionExecutor.class,
+        (executor, constructionContext) -> 
when(executor.execute()).thenReturn(Option.empty()))) {
+      assertFalse(table.scheduleCompaction(context, "002", 
Option.empty()).isPresent());
+      assertFalse(table.scheduleLogCompaction(context, "003", 
Option.empty()).isPresent());
+      assertEquals(2, mocked.constructed().size());
+    }
+  }
+
+  @Test
+  void testMergeOnReadRoutesAppendAndCompactionActions() throws IOException {
+    initMetaClient(HoodieTableType.MERGE_ON_READ);
+    HoodieFlinkMergeOnReadTable table = new 
HoodieFlinkMergeOnReadTable(config(), context, metaClient);
+    HoodieAppendHandle appendHandle = mock(HoodieAppendHandle.class);
+    BucketInfo bucketInfo = new BucketInfo(BucketType.UPDATE, "file-1", 
"partition");
+
+    assertConstructed(FlinkUpsertDeltaCommitActionExecutor.class,
+        () -> table.upsert(context, appendHandle, bucketInfo, "001", 
Collections.emptyIterator()));
+    assertConstructed(FlinkUpsertPreppedDeltaCommitActionExecutor.class,
+        () -> table.upsertPrepped(context, appendHandle, bucketInfo, "001", 
Collections.emptyList()));
+    assertConstructed(FlinkUpsertDeltaCommitActionExecutor.class,
+        () -> table.insert(context, appendHandle, bucketInfo, "001", 
Collections.emptyIterator()));
+
+    HoodieWriteMetadata compactionMetadata = new HoodieWriteMetadata();
+    
compactionMetadata.setWriteStatuses(HoodieListData.eager(Collections.emptyList()));
+    try (MockedConstruction<RunCompactionActionExecutor> ignored = 
Mockito.mockConstruction(
+        RunCompactionActionExecutor.class,
+        (executor, constructionContext) -> 
when(executor.execute()).thenReturn(compactionMetadata))) {
+      assertEquals(Collections.emptyList(), table.compact(context, 
"002").getWriteStatuses());
+      assertEquals(Collections.emptyList(), table.logCompact(context, 
"003").getWriteStatuses());
+    }
+
+    try (MockedConstruction<HoodieInlineLogAppendHandle> ignored = 
Mockito.mockConstruction(
+        HoodieInlineLogAppendHandle.class,
+        (handle, constructionContext) -> 
when(handle.close()).thenReturn(Collections.emptyList()))) {
+      assertEquals(Collections.emptyList(),
+          table.handleInsertsForLogCompaction(
+              "004", "partition", "file-1", Collections.emptyMap(), 
Collections.emptyMap()).next());
+    }
+  }
+
+  private <E> void assertConstructed(Class<E> executorClass, Supplier<Object> 
invocation) {

Review Comment:
   This only proves the executor's constructor was invoked, so any routing 
refactor breaks the test without it being able to catch a real regression. 
Suggest stubbing `execute()` to return a sentinel `HoodieWriteMetadata` and 
asserting the table method returns it -- same amount of code, but then the test 
also verifies the result flows through.



##########
hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/TestHoodieFlinkTableServiceClient.java:
##########
@@ -98,6 +117,134 @@ void 
testInitMetadataTableRespectsStreamingWriteFlag(boolean metadataStreamingWr
     verify(table, never()).maybeDeleteMetadataTable();
   }
 
+  @Test
+  void testInitMetadataTableWrapsMetadataWriterFailure() {
+    HoodieWriteConfig writeConfig = HoodieWriteConfig.newBuilder()
+        .withPath(metaClient.getBasePath())
+        .withLockConfig(HoodieLockConfig.newBuilder()
+            .withLockProvider(InProcessLockProvider.class)
+            .build())
+        
.withMetadataConfig(HoodieMetadataConfig.newBuilder().enable(true).build())
+        .build();
+    HoodieFlinkTable<?> table = mock(HoodieFlinkTable.class);
+    HoodieActiveTimeline activeTimeline = mock(HoodieActiveTimeline.class);
+    HoodieTimeline pendingTimeline = mock(HoodieTimeline.class);
+    when(table.getActiveTimeline()).thenReturn(activeTimeline);
+    
when(activeTimeline.filterInflightsAndRequested()).thenReturn(pendingTimeline);
+    when(pendingTimeline.lastInstant()).thenReturn(Option.empty());
+
+    TestableHoodieFlinkTableServiceClient client =
+        new TestableHoodieFlinkTableServiceClient(context, writeConfig, 
Option.empty(), table);
+    try (MockedStatic<FlinkHoodieBackedTableMetadataWriter> writerFactory =
+             Mockito.mockStatic(FlinkHoodieBackedTableMetadataWriter.class)) {
+      writerFactory.when(() -> 
FlinkHoodieBackedTableMetadataWriter.create(any(), any(), any(), any()))
+          .thenThrow(new IllegalStateException("expected metadata writer 
failure"));
+      assertThrows(HoodieException.class, client::initMetadataTable);
+    } finally {
+      client.close();
+    }
+  }
+
+  @Test
+  void testMetadataDisabledDeletesStaleMetadataTable() {
+    HoodieWriteConfig writeConfig = HoodieWriteConfig.newBuilder()
+        .withPath(metaClient.getBasePath())
+        
.withMetadataConfig(HoodieMetadataConfig.newBuilder().enable(false).build())
+        .build();
+    HoodieFlinkTable<?> table = mock(HoodieFlinkTable.class);
+    TestableHoodieFlinkTableServiceClient client =
+        new TestableHoodieFlinkTableServiceClient(context, writeConfig, 
Option.empty(), table);
+    try {
+      client.initMetadataTable();
+    } finally {
+      client.close();
+    }
+
+    verify(table).maybeDeleteMetadataTable();
+    verify(table, never()).deleteMetadataIndexIfNecessary();
+  }
+
+  @Test
+  void testOutputConversionAndNoOpHooks() {
+    HoodieWriteConfig writeConfig = HoodieWriteConfig.newBuilder()
+        .withPath(metaClient.getBasePath())
+        
.withMetadataConfig(HoodieMetadataConfig.newBuilder().enable(false).build())
+        .build();
+    TestableHoodieFlinkTableServiceClient client =
+        new TestableHoodieFlinkTableServiceClient(context, writeConfig, 
Option.empty(), mock(HoodieTable.class));
+    try {
+      WriteStatus status = new WriteStatus(false, 0.0);
+      status.setStat(new HoodieWriteStat());
+      HoodieWriteMetadata<java.util.List<WriteStatus>> metadata = new 
HoodieWriteMetadata<>();
+      metadata.setWriteStatuses(Collections.singletonList(status));
+
+      client.callTriggerWritesAndFetchWriteStats(metadata);
+      assertSame(metadata, client.callConvertToOutputMetadata(metadata));
+      
client.callHandleWriteErrors(Collections.singletonList(status.getStat()));
+      assertNull(client.cluster("001", false));

Review Comment:
   This pins the `return null` stub in `cluster()`. Fine as coverage, but add a 
short comment (e.g. `// cluster() is intentionally unimplemented for Flink`) so 
the failure is self-explanatory the day it gets implemented.



##########
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(
+          Collections.<HoodieRecord>emptyList().iterator(), bucketInfo, 
"001").isEmpty());
+      assertTrue(routingClient.insertOverwriteTable(
+          Collections.<HoodieRecord>emptyList().iterator(), bucketInfo, 
"002").isEmpty());
+      assertTrue(routingClient.delete(
+          Collections.singletonList(new HoodieKey("id1", PARTITION_PATH)), 
"003").isEmpty());
+      assertTrue(routingClient.deletePrepped(Collections.emptyList(), 
"004").isEmpty());
+      
assertTrue(routingClient.deletePartitions(Collections.singletonList(PARTITION_PATH),
 "005").isEmpty());
+    } finally {
+      routingClient.close();
+    }
+  }
+
+  private HoodieRecord insertRecord(String key, String name, long ts) {
+    return record(key, name, ts, HoodieOperation.INSERT, "I");
+  }
+
+  private HoodieRecord updateRecord(String key, String name, long ts, String 
instantTime) {
+    return record(key, name, ts, HoodieOperation.UPDATE_AFTER, instantTime);
+  }
+
+  private HoodieRecord deleteRecord(String key, long ts, String instantTime) {
+    return record(key, "deleted", ts, HoodieOperation.DELETE, instantTime);
+  }
+
+  private HoodieRecord record(
+      String key, String name, long ts, HoodieOperation operation, String 
locationInstant) {
+    GenericRowData row = GenericRowData.of(
+        StringData.fromString(key), StringData.fromString(name), ts);
+    HoodieFlinkRecord record = new HoodieFlinkRecord(
+        new HoodieKey(key, PARTITION_PATH), operation, ts, row);
+    record.setCurrentLocation(new HoodieRecordLocation(locationInstant, 
FILE_ID));
+    return record;
+  }
+
+  private void initWriteClient(
+      HoodieTableType tableType, boolean cdcEnabled, boolean 
useRecentClusteringStrategy)
+      throws IOException {
+    Properties tableProperties = new Properties();
+    tableProperties.setProperty(HoodieTableConfig.CDC_ENABLED.key(), 
Boolean.toString(cdcEnabled));
+    tableProperties.setProperty(
+        HoodieTableConfig.CDC_SUPPLEMENTAL_LOGGING_MODE.key(),
+        HoodieCDCSupplementalLoggingMode.DATA_BEFORE_AFTER.name());
+    tableProperties.setProperty(HoodieTableConfig.RECORDKEY_FIELDS.key(), 
"id");
+    tableProperties.setProperty(HoodieTableConfig.PARTITION_FIELDS.key(), 
"partition_path");
+    tableProperties.setProperty(HoodieTableConfig.ORDERING_FIELDS.key(), "ts");
+    tableProperties.setProperty(
+        HoodieWriteConfig.MERGE_ALLOW_DUPLICATE_ON_INSERTS_ENABLE.key(), 
"false");
+    metaClient = HoodieTableMetaClient.newTableBuilder()
+        .setTableName("flink_write_client_test")
+        .setTableType(tableType)
+        .fromProperties(tableProperties)
+        .initTable(storageConf, basePath);
+
+    HoodieWriteConfig.Builder builder = HoodieWriteConfig.newBuilder()
+        .withPath(basePath)
+        .withEngineType(EngineType.FLINK)
+        .withSchema(SCHEMA)
+        .withProperties(tableProperties)
+        
.withMetadataConfig(HoodieMetadataConfig.newBuilder().enable(false).build())
+        .withMergeHandleClassName(HoodieWriteMergeHandle.class.getName());
+    if (useRecentClusteringStrategy) {
+      builder.withClusteringConfig(HoodieClusteringConfig.newBuilder()
+          .withEngineType(EngineType.FLINK)
+          
.withClusteringPlanStrategyClass(FlinkSizeBasedClusteringPlanStrategyRecently.class.getName())
+          .withClusteringPlanSmallFileLimit(Long.MAX_VALUE)
+          .withClusteringMaxNumGroups(10)
+          .withClusteringSortColumns("id")
+          .build());
+    }
+    writeConfig = builder.build();
+    writeClient = new HoodieFlinkWriteClient<>(context, writeConfig);
+  }
+
+  private void assertWriteStatuses(List<WriteStatus> statuses, long 
expectedRecords) {
+    assertFalse(statuses.isEmpty());
+    assertTrue(statuses.stream().noneMatch(WriteStatus::hasErrors));
+    assertEquals(expectedRecords,
+        statuses.stream().map(WriteStatus::getStat).mapToLong(stat -> 
stat.getNumWrites()).sum());
+    statuses.forEach(status -> {
+      assertEquals(PARTITION_PATH, status.getStat().getPartitionPath());
+      assertNotNull(status.getStat().getPath());
+    });
+  }
+
+  private void transitionToInflight(String instantTime) {
+    metaClient.reloadActiveTimeline();
+    metaClient.getActiveTimeline().transitionRequestedToInflight(
+        metaClient.getCommitActionType(), instantTime);
+  }
+
+  private void createInvalidRetryFile(String instantTime) throws IOException {
+    StoragePath partitionPath = new StoragePath(metaClient.getBasePath(), 
PARTITION_PATH);
+    metaClient.getStorage().createDirectory(partitionPath);
+    String fileName = FSUtils.makeBaseFileName(
+        instantTime,
+        FSUtils.makeWriteToken(0, 1, 0),
+        FILE_ID,
+        metaClient.getTableConfig().getBaseFileFormat().getFileExtension());
+    metaClient.getStorage().create(new StoragePath(partitionPath, 
fileName)).close();
+  }
+
+  private void assertCommitMetadata(String instantTime, HoodieTableType 
tableType, long expectedRecords)
+      throws IOException {
+    metaClient = HoodieTableMetaClient.reload(metaClient);
+    String action = metaClient.getCommitActionType();
+    HoodieInstant instant = metaClient.getActiveTimeline()
+        .getTimelineOfActions(java.util.Collections.singleton(action))

Review Comment:
   `Collections` is already imported -- this can be 
`Collections.singleton(action)`. Same cleanup for the inline `java.util.Map`, 
`java.util.HashSet` and `java.util.function.Supplier` usages in this file.



##########
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,
+        () -> writeClient.insert(Collections.emptyList(), "001"));
+    assertThrows(IndexOutOfBoundsException.class,
+        () -> writeClient.upsert(Collections.emptyList(), "001"));
+    assertThrows(HoodieException.class,
+        () -> writeClient.delete(Collections.singletonList(new HoodieKey("id", 
"partition")), "001"));
+    assertThrows(HoodieException.class,
+        () -> writeClient.deletePrepped(Collections.emptyList(), "001"));
+    assertThrows(IllegalArgumentException.class,
+        () -> writeClient.completeTableService(TableServiceType.CLEAN, null, 
null, "001"));
+    assertThrows(AssertionError.class,
+        () -> 
writeClient.getPartitionToReplacedFileIds(WriteOperationType.UPSERT, 
Collections.emptyList()));

Review Comment:
   These lock in incidental behavior: the `IndexOutOfBoundsException` is just 
`records.get(0)` on an empty list, and the `AssertionError` below comes from an 
internal assertion. If we later add a graceful empty-input guard, these fail 
even though that change is an improvement. I'd either add a proper empty-input 
check in the client and assert a meaningful `HoodieException`, or drop these 
three asserts.



##########
hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/io/TestFlinkWriteHandleFactory.java:
##########
@@ -19,24 +19,169 @@
 
 package org.apache.hudi.io;
 
+import org.apache.hudi.common.engine.TaskContextSupplier;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.model.HoodieTableType;
 import org.apache.hudi.common.table.HoodieTableConfig;
 import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.HoodieTableVersion;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.io.v2.RowDataInlineLogWriteHandle;
+import org.apache.hudi.storage.StoragePath;
 import org.apache.hudi.table.HoodieTable;
+import org.apache.hudi.table.action.commit.BucketInfo;
+import org.apache.hudi.table.action.commit.BucketType;
 
+import org.apache.hadoop.fs.Path;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.mockito.MockedConstruction;
+import org.mockito.Mockito;
 
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
+/** Verifies operation-specific Flink write handle selection. */
+@SuppressWarnings({"rawtypes", "unchecked"})
 class TestFlinkWriteHandleFactory {
 
+  private HoodieTableConfig tableConfig;
+  private HoodieWriteConfig writeConfig;
+  private HoodieTable table;
+  private Iterator<HoodieRecord<Object>> records;
+
+  @BeforeEach
+  void setUp() {
+    tableConfig = mock(HoodieTableConfig.class);
+    writeConfig = mock(HoodieWriteConfig.class);
+    table = mock(HoodieTable.class);
+    records = Collections.<HoodieRecord<Object>>emptyList().iterator();
+    
when(table.getTaskContextSupplier()).thenReturn(mock(TaskContextSupplier.class));
+    HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class);
+    when(table.getMetaClient()).thenReturn(metaClient);
+    when(metaClient.getTableConfig()).thenReturn(tableConfig);
+    when(tableConfig.isLSMTreeStorageLayout()).thenReturn(false);
+    
when(writeConfig.getBasePath()).thenReturn("/tmp/flink-handle-factory-test");
+    when(writeConfig.getWriteVersion()).thenReturn(HoodieTableVersion.NINE);
+  }
+
+  @Test
+  void testCommitFactoryCreatesAndReusesBaseFileHandles() {

Review Comment:
   The "reuses" half of the name is never exercised -- there is a single 
`create` call. Either rename to `testCommitFactoryCreatesBaseFileHandle`, or 
add a second `create` against the populated `handles` map and assert the 
replaced path, like the cluster test below does.



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