danny0405 commented on code in PR #18984:
URL: https://github.com/apache/hudi/pull/18984#discussion_r3575446303


##########
hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/ddl/QueryBasedDDLExecutor.java:
##########
@@ -210,29 +223,40 @@ public void touchPartitionsToTable(String tableName, 
List<String> touchPartition
     }
     log.info("Touching partitions " + touchPartitions.size() + " on " + 
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) ...} per batch 
of
+   * {@code HIVE_BATCH_SYNC_PARTITION_NUM} partitions.
+   *
+   * <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 = 
config.getIntOrDefault(HIVE_BATCH_SYNC_PARTITION_NUM);
     switch (alterType) {
       case TOUCH:
-        String alterTable = alterTablePrefix + " TOUCH";
-        for (String partition : partitions) {
-          alterTable += " PARTITION (" + getPartitionClause(partition) + ")";
+        for (List<String> batch : CollectionUtils.batches(partitions, 
batchSyncPartitionNum)) {

Review Comment:
   The new opt-in flag is only consulted when constructing the Driver pool, but 
this path always splits TOUCH into `batch_num` chunks. With 
`hoodie.datasource.hive_sync.batching.enabled` left at its default `false`, a 
large TOUCH now becomes multiple ALTER statements instead of the previous 
single statement, changing failure/partial-application semantics despite the 
documented "existing behavior is unchanged" contract. Please gate this split on 
`HIVE_SYNC_BATCHING_ENABLED` (use one batch when false), or explicitly make and 
document TOUCH batching as unconditional.



##########
hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestHiveSyncTool.java:
##########
@@ -329,6 +332,105 @@ public void testDropUpperCasePartitionWithHMS() throws 
Exception {
         "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());
+
+    // Touch existing partitions (no new data) — should hit the batched TOUCH 
path
+    // without changing the partition count.
+    reInitHiveSyncClient();
+    reSyncHiveTable();

Review Comment:
   This second sync has no new commit. With incremental sync enabled by 
default, instant `100` is already recorded by the first sync, so 
`getWrittenPartitionsSince` returns an empty list and 
`HiveSyncTool.syncPartitions` returns before calling `touchPartitionsToTable`. 
As a result, this test never reaches the batched TOUCH path it claims to cover. 
Please directly call `hiveClient.touchPartitionsToTable` with the existing 
partition paths (as the SET_LOCATION test does), or add a new commit that 
rewrites existing partitions and verify the TOUCH behavior.



##########
hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/util/TestHiveDriverPool.java:
##########
@@ -0,0 +1,260 @@
+/*
+ * 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.Arrays;
+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.Future;
+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.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");
+      List<Future<?>> futures = pool.runAll(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)) {
+      List<Future<?>> futures = pool.runAll(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.
+      List<Future<?>> futures = pool.runAll(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.runAll(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 runAll()} 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`"));
+      List<Future<?>> futures = pool.runAll(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);
+      }
+    }
+  }
+
+  /**
+   * On the first failure, awaitAll must throw the original cause and cancel 
any
+   * futures that have not started yet. Futures already in-flight are not 
interrupted
+   * (per the cancel-with-mayInterruptIfRunning=false contract).
+   */
+  @Test
+  void awaitAllCancelsPendingFuturesOnFirstError() throws Exception {
+    HiveSyncConfig config = configWithEmptyHiveConf();
+    // Single-worker pool so SQLs run strictly sequentially → the 2nd SQL is
+    // pending when the 1st errors, and must be cancelled.
+    CountDownLatch fired = new CountDownLatch(1);
+    HiveDriverPool.DriverFactory factory = (db) -> {
+      Driver d = mock(Driver.class);
+      doAnswer(inv -> {
+        String sql = inv.getArgument(0);
+        if (sql.equals("FAIL")) {
+          fired.countDown();
+          throw new RuntimeException("boom");
+        }
+        return null;
+      }).when(d).run(anyString());
+      return d;
+    };
+    try (HiveDriverPool pool = new HiveDriverPool(config, 1, factory)) {
+      List<Future<?>> futures = pool.runAll(Arrays.asList("FAIL", "PENDING_A", 
"PENDING_B"));
+      HoodieHiveSyncException ex = assertThrows(HoodieHiveSyncException.class,
+          () -> pool.awaitAll(futures));
+      assertTrue(ex.getCause() != null && 
ex.getCause().getMessage().contains("boom"));
+      // The first future failed, so it's done (not cancelled). The remaining 
two
+      // were pending behind it on the single worker and should now be 
cancelled.
+      assertTrue(fired.await(1, TimeUnit.SECONDS), "Failing SQL must have 
run");
+      assertTrue(futures.get(1).isCancelled(), "Pending future after error 
must be cancelled");

Review Comment:
   This assertion is racy and fails consistently in the focused test run (the 
initial run plus all three Surefire retries). Once `FAIL` returns, the 
single-thread executor can immediately run and complete `PENDING_A` before 
`awaitAll` observes the failed future and cancels the queue, in which case 
`isCancelled()` is correctly false. Please block the pending driver calls on a 
latch until after the cancellation assertions (releasing it in `finally`), or 
otherwise make the queued-task state deterministic.



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

To unsubscribe, e-mail: [email protected]

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

Reply via email to