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

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


The following commit(s) were added to refs/heads/master by this push:
     new 483dd878b4ef feat(hive-sync): batch and parallelize HiveQL partition 
operations (#18984)
483dd878b4ef is described below

commit 483dd878b4ef1b3df1c6135b5daae3fd0274c892
Author: Sivabalan Narayanan <[email protected]>
AuthorDate: Mon Jul 27 01:35:41 2026 -0700

    feat(hive-sync): batch and parallelize HiveQL partition operations (#18984)
    
    * feat(hive-sync): batch and parallelize HiveQL partition operations
    
    Adds an opt-in pool that splits HiveQL partition DDL (add/update/touch/drop)
    into batches of `hoodie.datasource.hive_sync.batch_num` and dispatches them
    in parallel across a pool of single-thread workers. Each worker owns its
    own Hive `Driver` + `SessionState` (both thread-bound in Hive 2.x), so the
    fan-out is implemented as a fixed pool of dedicated single-thread executors
    rather than a shared thread pool.
    
    Table-level operations (create/alter table, last commit time, writer 
version)
    continue to use the single session `Driver`. Partition-phase SQL lists run
    through the pool only when `hoodie.datasource.hive_sync.batching.enabled`
    is set to true; default off, existing behavior unchanged.
    
    Hive 2.x's `ALTER PARTITION SET LOCATION` ignores db.table qualifiers and
    uses the connection's current database, so each worker is primed with the
    correct USE statement before any partition ALTER is dispatched.
    
    * fix(hive-sync): address review feedback on HiveQL partition batching
    
    - Gate TOUCH batch-splitting on hoodie.datasource.hive_sync.batching.enabled
      so the default (off) HiveQL path emits a single ALTER TABLE ... TOUCH
      statement as before, instead of always splitting into batch_num chunks.
    - Give each HiveDriverPool worker its own exclusively-owned SessionState
      instead of sharing one SessionState object across all workers. Concurrent
      Driver.run() calls against a shared session risked corrupting session-
      scoped state (current db, scratch dirs, txn/lock manager), and closing
      the shared session once per worker on teardown closed it multiple times
      while racing worker Driver.close() calls. Workers now bootstrap one at a
      time (each SessionState construction no longer races another).
    - Close the driver pool in HiveQueryDDLExecutor's constructor failure path,
      so a failed SessionState/Driver bootstrap doesn't leak the pool's worker
      threads, Drivers, and sessions (the pool is constructed by the caller
      before this constructor runs, and no one else can close it if we throw).
    - Fix testHiveQLTouchPartitionsWithBatching to drive touchPartitionsToTable
      directly; the previous resync-based version never reached the batched
      TOUCH path because incremental sync short-circuits with no new commit.
    - Make TestHiveDriverPool's cancel-on-first-error assertion race-free by
      parking pending tasks on a latch and asserting isCancelled() || !isDone().
    
    * fix(hive-sync): isolate per-worker HiveConf; document ADD parallel 
dispatch
    
    - HiveDriverPool.DefaultDriverFactory now builds a per-worker HiveConf copy
      (new HiveConf(hiveConf)) instead of sharing one HiveConf instance across
      all workers, and passes that copy to both SessionState and Driver. Hive's
      QueryState/Driver mutate per-query keys (e.g. HIVEQUERYID) on the conf
      during run(), so a shared HiveConf let concurrent Driver.run() calls
      overwrite each other's query-scoped configuration even though each
      worker already had its own SessionState object.
    - Reworded HIVE_SYNC_BATCHING_ENABLED's doc to explicitly include ADD in
      the parallel dispatch scope. addPartitionsToTable routes through
      runSQLs, so ADD batches are dispatched across the pool same as TOUCH/
      SET_LOCATION when the flag is on; only the batch size (not the fan-out)
      was already unchanged before this flag existed.
    
    * fix(hive-sync): abort dispatch on first error; scope TOUCH batching to 
parallel paths
    
    Two review fixes on the HiveQL partition batching path.
    
    1. awaitAll did not actually cancel pending work on first error.
    
    The futures returned by dispatchAll belong to N independent single-thread
    executors, each draining its own queue. awaitAll blocked on Future.get() in
    submission order, so a failure on a fast worker went unobserved while the
    awaiting thread was parked on a slow worker's earlier future -- and the 
failed
    worker kept pulling and applying more partition DDL from its own queue. The
    advertised "cancel pending futures on first error" behavior did not hold.
    
    dispatchAll now returns a Dispatch handle carrying a shared abort flag. Each
    task checks the flag on entry and bails with CancellationException without
    touching its Driver; the first task to fail sets it. awaitAll blocks on a 
latch
    that trips on either all-settled or first-abort, then sweeps cancel(false)
    before walking the futures. Cancelling from the awaiting thread is 
inherently
    late here, so the in-task check is what bounds how much extra DDL a failed 
sync
    can apply. mayInterruptIfRunning=false is preserved, so in-flight statements
    still run to completion rather than leaving a Driver mid-statement.
    
    2. hoodie.datasource.hive_sync.batching.enabled leaked into JDBC mode.
    
    QueryBasedDDLExecutor is also the base class for JDBCExecutor, which does 
not
    override runSQLs. With the flag on in JDBC mode, TOUCH was split into 
batch_num
    statements that then executed serially -- changing statement count and
    partial-application semantics for no benefit, and contradicting the 
documented
    "JDBC is unaffected" contract.
    
    The config read in constructPartitionAlterStatements is replaced by a
    getTouchBatchSize(int) hook. The base implementation returns the full 
partition
    count (one statement, the long-standing behavior); only HiveQueryDDLExecutor
    overrides it, and only when a driver pool is actually present. Keying on 
pool
    presence rather than on the config means the split can never take effect on 
a
    path that would just execute the batches serially.
    
    Tests:
    - awaitAllStopsLaterWorkerWhenEarlierFutureIsSlow: pins the interleaving 
the bug
      needs (slow statement on worker 0, fast failure on worker 1) and asserts 
the
      statement queued behind the failure never reaches a Driver.
    - awaitAllCancelsPendingFuturesOnFirstError: rewritten. The prior version
      asserted isCancelled() || !isDone() to hedge around the race; the abort 
flag
      makes the outcome deterministic, so it now asserts on what actually 
executed.
    - New TestQueryBasedDDLExecutorTouchBatching: asserts a serial executor 
emits a
      single TOUCH statement with the flag on, that its SQL is byte-identical 
with
      the flag on and off, and that a parallel-dispatch executor still splits.
    
    Both new pool tests were verified to fail on all surefire attempts against 
the
    prior logic and pass against the fix. Full hudi-hive-sync suite: 303/303.
    
    * fix(hive-sync): close driver pool if sync client construction fails
    
    HiveQueryDDLExecutor's own catch closes the pool, but it only covers throws 
from
    inside its try block. QueryBasedDDLExecutor's super(config) runs the
    PartitionValueExtractor reflection first, so a bad
    hoodie.datasource.hive_sync.partition_extractor_class throws before that 
try is
    entered -- the executor's catch never runs, HoodieHiveSyncClient's catch 
just
    rethrows, and the already-constructed pool leaks its worker threads and 
Drivers.
    
    Close the pool in HoodieHiveSyncClient's constructor catch, which covers 
every
    window between building the pool and handing ownership to the executor. 
close()
    is idempotent, so overlapping with the executor's own cleanup is harmless.
---
 .../org/apache/hudi/hive/HiveSyncConfigHolder.java |  22 +
 .../org/apache/hudi/hive/HoodieHiveSyncClient.java |  38 +-
 .../apache/hudi/hive/ddl/HiveQueryDDLExecutor.java |  91 +++-
 .../hudi/hive/ddl/QueryBasedDDLExecutor.java       |  70 +++-
 .../org/apache/hudi/hive/util/HiveDriverPool.java  | 463 +++++++++++++++++++++
 .../org/apache/hudi/hive/TestHiveSyncTool.java     | 107 +++++
 .../TestQueryBasedDDLExecutorTouchBatching.java    | 192 +++++++++
 .../apache/hudi/hive/util/TestHiveDriverPool.java  | 316 ++++++++++++++
 8 files changed, 1281 insertions(+), 18 deletions(-)

diff --git 
a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/HiveSyncConfigHolder.java
 
b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/HiveSyncConfigHolder.java
index 418676d59162..14afa81ea30f 100644
--- 
a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/HiveSyncConfigHolder.java
+++ 
b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/HiveSyncConfigHolder.java
@@ -122,6 +122,28 @@ public class HiveSyncConfigHolder {
       .defaultValue(1000)
       .markAdvanced()
       .withDocumentation("The number of partitions one batch when synchronous 
partitions to hive.");
+  public static final ConfigProperty<Boolean> HIVE_SYNC_BATCHING_ENABLED = 
ConfigProperty
+      .key("hoodie.datasource.hive_sync.batching.enabled")
+      .defaultValue(false)
+      .markAdvanced()
+      .sinceVersion("1.3.0")
+      .withDocumentation("Only applies to HiveQL sync mode; has no effect in 
HMS or JDBC mode. When true, "
+          + "ADD, TOUCH, and SET_LOCATION partition statements are dispatched 
in parallel across a pool of "
+          + "Hive Driver workers, with ADD and TOUCH additionally split into 
batches of "
+          + "`hoodie.datasource.hive_sync.batch_num` partitions per statement 
(ADD was already batched "
+          + "before this flag existed; only its dispatch becomes parallel 
here). SET_LOCATION remains one "
+          + "statement per partition, as Hive SQL has no multi-partition form. 
DROP remains serial. "
+          + "Table-level statements (create/alter table, last commit time, 
writer version) continue to run "
+          + "on the single session Driver. Default off; the default HiveQL 
path is unchanged unless "
+          + "explicitly opted in.");
+  public static final ConfigProperty<Integer> HIVE_SYNC_BATCHING_THREADS = 
ConfigProperty
+      .key("hoodie.datasource.hive_sync.batching.threads")
+      .defaultValue(4)
+      .markAdvanced()
+      .sinceVersion("1.3.0")
+      .withDocumentation("Pool size (number of Hive Driver workers) and 
worker-thread count for parallel "
+          + "HiveQL partition dispatch when 
`hoodie.datasource.hive_sync.batching.enabled` is true. "
+          + "Ignored otherwise.");
   public static final ConfigProperty<String> HIVE_SYNC_MODE = ConfigProperty
       .key("hoodie.datasource.hive_sync.mode")
       .noDefaultValue()
diff --git 
a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/HoodieHiveSyncClient.java
 
b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/HoodieHiveSyncClient.java
index 9c5033c80bd4..accc60cb9066 100644
--- 
a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/HoodieHiveSyncClient.java
+++ 
b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/HoodieHiveSyncClient.java
@@ -37,6 +37,7 @@ import org.apache.hudi.hive.ddl.HiveQueryDDLExecutor;
 import org.apache.hudi.hive.ddl.HiveSyncMode;
 import org.apache.hudi.hive.ddl.JDBCBasedMetadataOperator;
 import org.apache.hudi.hive.ddl.JDBCExecutor;
+import org.apache.hudi.hive.util.HiveDriverPool;
 import org.apache.hudi.hive.util.IMetaStoreClientUtil;
 import org.apache.hudi.hive.util.PartitionFilterGenerator;
 import org.apache.hudi.sync.common.HoodieSyncClient;
@@ -66,6 +67,8 @@ import static 
org.apache.hudi.hadoop.utils.HoodieHiveUtils.GLOBALLY_CONSISTENT_R
 import static 
org.apache.hudi.hadoop.utils.HoodieInputFormatUtils.getInputFormatClassName;
 import static 
org.apache.hudi.hadoop.utils.HoodieInputFormatUtils.getOutputFormatClassName;
 import static 
org.apache.hudi.hadoop.utils.HoodieInputFormatUtils.getSerDeClassName;
+import static 
org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_SYNC_BATCHING_ENABLED;
+import static 
org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_SYNC_BATCHING_THREADS;
 import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_SYNC_MODE;
 import static 
org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_SYNC_USE_SPARK_CATALOG;
 import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_USE_JDBC;
@@ -86,6 +89,10 @@ public class HoodieHiveSyncClient extends HoodieSyncClient {
   private final Map<String, Table> initialTableByName = new HashMap<>();
   DDLExecutor ddlExecutor;
   private IMetaStoreClient client;
+  // Present only when HIVE_SYNC_BATCHING_ENABLED and sync mode is HIVEQL 
(explicit
+  // or legacy default). Owned by HiveQueryDDLExecutor; this field is kept for
+  // reference only — close() is delegated through ddlExecutor.close().
+  private Option<HiveDriverPool> partitionDriverPool = Option.empty();
 
   /**
    * JDBC-based metadata operator, lazily initialized on first Thrift
@@ -124,7 +131,8 @@ public class HoodieHiveSyncClient extends HoodieSyncClient {
             ddlExecutor = new HMSDDLExecutor(config, this.client);
             break;
           case HIVEQL:
-            ddlExecutor = new HiveQueryDDLExecutor(config, this.client);
+            this.partitionDriverPool = maybeBuildHiveDriverPool(config);
+            ddlExecutor = new HiveQueryDDLExecutor(config, this.client, 
this.partitionDriverPool);
             break;
           case JDBC:
             JDBCExecutor jdbcExecutor = new JDBCExecutor(config);
@@ -142,14 +150,32 @@ public class HoodieHiveSyncClient extends 
HoodieSyncClient {
           jdbcMetadataOperator = new JDBCBasedMetadataOperator(
               jdbcExecutor.getConnection(), databaseName);
         } else {
-          ddlExecutor = new HiveQueryDDLExecutor(config, this.client);
+          this.partitionDriverPool = maybeBuildHiveDriverPool(config);
+          ddlExecutor = new HiveQueryDDLExecutor(config, this.client, 
this.partitionDriverPool);
         }
       }
     } catch (Exception e) {
+      // The pool owns live daemon threads and Hive Drivers, and is built 
before the
+      // executor that would otherwise own its lifecycle. Any throw between 
those two
+      // points would leak it -- notably QueryBasedDDLExecutor's 
super(config), which
+      // runs the PartitionValueExtractor reflection before 
HiveQueryDDLExecutor's own
+      // try block is even entered. Closing here covers every such window; the 
pool's
+      // close() is idempotent, so overlapping with the executor's cleanup is 
harmless.
+      closePartitionDriverPoolQuietly();
       throw new HoodieHiveSyncException("Failed to create 
HiveMetaStoreClient", e);
     }
   }
 
+  private void closePartitionDriverPoolQuietly() {
+    partitionDriverPool.ifPresent(pool -> {
+      try {
+        pool.close();
+      } catch (Exception e) {
+        log.warn("Error closing HiveDriverPool during failed sync client 
construction", e);
+      }
+    });
+  }
+
   /**
    * Returns true if Thrift API was detected as incompatible and JDBC
    * fallback is available. When true, metadata operations should use
@@ -201,6 +227,14 @@ public class HoodieHiveSyncClient extends HoodieSyncClient 
{
     }
   }
 
+  private Option<HiveDriverPool> maybeBuildHiveDriverPool(HiveSyncConfig 
config) {
+    if (!config.getBooleanOrDefault(HIVE_SYNC_BATCHING_ENABLED)) {
+      return Option.empty();
+    }
+    int size = config.getIntOrDefault(HIVE_SYNC_BATCHING_THREADS);
+    return Option.of(new HiveDriverPool(config, size));
+  }
+
   private Table getInitialTable(String table) {
     return initialTableByName.computeIfAbsent(table, t -> {
       try {
diff --git 
a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/ddl/HiveQueryDDLExecutor.java
 
b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/ddl/HiveQueryDDLExecutor.java
index 25434d29eb3f..c853313182ed 100644
--- 
a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/ddl/HiveQueryDDLExecutor.java
+++ 
b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/ddl/HiveQueryDDLExecutor.java
@@ -19,8 +19,10 @@
 package org.apache.hudi.hive.ddl;
 
 import org.apache.hudi.common.util.HoodieTimer;
+import org.apache.hudi.common.util.Option;
 import org.apache.hudi.hive.HiveSyncConfig;
 import org.apache.hudi.hive.HoodieHiveSyncException;
+import org.apache.hudi.hive.util.HiveDriverPool;
 import org.apache.hudi.hive.util.HivePartitionUtil;
 
 import lombok.extern.slf4j.Slf4j;
@@ -41,6 +43,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
 
+import static 
org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_BATCH_SYNC_PARTITION_NUM;
 import static org.apache.hudi.sync.common.util.TableUtils.tableId;
 
 /**
@@ -52,10 +55,20 @@ public class HiveQueryDDLExecutor extends 
QueryBasedDDLExecutor {
   private final IMetaStoreClient metaStoreClient;
   private SessionState sessionState;
   private Driver hiveDriver;
+  // When present, partition-phase SQL lists fan out across this pool; 
table-level SQL
+  // (createTable, schema evolution, single-statement runSQL callers) always 
uses the
+  // session `hiveDriver` above. See HiveDriverPool javadoc.
+  private final Option<HiveDriverPool> driverPool;
 
   public HiveQueryDDLExecutor(HiveSyncConfig config, IMetaStoreClient 
metaStoreClient) {
+    this(config, metaStoreClient, Option.empty());
+  }
+
+  public HiveQueryDDLExecutor(HiveSyncConfig config, IMetaStoreClient 
metaStoreClient,
+                              Option<HiveDriverPool> driverPool) {
     super(config);
     this.metaStoreClient = metaStoreClient;
+    this.driverPool = driverPool;
     try {
       this.sessionState = new SessionState(config.getHiveConf(),
           UserGroupInformation.getCurrentUser().getShortUserName());
@@ -73,6 +86,15 @@ public class HiveQueryDDLExecutor extends 
QueryBasedDDLExecutor {
       if (this.hiveDriver != null) {
         this.hiveDriver.close();
       }
+      // driverPool (if present) was already constructed by the caller before 
this
+      // ctor ran; since we're about to throw, no one else will call close() 
on it.
+      driverPool.ifPresent(pool -> {
+        try {
+          pool.close();
+        } catch (Exception poolCloseException) {
+          log.error("Error while closing HiveDriverPool", poolCloseException);
+        }
+      });
       throw new HoodieHiveSyncException("Failed to create HiveQueryDDL 
object", e);
     }
   }
@@ -82,19 +104,74 @@ public class HiveQueryDDLExecutor extends 
QueryBasedDDLExecutor {
     updateHiveSQLs(Collections.singletonList(sql));
   }
 
+  /**
+   * Partition-phase SQL fan-out. When the driver pool is present, any leading
+   * {@code USE database} statements are run on every worker (Hive 2.x's
+   * ALTER PARTITION SET LOCATION ignores db.table qualifiers and uses the
+   * connection's current database, so each worker needs to USE the right db
+   * before any partition ALTER). The remaining statements are then dispatched
+   * round-robin across the pool. Falls through to the sequential path on the
+   * session Driver when no pool is configured.
+   */
+  @Override
+  protected void runSQLs(List<String> sqls) {
+    if (sqls.isEmpty()) {
+      return;
+    }
+    if (!driverPool.isPresent()) {
+      updateHiveSQLs(sqls);
+      return;
+    }
+    HiveDriverPool pool = driverPool.get();
+    int useStatementCount = 0;
+    while (useStatementCount < sqls.size() && 
isUseStatement(sqls.get(useStatementCount))) {
+      useStatementCount++;
+    }
+    if (useStatementCount > 0) {
+      List<String> setupStatements = sqls.subList(0, useStatementCount);
+      pool.runOnEachWorker(setupStatements);
+    }
+    List<String> partitionStatements = sqls.subList(useStatementCount, 
sqls.size());
+    if (partitionStatements.isEmpty()) {
+      return;
+    }
+    pool.awaitAll(pool.dispatchAll(partitionStatements));
+  }
+
+  /**
+   * Splits TOUCH into batches of {@code HIVE_BATCH_SYNC_PARTITION_NUM} only 
when a
+   * driver pool is actually present — i.e. only when {@link #runSQLs(List)} 
will
+   * dispatch those batches in parallel. Keyed on pool presence rather than on 
the
+   * {@code batching.enabled} config so the split can never take effect on a 
path
+   * that would just execute the batches serially (the base class, and 
therefore
+   * {@code JDBCExecutor}, always emits one statement).
+   */
+  @Override
+  protected int getTouchBatchSize(int partitionCount) {
+    return driverPool.isPresent()
+        ? config.getIntOrDefault(HIVE_BATCH_SYNC_PARTITION_NUM) : 
partitionCount;
+  }
+
+  // Strict 4-char prefix match on "USE ". Internal callers 
(constructPartitionAlterStatements)
+  // always emit the USE statement without leading whitespace; do not call 
with externally
+  // supplied SQL that might be padded.
+  private static boolean isUseStatement(String sql) {
+    return sql != null && sql.regionMatches(true, 0, "USE ", 0, 4);
+  }
+
   private List<CommandProcessorResponse> updateHiveSQLs(List<String> sqls) {
     List<CommandProcessorResponse> responses = new ArrayList<>();
+    HoodieTimer timer = HoodieTimer.start();
     try {
       for (String sql : sqls) {
         if (hiveDriver != null) {
-          HoodieTimer timer = HoodieTimer.start();
           responses.add(hiveDriver.run(sql));
-          log.info("Time taken to execute [{}]: {} ms", sql, timer.endTimer());
         }
       }
     } catch (Exception e) {
       throw new HoodieHiveSyncException("Failed in executing SQL", e);
     }
+    log.info("Executed {} SQL statements sequentially in {} ms", sqls.size(), 
timer.endTimer());
     return responses;
   }
 
@@ -149,6 +226,16 @@ public class HiveQueryDDLExecutor extends 
QueryBasedDDLExecutor {
 
   @Override
   public void close() {
+    // Close the pool first so the worker threads stop dispatching against 
their
+    // Drivers before we tear down anything else. The pool's close() runs
+    // Driver/SessionState cleanup on each worker's own thread.
+    driverPool.ifPresent(pool -> {
+      try {
+        pool.close();
+      } catch (Exception e) {
+        log.warn("Error closing HiveDriverPool", e);
+      }
+    });
     if (metaStoreClient != null) {
       Hive.closeCurrent();
     }
diff --git 
a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/ddl/QueryBasedDDLExecutor.java
 
b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/ddl/QueryBasedDDLExecutor.java
index 78b8e684e49a..3bcbbe1841df 100644
--- 
a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/ddl/QueryBasedDDLExecutor.java
+++ 
b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/ddl/QueryBasedDDLExecutor.java
@@ -20,6 +20,7 @@ package org.apache.hudi.hive.ddl;
 
 import org.apache.hudi.common.fs.FSUtils;
 import org.apache.hudi.common.schema.HoodieSchema;
+import org.apache.hudi.common.util.CollectionUtils;
 import org.apache.hudi.common.util.PartitionPathEncodeUtils;
 import org.apache.hudi.common.util.ValidationUtils;
 import org.apache.hudi.common.util.collection.Pair;
@@ -75,6 +76,35 @@ public abstract class QueryBasedDDLExecutor implements 
DDLExecutor {
    */
   public abstract void runSQL(String sql);
 
+  /**
+   * Runs a list of SQL statements. The default implementation executes them
+   * sequentially via {@link #runSQL(String)}. Subclasses that can parallelize
+   * (e.g. {@link HiveQueryDDLExecutor} with a driver pool) override this hook
+   * to fan the list out across workers. The contract requires that the list
+   * has no positional dependencies — callers must fully qualify table names
+   * with {@code `db`.`tbl`} so any statement can run on any worker.
+   */
+  protected void runSQLs(List<String> sqls) {
+    for (String sql : sqls) {
+      runSQL(sql);
+    }
+  }
+
+  /**
+   * Number of partitions to pack into a single {@code ALTER TABLE ... TOUCH} 
statement.
+   *
+   * <p>The base implementation returns {@code partitionCount}, i.e. one 
statement
+   * covering every partition — the long-standing behavior, and the only 
correct choice
+   * when {@link #runSQLs(List)} executes the list serially. Splitting a TOUCH 
into
+   * several statements changes failure semantics (a mid-list failure leaves 
some
+   * partitions touched and some not), so it is only worth doing when the 
resulting
+   * statements are actually dispatched in parallel. Subclasses that 
parallelize
+   * override this; see {@link HiveQueryDDLExecutor}.
+   */
+  protected int getTouchBatchSize(int partitionCount) {
+    return partitionCount;
+  }
+
   @Override
   public void createDatabase(String databaseName) {
     runSQL("create database if not exists " + databaseName);
@@ -120,7 +150,7 @@ public abstract class QueryBasedDDLExecutor implements 
DDLExecutor {
     }
     log.info("Adding partitions {} to table {}", partitionsToAdd.size(), 
tableName);
     List<String> sqls = constructAddPartitions(tableName, partitionsToAdd);
-    sqls.stream().forEach(sql -> runSQL(sql));
+    runSQLs(sqls);
   }
 
   @Override
@@ -131,9 +161,7 @@ public abstract class QueryBasedDDLExecutor implements 
DDLExecutor {
     }
     log.info("Changing partitions {} on {}", changedPartitions.size(), 
tableName);
     List<String> sqls = constructPartitionAlterStatements(tableName, 
changedPartitions, PartitionAlterType.SET_LOCATION);
-    for (String sql : sqls) {
-      runSQL(sql);
-    }
+    runSQLs(sqls);
   }
 
   @Override
@@ -216,29 +244,43 @@ public abstract class QueryBasedDDLExecutor implements 
DDLExecutor {
     }
     log.info("Touching partitions {} on {}", touchPartitions.size(), 
tableName);
     List<String> sqls = constructPartitionAlterStatements(tableName, 
touchPartitions, PartitionAlterType.TOUCH);
-    for (String sql : sqls) {
-      runSQL(sql);
-    }
+    runSQLs(sqls);
   }
 
   /**
    * Builds SQL statements to either touch partitions or set their location.
-   * TOUCH: one ALTER TABLE ... TOUCH PARTITION (p1) PARTITION (p2) ...
-   * SET_LOCATION: one ALTER TABLE ... PARTITION (p) SET LOCATION '...' per 
partition.
+   *
+   * <p>The first element of the returned list is always a {@code USE database}
+   * statement. Hive 2.x's ALTER PARTITION ... SET LOCATION does not respect 
the
+   * {@code db.table} qualifier (silently routes to the connection's current
+   * database), so the {@code USE} is load-bearing. Parallel execution paths 
must
+   * run this statement on every worker before fanning out the rest.
+   *
+   * <p>TOUCH: one {@code ALTER TABLE ... TOUCH PARTITION (p1) ...} statement 
per
+   * batch of {@link #getTouchBatchSize(int)} partitions. The base 
implementation
+   * returns the full partition count, i.e. a single statement covering 
everything,
+   * matching pre-batching behavior. Only subclasses that actually dispatch the
+   * resulting statements in parallel override it to split.
+   *
+   * <p>SET_LOCATION: one {@code ALTER TABLE ... PARTITION (p) SET LOCATION 
'...'}
+   * per partition (Hive SQL does not support multi-partition SET LOCATION in 
one
+   * statement).
    */
   private List<String> constructPartitionAlterStatements(String tableName, 
List<String> partitions, PartitionAlterType alterType) {
     List<String> result = new ArrayList<>();
-    // Hive 2.x doesn't like db.table name for operations, hence we need to 
change to using the database first
     String useDatabase = "USE " + HIVE_ESCAPE_CHARACTER + databaseName + 
HIVE_ESCAPE_CHARACTER;
     result.add(useDatabase);
     String alterTablePrefix = "ALTER TABLE " + HIVE_ESCAPE_CHARACTER + 
tableName + HIVE_ESCAPE_CHARACTER;
+    int batchSyncPartitionNum = getTouchBatchSize(partitions.size());
     switch (alterType) {
       case TOUCH:
-        String alterTable = alterTablePrefix + " TOUCH";
-        for (String partition : partitions) {
-          alterTable += " PARTITION (" + getPartitionClause(partition) + ")";
+        for (List<String> batch : CollectionUtils.batches(partitions, 
batchSyncPartitionNum)) {
+          StringBuilder alterTable = new 
StringBuilder(alterTablePrefix).append(" TOUCH");
+          for (String partition : batch) {
+            alterTable.append(" PARTITION 
(").append(getPartitionClause(partition)).append(")");
+          }
+          result.add(alterTable.toString());
         }
-        result.add(alterTable);
         break;
       case SET_LOCATION:
         for (String partition : partitions) {
diff --git 
a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/util/HiveDriverPool.java
 
b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/util/HiveDriverPool.java
new file mode 100644
index 000000000000..5950bf442313
--- /dev/null
+++ 
b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/util/HiveDriverPool.java
@@ -0,0 +1,463 @@
+/*
+ * 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.hive.util;
+
+import org.apache.hudi.common.util.VisibleForTesting;
+import org.apache.hudi.exception.HoodieException;
+import org.apache.hudi.hive.HiveSyncConfig;
+import org.apache.hudi.hive.HoodieHiveSyncException;
+
+import org.apache.hadoop.hive.conf.HiveConf;
+import org.apache.hadoop.hive.ql.Driver;
+import org.apache.hadoop.hive.ql.session.SessionState;
+import org.apache.hadoop.security.UserGroupInformation;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static 
org.apache.hudi.sync.common.HoodieSyncConfig.META_SYNC_DATABASE_NAME;
+
+/**
+ * Pool of Hive {@link Driver} + {@link SessionState} pairs for parallel 
HiveQL DDL.
+ *
+ * <p>Hive's {@code SessionState.start(state)} binds state to the calling 
thread's
+ * thread-local, and {@code Driver} reads from that thread-local during {@code 
run()}.
+ * A Driver constructed on one thread cannot be safely used from another. This 
pool
+ * solves that by giving each slot its own dedicated worker thread (a 
single-thread
+ * executor) — the Driver and SessionState are built on that thread by a 
bootstrap
+ * task, and all subsequent SQL for that slot runs on the same thread.
+ *
+ * <p><b>Usage contract:</b> use this pool only for partition-row DDL 
statements that
+ * are independent of each other and freely shuffleable across workers. 
Table-level
+ * statements (createTable, schema evolution, USE database) must continue to 
run on
+ * the session {@code Driver} held by {@code HiveQueryDDLExecutor} on the sync 
driver
+ * thread. The pool is gated behind {@code 
hoodie.datasource.hive_sync.batching.enabled}
+ * and is constructed only for HiveQL sync mode.
+ */
+public class HiveDriverPool implements AutoCloseable {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(HiveDriverPool.class);
+
+  // Per-worker Driver construction has to be fast in practice (a few hundred 
ms
+  // for the SessionState + Driver init). A 60s ceiling per worker leaves 
plenty of
+  // headroom for a slow JVM warm-up but bounds the failure mode if the 
metastore
+  // is unreachable or Hive hangs during init.
+  private static final long BOOTSTRAP_TIMEOUT_SECONDS = 60;
+
+  private final List<Worker> workers;
+  private final int size;
+  private volatile boolean closed;
+
+  public HiveDriverPool(HiveSyncConfig config, int size) {
+    this(config, size, new DefaultDriverFactory(config));
+  }
+
+  // Package-private for tests: accepts a DriverFactory so unit tests can 
inject
+  // mock Driver instances without standing up a real Hive instance.
+  HiveDriverPool(HiveSyncConfig config, int size, DriverFactory factory) {
+    if (size < 1) {
+      throw new IllegalArgumentException("Pool size must be >= 1, got " + 
size);
+    }
+    this.size = size;
+    this.workers = new ArrayList<>(size);
+    String databaseName = config.getStringOrDefault(META_SYNC_DATABASE_NAME);
+    PoolThreadFactory threadFactory = new PoolThreadFactory();
+    try {
+      // Bootstrap workers one at a time (not concurrently): each worker 
builds its
+      // own exclusively-owned SessionState, and constructing several 
SessionStates
+      // in parallel risks racing on shared scratch-dir creation. This only 
affects
+      // one-time pool startup cost, not per-statement dispatch latency.
+      for (int i = 0; i < size; i++) {
+        Worker worker = new Worker(threadFactory);
+        workers.add(worker);
+        worker.executor.submit(() -> {
+          worker.driver = factory.newDriver(databaseName);
+          worker.sessionState = SessionState.get();
+          return null;
+        }).get(BOOTSTRAP_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+      }
+    } catch (Exception e) {
+      tearDown();
+      throw new HoodieException("Failed to construct HiveDriverPool of size " 
+ size, e);
+    }
+    LOG.info("Initialized HiveDriverPool with {} workers", size);
+  }
+
+  /**
+   * Runs each given SQL on <i>every</i> worker, in order. Used for setup 
statements
+   * (e.g. {@code USE database}) that must establish per-thread session context
+   * before any partition statement runs. Blocks until all workers have 
completed
+   * the setup. Throws on first error.
+   */
+  public void runOnEachWorker(List<String> setupSqls) {
+    if (closed) {
+      throw new IllegalStateException("Cannot dispatch to a closed 
HiveDriverPool");
+    }
+    if (setupSqls.isEmpty()) {
+      return;
+    }
+    Dispatch dispatch = new Dispatch(workers.size());
+    for (Worker worker : workers) {
+      dispatch.add(worker.executor.submit(() -> {
+        if (dispatch.aborted()) {
+          throw new CancellationException("Skipped after an earlier setup 
statement failed");
+        }
+        try {
+          for (String sql : setupSqls) {
+            worker.driver.run(sql);
+          }
+        } catch (Throwable t) {
+          dispatch.abort();
+          throw t;
+        } finally {
+          dispatch.taskSettled();
+        }
+        return null;
+      }));
+    }
+    dispatch.sealed();
+    awaitAll(dispatch);
+  }
+
+  /**
+   * Dispatches each SQL string to a worker (round-robin) and returns a handle 
to the
+   * in-flight batch — this method does not block. The caller is responsible 
for
+   * awaiting completion via {@link #awaitAll(Dispatch)} and collecting 
errors. SQL text
+   * is intentionally not logged per-statement here: batched TOUCH/ADD 
statements can
+   * be many kilobytes, and N parallel workers would multiply the log volume. 
See
+   * {@link #awaitAll(Dispatch)} for the per-call summary log.
+   *
+   * <p>Statements are spread round-robin across workers, so worker <i>w</i> 
owns
+   * indices {@code w, w + N, w + 2N, ...}. Each worker drains its own queue
+   * independently, which is why abort has to be observed by the tasks 
themselves
+   * rather than by the awaiting thread — see {@link Dispatch}.
+   */
+  public Dispatch dispatchAll(List<String> sqls) {
+    if (closed) {
+      throw new IllegalStateException("Cannot dispatch to a closed 
HiveDriverPool");
+    }
+    Dispatch dispatch = new Dispatch(sqls.size());
+    for (int i = 0; i < sqls.size(); i++) {
+      String sql = sqls.get(i);
+      Worker worker = workers.get(i % workers.size());
+      dispatch.add(worker.executor.submit(() -> {
+        // Abort check inside the task: a worker can dequeue this statement 
while the
+        // awaiting thread is still parked on some other worker's slower 
statement, so
+        // Future.cancel() alone cannot stop it in time. Checking here means no
+        // statement starts after a sibling has already failed.
+        if (dispatch.aborted()) {
+          throw new CancellationException("Skipped after an earlier statement 
failed");
+        }
+        try {
+          worker.driver.run(sql);
+        } catch (Throwable t) {
+          dispatch.abort();
+          throw t;
+        } finally {
+          dispatch.taskSettled();
+        }
+        return null;
+      }));
+    }
+    dispatch.sealed();
+    return dispatch;
+  }
+
+  /**
+   * Awaits the dispatched batch and throws the first error encountered. 
Errors are
+   * observed in <i>completion</i> order, not submission order: the awaiting 
thread
+   * blocks until every task has settled (or the batch has aborted), so a 
failure on a
+   * fast worker cancels the queues of all other workers even while a slow 
worker is
+   * still mid-statement. Errors that finished before cancellation are logged 
at WARN.
+   * Callers do not need per-statement results (Hive's Driver.run side-effects 
the
+   * metastore), so this method is void.
+   */
+  public void awaitAll(Dispatch dispatch) {
+    long start = System.currentTimeMillis();
+    // Block until either every task settled or one of them aborted the batch. 
Only
+    // then walk the futures — by that point no un-started task can still 
begin, so
+    // the walk order no longer affects how much extra DDL gets applied.
+    dispatch.awaitSettledOrAborted();
+    int cancelled = dispatch.cancelPending();
+
+    Exception firstError = null;
+    int completed = 0;
+    for (Future<?> f : dispatch.futures()) {
+      try {
+        f.get();
+        completed++;
+      } catch (CancellationException ce) {
+        // Either we cancelled it before it started, or the task itself 
observed the
+        // abort flag and bailed. Not a new failure; just note it for the 
summary.
+        cancelled++;
+      } catch (InterruptedException ie) {
+        Thread.currentThread().interrupt();
+        if (firstError == null) {
+          firstError = ie;
+        }
+      } catch (ExecutionException ee) {
+        Exception cause = unwrap(ee);
+        if (cause instanceof CancellationException) {
+          cancelled++;
+        } else if (firstError == null) {
+          firstError = cause;
+        } else {
+          LOG.warn("Additional SQL batch failed (suppressed in favor of first 
error)", cause);
+        }
+      }
+    }
+    if (firstError != null) {
+      throw new HoodieHiveSyncException("Failed in executing SQL", firstError);
+    }
+    LOG.info("Completed {} SQL statements ({} cancelled) in {} ms across {} 
workers",
+        completed, cancelled, System.currentTimeMillis() - start, size);
+  }
+
+  /**
+   * Handle to one {@link #dispatchAll(List)} batch: the submitted futures 
plus the
+   * shared abort flag the tasks consult before running.
+   *
+   * <p>The flag exists because the futures belong to N independent 
single-thread
+   * executors. Cancelling from the awaiting thread is inherently late — a 
worker can
+   * pull its next statement off its own queue at any moment — so each task 
also
+   * re-checks {@link #aborted()} on entry. That is what actually bounds how 
much extra
+   * partition DDL a failed sync can apply.
+   */
+  public static final class Dispatch {
+    private final List<Future<?>> futures;
+    private final int total;
+    private final AtomicInteger settled = new AtomicInteger(0);
+    private final AtomicBoolean aborted = new AtomicBoolean(false);
+    private final CountDownLatch done = new CountDownLatch(1);
+    private volatile boolean sealed;
+
+    private Dispatch(int total) {
+      this.total = total;
+      this.futures = new ArrayList<>(total);
+    }
+
+    private void add(Future<?> future) {
+      futures.add(future);
+    }
+
+    // Called once submission finishes. A task that settles before the last 
submit
+    // would otherwise see settled < total and never trip the latch, so 
re-check here.
+    private void sealed() {
+      sealed = true;
+      signalIfComplete();
+    }
+
+    private boolean aborted() {
+      return aborted.get();
+    }
+
+    private void abort() {
+      aborted.set(true);
+      done.countDown();
+    }
+
+    private void taskSettled() {
+      settled.incrementAndGet();
+      signalIfComplete();
+    }
+
+    private void signalIfComplete() {
+      if (sealed && settled.get() >= total) {
+        done.countDown();
+      }
+    }
+
+    private void awaitSettledOrAborted() {
+      if (total == 0) {
+        return;
+      }
+      try {
+        done.await();
+      } catch (InterruptedException ie) {
+        Thread.currentThread().interrupt();
+        aborted.set(true);
+      }
+    }
+
+    // mayInterruptIfRunning=false: the worker thread is bound to a Hive 
Driver whose
+    // state we don't want to corrupt mid-statement. Cancel only tasks that 
haven't
+    // started; in-flight statements run to completion.
+    private int cancelPending() {
+      int cancelled = 0;
+      for (Future<?> f : futures) {
+        if (f.cancel(false)) {
+          cancelled++;
+        }
+      }
+      return cancelled;
+    }
+
+    private List<Future<?>> futures() {
+      return futures;
+    }
+
+    @VisibleForTesting
+    public int size() {
+      return futures.size();
+    }
+
+    @VisibleForTesting
+    public Future<?> futureAt(int index) {
+      return futures.get(index);
+    }
+  }
+
+  private static Exception unwrap(ExecutionException ee) {
+    Throwable cause = ee.getCause();
+    return (cause instanceof Exception) ? (Exception) cause : ee;
+  }
+
+  public int size() {
+    return size;
+  }
+
+  @Override
+  public void close() {
+    if (closed) {
+      return;
+    }
+    closed = true;
+    tearDown();
+  }
+
+  private void tearDown() {
+    // Close each worker's own Driver and SessionState on its own thread, then 
shut
+    // the executor down. Each worker owns an exclusive SessionState (see
+    // DefaultDriverFactory), so there is no cross-worker close ordering to 
worry
+    // about here — closing worker i never affects worker j.
+    for (Worker worker : workers) {
+      try {
+        worker.executor.submit(() -> {
+          if (worker.driver != null) {
+            try {
+              worker.driver.close();
+            } catch (Exception e) {
+              LOG.warn("Error closing pooled Driver", e);
+            }
+          }
+          if (worker.sessionState != null) {
+            try {
+              worker.sessionState.close();
+            } catch (Exception e) {
+              LOG.warn("Error closing pooled SessionState", e);
+            }
+          }
+          return null;
+        }).get(30, TimeUnit.SECONDS);
+      } catch (Exception e) {
+        LOG.warn("Error during pool worker shutdown", e);
+      }
+      worker.executor.shutdown();
+      try {
+        if (!worker.executor.awaitTermination(10, TimeUnit.SECONDS)) {
+          worker.executor.shutdownNow();
+        }
+      } catch (InterruptedException ie) {
+        worker.executor.shutdownNow();
+        Thread.currentThread().interrupt();
+      }
+    }
+    workers.clear();
+  }
+
+  /**
+   * Per-slot state: a single-thread executor and the Driver + SessionState 
bound to
+   * its thread. Both are volatile because they are written by the bootstrap 
task and
+   * read by subsequent dispatch/teardown tasks on the same executor.
+   */
+  private static final class Worker {
+    final ExecutorService executor;
+    volatile Driver driver;
+    volatile SessionState sessionState;
+
+    Worker(ThreadFactory threadFactory) {
+      this.executor = Executors.newSingleThreadExecutor(threadFactory);
+    }
+  }
+
+  @FunctionalInterface
+  interface DriverFactory {
+    Driver newDriver(String databaseName) throws Exception;
+  }
+
+  /**
+   * Builds a real Hive {@link Driver} on the calling thread, backed by a
+   * {@link SessionState} that is exclusively owned by that thread (not shared 
with
+   * any other worker). Hive's session-scoped state (current database, scratch
+   * directories, and the transaction/lock manager under {@code DbTxnManager}) 
is
+   * mutated by {@code Driver.run()} and is not safe for concurrent use from 
multiple
+   * threads, so each worker must have its own instance. Bootstrap of all 
workers is
+   * done sequentially by the pool constructor specifically so these 
constructions
+   * don't race each other (e.g. on scratch-dir creation).
+   *
+   * <p>Each worker also gets its own {@link HiveConf} copy. {@code 
SessionState}
+   * retains whatever {@code HiveConf} it's given, and {@code 
QueryState}/{@code Driver}
+   * mutate per-query keys on that conf during {@code run()} (e.g. {@code 
HIVEQUERYID}).
+   * Sharing one {@code HiveConf} instance across workers would let concurrent
+   * {@code Driver.run()} calls overwrite each other's query-scoped 
configuration even
+   * though each worker has its own {@code SessionState} object.
+   */
+  private static final class DefaultDriverFactory implements DriverFactory {
+    private final HiveConf hiveConf;
+
+    DefaultDriverFactory(HiveSyncConfig config) {
+      this.hiveConf = config.getHiveConf();
+    }
+
+    @Override
+    public Driver newDriver(String databaseName) throws Exception {
+      HiveConf workerConf = new HiveConf(hiveConf);
+      SessionState sessionState = new SessionState(workerConf,
+          UserGroupInformation.getCurrentUser().getShortUserName());
+      sessionState.setCurrentDatabase(databaseName);
+      SessionState.start(sessionState);
+      return new Driver(workerConf);
+    }
+  }
+
+  private static final class PoolThreadFactory implements ThreadFactory {
+    private static final AtomicInteger POOL_ID = new AtomicInteger(0);
+    private final AtomicInteger threadId = new AtomicInteger(0);
+    private final String namePrefix = "hudi-hive-driver-pool-" + 
POOL_ID.incrementAndGet() + "-";
+
+    @Override
+    public Thread newThread(Runnable r) {
+      Thread t = new Thread(r, namePrefix + threadId.incrementAndGet());
+      t.setDaemon(true);
+      return t;
+    }
+  }
+}
diff --git 
a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestHiveSyncTool.java
 
b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestHiveSyncTool.java
index d524d1c44064..4f109c9cdd64 100644
--- 
a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestHiveSyncTool.java
+++ 
b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestHiveSyncTool.java
@@ -96,9 +96,12 @@ import static 
org.apache.hudi.hadoop.fs.HadoopFSUtils.getRelativePartitionPath;
 import static 
org.apache.hudi.hive.HiveSyncConfig.HIVE_SYNC_FILTER_PUSHDOWN_ENABLED;
 import static org.apache.hudi.hive.HiveSyncConfig.RECREATE_HIVE_TABLE_ON_ERROR;
 import static 
org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_AUTO_CREATE_DATABASE;
+import static 
org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_BATCH_SYNC_PARTITION_NUM;
 import static 
org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_CREATE_MANAGED_TABLE;
 import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_IGNORE_EXCEPTIONS;
 import static 
org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_SYNC_AS_DATA_SOURCE_TABLE;
+import static 
org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_SYNC_BATCHING_ENABLED;
+import static 
org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_SYNC_BATCHING_THREADS;
 import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_SYNC_COMMENT;
 import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_SYNC_MODE;
 import static 
org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_SYNC_TABLE_STRATEGY;
@@ -329,6 +332,110 @@ public class TestHiveSyncTool {
         "Table partitions should match the number of partitions we wrote");
   }
 
+  /**
+   * Exercises HiveQL sync with parallel partition batching enabled. Routes 
through
+   * the HiveDriverPool — each worker thread owns a Driver+SessionState pair, 
and
+   * the SQL list (qualified with `db`.`tbl`) is fanned out across them.
+   */
+  @Test
+  public void testHiveQLSyncWithBatchingEnabled() throws Exception {
+    hiveSyncProps.setProperty(HIVE_SYNC_MODE.key(), 
HiveSyncMode.HIVEQL.name());
+    hiveSyncProps.setProperty(HIVE_SYNC_BATCHING_ENABLED.key(), "true");
+    hiveSyncProps.setProperty(HIVE_SYNC_BATCHING_THREADS.key(), "3");
+    hiveSyncProps.setProperty(HIVE_BATCH_SYNC_PARTITION_NUM.key(), "3");
+
+    int partitionCount = 10;
+    HiveTestUtil.createCOWTable("100", partitionCount, true);
+
+    reInitHiveSyncClient();
+    assertFalse(hiveClient.tableExists(HiveTestUtil.TABLE_NAME),
+        "Table should not exist before initial sync");
+    reSyncHiveTable();
+    assertEquals(partitionCount, 
hiveClient.getAllPartitions(HiveTestUtil.TABLE_NAME).size(),
+        "All partitions should be added under parallel HiveQL batching");
+
+    // Add more partitions, then sync again to exercise the parallel update 
path.
+    HiveTestUtil.addCOWPartition("2050/01/01", true, true, "101");
+    HiveTestUtil.addCOWPartition("2050/01/02", true, true, "102");
+    HiveTestUtil.addCOWPartition("2050/01/03", true, true, "103");
+    HiveTestUtil.addCOWPartition("2050/01/04", true, true, "104");
+    reInitHiveSyncClient();
+    reSyncHiveTable();
+    assertEquals(partitionCount + 4, 
hiveClient.getAllPartitions(HiveTestUtil.TABLE_NAME).size(),
+        "Incremental add via parallel HiveQL batching should sync the new 
partitions");
+  }
+
+  /**
+   * Exercises the SET_LOCATION path in HiveQL mode with batching on. 
SET_LOCATION
+   * emits one ALTER PARTITION ... SET LOCATION statement per partition (Hive 
SQL
+   * has no multi-partition SET LOCATION), so this is the fan-out path most 
likely
+   * to exercise concurrent ALTER PARTITION calls against the same table.
+   */
+  @Test
+  public void testHiveQLSetLocationWithBatching() throws Exception {
+    hiveSyncProps.setProperty(HIVE_SYNC_MODE.key(), 
HiveSyncMode.HIVEQL.name());
+    hiveSyncProps.setProperty(HIVE_SYNC_BATCHING_ENABLED.key(), "true");
+    hiveSyncProps.setProperty(HIVE_SYNC_BATCHING_THREADS.key(), "3");
+    hiveSyncProps.setProperty(HIVE_BATCH_SYNC_PARTITION_NUM.key(), "2");
+
+    int partitionCount = 6;
+    HiveTestUtil.createCOWTable("100", partitionCount, true);
+    reInitHiveSyncClient();
+    reSyncHiveTable();
+    assertEquals(partitionCount, 
hiveClient.getAllPartitions(HiveTestUtil.TABLE_NAME).size());
+
+    // Drive the SET_LOCATION path by directly calling updatePartitionsToTable 
with
+    // existing partition paths. Each partition produces its own ALTER ... SET 
LOCATION
+    // statement, fanned out across the 3 workers in the pool.
+    List<String> existingPartitions = 
hiveClient.getAllPartitions(HiveTestUtil.TABLE_NAME).stream()
+        .map(p -> getRelativePartitionPath(new Path(basePath), new 
Path(p.getStorageLocation())))
+        .collect(Collectors.toList());
+    hiveClient.updatePartitionsToTable(HiveTestUtil.TABLE_NAME, 
existingPartitions);
+
+    List<Partition> after = 
hiveClient.getAllPartitions(HiveTestUtil.TABLE_NAME);
+    assertEquals(partitionCount, after.size(),
+        "Parallel SET_LOCATION must not change the partition set");
+    Set<String> relativePaths = after.stream()
+        .map(p -> getRelativePartitionPath(new Path(basePath), new 
Path(p.getStorageLocation())))
+        .collect(Collectors.toSet());
+    assertEquals(partitionCount, relativePaths.size(),
+        "Each partition should resolve to a unique relative path after 
parallel SET_LOCATION");
+    assertTrue(relativePaths.containsAll(existingPartitions),
+        "All original partition paths should still be present after parallel 
SET_LOCATION");
+  }
+
+  /**
+   * Exercises the TOUCH path in HiveQL mode with batching on. Verifies that
+   * splitting one giant ALTER TABLE TOUCH PARTITION(...)... into multiple 
smaller
+   * statements does not break partition visibility downstream.
+   */
+  @Test
+  public void testHiveQLTouchPartitionsWithBatching() throws Exception {
+    hiveSyncProps.setProperty(HIVE_SYNC_MODE.key(), 
HiveSyncMode.HIVEQL.name());
+    hiveSyncProps.setProperty(HIVE_SYNC_BATCHING_ENABLED.key(), "true");
+    hiveSyncProps.setProperty(HIVE_SYNC_BATCHING_THREADS.key(), "2");
+    hiveSyncProps.setProperty(HIVE_BATCH_SYNC_PARTITION_NUM.key(), "2");
+    hiveSyncProps.setProperty(META_SYNC_TOUCH_PARTITIONS_ENABLED.key(), 
"true");
+
+    int partitionCount = 6;
+    HiveTestUtil.createCOWTable("100", partitionCount, true);
+    reInitHiveSyncClient();
+    reSyncHiveTable();
+    assertEquals(partitionCount, 
hiveClient.getAllPartitions(HiveTestUtil.TABLE_NAME).size());
+
+    // Drive the TOUCH path directly by calling touchPartitionsToTable with 
existing
+    // partition paths. Partitions are batched into groups of 
HIVE_BATCH_SYNC_PARTITION_NUM
+    // and each batch's ALTER ... TOUCH statement is fanned out across the 2 
workers in
+    // the pool.
+    List<String> existingPartitions = 
hiveClient.getAllPartitions(HiveTestUtil.TABLE_NAME).stream()
+        .map(p -> getRelativePartitionPath(new Path(basePath), new 
Path(p.getStorageLocation())))
+        .collect(Collectors.toList());
+    hiveClient.touchPartitionsToTable(HiveTestUtil.TABLE_NAME, 
existingPartitions);
+
+    assertEquals(partitionCount, 
hiveClient.getAllPartitions(HiveTestUtil.TABLE_NAME).size(),
+        "TOUCH batching must not change the partition set");
+  }
+
   @ParameterizedTest
   @MethodSource({"syncModeAndSchemaFromCommitMetadata"})
   public void testBasicSync(boolean useSchemaFromCommitMetadata, String 
syncMode, String enablePushDown) throws Exception {
diff --git 
a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/ddl/TestQueryBasedDDLExecutorTouchBatching.java
 
b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/ddl/TestQueryBasedDDLExecutorTouchBatching.java
new file mode 100644
index 000000000000..73d23859dbfa
--- /dev/null
+++ 
b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/ddl/TestQueryBasedDDLExecutorTouchBatching.java
@@ -0,0 +1,192 @@
+/*
+ * 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.hive.ddl;
+
+import org.apache.hudi.common.config.TypedProperties;
+import org.apache.hudi.hive.HiveSyncConfig;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import static 
org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_BATCH_SYNC_PARTITION_NUM;
+import static 
org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_SYNC_BATCHING_ENABLED;
+import static org.apache.hudi.sync.common.HoodieSyncConfig.META_SYNC_BASE_PATH;
+import static 
org.apache.hudi.sync.common.HoodieSyncConfig.META_SYNC_DATABASE_NAME;
+import static 
org.apache.hudi.sync.common.HoodieSyncConfig.META_SYNC_PARTITION_FIELDS;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Verifies which execution paths TOUCH batching applies to.
+ *
+ * <p>{@code hoodie.datasource.hive_sync.batching.enabled} only makes sense 
where the
+ * resulting statements are dispatched in parallel. {@link 
QueryBasedDDLExecutor} is
+ * also the base class for {@link JDBCExecutor}, which executes the list 
serially — so
+ * splitting there would change statement count and partial-application 
semantics for
+ * no benefit. The split is therefore driven by {@link 
QueryBasedDDLExecutor#getTouchBatchSize(int)},
+ * which only {@link HiveQueryDDLExecutor} overrides (and only when a driver 
pool is
+ * actually present).
+ */
+class TestQueryBasedDDLExecutorTouchBatching {
+
+  private static final String TABLE_NAME = "tbl";
+  private static final int PARTITION_COUNT = 5;
+  private static final int BATCH_SIZE = 2;
+
+  /**
+   * Captures the SQL handed to the executor instead of running it. Uses the 
base
+   * class's serial {@code runSQLs}, exactly as {@link JDBCExecutor} does.
+   *
+   * <p>{@code parallelBatchSize} stands in for a subclass that dispatches in 
parallel:
+   * when set, {@link #getTouchBatchSize(int)} returns it, mimicking
+   * {@link HiveQueryDDLExecutor} with a driver pool present. When unset, the 
base-class
+   * default applies — the JDBC-mode shape.
+   */
+  private static final class RecordingExecutor extends QueryBasedDDLExecutor {
+    private final List<String> executed = new ArrayList<>();
+    private final Integer parallelBatchSize;
+
+    RecordingExecutor(HiveSyncConfig config) {
+      this(config, null);
+    }
+
+    RecordingExecutor(HiveSyncConfig config, Integer parallelBatchSize) {
+      super(config);
+      this.parallelBatchSize = parallelBatchSize;
+    }
+
+    @Override
+    protected int getTouchBatchSize(int partitionCount) {
+      return parallelBatchSize != null ? parallelBatchSize : 
super.getTouchBatchSize(partitionCount);
+    }
+
+    @Override
+    public void runSQL(String sql) {
+      executed.add(sql);
+    }
+
+    @Override
+    public Map<String, String> getTableSchema(String tableName) {
+      return Collections.emptyMap();
+    }
+
+    @Override
+    public void dropPartitionsToTable(String tableName, List<String> 
partitionsToDrop) {
+      // not exercised here
+    }
+
+    @Override
+    public void close() {
+      // no resources held
+    }
+  }
+
+  private static HiveSyncConfig configWithBatching(boolean batchingEnabled) {
+    TypedProperties props = new TypedProperties();
+    props.setProperty(META_SYNC_DATABASE_NAME.key(), "db");
+    props.setProperty(META_SYNC_BASE_PATH.key(), "file:///tmp/base");
+    props.setProperty(META_SYNC_PARTITION_FIELDS.key(), "dt");
+    props.setProperty(HIVE_BATCH_SYNC_PARTITION_NUM.key(), 
String.valueOf(BATCH_SIZE));
+    props.setProperty(HIVE_SYNC_BATCHING_ENABLED.key(), 
String.valueOf(batchingEnabled));
+    return new HiveSyncConfig(props);
+  }
+
+  private static List<String> partitions() {
+    return IntStream.range(0, PARTITION_COUNT)
+        .mapToObj(i -> "2026-01-0" + (i + 1))
+        .collect(Collectors.toList());
+  }
+
+  private static List<String> touchStatements(RecordingExecutor executor) {
+    // constructPartitionAlterStatements always emits a leading `USE db`; the 
TOUCH
+    // statements are everything after it.
+    return executor.executed.stream()
+        .filter(sql -> sql.contains(" TOUCH "))
+        .collect(Collectors.toList());
+  }
+
+  /**
+   * Given a serial executor (the JDBC-mode shape) with batching enabled, when 
TOUCH is
+   * issued for more partitions than the batch size, then a single TOUCH 
statement is
+   * still emitted — the flag must not reach non-parallel execution paths.
+   */
+  @Test
+  void serialExecutorEmitsSingleTouchStatementEvenWithBatchingEnabled() {
+    RecordingExecutor executor = new 
RecordingExecutor(configWithBatching(true));
+
+    executor.touchPartitionsToTable(TABLE_NAME, partitions());
+
+    List<String> touches = touchStatements(executor);
+    assertEquals(1, touches.size(),
+        "Serial executors (e.g. JDBC mode) must emit one TOUCH statement 
regardless of "
+            + "hoodie.datasource.hive_sync.batching.enabled");
+    assertEquals(PARTITION_COUNT, countPartitionClauses(touches.get(0)),
+        "The single statement must still cover every partition");
+  }
+
+  /**
+   * Given the same executor with batching disabled, when TOUCH is issued, 
then the SQL
+   * is byte-identical to the enabled case — pinning that the flag is a no-op 
here.
+   */
+  @Test
+  void serialExecutorTouchSqlIsIdenticalWithAndWithoutBatchingFlag() {
+    RecordingExecutor withFlag = new 
RecordingExecutor(configWithBatching(true));
+    RecordingExecutor withoutFlag = new 
RecordingExecutor(configWithBatching(false));
+
+    withFlag.touchPartitionsToTable(TABLE_NAME, partitions());
+    withoutFlag.touchPartitionsToTable(TABLE_NAME, partitions());
+
+    assertEquals(withoutFlag.executed, withFlag.executed,
+        "Enabling the batching flag must not change JDBC-mode TOUCH SQL 
shape");
+  }
+
+  /**
+   * Given an executor that reports a parallel-dispatch batch size (the 
HiveQL-with-pool
+   * shape), when TOUCH is issued, then partitions are split across multiple 
statements.
+   * This pins that the base-class default is the only thing suppressing the 
split.
+   */
+  @Test
+  void parallelExecutorSplitsTouchIntoBatches() {
+    RecordingExecutor executor = new 
RecordingExecutor(configWithBatching(true), BATCH_SIZE);
+
+    executor.touchPartitionsToTable(TABLE_NAME, partitions());
+
+    List<String> touches = touchStatements(executor);
+    // 5 partitions at 2 per batch -> 3 statements (2, 2, 1).
+    assertEquals(3, touches.size());
+    assertEquals(PARTITION_COUNT,
+        
touches.stream().mapToInt(TestQueryBasedDDLExecutorTouchBatching::countPartitionClauses).sum(),
+        "Batching must not drop or duplicate partitions");
+  }
+
+  private static int countPartitionClauses(String sql) {
+    int count = 0;
+    int idx = sql.indexOf("PARTITION (");
+    while (idx >= 0) {
+      count++;
+      idx = sql.indexOf("PARTITION (", idx + 1);
+    }
+    return count;
+  }
+}
diff --git 
a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/util/TestHiveDriverPool.java
 
b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/util/TestHiveDriverPool.java
new file mode 100644
index 000000000000..fd58b7023aa1
--- /dev/null
+++ 
b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/util/TestHiveDriverPool.java
@@ -0,0 +1,316 @@
+/*
+ * 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.hive.util;
+
+import org.apache.hudi.exception.HoodieException;
+import org.apache.hudi.hive.HiveSyncConfig;
+import org.apache.hudi.hive.HoodieHiveSyncException;
+
+import org.apache.hadoop.hive.conf.HiveConf;
+import org.apache.hadoop.hive.ql.Driver;
+import org.junit.jupiter.api.Test;
+import org.mockito.invocation.InvocationOnMock;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+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;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Unit tests for {@link HiveDriverPool} that exercise bootstrap, dispatch, 
error
+ * propagation, and close semantics without standing up a real Hive instance.
+ */
+class TestHiveDriverPool {
+
+  private static HiveSyncConfig configWithEmptyHiveConf() {
+    HiveSyncConfig config = mock(HiveSyncConfig.class);
+    doAnswer(inv -> new HiveConf()).when(config).getHiveConf();
+    doAnswer(inv -> "default").when(config).getStringOrDefault(
+        org.mockito.ArgumentMatchers.any());
+    return config;
+  }
+
+  @Test
+  void bootstrapBuildsOneDriverPerSlot() throws Exception {
+    HiveSyncConfig config = configWithEmptyHiveConf();
+    AtomicInteger built = new AtomicInteger();
+    HiveDriverPool.DriverFactory factory = (db) -> {
+      built.incrementAndGet();
+      return mock(Driver.class);
+    };
+    try (HiveDriverPool pool = new HiveDriverPool(config, 3, factory)) {
+      assertEquals(3, pool.size());
+      assertEquals(3, built.get(), "One Driver per slot should be constructed 
eagerly");
+    }
+  }
+
+  @Test
+  void bootstrapFailurePropagatesAndTearsDown() {
+    HiveSyncConfig config = configWithEmptyHiveConf();
+    AtomicInteger calls = new AtomicInteger();
+    HiveDriverPool.DriverFactory factory = (db) -> {
+      int n = calls.incrementAndGet();
+      if (n == 2) {
+        throw new RuntimeException("simulated driver build failure");
+      }
+      return mock(Driver.class);
+    };
+    HoodieException ex = assertThrows(HoodieException.class,
+        () -> new HiveDriverPool(config, 3, factory));
+    assertTrue(ex.getMessage().contains("Failed to construct HiveDriverPool"));
+  }
+
+  @Test
+  void runAllDispatchesEachSqlAcrossWorkers() throws Exception {
+    HiveSyncConfig config = configWithEmptyHiveConf();
+    // Each worker counts how many SQLs it received and remembers the thread.
+    ConcurrentHashMap<Driver, Set<String>> seenThreadsByDriver = new 
ConcurrentHashMap<>();
+    HiveDriverPool.DriverFactory factory = (db) -> {
+      Driver d = mock(Driver.class);
+      seenThreadsByDriver.put(d, ConcurrentHashMap.newKeySet());
+      doAnswer((InvocationOnMock inv) -> {
+        seenThreadsByDriver.get(d).add(Thread.currentThread().getName());
+        return null;
+      }).when(d).run(anyString());
+      return d;
+    };
+    try (HiveDriverPool pool = new HiveDriverPool(config, 2, factory)) {
+      List<String> sqls = Arrays.asList("SELECT 1", "SELECT 2", "SELECT 3", 
"SELECT 4");
+      HiveDriverPool.Dispatch futures = pool.dispatchAll(sqls);
+      pool.awaitAll(futures);
+      assertEquals(2, seenThreadsByDriver.size(), "Expected exactly 2 worker 
Drivers");
+      int totalCalls = 
seenThreadsByDriver.values().stream().mapToInt(Set::size).sum();
+      assertTrue(totalCalls >= 1, "At least one worker should have logged a 
thread");
+      // Each Driver should have been invoked exactly twice (round-robin with 
4 sqls, 2 workers).
+      for (Driver d : seenThreadsByDriver.keySet()) {
+        verify(d, times(2)).run(anyString());
+      }
+    }
+  }
+
+  @Test
+  void awaitAllThrowsFirstError() throws Exception {
+    HiveSyncConfig config = configWithEmptyHiveConf();
+    HiveDriverPool.DriverFactory factory = (db) -> {
+      Driver d = mock(Driver.class);
+      doAnswer(inv -> {
+        String sql = inv.getArgument(0);
+        if (sql.equals("FAIL")) {
+          throw new RuntimeException("boom: " + sql);
+        }
+        return null;
+      }).when(d).run(anyString());
+      return d;
+    };
+    try (HiveDriverPool pool = new HiveDriverPool(config, 2, factory)) {
+      HiveDriverPool.Dispatch futures = pool.dispatchAll(Arrays.asList("OK", 
"FAIL", "OK"));
+      HoodieHiveSyncException ex = assertThrows(HoodieHiveSyncException.class,
+          () -> pool.awaitAll(futures));
+      assertTrue(ex.getCause() != null && 
ex.getCause().getMessage().contains("boom"));
+    }
+  }
+
+  @Test
+  void concurrentDispatchBoundedByPoolSize() throws Exception {
+    HiveSyncConfig config = configWithEmptyHiveConf();
+    AtomicInteger inFlight = new AtomicInteger();
+    AtomicInteger maxInFlight = new AtomicInteger();
+    CountDownLatch hold = new CountDownLatch(1);
+    HiveDriverPool.DriverFactory factory = (db) -> {
+      Driver d = mock(Driver.class);
+      doAnswer(inv -> {
+        int now = inFlight.incrementAndGet();
+        maxInFlight.updateAndGet(prev -> Math.max(prev, now));
+        hold.await(2, TimeUnit.SECONDS);
+        inFlight.decrementAndGet();
+        return null;
+      }).when(d).run(anyString());
+      return d;
+    };
+    try (HiveDriverPool pool = new HiveDriverPool(config, 2, factory)) {
+      // 5 SQLs against pool of size 2 → max in-flight should be 2.
+      HiveDriverPool.Dispatch futures = pool.dispatchAll(Arrays.asList("a", 
"b", "c", "d", "e"));
+      // Release after a short wait so all SQLs progress.
+      Thread.sleep(150);
+      hold.countDown();
+      pool.awaitAll(futures);
+      assertTrue(maxInFlight.get() <= 2,
+          "Max concurrent dispatches must not exceed pool size, observed " + 
maxInFlight.get());
+      assertTrue(maxInFlight.get() >= 1, "Sanity: at least one dispatch ran");
+    }
+  }
+
+  @Test
+  void closeIsIdempotentAndPreventsFurtherDispatch() throws Exception {
+    HiveSyncConfig config = configWithEmptyHiveConf();
+    HiveDriverPool.DriverFactory factory = (db) -> mock(Driver.class);
+    HiveDriverPool pool = new HiveDriverPool(config, 2, factory);
+    pool.close();
+    pool.close();
+    assertThrows(IllegalStateException.class,
+        () -> pool.dispatchAll(Arrays.asList("anything")));
+  }
+
+  @Test
+  void invalidSizeRejected() {
+    HiveSyncConfig config = configWithEmptyHiveConf();
+    HiveDriverPool.DriverFactory factory = (db) -> mock(Driver.class);
+    assertThrows(IllegalArgumentException.class,
+        () -> new HiveDriverPool(config, 0, factory));
+  }
+
+  /**
+   * runOnEachWorker must execute the setup SQL on every worker (each on its 
bound
+   * thread) before {@code dispatchAll()} fans the partition statements out. 
Without this,
+   * Hive 2.x's SET LOCATION would silently route to the wrong database on the 
workers
+   * that never saw the leading USE statement.
+   */
+  @Test
+  void runOnEachWorkerRunsSetupOnEveryWorker() throws Exception {
+    HiveSyncConfig config = configWithEmptyHiveConf();
+    ConcurrentHashMap<Driver, List<String>> sqlsByDriver = new 
ConcurrentHashMap<>();
+    HiveDriverPool.DriverFactory factory = (db) -> {
+      Driver d = mock(Driver.class);
+      sqlsByDriver.put(d, java.util.Collections.synchronizedList(new 
java.util.ArrayList<>()));
+      doAnswer((InvocationOnMock inv) -> {
+        sqlsByDriver.get(d).add(inv.getArgument(0));
+        return null;
+      }).when(d).run(anyString());
+      return d;
+    };
+    try (HiveDriverPool pool = new HiveDriverPool(config, 3, factory)) {
+      pool.runOnEachWorker(Arrays.asList("USE `db1`"));
+      HiveDriverPool.Dispatch futures = pool.dispatchAll(Arrays.asList("ALTER 
1", "ALTER 2", "ALTER 3"));
+      pool.awaitAll(futures);
+
+      assertEquals(3, sqlsByDriver.size(), "Expected one Driver per worker");
+      for (Map.Entry<Driver, List<String>> e : sqlsByDriver.entrySet()) {
+        List<String> seen = e.getValue();
+        assertTrue(!seen.isEmpty() && seen.get(0).equals("USE `db1`"),
+            "Each worker must see USE first; saw " + seen);
+      }
+    }
+  }
+
+  /**
+   * Given a single-worker pool where the first statement fails, when awaitAll 
runs,
+   * then it throws the original cause and neither queued statement is ever 
executed.
+   *
+   * <p>Deterministic: statements queued behind the failure observe the 
batch's abort
+   * flag on entry and bail out without touching the Driver, so it does not 
matter
+   * whether the worker dequeues them before or after awaitAll's cancel() 
sweep.
+   */
+  @Test
+  void awaitAllCancelsPendingFuturesOnFirstError() throws Exception {
+    HiveSyncConfig config = configWithEmptyHiveConf();
+    List<String> executed = Collections.synchronizedList(new ArrayList<>());
+    HiveDriverPool.DriverFactory factory = (db) -> {
+      Driver d = mock(Driver.class);
+      doAnswer(inv -> {
+        String sql = inv.getArgument(0);
+        executed.add(sql);
+        if (sql.equals("FAIL")) {
+          throw new RuntimeException("boom");
+        }
+        return null;
+      }).when(d).run(anyString());
+      return d;
+    };
+    try (HiveDriverPool pool = new HiveDriverPool(config, 1, factory)) {
+      HiveDriverPool.Dispatch dispatch = 
pool.dispatchAll(Arrays.asList("FAIL", "PENDING_A", "PENDING_B"));
+
+      HoodieHiveSyncException ex = assertThrows(HoodieHiveSyncException.class,
+          () -> pool.awaitAll(dispatch));
+
+      assertTrue(ex.getCause() != null && 
ex.getCause().getMessage().contains("boom"));
+      assertEquals(Collections.singletonList("FAIL"), executed,
+          "Statements queued behind the failure must never reach the Driver");
+    }
+  }
+
+  /**
+   * Regression for the in-order-await bug: a slow statement on worker 0 must 
not let
+   * worker 1 keep applying partition DDL after worker 1 has already failed.
+   *
+   * <p>Given two workers, statements are dispatched round-robin — worker 0 
gets
+   * {@code SLOW} and worker 1 gets {@code FAIL} then {@code AFTER_FAIL}. When 
awaitAll
+   * blocks in submission order, it parks on SLOW's future while worker 1 
races ahead
+   * and runs AFTER_FAIL. Then AFTER_FAIL must never execute: the abort flag 
is set by
+   * FAIL before worker 1 can dequeue its next statement.
+   *
+   * <p>SLOW is released only after the batch has aborted, which pins the 
interleaving
+   * the bug needs — without that, SLOW could finish first and mask the race.
+   */
+  @Test
+  void awaitAllStopsLaterWorkerWhenEarlierFutureIsSlow() throws Exception {
+    HiveSyncConfig config = configWithEmptyHiveConf();
+    List<String> executed = Collections.synchronizedList(new ArrayList<>());
+    CountDownLatch failed = new CountDownLatch(1);
+    CountDownLatch releaseSlow = new CountDownLatch(1);
+    HiveDriverPool.DriverFactory factory = (db) -> {
+      Driver d = mock(Driver.class);
+      doAnswer(inv -> {
+        String sql = inv.getArgument(0);
+        executed.add(sql);
+        if (sql.equals("FAIL")) {
+          failed.countDown();
+          throw new RuntimeException("boom");
+        }
+        if (sql.equals("SLOW")) {
+          // Hold worker 0 until worker 1 has failed, so awaitAll is 
definitely still
+          // parked on future 0 at the moment worker 1 would pick up 
AFTER_FAIL.
+          releaseSlow.await(5, TimeUnit.SECONDS);
+        }
+        return null;
+      }).when(d).run(anyString());
+      return d;
+    };
+    try (HiveDriverPool pool = new HiveDriverPool(config, 2, factory)) {
+      // Round-robin over 2 workers: index 0 -> worker 0, indices 1 and 2 -> 
worker 1.
+      HiveDriverPool.Dispatch dispatch =
+          pool.dispatchAll(Arrays.asList("SLOW", "FAIL", "AFTER_FAIL"));
+      assertTrue(failed.await(5, TimeUnit.SECONDS), "FAIL must have run");
+      releaseSlow.countDown();
+
+      HoodieHiveSyncException ex = assertThrows(HoodieHiveSyncException.class,
+          () -> pool.awaitAll(dispatch));
+
+      assertTrue(ex.getCause() != null && 
ex.getCause().getMessage().contains("boom"));
+      assertFalse(executed.contains("AFTER_FAIL"),
+          "Statement queued behind a failure on the same worker must not be 
applied, "
+              + "even while an earlier future on another worker is still 
running");
+    }
+  }
+}

Reply via email to