This is an automated email from the ASF dual-hosted git repository.
voonhous 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 9f8376e3723d fix(hive-sync): keep HMS lock heartbeat alive and release
its thread pool on close (#19334)
9f8376e3723d is described below
commit 9f8376e3723d6e51f5e1a27f52f396b6181a6f6f
Author: Vova Kolmakov <[email protected]>
AuthorDate: Wed Jul 22 17:03:41 2026 +0700
fix(hive-sync): keep HMS lock heartbeat alive and release its thread pool
on close (#19334)
* fix(hive-sync): keep HMS lock heartbeat alive and release its thread pool
on close
Fixes three defects in HiveMetastoreBasedLockProvider and its Heartbeat
(package transaction/lock):
- Heartbeat.run() no longer rethrows on failure. Under
ScheduledExecutorService.scheduleAtFixedRate a thrown exception permanently
cancels all subsequent executions (observable only through the unread
ScheduledFuture), so one transient HMS/network hiccup silently stopped lock
renewal while the writer still believed it held the lock. It now logs a warning
with the cause and lets the next tick retry; the previous throw also discarded
the cause.
- close() moves executor.shutdown() into a finally block so the 2-thread
scheduled pool is always released even when unlock()/Hive.closeCurrent()
throws, and passes the caught exception to log.error instead of dropping it.
- acquireLock recovery path: when the client times out on Future.get but
the lock is granted server-side and recovered via checkLock, a heartbeat is now
scheduled (via the extracted scheduleHeartbeat()) so the recovered lock is
renewed instead of being left to expire mid-write.
Adds unit tests TestHeartbeat and TestHiveMetastoreBasedLockProviderClose.
* addressed review comments: drop the hardcoded thread count from the
executor shutdown comment
---------
Co-authored-by: Vova Kolmakov <[email protected]>
---
.../hudi/hive/transaction/lock/Heartbeat.java | 10 +-
.../lock/HiveMetastoreBasedLockProvider.java | 28 +++--
.../hudi/hive/transaction/lock/TestHeartbeat.java | 55 ++++++++++
.../TestHiveMetastoreBasedLockProviderClose.java | 113 +++++++++++++++++++++
4 files changed, 195 insertions(+), 11 deletions(-)
diff --git
a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/transaction/lock/Heartbeat.java
b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/transaction/lock/Heartbeat.java
index f91b66038044..bf0037faf6df 100644
---
a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/transaction/lock/Heartbeat.java
+++
b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/transaction/lock/Heartbeat.java
@@ -19,10 +19,10 @@
package org.apache.hudi.hive.transaction.lock;
-import org.apache.hudi.exception.HoodieLockException;
-
+import lombok.extern.slf4j.Slf4j;
import org.apache.hadoop.hive.metastore.IMetaStoreClient;
+@Slf4j
class Heartbeat implements Runnable {
private final IMetaStoreClient client;
private final long lockId;
@@ -37,7 +37,11 @@ class Heartbeat implements Runnable {
try {
client.heartbeat(0, lockId);
} catch (Exception e) {
- throw new HoodieLockException(String.format("Failed to heartbeat for
lock: %d", lockId));
+ // Do not rethrow. This task is scheduled via
ScheduledExecutorService.scheduleAtFixedRate,
+ // where a thrown exception permanently cancels all subsequent
executions and is only
+ // observable through the (unread) ScheduledFuture. Swallowing a
transient failure here keeps
+ // the lock heartbeated on the next tick instead of silently stopping
renewal altogether.
+ log.warn("Failed to heartbeat for lock: {}", lockId, e);
}
}
}
diff --git
a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/transaction/lock/HiveMetastoreBasedLockProvider.java
b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/transaction/lock/HiveMetastoreBasedLockProvider.java
index 80a95801fbdb..c83267be5319 100644
---
a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/transaction/lock/HiveMetastoreBasedLockProvider.java
+++
b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/transaction/lock/HiveMetastoreBasedLockProvider.java
@@ -167,9 +167,12 @@ public class HiveMetastoreBasedLockProvider implements
LockProvider<LockResponse
future.cancel(false);
}
Hive.closeCurrent();
- executor.shutdown();
} catch (Exception e) {
-
log.error(generateLogStatement(org.apache.hudi.common.lock.LockState.FAILED_TO_RELEASE,
generateLogSuffixString()));
+
log.error(generateLogStatement(org.apache.hudi.common.lock.LockState.FAILED_TO_RELEASE,
generateLogSuffixString()), e);
+ } finally {
+ // Always release the heartbeat thread pool, even if unlock/closeCurrent
above threw,
+ // otherwise its scheduled threads leak for the lifetime of the JVM.
+ executor.shutdown();
}
}
@@ -192,17 +195,15 @@ public class HiveMetastoreBasedLockProvider implements
LockProvider<LockResponse
final LockRequest lockRequestFinal = lockRequest;
this.lock = executor.submit(() -> hiveClient.lock(lockRequestFinal))
.get(time, unit);
-
- // refresh lock in case that certain commit takes a long time.
- Heartbeat heartbeat = new Heartbeat(hiveClient, lock.getLockid());
- long heartbeatIntervalMs = lockConfiguration.getConfig()
- .getLong(LOCK_HEARTBEAT_INTERVAL_MS_KEY,
DEFAULT_LOCK_HEARTBEAT_INTERVAL_MS);
- future = executor.scheduleAtFixedRate(heartbeat, heartbeatIntervalMs /
2, heartbeatIntervalMs, TimeUnit.MILLISECONDS);
+ scheduleHeartbeat();
} catch (InterruptedException | TimeoutException e) {
if (this.lock == null || this.lock.getState() != LockState.ACQUIRED) {
LockResponse lockResponse =
this.hiveClient.checkLock(lockRequest.getTxnid());
if (lockResponse.getState() == LockState.ACQUIRED) {
this.lock = lockResponse;
+ // The lock was granted server-side even though the client timed out
waiting on the
+ // future; it still needs a heartbeat, otherwise a long-running
commit lets HMS expire it.
+ scheduleHeartbeat();
} else {
throw e;
}
@@ -219,6 +220,17 @@ public class HiveMetastoreBasedLockProvider implements
LockProvider<LockResponse
}
}
+ /**
+ * Schedules a periodic {@link Heartbeat} to refresh the currently held lock
in case a commit
+ * takes a long time. Must be called only after {@link #lock} has been set.
+ */
+ private void scheduleHeartbeat() {
+ Heartbeat heartbeat = new Heartbeat(hiveClient, lock.getLockid());
+ long heartbeatIntervalMs = lockConfiguration.getConfig()
+ .getLong(LOCK_HEARTBEAT_INTERVAL_MS_KEY,
DEFAULT_LOCK_HEARTBEAT_INTERVAL_MS);
+ future = executor.scheduleAtFixedRate(heartbeat, heartbeatIntervalMs / 2,
heartbeatIntervalMs, TimeUnit.MILLISECONDS);
+ }
+
private void checkRequiredProps(final LockConfiguration lockConfiguration) {
ValidationUtils.checkArgument(lockConfiguration.getConfig().getString(HIVE_DATABASE_NAME_PROP_KEY)
!= null);
ValidationUtils.checkArgument(lockConfiguration.getConfig().getString(HIVE_TABLE_NAME_PROP_KEY)
!= null);
diff --git
a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/transaction/lock/TestHeartbeat.java
b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/transaction/lock/TestHeartbeat.java
new file mode 100644
index 000000000000..ca2386f7a28f
--- /dev/null
+++
b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/transaction/lock/TestHeartbeat.java
@@ -0,0 +1,55 @@
+/*
+ * 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.transaction.lock;
+
+import org.apache.hadoop.hive.metastore.IMetaStoreClient;
+import org.apache.thrift.TException;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+class TestHeartbeat {
+
+ @Test
+ void runDoesNotRethrowWhenHeartbeatFails() throws TException {
+ IMetaStoreClient client = mock(IMetaStoreClient.class);
+ doThrow(new TException("transient
failure")).when(client).heartbeat(anyLong(), anyLong());
+
+ Heartbeat heartbeat = new Heartbeat(client, 7L);
+
+ // Rethrowing here would cancel every subsequent execution of a
scheduleAtFixedRate task,
+ // silently stopping lock renewal. The fix must swallow the failure so the
next tick retries.
+ assertDoesNotThrow(heartbeat::run);
+ }
+
+ @Test
+ void runHeartbeatsTheLockOnSuccess() throws TException {
+ IMetaStoreClient client = mock(IMetaStoreClient.class);
+
+ new Heartbeat(client, 99L).run();
+
+ verify(client, times(1)).heartbeat(0L, 99L);
+ }
+}
diff --git
a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/transaction/lock/TestHiveMetastoreBasedLockProviderClose.java
b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/transaction/lock/TestHiveMetastoreBasedLockProviderClose.java
new file mode 100644
index 000000000000..817c1407f3b9
--- /dev/null
+++
b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/transaction/lock/TestHiveMetastoreBasedLockProviderClose.java
@@ -0,0 +1,113 @@
+/*
+ * 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.transaction.lock;
+
+import org.apache.hudi.common.config.LockConfiguration;
+import org.apache.hudi.common.config.TypedProperties;
+
+import org.apache.hadoop.hive.metastore.IMetaStoreClient;
+import org.apache.hadoop.hive.metastore.api.LockComponent;
+import org.apache.hadoop.hive.metastore.api.LockLevel;
+import org.apache.hadoop.hive.metastore.api.LockResponse;
+import org.apache.hadoop.hive.metastore.api.LockState;
+import org.apache.hadoop.hive.metastore.api.LockType;
+import org.apache.thrift.TException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import static
org.apache.hudi.common.config.LockConfiguration.HIVE_DATABASE_NAME_PROP_KEY;
+import static
org.apache.hudi.common.config.LockConfiguration.HIVE_TABLE_NAME_PROP_KEY;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link HiveMetastoreBasedLockProvider#close()} that exercise
the thread-pool
+ * shutdown path with a mocked {@link IMetaStoreClient}, without a live
metastore or ZooKeeper.
+ */
+class TestHiveMetastoreBasedLockProviderClose {
+
+ private static final String DB = "testdb";
+ private static final String TABLE = "testtable";
+
+ private LockConfiguration lockConfiguration;
+ private LockComponent lockComponent;
+
+ @BeforeEach
+ void setUp() {
+ TypedProperties props = new TypedProperties();
+ props.setProperty(HIVE_DATABASE_NAME_PROP_KEY, DB);
+ props.setProperty(HIVE_TABLE_NAME_PROP_KEY, TABLE);
+ lockConfiguration = new LockConfiguration(props);
+ lockComponent = new LockComponent(LockType.EXCLUSIVE, LockLevel.TABLE, DB);
+ lockComponent.setTablename(TABLE);
+ }
+
+ @Test
+ void closeShutsDownExecutorEvenWhenUnlockThrows() throws Exception {
+ IMetaStoreClient client = mock(IMetaStoreClient.class);
+ when(client.lock(any())).thenReturn(acquiredLock(42L));
+ doThrow(new TException("boom")).when(client).unlock(anyLong());
+
+ HiveMetastoreBasedLockProvider provider = new
HiveMetastoreBasedLockProvider(lockConfiguration, client);
+ assertTrue(provider.acquireLock(1000L, TimeUnit.MILLISECONDS,
lockComponent));
+
+ // A failing unlock() must not prevent the heartbeat thread pool from
being shut down.
+ assertDoesNotThrow(provider::close);
+ assertTrue(executorOf(provider).isShutdown(),
+ "executor must be shut down even when unlock() throws");
+ }
+
+ @Test
+ void closeShutsDownExecutorOnNormalPath() throws Exception {
+ IMetaStoreClient client = mock(IMetaStoreClient.class);
+ when(client.lock(any())).thenReturn(acquiredLock(1L));
+
+ HiveMetastoreBasedLockProvider provider = new
HiveMetastoreBasedLockProvider(lockConfiguration, client);
+ assertTrue(provider.acquireLock(1000L, TimeUnit.MILLISECONDS,
lockComponent));
+
+ provider.close();
+
+ verify(client).unlock(1L);
+ assertTrue(executorOf(provider).isShutdown());
+ }
+
+ private static LockResponse acquiredLock(long lockId) {
+ LockResponse response = new LockResponse();
+ response.setLockid(lockId);
+ response.setState(LockState.ACQUIRED);
+ return response;
+ }
+
+ private static ScheduledExecutorService
executorOf(HiveMetastoreBasedLockProvider provider) throws Exception {
+ Field field =
HiveMetastoreBasedLockProvider.class.getDeclaredField("executor");
+ field.setAccessible(true);
+ return (ScheduledExecutorService) field.get(provider);
+ }
+}