This is an automated email from the ASF dual-hosted git repository.

nsivabalan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git


The following commit(s) were added to refs/heads/master by this push:
     new e2cf7216fddf fix(spark): reject INSERT_OVERWRITE when overlapping with 
pending clustering (#18829)
e2cf7216fddf is described below

commit e2cf7216fddf37f49cfdf10861be7aaa31e3feb5
Author: Sivabalan Narayanan <[email protected]>
AuthorDate: Fri Jun 26 16:50:15 2026 -0700

    fix(spark): reject INSERT_OVERWRITE when overlapping with pending 
clustering (#18829)
    
    fix(spark): detect INSERT_OVERWRITE / pending-clustering overlap in update 
strategy
    
    Clustering update strategies only saw record-level updates and missed file
      groups being wholesale replaced by INSERT_OVERWRITE / 
INSERT_OVERWRITE_TABLE.
      Under the default SparkRejectUpdateStrategy the overwrite silently won the
      race against pending clustering instead of being rejected.
    
      Plumb fileGroupsToBeReplaced through UpdateStrategy (3-arg ctor kept for
      back-compat; loader falls back on NoSuchMethodException). Replace 
executors
      populate the set; SparkRejectUpdateStrategy unions it with explicit 
updates
      before the overlap check. Extended to the bulk-insert overwrite path via
      BaseDatasetBulkInsertCommitActionExecutor.
    
      Closes #19074
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
---
 .../action/cluster/strategy/UpdateStrategy.java    |  17 +-
 .../update/strategy/BaseSparkUpdateStrategy.java   |  13 +-
 .../update/strategy/SparkAllowUpdateStrategy.java  |  11 +-
 ...arkConsistentBucketDuplicateUpdateStrategy.java |   8 +-
 .../update/strategy/SparkRejectUpdateStrategy.java |  17 +-
 .../commit/BaseSparkCommitActionExecutor.java      |  53 +-
 .../SparkInsertOverwriteCommitActionExecutor.java  |  28 +-
 ...rkInsertOverwriteTableCommitActionExecutor.java |  22 +
 .../commit/TestInsertOverwriteWithClustering.java  | 834 +++++++++++++++++++++
 .../strategy/ConsistentBucketUpdateStrategy.java   |   2 +-
 .../FlinkConsistentBucketUpdateStrategy.java       |   2 +-
 .../BaseDatasetBulkInsertCommitActionExecutor.java |  89 +++
 ...setBulkInsertOverwriteCommitActionExecutor.java |  44 ++
 ...lkInsertOverwriteTableCommitActionExecutor.java |  11 +
 14 files changed, 1123 insertions(+), 28 deletions(-)

diff --git 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/cluster/strategy/UpdateStrategy.java
 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/cluster/strategy/UpdateStrategy.java
index 1c61db4b572e..281aa97acfa5 100644
--- 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/cluster/strategy/UpdateStrategy.java
+++ 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/cluster/strategy/UpdateStrategy.java
@@ -24,6 +24,7 @@ import org.apache.hudi.common.util.collection.Pair;
 import org.apache.hudi.table.HoodieTable;
 
 import java.io.Serializable;
+import java.util.Collections;
 import java.util.Set;
 
 /**
@@ -34,11 +35,25 @@ public abstract class UpdateStrategy<T, I> implements 
Serializable {
   protected final transient HoodieEngineContext engineContext;
   protected HoodieTable table;
   protected Set<HoodieFileGroupId> fileGroupsInPendingClustering;
+  protected Set<HoodieFileGroupId> fileGroupsToBeReplaced;
 
-  public UpdateStrategy(HoodieEngineContext engineContext, HoodieTable table, 
Set<HoodieFileGroupId> fileGroupsInPendingClustering) {
+  public UpdateStrategy(HoodieEngineContext engineContext, HoodieTable table,
+                        Set<HoodieFileGroupId> fileGroupsInPendingClustering,
+                        Set<HoodieFileGroupId> fileGroupsToBeReplaced) {
     this.engineContext = engineContext;
     this.table = table;
     this.fileGroupsInPendingClustering = fileGroupsInPendingClustering;
+    this.fileGroupsToBeReplaced = fileGroupsToBeReplaced;
+  }
+
+  /**
+   * Backward-compatible 3-arg constructor for custom {@code 
hoodie.clustering.updates.strategy}
+   * classes that pre-date the addition of {@code fileGroupsToBeReplaced}. 
Delegates to the 4-arg
+   * form with an empty replaced set so the existing reflection lookup keeps 
working.
+   */
+  public UpdateStrategy(HoodieEngineContext engineContext, HoodieTable table,
+                        Set<HoodieFileGroupId> fileGroupsInPendingClustering) {
+    this(engineContext, table, fileGroupsInPendingClustering, 
Collections.emptySet());
   }
 
   /**
diff --git 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/BaseSparkUpdateStrategy.java
 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/BaseSparkUpdateStrategy.java
index 751e2a2858bc..3ff660f7dcb1 100644
--- 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/BaseSparkUpdateStrategy.java
+++ 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/BaseSparkUpdateStrategy.java
@@ -25,7 +25,7 @@ import org.apache.hudi.common.model.HoodieRecord;
 import org.apache.hudi.table.HoodieTable;
 import org.apache.hudi.table.action.cluster.strategy.UpdateStrategy;
 
-import java.util.List;
+import java.util.HashSet;
 import java.util.Set;
 
 /**
@@ -35,8 +35,9 @@ import java.util.Set;
 public abstract class BaseSparkUpdateStrategy<T> extends UpdateStrategy<T, 
HoodieData<HoodieRecord<T>>> {
 
   public BaseSparkUpdateStrategy(HoodieEngineContext engineContext, 
HoodieTable table,
-                                 Set<HoodieFileGroupId> 
fileGroupsInPendingClustering) {
-    super(engineContext, table, fileGroupsInPendingClustering);
+                                 Set<HoodieFileGroupId> 
fileGroupsInPendingClustering,
+                                 Set<HoodieFileGroupId> 
fileGroupsToBeReplaced) {
+    super(engineContext, table, fileGroupsInPendingClustering, 
fileGroupsToBeReplaced);
   }
 
   /**
@@ -44,9 +45,9 @@ public abstract class BaseSparkUpdateStrategy<T> extends 
UpdateStrategy<T, Hoodi
    * @param inputRecords the records to write, tagged with target file id
    * @return the records matched file group ids
    */
-  protected List<HoodieFileGroupId> 
getGroupIdsWithUpdate(HoodieData<HoodieRecord<T>> inputRecords) {
-    return inputRecords
+  protected Set<HoodieFileGroupId> 
getGroupIdsWithUpdate(HoodieData<HoodieRecord<T>> inputRecords) {
+    return new HashSet<>(inputRecords
             .filter(record -> record.getCurrentLocation() != null)
-            .map(record -> new HoodieFileGroupId(record.getPartitionPath(), 
record.getCurrentLocation().getFileId())).distinct().collectAsList();
+            .map(record -> new HoodieFileGroupId(record.getPartitionPath(), 
record.getCurrentLocation().getFileId())).distinct().collectAsList());
   }
 }
diff --git 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/SparkAllowUpdateStrategy.java
 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/SparkAllowUpdateStrategy.java
index 7de85ae97787..7da558a5d568 100644
--- 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/SparkAllowUpdateStrategy.java
+++ 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/SparkAllowUpdateStrategy.java
@@ -25,7 +25,6 @@ import org.apache.hudi.common.model.HoodieRecord;
 import org.apache.hudi.common.util.collection.Pair;
 import org.apache.hudi.table.HoodieTable;
 
-import java.util.List;
 import java.util.Set;
 import java.util.stream.Collectors;
 
@@ -35,13 +34,17 @@ import java.util.stream.Collectors;
 public class SparkAllowUpdateStrategy<T> extends BaseSparkUpdateStrategy<T> {
 
   public SparkAllowUpdateStrategy(
-      HoodieEngineContext engineContext, HoodieTable table, 
Set<HoodieFileGroupId> fileGroupsInPendingClustering) {
-    super(engineContext, table, fileGroupsInPendingClustering);
+      HoodieEngineContext engineContext, HoodieTable table,
+      Set<HoodieFileGroupId> fileGroupsInPendingClustering,
+      Set<HoodieFileGroupId> fileGroupsToBeReplaced) {
+    super(engineContext, table, fileGroupsInPendingClustering, 
fileGroupsToBeReplaced);
   }
 
   @Override
   public Pair<HoodieData<HoodieRecord<T>>, Set<HoodieFileGroupId>> 
handleUpdate(HoodieData<HoodieRecord<T>> taggedRecordsRDD) {
-    List<HoodieFileGroupId> fileGroupIdsWithRecordUpdate = 
getGroupIdsWithUpdate(taggedRecordsRDD);
+    // TODO: also consider fileGroupsToBeReplaced so INSERT_OVERWRITE 
overlapping with pending
+    // clustering can be rejected/handled here for users who set 
SparkAllowUpdateStrategy.
+    Set<HoodieFileGroupId> fileGroupIdsWithRecordUpdate = 
getGroupIdsWithUpdate(taggedRecordsRDD);
     Set<HoodieFileGroupId> fileGroupIdsWithUpdatesAndPendingClustering = 
fileGroupIdsWithRecordUpdate.stream()
         .filter(fileGroupsInPendingClustering::contains)
         .collect(Collectors.toSet());
diff --git 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/SparkConsistentBucketDuplicateUpdateStrategy.java
 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/SparkConsistentBucketDuplicateUpdateStrategy.java
index eb4b308a668a..d63fe860cdac 100644
--- 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/SparkConsistentBucketDuplicateUpdateStrategy.java
+++ 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/SparkConsistentBucketDuplicateUpdateStrategy.java
@@ -49,12 +49,16 @@ import static 
org.apache.hudi.index.HoodieIndexUtils.tagAsNewRecordIfNeeded;
  */
 public class SparkConsistentBucketDuplicateUpdateStrategy<T> extends 
UpdateStrategy<T, HoodieData<HoodieRecord<T>>> {
 
-  public SparkConsistentBucketDuplicateUpdateStrategy(HoodieEngineContext 
engineContext, HoodieTable table, Set<HoodieFileGroupId> 
fileGroupsInPendingClustering) {
-    super(engineContext, table, fileGroupsInPendingClustering);
+  public SparkConsistentBucketDuplicateUpdateStrategy(HoodieEngineContext 
engineContext, HoodieTable table,
+                                                       Set<HoodieFileGroupId> 
fileGroupsInPendingClustering,
+                                                       Set<HoodieFileGroupId> 
fileGroupsToBeReplaced) {
+    super(engineContext, table, fileGroupsInPendingClustering, 
fileGroupsToBeReplaced);
   }
 
   @Override
   public Pair<HoodieData<HoodieRecord<T>>, Set<HoodieFileGroupId>> 
handleUpdate(HoodieData<HoodieRecord<T>> taggedRecordsRDD) {
+    // TODO: also consider fileGroupsToBeReplaced so INSERT_OVERWRITE 
overlapping with pending
+    // clustering is handled here for the consistent-bucket duplicate-update 
strategy.
     if (fileGroupsInPendingClustering.isEmpty()) {
       return Pair.of(taggedRecordsRDD, Collections.emptySet());
     }
diff --git 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/SparkRejectUpdateStrategy.java
 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/SparkRejectUpdateStrategy.java
index 8f943d92fd7d..0e9874705968 100644
--- 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/SparkRejectUpdateStrategy.java
+++ 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/SparkRejectUpdateStrategy.java
@@ -29,7 +29,6 @@ import org.apache.hudi.table.HoodieTable;
 import lombok.extern.slf4j.Slf4j;
 
 import java.util.Collections;
-import java.util.List;
 import java.util.Set;
 
 /**
@@ -39,18 +38,22 @@ import java.util.Set;
 @Slf4j
 public class SparkRejectUpdateStrategy<T> extends BaseSparkUpdateStrategy<T> {
 
-  public SparkRejectUpdateStrategy(HoodieEngineContext engineContext, 
HoodieTable table, Set<HoodieFileGroupId> fileGroupsInPendingClustering) {
-    super(engineContext, table, fileGroupsInPendingClustering);
+  public SparkRejectUpdateStrategy(HoodieEngineContext engineContext, 
HoodieTable table,
+                                   Set<HoodieFileGroupId> 
fileGroupsInPendingClustering,
+                                   Set<HoodieFileGroupId> 
fileGroupsToBeReplaced) {
+    super(engineContext, table, fileGroupsInPendingClustering, 
fileGroupsToBeReplaced);
   }
 
   @Override
   public Pair<HoodieData<HoodieRecord<T>>, Set<HoodieFileGroupId>> 
handleUpdate(HoodieData<HoodieRecord<T>> taggedRecordsRDD) {
-    List<HoodieFileGroupId> fileGroupIdsWithRecordUpdate = 
getGroupIdsWithUpdate(taggedRecordsRDD);
-    fileGroupIdsWithRecordUpdate.forEach(fileGroupIdWithRecordUpdate -> {
-      if (fileGroupsInPendingClustering.contains(fileGroupIdWithRecordUpdate)) 
{
+    Set<HoodieFileGroupId> allAffectedFileGroups = 
getGroupIdsWithUpdate(taggedRecordsRDD);
+    // also treat replaced file groups as potential conflict targets
+    allAffectedFileGroups.addAll(fileGroupsToBeReplaced);
+    allAffectedFileGroups.forEach(affectedFileGroup -> {
+      if (fileGroupsInPendingClustering.contains(affectedFileGroup)) {
         String msg = String.format("Not allowed to update the clustering file 
group %s. "
                 + "For pending clustering operations, we are not going to 
support update for now.",
-            fileGroupIdWithRecordUpdate.toString());
+            affectedFileGroup.toString());
         log.error(msg);
         throw new HoodieClusteringUpdateException(msg);
       }
diff --git 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/BaseSparkCommitActionExecutor.java
 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/BaseSparkCommitActionExecutor.java
index e79ee136e98f..ed0dcaf321be 100644
--- 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/BaseSparkCommitActionExecutor.java
+++ 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/BaseSparkCommitActionExecutor.java
@@ -44,6 +44,7 @@ import org.apache.hudi.common.util.collection.Pair;
 import org.apache.hudi.config.HoodieWriteConfig;
 import org.apache.hudi.data.HoodieJavaPairRDD;
 import org.apache.hudi.data.HoodieJavaRDD;
+import org.apache.hudi.exception.HoodieException;
 import org.apache.hudi.exception.HoodieUpsertException;
 import org.apache.hudi.execution.SparkLazyInsertIterable;
 import org.apache.hudi.index.HoodieIndex;
@@ -121,9 +122,11 @@ public abstract class BaseSparkCommitActionExecutor<T> 
extends
       return inputRecords;
     }
 
-    UpdateStrategy<T, HoodieData<HoodieRecord<T>>> updateStrategy = 
(UpdateStrategy<T, HoodieData<HoodieRecord<T>>>) ReflectionUtils
-        .loadClass(config.getClusteringUpdatesStrategyClass(), new Class<?>[] 
{HoodieEngineContext.class, HoodieTable.class, Set.class},
-            this.context, table, fileGroupsInPendingClustering);
+    // Get file groups that will be replaced by this operation (for 
INSERT_OVERWRITE, etc.)
+    Set<HoodieFileGroupId> fileGroupsToBeReplaced = 
getFileGroupsBeingReplaced(inputRecords);
+
+    UpdateStrategy<T, HoodieData<HoodieRecord<T>>> updateStrategy =
+        loadClusteringUpdateStrategy(fileGroupsInPendingClustering, 
fileGroupsToBeReplaced);
     // For SparkAllowUpdateStrategy with rollback pending clustering as false, 
need not handle
     // the file group intersection between current ingestion and pending 
clustering file groups.
     // This will be handled at the conflict resolution strategy.
@@ -163,6 +166,50 @@ public abstract class BaseSparkCommitActionExecutor<T> 
extends
     return recordsAndPendingClusteringFileGroups.getLeft();
   }
 
+  /**
+   * Get the file groups that will be replaced by the current operation.
+   * This is relevant for INSERT_OVERWRITE, INSERT_OVERWRITE_TABLE, and 
DELETE_PARTITION operations.
+   *
+   * @param inputRecords the input records for the operation
+   * @return set of file group IDs that will be replaced
+   */
+  protected Set<HoodieFileGroupId> 
getFileGroupsBeingReplaced(HoodieData<HoodieRecord<T>> inputRecords) {
+    // Default implementation returns empty set. Subclasses should override as 
needed.
+    return Collections.emptySet();
+  }
+
+  /**
+   * Loads {@code hoodie.clustering.updates.strategy} via reflection, 
preferring the new 4-arg
+   * constructor (with {@code fileGroupsToBeReplaced}) and falling back to the 
legacy 3-arg
+   * constructor for custom strategies that pre-date this PR.
+   */
+  @SuppressWarnings("unchecked")
+  private UpdateStrategy<T, HoodieData<HoodieRecord<T>>> 
loadClusteringUpdateStrategy(
+      Set<HoodieFileGroupId> fileGroupsInPendingClustering,
+      Set<HoodieFileGroupId> fileGroupsToBeReplaced) {
+    String strategyClass = config.getClusteringUpdatesStrategyClass();
+    try {
+      return (UpdateStrategy<T, HoodieData<HoodieRecord<T>>>) 
ReflectionUtils.loadClass(
+          strategyClass,
+          new Class<?>[] {HoodieEngineContext.class, HoodieTable.class, 
Set.class, Set.class},
+          this.context, table, fileGroupsInPendingClustering, 
fileGroupsToBeReplaced);
+    } catch (HoodieException ex) {
+      if (!(ex.getCause() instanceof NoSuchMethodException)) {
+        throw ex;
+      }
+      // Legacy custom strategies only have the 3-arg constructor. 
INSERT_OVERWRITE overlap with
+      // pending clustering will not be detected for these classes (they never 
see
+      // fileGroupsToBeReplaced); recommend bumping to the 4-arg constructor.
+      log.warn("Clustering update strategy {} is missing the 4-arg constructor 
with "
+          + "fileGroupsToBeReplaced; falling back to the 3-arg constructor. 
INSERT_OVERWRITE "
+          + "overlap with pending clustering will not be detected for this 
strategy.", strategyClass);
+      return (UpdateStrategy<T, HoodieData<HoodieRecord<T>>>) 
ReflectionUtils.loadClass(
+          strategyClass,
+          new Class<?>[] {HoodieEngineContext.class, HoodieTable.class, 
Set.class},
+          this.context, table, fileGroupsInPendingClustering);
+    }
+  }
+
   @Override
   public HoodieWriteMetadata<HoodieData<WriteStatus>> 
execute(HoodieData<HoodieRecord<T>> inputRecords) {
     return this.execute(inputRecords, Option.empty());
diff --git 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkInsertOverwriteCommitActionExecutor.java
 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkInsertOverwriteCommitActionExecutor.java
index 6ac976f2e544..0b699ae540e8 100644
--- 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkInsertOverwriteCommitActionExecutor.java
+++ 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkInsertOverwriteCommitActionExecutor.java
@@ -22,6 +22,7 @@ import org.apache.hudi.client.WriteStatus;
 import org.apache.hudi.common.data.HoodieData;
 import org.apache.hudi.common.engine.HoodieEngineContext;
 import org.apache.hudi.common.model.FileSlice;
+import org.apache.hudi.common.model.HoodieFileGroupId;
 import org.apache.hudi.common.model.HoodieRecord;
 import org.apache.hudi.common.model.WriteOperationType;
 import org.apache.hudi.common.table.timeline.HoodieTimeline;
@@ -41,6 +42,7 @@ import java.util.Arrays;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.stream.Collectors;
 
 public class SparkInsertOverwriteCommitActionExecutor<T>
@@ -82,10 +84,10 @@ public class SparkInsertOverwriteCommitActionExecutor<T>
 
   @Override
   protected Map<String, List<String>> 
getPartitionToReplacedFileIds(HoodieWriteMetadata<HoodieData<WriteStatus>> 
writeMetadata) {
-    String staticOverwritePartition = 
config.getStringOrDefault(HoodieInternalConfig.STATIC_OVERWRITE_PARTITION_PATHS);
-    if (StringUtils.nonEmpty(staticOverwritePartition)) {
+    String staticOverwritePartitionPaths = 
config.getStringOrDefault(HoodieInternalConfig.STATIC_OVERWRITE_PARTITION_PATHS);
+    if (StringUtils.nonEmpty(staticOverwritePartitionPaths)) {
       // static insert overwrite partitions
-      List<String> partitionPaths = 
Arrays.asList(staticOverwritePartition.split(","));
+      List<String> partitionPaths = 
Arrays.asList(staticOverwritePartitionPaths.split(","));
       context.setJobStatus(this.getClass().getSimpleName(), "Getting 
ExistingFileIds of matching static partitions");
       return 
HoodieJavaPairRDD.getJavaPairRDD(context.parallelize(partitionPaths, 
partitionPaths.size()).mapToPair(
           partitionPath -> Pair.of(partitionPath, 
getAllExistingFileIds(partitionPath)))).collectAsMap();
@@ -101,6 +103,26 @@ public class SparkInsertOverwriteCommitActionExecutor<T>
     return 
table.getSliceView().getLatestFileSlices(partitionPath).map(FileSlice::getFileId).distinct().collect(Collectors.toList());
   }
 
+  @Override
+  protected Set<HoodieFileGroupId> 
getFileGroupsBeingReplaced(HoodieData<HoodieRecord<T>> inputRecords) {
+    String staticOverwritePartitionPaths = 
config.getStringOrDefault(HoodieInternalConfig.STATIC_OVERWRITE_PARTITION_PATHS);
+    List<String> partitionPaths;
+
+    if (StringUtils.nonEmpty(staticOverwritePartitionPaths)) {
+      // Static insert overwrite: use the configured partitions
+      partitionPaths = Arrays.asList(staticOverwritePartitionPaths.split(","));
+    } else {
+      // Dynamic insert overwrite: determine partitions from input records
+      partitionPaths = 
inputRecords.map(HoodieRecord::getPartitionPath).distinct().collectAsList();
+    }
+
+    // Get all file groups in the partitions to be overwritten
+    return partitionPaths.stream()
+        .flatMap(partitionPath -> getAllExistingFileIds(partitionPath).stream()
+            .map(fileId -> new HoodieFileGroupId(partitionPath, fileId)))
+        .collect(Collectors.toSet());
+  }
+
   @Override
   protected Iterator<List<WriteStatus>> handleInsertPartition(String 
instantTime, Integer partition, Iterator recordItr, 
Broadcast<SparkBucketInfoGetter> bucketInfoGetter) {
     BucketInfo binfo = bucketInfoGetter.getValue().getBucketInfo(partition);
diff --git 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkInsertOverwriteTableCommitActionExecutor.java
 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkInsertOverwriteTableCommitActionExecutor.java
index d300ea683a90..67f1ab352073 100644
--- 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkInsertOverwriteTableCommitActionExecutor.java
+++ 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkInsertOverwriteTableCommitActionExecutor.java
@@ -22,6 +22,7 @@ import org.apache.hudi.client.WriteStatus;
 import org.apache.hudi.common.data.HoodieData;
 import org.apache.hudi.common.engine.HoodieEngineContext;
 import org.apache.hudi.common.fs.FSUtils;
+import org.apache.hudi.common.model.HoodieFileGroupId;
 import org.apache.hudi.common.model.HoodieRecord;
 import org.apache.hudi.common.model.WriteOperationType;
 import org.apache.hudi.common.util.collection.Pair;
@@ -31,8 +32,10 @@ import org.apache.hudi.table.HoodieTable;
 import org.apache.hudi.table.action.HoodieWriteMetadata;
 
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 public class SparkInsertOverwriteTableCommitActionExecutor<T>
     extends SparkInsertOverwriteCommitActionExecutor<T> {
@@ -53,4 +56,23 @@ public class SparkInsertOverwriteTableCommitActionExecutor<T>
     return 
HoodieJavaPairRDD.getJavaPairRDD(context.parallelize(partitionPaths, 
partitionPaths.size()).mapToPair(
         partitionPath -> Pair.of(partitionPath, 
getAllExistingFileIds(partitionPath)))).collectAsMap();
   }
+
+  @Override
+  protected Set<HoodieFileGroupId> 
getFileGroupsBeingReplaced(HoodieData<HoodieRecord<T>> inputRecords) {
+    // INSERT_OVERWRITE_TABLE replaces every file group across every 
partition, not just the
+    // partitions present in the input records. Enumerate all partitions in 
parallel via the
+    // engine context (matches the parallelization in 
getPartitionToReplacedFileIds above and
+    // avoids a sequential driver-side walk for tables with many partitions 
whose file system
+    // view isn't fully cached).
+    List<String> partitionPaths = FSUtils.getAllPartitionPaths(context, 
table.getMetaClient(), config.getMetadataConfig());
+    if (partitionPaths == null || partitionPaths.isEmpty()) {
+      return Collections.emptySet();
+    }
+    context.setJobStatus(this.getClass().getSimpleName(), "Resolving file 
groups being replaced across all partitions");
+    return new HashSet<>(context.parallelize(partitionPaths, 
partitionPaths.size())
+        .flatMap(partitionPath -> 
table.getSliceView().getLatestFileSlices(partitionPath)
+            .map(fileSlice -> new HoodieFileGroupId(partitionPath, 
fileSlice.getFileId()))
+            .iterator())
+        .collectAsList());
+  }
 }
diff --git 
a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/action/commit/TestInsertOverwriteWithClustering.java
 
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/action/commit/TestInsertOverwriteWithClustering.java
new file mode 100644
index 000000000000..955a8160c264
--- /dev/null
+++ 
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/action/commit/TestInsertOverwriteWithClustering.java
@@ -0,0 +1,834 @@
+/*
+ * 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.action.commit;
+
+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.client.clustering.plan.strategy.SparkSingleFileSortPlanStrategy;
+import 
org.apache.hudi.client.clustering.run.strategy.SparkSingleFileSortExecutionStrategy;
+import org.apache.hudi.common.data.HoodieData;
+import org.apache.hudi.common.model.HoodieFileGroupId;
+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.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.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.data.HoodieJavaRDD;
+import org.apache.hudi.exception.HoodieUpsertException;
+import org.apache.hudi.table.HoodieSparkTable;
+import org.apache.hudi.table.HoodieTable;
+import org.apache.hudi.testutils.HoodieClientTestBase;
+
+import org.apache.spark.api.java.JavaRDD;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.TRIP_EXAMPLE_SCHEMA;
+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;
+
+/**
+ * Tests for INSERT_OVERWRITE, INSERT_OVERWRITE_TABLE, and DELETE_PARTITION 
operations
+ * when there are pending clustering operations on the file groups being 
replaced.
+ */
+public class TestInsertOverwriteWithClustering extends HoodieClientTestBase {
+
+  private HoodieTestDataGenerator dataGen;
+
+  @BeforeEach
+  public void setUp() throws Exception {
+    initPath();
+    initSparkContexts();
+    initTestDataGenerator();
+    initMetaClient(HoodieTableType.COPY_ON_WRITE);
+    dataGen = new HoodieTestDataGenerator();
+  }
+
+  @AfterEach
+  public void tearDown() throws Exception {
+    cleanupResources();
+  }
+
+  private HoodieClusteringConfig.Builder baseClusteringConfigBuilder(boolean 
rollbackPendingClustering) {
+    return HoodieClusteringConfig.newBuilder()
+        
.withClusteringPlanStrategyClass(SparkSingleFileSortPlanStrategy.class.getName())
+        
.withClusteringExecutionStrategyClass(SparkSingleFileSortExecutionStrategy.class.getName())
+        .withClusteringMaxNumGroups(10)
+        .withRollbackPendingClustering(rollbackPendingClustering);
+  }
+
+  private HoodieWriteConfig.Builder getConfigBuilder(boolean 
rollbackPendingClustering) {
+    return HoodieWriteConfig.newBuilder()
+        .withPath(basePath)
+        .withSchema(TRIP_EXAMPLE_SCHEMA)
+        .withParallelism(2, 2)
+        .withBulkInsertParallelism(2)
+        .withFinalizeWriteParallelism(2)
+        .withDeleteParallelism(2)
+        .withRollbackParallelism(2)
+        
.withClusteringConfig(baseClusteringConfigBuilder(rollbackPendingClustering).build());
+  }
+
+  private HoodieWriteConfig.Builder 
getConfigBuilderWithPartitionFilter(boolean rollbackPendingClustering, String 
partitionFilter) {
+    return HoodieWriteConfig.newBuilder()
+        .withPath(basePath)
+        .withSchema(TRIP_EXAMPLE_SCHEMA)
+        .withParallelism(2, 2)
+        .withBulkInsertParallelism(2)
+        .withFinalizeWriteParallelism(2)
+        .withDeleteParallelism(2)
+        .withRollbackParallelism(2)
+        
.withClusteringConfig(baseClusteringConfigBuilder(rollbackPendingClustering)
+            .withClusteringPartitionSelected(partitionFilter)
+            .build());
+  }
+
+  /**
+   * Test that INSERT_OVERWRITE operation throws an exception when file groups
+   * to be replaced conflict with pending clustering.
+   */
+  @Test
+  public void testStaticInsertOverwriteWithPendingClusteringRejectsUpdate() 
throws Exception {
+    HoodieWriteConfig config = getConfigBuilder(false).build();
+    SparkRDDWriteClient client = getHoodieWriteClient(config);
+
+    // Step 1: Initial insert to create some data
+    String instant1 = nextInstant();
+    List<HoodieRecord> records1 = dataGen.generateInserts(instant1, 100);
+    JavaRDD<HoodieRecord> writeRecords1 = jsc.parallelize(records1, 2);
+    commitInsert(client, writeRecords1, instant1);
+
+    // Get partition path from the first record
+    String partitionPath = records1.get(0).getPartitionPath();
+
+    // Step 2: Schedule clustering for the partition
+    Option<String> clusteringInstantOpt = 
client.scheduleClustering(Option.empty());
+    assertTrue(clusteringInstantOpt.isPresent(), "Expected clustering to be 
scheduled but returned empty");
+    String clusteringInstant = clusteringInstantOpt.get();
+
+    // Verify clustering is pending
+    HoodieTableMetaClient metaClient = 
HoodieTableMetaClient.reload(this.metaClient);
+    HoodieTimeline pendingReplaceTimeline = metaClient.getActiveTimeline()
+        .filterPendingClusteringTimeline();
+    assertEquals(1, pendingReplaceTimeline.countInstants());
+
+    // Get file groups involved in pending clustering
+    Set<HoodieFileGroupId> pendingClusteringFileGroups = ClusteringUtils
+        .getAllFileGroupsInPendingClusteringPlans(metaClient).keySet();
+    assertFalse(pendingClusteringFileGroups.isEmpty());
+
+    // Step 3: Perform static INSERT_OVERWRITE on the same partition
+    // This should throw HoodieUpsertException because file groups to be 
replaced
+    // conflict with pending clustering
+    String instant3 = nextInstant();
+    List<HoodieRecord> records3 = 
dataGen.generateInsertsForPartition(instant3, 50, partitionPath);
+    JavaRDD<HoodieRecord> writeRecords3 = jsc.parallelize(records3, 2);
+
+    metaClient = HoodieTableMetaClient.reload(this.metaClient);
+    HoodieUpsertException upsertException = 
assertThrows(HoodieUpsertException.class, () ->
+        commitInsertOverwrite(client, writeRecords3, instant3)
+    );
+    assertTrue(upsertException.getCause().getMessage().contains("Not allowed 
to update the clustering file group"));
+
+    // Verify clustering is still pending (NOT rolled back)
+    metaClient = HoodieTableMetaClient.reload(this.metaClient);
+    pendingReplaceTimeline = 
metaClient.getActiveTimeline().filterPendingClusteringTimeline();
+    assertEquals(1, pendingReplaceTimeline.countInstants(),
+        "Pending clustering should remain pending after failed 
INSERT_OVERWRITE");
+    assertTrue(pendingReplaceTimeline.containsInstant(clusteringInstant));
+
+    // Verify the INSERT_OVERWRITE did NOT complete
+    HoodieTimeline completedReplaceTimeline = metaClient.getCommitTimeline()
+        .filterCompletedInstants();
+    assertFalse(completedReplaceTimeline.containsInstant(instant3));
+  }
+
+  /**
+   * Test that dynamic INSERT_OVERWRITE_TABLE operation gets aborted when it 
overlaps w/ pending clustering.
+   */
+  @Test
+  public void testDynamicInsertOverwriteWithPendingClustering() throws 
Exception {
+    HoodieWriteConfig config = getConfigBuilder(true).build();
+    SparkRDDWriteClient client = getHoodieWriteClient(config);
+
+    // Step 1: Initial insert to create data in multiple partitions
+    String instant1 = nextInstant();
+    List<HoodieRecord> records1 = dataGen.generateInserts(instant1, 100);
+    JavaRDD<HoodieRecord> writeRecords1 = jsc.parallelize(records1, 2);
+    commitInsert(client, writeRecords1, instant1);
+
+    // Get partitions that have data
+    Set<String> partitionsWithData = records1.stream()
+        .map(HoodieRecord::getPartitionPath)
+        .collect(Collectors.toSet());
+
+    // Step 2: Schedule clustering
+    Option<String> clusteringInstantOpt = 
client.scheduleClustering(Option.empty());
+    assertTrue(clusteringInstantOpt.isPresent(), "Expected clustering to be 
scheduled but returned empty");
+    String clusteringInstant = clusteringInstantOpt.get();
+
+    // Verify clustering is pending
+    HoodieTableMetaClient metaClient = 
HoodieTableMetaClient.reload(this.metaClient);
+    assertEquals(1, 
metaClient.getActiveTimeline().filterPendingClusteringTimeline().countInstants());
+
+    // Get file groups involved in pending clustering
+    Set<HoodieFileGroupId> pendingClusteringFileGroups = ClusteringUtils
+        .getAllFileGroupsInPendingClusteringPlans(metaClient).keySet();
+    assertFalse(pendingClusteringFileGroups.isEmpty());
+
+    // Step 3: Perform dynamic INSERT_OVERWRITE_TABLE on overlapping partitions
+    String instant3 = nextInstant();
+    String targetPartition = partitionsWithData.iterator().next();
+    List<HoodieRecord> records3 = 
dataGen.generateInsertsForPartition(instant3, 50, targetPartition);
+    JavaRDD<HoodieRecord> writeRecords3 = jsc.parallelize(records3, 2);
+    HoodieUpsertException upsertException = 
assertThrows(HoodieUpsertException.class, () ->
+        commitInsertOverwrite(client, writeRecords3, instant3)
+    );
+    assertTrue(upsertException.getCause().getMessage().contains("Not allowed 
to update the clustering file group"));
+
+    // Verify clustering was never rolled back.
+    metaClient = HoodieTableMetaClient.reload(this.metaClient);
+    HoodieTimeline pendingReplaceTimeline = 
metaClient.getActiveTimeline().filterPendingClusteringTimeline();
+    assertTrue(pendingReplaceTimeline.containsInstant(clusteringInstant),
+        "Pending clustering should not be rolled back");
+
+    // Verify the INSERT_OVERWRITE did NOT complete
+    HoodieTimeline completedReplaceTimeline = metaClient.getCommitTimeline()
+        .filterCompletedInstants();
+    assertFalse(completedReplaceTimeline.containsInstant(instant3));
+  }
+
+  /**
+   * Test that DELETE_PARTITION operation succeeds when pending clustering 
does not overlap.
+   */
+  @Test
+  public void testDeletePartitionWithNonOverlappingPendingClustering() throws 
Exception {
+    HoodieWriteConfig config = getConfigBuilderWithPartitionFilter(false, 
"partition1").build();
+    SparkRDDWriteClient client = getHoodieWriteClient(config);
+
+    // Step 1: insert into partition1
+    String instant1 = nextInstant();
+    List<HoodieRecord> records1 = 
dataGen.generateInsertsForPartition(instant1, 100, "partition1");
+    JavaRDD<HoodieRecord> writeRecords1 = jsc.parallelize(records1, 2);
+    commitInsert(client, writeRecords1, instant1);
+
+    String instant2 = nextInstant();
+    List<HoodieRecord> records2 = 
dataGen.generateInsertsForPartition(instant2, 100, "partition2");
+    JavaRDD<HoodieRecord> writeRecords2 = jsc.parallelize(records2, 2);
+    commitInsert(client, writeRecords2, instant2);
+
+    // Step 3: Schedule clustering for the partition1
+    Option<String> clusteringInstantOpt = 
client.scheduleClustering(Option.empty());
+    assertTrue(clusteringInstantOpt.isPresent(), "Expected clustering to be 
scheduled but returned empty");
+    String clusteringInstant = clusteringInstantOpt.get();
+
+    // Verify clustering is pending
+    HoodieTableMetaClient metaClient = 
HoodieTableMetaClient.reload(this.metaClient);
+    assertEquals(1, 
metaClient.getActiveTimeline().filterPendingClusteringTimeline().countInstants());
+
+    // Get file groups involved in pending clustering
+    Set<HoodieFileGroupId> pendingClusteringFileGroups = ClusteringUtils
+        .getAllFileGroupsInPendingClusteringPlans(metaClient).keySet();
+    assertFalse(pendingClusteringFileGroups.isEmpty());
+
+    client.close();
+    SparkRDDWriteClient client2 = getHoodieWriteClient(config);
+
+    // Step 3: Delete the partition2
+    String instant3 = nextInstant();
+    commitDeletePartitions(client2, Arrays.asList("partition2"), instant3);
+
+    // Verify clustering is still pending (NOT rolled back)
+    metaClient = HoodieTableMetaClient.reload(this.metaClient);
+    HoodieTimeline pendingReplaceTimeline = 
metaClient.getActiveTimeline().filterPendingClusteringTimeline();
+    assertTrue(pendingReplaceTimeline.containsInstant(clusteringInstant),
+        "Pending clustering should remain pending after successful 
DELETE_PARTITION");
+
+    // Verify the DELETE_PARTITION is completed
+    HoodieTimeline completedReplaceTimeline = metaClient.getCommitTimeline()
+        .filterCompletedInstants();
+    assertTrue(completedReplaceTimeline.containsInstant(instant3));
+  }
+
+  /**
+   * Test getFileGroupsBeingReplaced method in 
SparkInsertOverwriteCommitActionExecutor
+   * for static INSERT_OVERWRITE scenario.
+   */
+  @Test
+  public void testGetFileGroupsBeingReplacedForStaticOverwrite() throws 
Exception {
+    HoodieWriteConfig config = getConfigBuilder(true).build();
+    SparkRDDWriteClient client = getHoodieWriteClient(config);
+
+    // Step 1: Initial insert to create some data
+    String instant1 = nextInstant();
+    String partitionPath = "2023/01/01";
+    List<HoodieRecord> records1 = 
dataGen.generateInsertsForPartition(instant1, 100, partitionPath);
+    JavaRDD<HoodieRecord> writeRecords1 = jsc.parallelize(records1, 2);
+    commitInsert(client, writeRecords1, instant1);
+
+    // Step 2: Get the file groups in the partition
+    HoodieTableMetaClient metaClient = 
HoodieTableMetaClient.reload(this.metaClient);
+    HoodieTable table = HoodieSparkTable.create(config, context, metaClient);
+    List<String> existingFileIds = table.getSliceView()
+        .getLatestFileSlices(partitionPath)
+        .map(fileSlice -> fileSlice.getFileId())
+        .collect(Collectors.toList());
+    assertFalse(existingFileIds.isEmpty(), "Should have at least one file 
group");
+
+    // Step 3: Create INSERT_OVERWRITE executor and test 
getFileGroupsBeingReplaced
+    String instant2 = nextInstant();
+    List<HoodieRecord> records = dataGen.generateInsertsForPartition(instant2, 
10, partitionPath);
+    HoodieData<HoodieRecord> inputRecords = 
HoodieJavaRDD.of(jsc.parallelize(records, 1));
+
+    SparkInsertOverwriteCommitActionExecutor executor =
+        new SparkInsertOverwriteCommitActionExecutor(
+            context, config, table, instant2, inputRecords);
+
+    // Invoke the method - it's protected so we test it indirectly through the 
workflow
+    Set<HoodieFileGroupId> fileGroupsBeingReplaced = 
executor.getFileGroupsBeingReplaced(inputRecords);
+
+    // Verify that the file groups in the partition are identified as being 
replaced
+    assertEquals(existingFileIds.size(), fileGroupsBeingReplaced.size(),
+        "Should identify all existing file groups in the partition as being 
replaced");
+    assertTrue(fileGroupsBeingReplaced.stream()
+        .allMatch(fg -> fg.getPartitionPath().equals(partitionPath)),
+        "All identified file groups should be in the target partition");
+  }
+
+  /**
+   * Test that INSERT_OVERWRITE on a non-overlapping partition succeeds
+   * even when there is pending clustering on a different partition.
+   */
+  @Test
+  public void 
testInsertOverwriteNonOverlappingPartitionWithPendingClustering() throws 
Exception {
+    HoodieWriteConfig config = getConfigBuilder(true)
+        .withClusteringConfig(HoodieClusteringConfig.newBuilder()
+            
.withClusteringPlanStrategyClass(SparkSingleFileSortPlanStrategy.class.getName())
+            
.withClusteringExecutionStrategyClass(SparkSingleFileSortExecutionStrategy.class.getName())
+            .withClusteringMaxNumGroups(10)
+            .withRollbackPendingClustering(true)
+            .withClusteringPartitionSelected("partition1")
+            .build())
+        .build();
+    SparkRDDWriteClient client = getHoodieWriteClient(config);
+
+    // Step 1: Insert data into partition1
+    String instant1 = nextInstant();
+    List<HoodieRecord> records1 = 
dataGen.generateInsertsForPartition(instant1, 100, "partition1");
+    JavaRDD<HoodieRecord> writeRecords1 = jsc.parallelize(records1, 2);
+    commitInsert(client, writeRecords1, instant1);
+
+    String instant2 = nextInstant();
+    List<HoodieRecord> records2 = 
dataGen.generateInsertsForPartition(instant1, 100, "partition2");
+    JavaRDD<HoodieRecord> writeRecords2 = jsc.parallelize(records2, 2);
+    commitInsert(client, writeRecords2, instant2);
+
+    // Step 2: Schedule clustering for partition1
+    Option<String> clusteringInstantOpt = 
client.scheduleClustering(Option.empty());
+    assertTrue(clusteringInstantOpt.isPresent(), "Expected clustering to be 
scheduled but returned empty");
+    String clusteringInstant = clusteringInstantOpt.get();
+
+    // Verify clustering is pending
+    HoodieTableMetaClient metaClient = 
HoodieTableMetaClient.reload(this.metaClient);
+    assertEquals(1, 
metaClient.getActiveTimeline().filterPendingClusteringTimeline().countInstants());
+
+    // Step 3: Perform INSERT_OVERWRITE on partition2 (non-overlapping)
+    String instant3 = nextInstant();
+    List<HoodieRecord> records3 = 
dataGen.generateInsertsForPartition(instant3, 50, "partition2");
+    JavaRDD<HoodieRecord> writeRecords3 = jsc.parallelize(records3, 2);
+    commitInsertOverwrite(client, writeRecords3, instant3);
+
+    // Verify clustering was NOT rolled back (no overlap)
+    metaClient = HoodieTableMetaClient.reload(this.metaClient);
+    HoodieTimeline pendingReplaceTimeline = 
metaClient.getActiveTimeline().filterPendingClusteringTimeline();
+    assertEquals(1, pendingReplaceTimeline.countInstants(),
+        "Pending clustering should NOT be rolled back for non-overlapping 
partition");
+
+    // Verify the INSERT_OVERWRITE completed successfully
+    HoodieTimeline completedReplaceTimeline = metaClient.getCommitTimeline()
+        .filterCompletedInstants();
+    assertTrue(completedReplaceTimeline.containsInstant(instant3));
+  }
+
+  /**
+   * Test dynamic INSERT_OVERWRITE that determines partitions from input 
records.
+   * If input records target a partition with pending clustering, it should 
detect the conflict and abort.
+   */
+  @Test
+  public void testDynamicInsertOverwriteDetectsOverlap() throws Exception {
+    HoodieWriteConfig config = getConfigBuilder(false).build();
+    SparkRDDWriteClient client = getHoodieWriteClient(config);
+
+    // Step 1: Insert data into multiple partitions
+    String instant1 = nextInstant();
+    List<HoodieRecord> partition1Records = 
dataGen.generateInsertsForPartition(instant1, 100, "2023/01/01");
+    List<HoodieRecord> partition2Records = 
dataGen.generateInsertsForPartition(instant1, 100, "2023/01/02");
+    List<HoodieRecord> partition3Records = 
dataGen.generateInsertsForPartition(instant1, 100, "2023/01/03");
+    List<HoodieRecord> allRecords = new ArrayList<>();
+    allRecords.addAll(partition1Records);
+    allRecords.addAll(partition2Records);
+    allRecords.addAll(partition3Records);
+    JavaRDD<HoodieRecord> writeRecords1 = jsc.parallelize(allRecords, 2);
+    commitInsert(client, writeRecords1, instant1);
+
+    // Step 2: Schedule clustering
+    Option<String> clusteringInstantOpt = 
client.scheduleClustering(Option.empty());
+    assertTrue(clusteringInstantOpt.isPresent(), "Expected clustering to be 
scheduled but returned empty");
+    String clusteringInstant = clusteringInstantOpt.get();
+
+    // Verify clustering is pending
+    HoodieTableMetaClient metaClient = 
HoodieTableMetaClient.reload(this.metaClient);
+    assertEquals(1, 
metaClient.getActiveTimeline().filterPendingClusteringTimeline().countInstants());
+
+    // Get partitions in clustering
+    Set<HoodieFileGroupId> clusteringFileGroups = ClusteringUtils
+        .getAllFileGroupsInPendingClusteringPlans(metaClient).keySet();
+    Set<String> clusteringPartitions = clusteringFileGroups.stream()
+        .map(HoodieFileGroupId::getPartitionPath)
+        .collect(Collectors.toSet());
+    assertFalse(clusteringPartitions.isEmpty());
+
+    // Step 3: Perform dynamic INSERT_OVERWRITE_TABLE with records in 
overlapping partition
+    // This should fail
+    metaClient = HoodieTableMetaClient.reload(this.metaClient);
+    String instant3 = nextInstant();
+    String targetPartition = clusteringPartitions.iterator().next();
+    List<HoodieRecord> records3 = 
dataGen.generateInsertsForPartition(instant3, 50, targetPartition);
+    JavaRDD<HoodieRecord> writeRecords3 = jsc.parallelize(records3, 2);
+    HoodieUpsertException upsertException = 
assertThrows(HoodieUpsertException.class, () ->
+        commitInsertOverwrite(client, writeRecords3, instant3)
+    );
+    assertTrue(upsertException.getCause().getMessage().contains("Not allowed 
to update the clustering file group"));
+
+    // Verify clustering is still pending (NOT rolled back)
+    metaClient = HoodieTableMetaClient.reload(this.metaClient);
+    HoodieTimeline pendingReplaceTimeline = 
metaClient.getActiveTimeline().filterPendingClusteringTimeline();
+    assertEquals(1, pendingReplaceTimeline.countInstants(),
+        "Pending clustering should remain pending after failed 
INSERT_OVERWRITE_TABLE");
+    assertTrue(pendingReplaceTimeline.containsInstant(clusteringInstant));
+
+    // Verify the INSERT_OVERWRITE_TABLE did NOT complete
+    HoodieTimeline completedReplaceTimeline = metaClient.getCommitTimeline()
+        .filterCompletedInstants();
+    assertFalse(completedReplaceTimeline.containsInstant(instant3));
+  }
+
+  /**
+   * Test multiple concurrent INSERT_OVERWRITE operations on different 
partitions
+   * with one partition having pending clustering.
+   */
+  @Test
+  public void testMultipleInsertOverwriteWithSelectiveOverlap() throws 
Exception {
+    HoodieWriteConfig config = 
getConfigBuilderWithPartitionFilter(false,"2023/01/01,2023/01/02").build();
+    SparkRDDWriteClient client = getHoodieWriteClient(config);
+
+    // Step 1: Insert data into three partitions
+    String instant1 = nextInstant();
+    List<HoodieRecord> partition1Records = 
dataGen.generateInsertsForPartition(instant1, 100, "2023/01/01");
+    List<HoodieRecord> partition2Records = 
dataGen.generateInsertsForPartition(instant1, 100, "2023/01/02");
+    List<HoodieRecord> partition3Records = 
dataGen.generateInsertsForPartition(instant1, 100, "2023/01/03");
+    List<HoodieRecord> allRecords = new ArrayList<>();
+    allRecords.addAll(partition1Records);
+    allRecords.addAll(partition2Records);
+    allRecords.addAll(partition3Records);
+    JavaRDD<HoodieRecord> writeRecords1 = jsc.parallelize(allRecords, 2);
+    commitInsert(client, writeRecords1, instant1);
+
+    // Step 2: Schedule clustering (will cluster partition1 and partition2)
+    Option<String> clusteringInstantOpt = 
client.scheduleClustering(Option.empty());
+    assertTrue(clusteringInstantOpt.isPresent(), "Expected clustering to be 
scheduled but returned empty");
+    String clusteringInstant = clusteringInstantOpt.get();
+
+    // Verify clustering is pending
+    HoodieTableMetaClient metaClient = 
HoodieTableMetaClient.reload(this.metaClient);
+    assertEquals(1, 
metaClient.getActiveTimeline().filterPendingClusteringTimeline().countInstants());
+
+    client.close();
+    SparkRDDWriteClient client2 = getHoodieWriteClient(config);
+
+    // Step 3: Perform INSERT_OVERWRITE on partition3 (no overlap) - should 
succeed without rollback
+    String instant3 = nextInstant();
+    List<HoodieRecord> records3 = 
dataGen.generateInsertsForPartition(instant3, 50, "2023/01/03");
+    JavaRDD<HoodieRecord> writeRecords3 = jsc.parallelize(records3, 2);
+    commitInsertOverwrite(client2, writeRecords3, instant3);
+
+    // Verify clustering is still pending (no rollback for non-overlapping 
partition)
+    metaClient = HoodieTableMetaClient.reload(this.metaClient);
+    assertEquals(1, 
metaClient.getActiveTimeline().filterPendingClusteringTimeline().countInstants(),
+        "Clustering should still be pending after INSERT_OVERWRITE on 
non-overlapping partition");
+
+    client2.close();
+    SparkRDDWriteClient client3 = getHoodieWriteClient(config);
+    // Step 4: Now perform INSERT_OVERWRITE on partition1 (with overlap) - 
should fail
+    String instant4 = nextInstant();
+    List<HoodieRecord> records4 = 
dataGen.generateInsertsForPartition(instant4, 50, "2023/01/01");
+    JavaRDD<HoodieRecord> writeRecords4 = jsc.parallelize(records4, 2);
+    HoodieUpsertException upsertException = 
assertThrows(HoodieUpsertException.class, () ->
+        commitInsertOverwrite(client3, writeRecords4, instant4)
+    );
+    assertTrue(upsertException.getCause().getMessage().contains("Not allowed 
to update the clustering file group"));
+
+    // Verify clustering is still pending (NOT rolled back)
+    metaClient = HoodieTableMetaClient.reload(this.metaClient);
+    HoodieTimeline pendingReplaceTimeline = 
metaClient.getActiveTimeline().filterPendingClusteringTimeline();
+    assertTrue(pendingReplaceTimeline.containsInstant(clusteringInstant), 
"Pending clustering should remain pending after failed INSERT_OVERWRITE");
+
+    // Verify the INSERT_OVERWRITE did NOT complete
+    HoodieTimeline completedReplaceTimeline = metaClient.getCommitTimeline()
+        .filterCompletedInstants();
+    assertFalse(completedReplaceTimeline.containsInstant(instant4));
+  }
+
+  /**
+   * Test getPartitionToReplacedFileIds for static INSERT_OVERWRITE.
+   * Static overwrite uses configured partition paths, not input records.
+   */
+  @Test
+  public void testGetPartitionToReplacedFileIdsForStaticOverwrite() throws 
Exception {
+    HoodieWriteConfig config = getConfigBuilder(false).build();
+    SparkRDDWriteClient client = getHoodieWriteClient(config);
+
+    // Step 1: Insert data into multiple partitions
+    String instant1 = nextInstant();
+    List<HoodieRecord> partition1Records = 
dataGen.generateInsertsForPartition(instant1, 100, "2023/01/01");
+    List<HoodieRecord> partition2Records = 
dataGen.generateInsertsForPartition(instant1, 100, "2023/01/02");
+    List<HoodieRecord> allRecords = new ArrayList<>();
+    allRecords.addAll(partition1Records);
+    allRecords.addAll(partition2Records);
+    commitInsert(client, jsc.parallelize(allRecords, 2), instant1);
+
+    // Insert more data to create multiple file groups per partition
+    String instant2 = nextInstant();
+    List<HoodieRecord> moreRecords1 = 
dataGen.generateInsertsForPartition(instant2, 50, "2023/01/01");
+    List<HoodieRecord> moreRecords2 = 
dataGen.generateInsertsForPartition(instant2, 50, "2023/01/02");
+    List<HoodieRecord> moreRecords = new ArrayList<>();
+    moreRecords.addAll(moreRecords1);
+    moreRecords.addAll(moreRecords2);
+    commitInsert(client, jsc.parallelize(moreRecords, 2), instant2);
+
+    // Get existing file IDs before overwrite
+    HoodieTableMetaClient metaClient = 
HoodieTableMetaClient.reload(this.metaClient);
+    HoodieTable table = HoodieSparkTable.create(config, context, metaClient);
+    List<String> partition1FileIds = table.getSliceView()
+        .getLatestFileSlices("2023/01/01")
+        .map(slice -> slice.getFileId())
+        .collect(Collectors.toList());
+    List<String> partition2FileIds = table.getSliceView()
+        .getLatestFileSlices("2023/01/02")
+        .map(slice -> slice.getFileId())
+        .collect(Collectors.toList());
+
+    assertFalse(partition1FileIds.isEmpty(), "Partition 2023/01/01 should have 
file groups");
+    assertFalse(partition2FileIds.isEmpty(), "Partition 2023/01/02 should have 
file groups");
+
+    // Step 2: Perform static INSERT_OVERWRITE on partition 2023/01/01 only
+    String instant3 = nextInstant();
+    List<HoodieRecord> overwriteRecords = 
dataGen.generateInsertsForPartition(instant3, 30, "2023/01/01");
+    commitInsertOverwrite(client, jsc.parallelize(overwriteRecords, 2), 
instant3);
+
+    // Step 3: Verify replaced file IDs - should only include partition 
2023/01/01
+    metaClient = HoodieTableMetaClient.reload(metaClient);
+    HoodieInstant instant3Instant = 
metaClient.getActiveTimeline().filterCompletedInstants()
+        .filter(i -> i.requestedTime().equals(instant3)).firstInstant().get();
+    HoodieReplaceCommitMetadata replaceMetadata = 
metaClient.getActiveTimeline()
+        .readReplaceCommitMetadata(instant3Instant);
+
+    Map<String, List<String>> partitionToReplacedFileIds = 
replaceMetadata.getPartitionToReplaceFileIds();
+
+    // Verify partition 2023/01/01 is in replaced file IDs
+    assertTrue(partitionToReplacedFileIds.containsKey("2023/01/01"),
+        "Partition 2023/01/01 should be in replaced file IDs");
+
+    // Verify all file IDs from partition 2023/01/01 are marked as replaced
+    List<String> replacedFileIds = 
partitionToReplacedFileIds.get("2023/01/01");
+    assertEquals(partition1FileIds.size(), replacedFileIds.size(),
+        "All file IDs from partition 2023/01/01 should be marked as replaced");
+    assertTrue(replacedFileIds.containsAll(partition1FileIds),
+        "Replaced file IDs should match original file IDs in partition 
2023/01/01");
+
+    // Verify partition 2023/01/02 is NOT in replaced file IDs (was not 
overwritten)
+    assertFalse(partitionToReplacedFileIds.containsKey("2023/01/02"),
+        "Partition 2023/01/02 should NOT be in replaced file IDs");
+  }
+
+  /**
+   * Test getPartitionToReplacedFileIds for dynamic INSERT_OVERWRITE.
+   * Dynamic overwrite determines partitions from input records.
+   */
+  @Test
+  public void testGetPartitionToReplacedFileIdsForDynamicOverwrite() throws 
Exception {
+    HoodieWriteConfig config = getConfigBuilder(false).build();
+    SparkRDDWriteClient client = getHoodieWriteClient(config);
+
+    // Step 1: Insert data into three partitions
+    String instant1 = nextInstant();
+    List<HoodieRecord> partition1Records = 
dataGen.generateInsertsForPartition(instant1, 50, "2023/01/01");
+    List<HoodieRecord> partition2Records = 
dataGen.generateInsertsForPartition(instant1, 50, "2023/01/02");
+    List<HoodieRecord> partition3Records = 
dataGen.generateInsertsForPartition(instant1, 50, "2023/01/03");
+    List<HoodieRecord> allRecords = new ArrayList<>();
+    allRecords.addAll(partition1Records);
+    allRecords.addAll(partition2Records);
+    allRecords.addAll(partition3Records);
+    commitInsert(client, jsc.parallelize(allRecords, 2), instant1);
+
+    // Get existing file IDs
+    HoodieTableMetaClient metaClient = 
HoodieTableMetaClient.reload(this.metaClient);
+    HoodieTable table = HoodieSparkTable.create(config, context, metaClient);
+    List<String> partition1FileIds = table.getSliceView()
+        .getLatestFileSlices("2023/01/01")
+        .map(slice -> slice.getFileId())
+        .collect(Collectors.toList());
+    List<String> partition2FileIds = table.getSliceView()
+        .getLatestFileSlices("2023/01/02")
+        .map(slice -> slice.getFileId())
+        .collect(Collectors.toList());
+
+    // Step 2: Perform dynamic INSERT_OVERWRITE with records for partitions 
01/01 and 01/02
+    String instant2 = nextInstant();
+    List<HoodieRecord> overwriteRecords = new ArrayList<>();
+    overwriteRecords.addAll(dataGen.generateInsertsForPartition(instant2, 30, 
"2023/01/01"));
+    overwriteRecords.addAll(dataGen.generateInsertsForPartition(instant2, 30, 
"2023/01/02"));
+    commitInsertOverwrite(client, jsc.parallelize(overwriteRecords, 2), 
instant2);
+
+    // Step 3: Verify replaced file IDs include both partitions
+    metaClient = HoodieTableMetaClient.reload(metaClient);
+    HoodieInstant instant2Instant = 
metaClient.getActiveTimeline().filterCompletedInstants()
+        .filter(i -> i.requestedTime().equals(instant2)).firstInstant().get();
+    HoodieReplaceCommitMetadata replaceMetadata = 
metaClient.getActiveTimeline()
+        .readReplaceCommitMetadata(instant2Instant);
+
+    Map<String, List<String>> partitionToReplacedFileIds = 
replaceMetadata.getPartitionToReplaceFileIds();
+
+    // Verify both partitions are in replaced file IDs
+    assertTrue(partitionToReplacedFileIds.containsKey("2023/01/01"),
+        "Partition 2023/01/01 should be in replaced file IDs");
+    assertTrue(partitionToReplacedFileIds.containsKey("2023/01/02"),
+        "Partition 2023/01/02 should be in replaced file IDs");
+
+    // Verify file IDs match
+    assertEquals(partition1FileIds.size(), 
partitionToReplacedFileIds.get("2023/01/01").size());
+    assertEquals(partition2FileIds.size(), 
partitionToReplacedFileIds.get("2023/01/02").size());
+
+    // Verify partition 2023/01/03 is NOT in replaced file IDs
+    assertFalse(partitionToReplacedFileIds.containsKey("2023/01/03"),
+        "Partition 2023/01/03 should NOT be in replaced file IDs");
+  }
+
+  /**
+   * Test getPartitionToReplacedFileIds when overwriting a partition with 
multiple file groups.
+   */
+  @Test
+  public void testGetPartitionToReplacedFileIdsWithMultipleFileGroups() throws 
Exception {
+    HoodieWriteConfig config = getConfigBuilder(false).build();
+    SparkRDDWriteClient client = getHoodieWriteClient(config);
+
+    // Step 1: Create multiple file groups in a single partition through 
multiple inserts
+    String partitionPath = "2023/01/01";
+    for (int i = 1; i <= 3; i++) {
+      String instant = nextInstant();
+      List<HoodieRecord> records = 
dataGen.generateInsertsForPartition(instant, 50, partitionPath);
+      commitBulkInsert(client, jsc.parallelize(records, 2), instant);
+    }
+
+    // Get all file IDs in the partition
+    HoodieTableMetaClient metaClient = 
HoodieTableMetaClient.reload(this.metaClient);
+    HoodieTable table = HoodieSparkTable.create(config, context, metaClient);
+    List<String> existingFileIds = table.getSliceView()
+        .getLatestFileSlices(partitionPath)
+        .map(slice -> slice.getFileId())
+        .distinct()
+        .collect(Collectors.toList());
+
+    assertTrue(existingFileIds.size() >= 2,
+        "Should have multiple file groups in partition from multiple inserts");
+
+    // Step 2: Perform INSERT_OVERWRITE
+    metaClient = HoodieTableMetaClient.reload(metaClient);
+    String overwriteInstant = nextInstant();
+    List<HoodieRecord> overwriteRecords = 
dataGen.generateInsertsForPartition(overwriteInstant, 100, partitionPath);
+    commitInsertOverwrite(client, jsc.parallelize(overwriteRecords, 2), 
overwriteInstant);
+
+    // Step 3: Verify all file groups are marked as replaced
+    metaClient = HoodieTableMetaClient.reload(metaClient);
+    HoodieInstant overwriteInstantObj = 
metaClient.getActiveTimeline().filterCompletedInstants()
+        .filter(i -> 
i.requestedTime().equals(overwriteInstant)).firstInstant().get();
+    HoodieReplaceCommitMetadata replaceMetadata = 
metaClient.getActiveTimeline()
+        .readReplaceCommitMetadata(overwriteInstantObj);
+
+    Map<String, List<String>> partitionToReplacedFileIds = 
replaceMetadata.getPartitionToReplaceFileIds();
+
+    assertTrue(partitionToReplacedFileIds.containsKey(partitionPath));
+    List<String> replacedFileIds = 
partitionToReplacedFileIds.get(partitionPath);
+
+    // All existing file IDs should be marked as replaced
+    assertEquals(existingFileIds.size(), replacedFileIds.size(),
+        "All file groups should be marked as replaced");
+    assertTrue(replacedFileIds.containsAll(existingFileIds),
+        "Replaced file IDs should include all original file groups");
+  }
+
+  /**
+   * Test getPartitionToReplacedFileIds when overwriting an empty partition.
+   */
+  @Test
+  public void testGetPartitionToReplacedFileIdsForEmptyPartition() throws 
Exception {
+    HoodieWriteConfig config = getConfigBuilder(false).build();
+    SparkRDDWriteClient client = getHoodieWriteClient(config);
+
+    // Step 1: Insert data into partition1
+    String instant1 = nextInstant();
+    String partition1 = "2023/01/01";
+    List<HoodieRecord> records1 = 
dataGen.generateInsertsForPartition(instant1, 100, partition1);
+    commitInsert(client, jsc.parallelize(records1, 2), instant1);
+
+    // Step 2: Perform INSERT_OVERWRITE on a different partition (empty 
partition2)
+    String instant2 = nextInstant();
+    String partition2 = "2023/01/02";
+    List<HoodieRecord> overwriteRecords = 
dataGen.generateInsertsForPartition(instant2, 50, partition2);
+    commitInsertOverwrite(client, jsc.parallelize(overwriteRecords, 2), 
instant2);
+
+    // Step 3: Verify partition2 is in replaced file IDs even though it was 
empty
+    HoodieTableMetaClient metaClient = 
HoodieTableMetaClient.reload(this.metaClient);
+    HoodieInstant instant2Instant = 
metaClient.getActiveTimeline().filterCompletedInstants()
+        .filter(i -> i.requestedTime().equals(instant2)).firstInstant().get();
+    HoodieReplaceCommitMetadata replaceMetadata = 
metaClient.getActiveTimeline()
+        .readReplaceCommitMetadata(instant2Instant);
+    Map<String, List<String>> partitionToReplacedFileIds = 
replaceMetadata.getPartitionToReplaceFileIds();
+
+    // partition2 should be present with empty list (no existing files to 
replace)
+    assertTrue(partitionToReplacedFileIds.containsKey(partition2),
+        "Empty partition should still be in replaced file IDs map");
+    assertTrue(partitionToReplacedFileIds.get(partition2).isEmpty(),
+        "Empty partition should have empty list of replaced file IDs");
+
+    // partition1 should NOT be in replaced file IDs
+    assertFalse(partitionToReplacedFileIds.containsKey(partition1),
+        "Non-overwritten partition should not be in replaced file IDs");
+  }
+
+  /**
+   * Test getPartitionToReplacedFileIds validates actual file IDs, not just 
counts.
+   * Ensures the specific file IDs returned match the file groups that existed.
+   */
+  @Test
+  public void testGetPartitionToReplacedFileIdsValidatesActualFileIds() throws 
Exception {
+    HoodieWriteConfig config = getConfigBuilder(false).build();
+    SparkRDDWriteClient client = getHoodieWriteClient(config);
+
+    // Step 1: Insert data
+    String instant1 = nextInstant();
+    String partitionPath = "2023/01/01";
+    List<HoodieRecord> records = dataGen.generateInsertsForPartition(instant1, 
100, partitionPath);
+    commitInsert(client, jsc.parallelize(records, 2), instant1);
+
+    // Step 2: Insert more data to create additional file groups
+    String instant2 = nextInstant();
+    List<HoodieRecord> moreRecords = 
dataGen.generateInsertsForPartition(instant2, 50, partitionPath);
+    commitInsert(client, jsc.parallelize(moreRecords, 2), instant2);
+
+    // Capture exact file IDs before overwrite
+    HoodieTableMetaClient metaClient = 
HoodieTableMetaClient.reload(this.metaClient);
+    HoodieTable table = HoodieSparkTable.create(config, context, metaClient);
+    Set<String> expectedFileIds = table.getSliceView()
+        .getLatestFileSlices(partitionPath)
+        .map(slice -> slice.getFileId())
+        .collect(Collectors.toSet());
+
+    assertFalse(expectedFileIds.isEmpty(), "Should have file groups before 
overwrite");
+
+    // Step 3: Perform INSERT_OVERWRITE
+    String instant3 = nextInstant();
+    List<HoodieRecord> overwriteRecords = 
dataGen.generateInsertsForPartition(instant3, 75, partitionPath);
+    commitInsertOverwrite(client, jsc.parallelize(overwriteRecords, 2), 
instant3);
+
+    // Step 4: Validate exact file IDs in replaced list
+    metaClient = HoodieTableMetaClient.reload(metaClient);
+    HoodieInstant instant3Instant = 
metaClient.getActiveTimeline().filterCompletedInstants()
+        .filter(i -> i.requestedTime().equals(instant3)).firstInstant().get();
+    HoodieReplaceCommitMetadata replaceMetadata = 
metaClient.getActiveTimeline()
+        .readReplaceCommitMetadata(instant3Instant);
+    Map<String, List<String>> partitionToReplacedFileIds = 
replaceMetadata.getPartitionToReplaceFileIds();
+
+    Set<String> actualReplacedFileIds = new 
HashSet<>(partitionToReplacedFileIds.get(partitionPath));
+
+    // Validate exact file IDs, not just counts
+    assertEquals(expectedFileIds, actualReplacedFileIds,
+        "Replaced file IDs should exactly match the file groups that existed 
before overwrite");
+  }
+
+  // Hudi instant times are millisecond-resolution; sleep briefly between 
generations so
+  // back-to-back instants in a single test method are strictly ordered.
+  private String nextInstant() throws InterruptedException {
+    Thread.sleep(2);
+    return WriteClientTestUtils.createNewInstantTime();
+  }
+
+  private JavaRDD<WriteStatus> commitInsert(SparkRDDWriteClient client, 
JavaRDD<HoodieRecord> records, String instantTime) {
+    WriteClientTestUtils.startCommitWithTime(client, instantTime);
+    JavaRDD<WriteStatus> writeStatuses = client.insert(records, instantTime);
+    List<WriteStatus> statusList = writeStatuses.collect();
+    client.commit(instantTime, jsc.parallelize(statusList, 1));
+    return writeStatuses;
+  }
+
+  private JavaRDD<WriteStatus> commitBulkInsert(SparkRDDWriteClient client, 
JavaRDD<HoodieRecord> records, String instantTime) {
+    WriteClientTestUtils.startCommitWithTime(client, instantTime);
+    JavaRDD<WriteStatus> writeStatuses = client.bulkInsert(records, 
instantTime);
+    List<WriteStatus> statusList = writeStatuses.collect();
+    client.commit(instantTime, jsc.parallelize(statusList, 1));
+    return writeStatuses;
+  }
+
+  private HoodieWriteResult commitInsertOverwrite(SparkRDDWriteClient client, 
JavaRDD<HoodieRecord> records, String instantTime) {
+    WriteClientTestUtils.startCommitWithTime(client, instantTime, 
HoodieTimeline.REPLACE_COMMIT_ACTION);
+    HoodieWriteResult result = client.insertOverwrite(records, instantTime);
+    List<WriteStatus> statusList = result.getWriteStatuses().collect();
+    client.commit(instantTime, jsc.parallelize(statusList, 1), Option.empty(), 
HoodieTimeline.REPLACE_COMMIT_ACTION,
+        result.getPartitionToReplaceFileIds());
+    return result;
+  }
+
+  private HoodieWriteResult commitDeletePartitions(SparkRDDWriteClient client, 
List<String> partitions, String instantTime) {
+    WriteClientTestUtils.startCommitWithTime(client, instantTime, 
HoodieTimeline.REPLACE_COMMIT_ACTION);
+    HoodieWriteResult result = client.deletePartitions(partitions, 
instantTime);
+    List<WriteStatus> statusList = result.getWriteStatuses().collect();
+    client.commit(instantTime, jsc.parallelize(statusList, 1), Option.empty(), 
HoodieTimeline.REPLACE_COMMIT_ACTION,
+        result.getPartitionToReplaceFileIds());
+    return result;
+  }
+}
diff --git 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/update/strategy/ConsistentBucketUpdateStrategy.java
 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/update/strategy/ConsistentBucketUpdateStrategy.java
index c7ea6d8747f6..3f4515e1b2f1 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/update/strategy/ConsistentBucketUpdateStrategy.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/update/strategy/ConsistentBucketUpdateStrategy.java
@@ -62,7 +62,7 @@ public class ConsistentBucketUpdateStrategy<T> extends 
UpdateStrategy<T, List<Bu
 
   public ConsistentBucketUpdateStrategy(
       HoodieFlinkWriteClient writeClient, List<String> indexKeyFields) {
-    super(writeClient.getEngineContext(), writeClient.getHoodieTable(), 
Collections.emptySet());
+    super(writeClient.getEngineContext(), writeClient.getHoodieTable(), 
Collections.emptySet(), Collections.emptySet());
 
     this.indexKeyFields = indexKeyFields;
     this.partitionToIdentifier = new HashMap<>();
diff --git 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/update/strategy/FlinkConsistentBucketUpdateStrategy.java
 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/update/strategy/FlinkConsistentBucketUpdateStrategy.java
index b6159f411bd2..9272653096da 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/update/strategy/FlinkConsistentBucketUpdateStrategy.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/update/strategy/FlinkConsistentBucketUpdateStrategy.java
@@ -62,7 +62,7 @@ public class FlinkConsistentBucketUpdateStrategy<T extends 
HoodieRecordPayload>
   private String lastRefreshInstant = HoodieTimeline.INIT_INSTANT_TS;
 
   public FlinkConsistentBucketUpdateStrategy(HoodieFlinkWriteClient 
writeClient, List<String> indexKeyFields) {
-    super(writeClient.getEngineContext(), writeClient.getHoodieTable(), 
Collections.emptySet());
+    super(writeClient.getEngineContext(), writeClient.getHoodieTable(), 
Collections.emptySet(), Collections.emptySet());
     this.indexKeyFields = indexKeyFields;
     this.partitionToIdentifier = new HashMap<>();
   }
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/commit/BaseDatasetBulkInsertCommitActionExecutor.java
 
b/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/commit/BaseDatasetBulkInsertCommitActionExecutor.java
index 75b5e6637f9a..8b6466d5117d 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/commit/BaseDatasetBulkInsertCommitActionExecutor.java
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/commit/BaseDatasetBulkInsertCommitActionExecutor.java
@@ -24,12 +24,18 @@ import org.apache.hudi.HoodieDatasetBulkInsertHelper;
 import org.apache.hudi.client.HoodieWriteResult;
 import org.apache.hudi.client.SparkRDDWriteClient;
 import org.apache.hudi.client.WriteStatus;
+import 
org.apache.hudi.client.clustering.update.strategy.SparkAllowUpdateStrategy;
 import org.apache.hudi.common.data.HoodieData;
+import org.apache.hudi.common.engine.HoodieEngineContext;
+import org.apache.hudi.common.model.HoodieFileGroupId;
+import org.apache.hudi.common.model.HoodieRecord;
 import org.apache.hudi.common.model.WriteOperationType;
 import org.apache.hudi.common.table.HoodieTableConfig;
 import org.apache.hudi.common.table.timeline.HoodieInstant;
 import org.apache.hudi.common.util.CommitUtils;
 import org.apache.hudi.common.util.Option;
+import org.apache.hudi.common.util.ReflectionUtils;
+import org.apache.hudi.common.util.collection.Pair;
 import org.apache.hudi.config.HoodieWriteConfig;
 import org.apache.hudi.data.HoodieJavaRDD;
 import org.apache.hudi.exception.HoodieException;
@@ -41,8 +47,10 @@ import org.apache.hudi.index.HoodieIndex;
 import org.apache.hudi.table.BulkInsertPartitioner;
 import org.apache.hudi.table.HoodieTable;
 import org.apache.hudi.table.action.HoodieWriteMetadata;
+import org.apache.hudi.table.action.cluster.strategy.UpdateStrategy;
 
 import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
 import org.apache.spark.api.java.JavaRDD;
 import org.apache.spark.sql.Dataset;
 import org.apache.spark.sql.Row;
@@ -51,9 +59,12 @@ import java.io.Serializable;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
 
 import static 
org.apache.hudi.config.HoodieWriteConfig.WRITE_STATUS_STORAGE_LEVEL_VALUE;
 
+@Slf4j
 public abstract class BaseDatasetBulkInsertCommitActionExecutor implements 
Serializable {
 
   protected final transient HoodieWriteConfig writeConfig;
@@ -109,6 +120,11 @@ public abstract class 
BaseDatasetBulkInsertCommitActionExecutor implements Seria
     BulkInsertPartitioner<Dataset<Row>> bulkInsertPartitionerRows = 
getPartitioner(populateMetaFields, isTablePartitioned);
     Dataset<Row> hoodieDF = 
HoodieDatasetBulkInsertHelper.prepareForBulkInsert(records, writeConfig, 
table.getMetaClient().getTableConfig(), bulkInsertPartitionerRows, instantTime);
 
+    // Reject INSERT_OVERWRITE / INSERT_OVERWRITE_TABLE against partitions 
with pending
+    // clustering before any writes materialize. Subclasses override 
getFileGroupsBeingReplaced;
+    // default is a no-op (empty set) which preserves the non-overwrite paths.
+    rejectIfOverlappingPendingClustering(hoodieDF);
+
     HoodieWriteMetadata<JavaRDD<WriteStatus>> result = 
buildHoodieWriteMetadata(doExecute(hoodieDF, 
bulkInsertPartitionerRows.arePartitionRecordsSorted()));
     afterExecute(result);
 
@@ -141,4 +157,77 @@ public abstract class 
BaseDatasetBulkInsertCommitActionExecutor implements Seria
   }
 
   protected abstract Map<String, List<String>> 
getPartitionToReplacedFileIds(HoodieData<WriteStatus> writeStatuses);
+
+  /**
+   * Returns the file groups this operation will replace. Default is empty 
(non-overwrite paths).
+   * Bulk-insert overwrite executors override this so the 
overlap-with-pending-clustering check
+   * can fire before any writes materialize.
+   *
+   * @param preparedRecords the dataset after {@code 
HoodieDatasetBulkInsertHelper.prepareForBulkInsert}
+   *                        has populated the {@code _hoodie_partition_path} 
meta field, so dynamic
+   *                        partition resolution can read it.
+   */
+  protected Set<HoodieFileGroupId> getFileGroupsBeingReplaced(Dataset<Row> 
preparedRecords) {
+    return Collections.emptySet();
+  }
+
+  /**
+   * Mirrors {@code BaseSparkCommitActionExecutor#clusteringHandleUpdate} for 
the bulk-insert row
+   * path: if any of the file groups this operation will replace are in 
pending clustering, route
+   * through the configured {@code hoodie.clustering.updates.strategy}. With 
the default
+   * {@code SparkRejectUpdateStrategy} this throws {@code 
HoodieClusteringUpdateException}; with
+   * {@code SparkAllowUpdateStrategy} (and {@code 
!isRollbackPendingClustering()}) the overlap is
+   * deferred to the conflict-resolution strategy, matching the existing 
Spark-side behavior.
+   */
+  protected void rejectIfOverlappingPendingClustering(Dataset<Row> 
preparedRecords) {
+    Set<HoodieFileGroupId> fileGroupsInPendingClustering = 
table.getFileSystemView()
+        
.getFileGroupsInPendingClustering().map(Pair::getKey).collect(Collectors.toSet());
+    if (fileGroupsInPendingClustering.isEmpty()) {
+      return;
+    }
+    Set<HoodieFileGroupId> fileGroupsToBeReplaced = 
getFileGroupsBeingReplaced(preparedRecords);
+    if (fileGroupsToBeReplaced.isEmpty()) {
+      return;
+    }
+
+    HoodieEngineContext engineContext = writeClient.getEngineContext();
+    UpdateStrategy<HoodieRecord, HoodieData<HoodieRecord>> updateStrategy = 
loadClusteringUpdateStrategy(
+        engineContext, fileGroupsInPendingClustering, fileGroupsToBeReplaced);
+    if (updateStrategy instanceof SparkAllowUpdateStrategy && 
!writeConfig.isRollbackPendingClustering()) {
+      return;
+    }
+    // handleUpdate consumes only fileGroupsToBeReplaced on this path (no 
tagged records to
+    // inspect for the bulk-insert overwrite case), so pass an empty 
HoodieData.
+    updateStrategy.handleUpdate(engineContext.emptyHoodieData());
+  }
+
+  /**
+   * Loads {@code hoodie.clustering.updates.strategy} via reflection, 
preferring the 4-arg
+   * constructor (with {@code fileGroupsToBeReplaced}) and falling back to the 
legacy 3-arg
+   * constructor for custom strategies that pre-date this PR.
+   */
+  @SuppressWarnings("unchecked")
+  private UpdateStrategy<HoodieRecord, HoodieData<HoodieRecord>> 
loadClusteringUpdateStrategy(
+      HoodieEngineContext engineContext,
+      Set<HoodieFileGroupId> fileGroupsInPendingClustering,
+      Set<HoodieFileGroupId> fileGroupsToBeReplaced) {
+    String strategyClass = writeConfig.getClusteringUpdatesStrategyClass();
+    try {
+      return (UpdateStrategy<HoodieRecord, HoodieData<HoodieRecord>>) 
ReflectionUtils.loadClass(
+          strategyClass,
+          new Class<?>[] {HoodieEngineContext.class, HoodieTable.class, 
Set.class, Set.class},
+          engineContext, table, fileGroupsInPendingClustering, 
fileGroupsToBeReplaced);
+    } catch (HoodieException ex) {
+      if (!(ex.getCause() instanceof NoSuchMethodException)) {
+        throw ex;
+      }
+      log.warn("Clustering update strategy {} is missing the 4-arg constructor 
with "
+          + "fileGroupsToBeReplaced; falling back to the 3-arg constructor. 
INSERT_OVERWRITE "
+          + "overlap with pending clustering will not be detected for this 
strategy.", strategyClass);
+      return (UpdateStrategy<HoodieRecord, HoodieData<HoodieRecord>>) 
ReflectionUtils.loadClass(
+          strategyClass,
+          new Class<?>[] {HoodieEngineContext.class, HoodieTable.class, 
Set.class},
+          engineContext, table, fileGroupsInPendingClustering);
+    }
+  }
 }
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/commit/DatasetBulkInsertOverwriteCommitActionExecutor.java
 
b/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/commit/DatasetBulkInsertOverwriteCommitActionExecutor.java
index deaeac4df45f..397407adb6b3 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/commit/DatasetBulkInsertOverwriteCommitActionExecutor.java
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/commit/DatasetBulkInsertOverwriteCommitActionExecutor.java
@@ -23,6 +23,8 @@ import org.apache.hudi.client.SparkRDDWriteClient;
 import org.apache.hudi.client.WriteStatus;
 import org.apache.hudi.common.data.HoodieData;
 import org.apache.hudi.common.model.FileSlice;
+import org.apache.hudi.common.model.HoodieFileGroupId;
+import org.apache.hudi.common.model.HoodieRecord;
 import org.apache.hudi.common.model.WriteOperationType;
 import org.apache.hudi.common.table.timeline.HoodieInstant;
 import org.apache.hudi.common.util.Option;
@@ -33,12 +35,14 @@ import org.apache.hudi.config.HoodieWriteConfig;
 import org.apache.hudi.data.HoodieJavaPairRDD;
 
 import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Encoders;
 import org.apache.spark.sql.Row;
 
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.stream.Collectors;
 
 public class DatasetBulkInsertOverwriteCommitActionExecutor extends 
BaseDatasetBulkInsertCommitActionExecutor {
@@ -56,6 +60,46 @@ public class DatasetBulkInsertOverwriteCommitActionExecutor 
extends BaseDatasetB
         .bulkInsert(records, instantTime, table, writeConfig, 
arePartitionRecordsSorted, false));
   }
 
+  /**
+   * For INSERT_OVERWRITE: enumerate latest file groups in the targeted 
partitions so the caller
+   * can reject overlap with pending clustering before the bulk-insert 
materializes. Mirrors
+   * {@code 
SparkInsertOverwriteCommitActionExecutor#getFileGroupsBeingReplaced}; called by 
the
+   * base class on the prepared dataset (after {@code prepareForBulkInsert} 
populates the
+   * {@code _hoodie_partition_path} meta field), so dynamic partition 
resolution can read it.
+   */
+  @Override
+  protected Set<HoodieFileGroupId> getFileGroupsBeingReplaced(Dataset<Row> 
preparedRecords) {
+    List<String> partitionPaths = resolveTargetPartitions(preparedRecords);
+    if (partitionPaths.isEmpty()) {
+      return Collections.emptySet();
+    }
+    return partitionPaths.stream()
+        .flatMap(partitionPath -> 
table.getSliceView().getLatestFileSlices(partitionPath)
+            .map(FileSlice::getFileGroupId))
+        .collect(Collectors.toSet());
+  }
+
+  /**
+   * Resolves the partition paths this overwrite will replace. Subclasses 
override for the
+   * table-wide variant (enumerate every partition).
+   */
+  protected List<String> resolveTargetPartitions(Dataset<Row> preparedRecords) 
{
+    if (!table.isPartitioned()) {
+      return Collections.singletonList(StringUtils.EMPTY_STRING);
+    }
+    String staticOverwritePartitionPaths = 
writeConfig.getStringOrDefault(HoodieInternalConfig.STATIC_OVERWRITE_PARTITION_PATHS);
+    if (StringUtils.nonEmpty(staticOverwritePartitionPaths)) {
+      return Arrays.asList(staticOverwritePartitionPaths.split(","));
+    }
+    // Dynamic partition path: read the populated _hoodie_partition_path meta 
field. The base
+    // class invokes this hook after 
HoodieDatasetBulkInsertHelper.prepareForBulkInsert, so the
+    // field is guaranteed to be present and populated by the configured key 
generator.
+    return preparedRecords.select(HoodieRecord.PARTITION_PATH_METADATA_FIELD)
+        .distinct()
+        .as(Encoders.STRING())
+        .collectAsList();
+  }
+
   @Override
   public WriteOperationType getWriteOperationType() {
     return WriteOperationType.INSERT_OVERWRITE;
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/commit/DatasetBulkInsertOverwriteTableCommitActionExecutor.java
 
b/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/commit/DatasetBulkInsertOverwriteTableCommitActionExecutor.java
index d35325360745..3dc1df5dc270 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/commit/DatasetBulkInsertOverwriteTableCommitActionExecutor.java
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/commit/DatasetBulkInsertOverwriteTableCommitActionExecutor.java
@@ -28,6 +28,9 @@ import org.apache.hudi.common.util.collection.Pair;
 import org.apache.hudi.config.HoodieWriteConfig;
 import org.apache.hudi.data.HoodieJavaPairRDD;
 
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
@@ -44,6 +47,14 @@ public class 
DatasetBulkInsertOverwriteTableCommitActionExecutor extends Dataset
     return WriteOperationType.INSERT_OVERWRITE_TABLE;
   }
 
+  @Override
+  protected List<String> resolveTargetPartitions(Dataset<Row> preparedRecords) 
{
+    // Table-wide overwrite replaces every file group in every partition; 
enumerate them all.
+    List<String> partitionPaths = 
FSUtils.getAllPartitionPaths(writeClient.getEngineContext(),
+        table.getMetaClient(), writeConfig.getMetadataConfig());
+    return partitionPaths == null ? Collections.emptyList() : partitionPaths;
+  }
+
   @Override
   protected Map<String, List<String>> 
getPartitionToReplacedFileIds(HoodieData<WriteStatus> writeStatuses) {
     HoodieEngineContext context = writeClient.getEngineContext();

Reply via email to