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 48fe2c6bd0a4 fix(hive-sync): stop the HMS lock heartbeat once the 
metastore drops the lock (#19370)
48fe2c6bd0a4 is described below

commit 48fe2c6bd0a44fa1ef5693aa7f7460c32f0cfd61
Author: Vova Kolmakov <[email protected]>
AuthorDate: Fri Jul 24 19:50:07 2026 +0700

    fix(hive-sync): stop the HMS lock heartbeat once the metastore drops the 
lock (#19370)
    
    * fix(hive-sync): stop the HMS lock heartbeat once the metastore drops the 
lock
    
    * addressed review comments: scope the lock-loss callback to its lock id
    
    * addressed review comments: document the unlock throw, snapshot lock reads 
and extend lock-loss coverage
    
    * addressed review comments: skip the heartbeat for a lock that was never 
granted
    
    ---------
    
    Co-authored-by: Vova Kolmakov <[email protected]>
---
 .../hudi/hive/transaction/lock/Heartbeat.java      |  22 +-
 .../lock/HiveMetastoreBasedLockProvider.java       | 117 +++++--
 .../HiveMetastoreBasedLockProviderTestBase.java    |  95 ++++++
 .../hudi/hive/transaction/lock/TestHeartbeat.java  |  73 ++++-
 .../TestHiveMetastoreBasedLockProviderClose.java   |  50 +--
 ...TestHiveMetastoreBasedLockProviderLockLoss.java | 362 +++++++++++++++++++++
 6 files changed, 652 insertions(+), 67 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 bf0037faf6df..0766419d677a 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
@@ -21,21 +21,41 @@ package org.apache.hudi.hive.transaction.lock;
 
 import lombok.extern.slf4j.Slf4j;
 import org.apache.hadoop.hive.metastore.IMetaStoreClient;
+import org.apache.hadoop.hive.metastore.api.NoSuchLockException;
+import org.apache.hadoop.hive.metastore.api.NoSuchTxnException;
+import org.apache.hadoop.hive.metastore.api.TxnAbortedException;
+
+import java.util.function.Consumer;
 
 @Slf4j
 class Heartbeat implements Runnable {
   private final IMetaStoreClient client;
   private final long lockId;
+  private final Consumer<Exception> onLockLost;
+  // Latches the terminal failure so that a tick already queued when the 
schedule was cancelled
+  // does not issue another doomed heartbeat or report the loss a second time.
+  private volatile boolean lockLost = false;
 
-  Heartbeat(IMetaStoreClient client, long lockId) {
+  Heartbeat(IMetaStoreClient client, long lockId, Consumer<Exception> 
onLockLost) {
     this.client = client;
     this.lockId = lockId;
+    this.onLockLost = onLockLost;
   }
 
   @Override
   public void run() {
+    if (lockLost) {
+      return;
+    }
     try {
       client.heartbeat(0, lockId);
+    } catch (NoSuchLockException | NoSuchTxnException | TxnAbortedException e) 
{
+      // Terminal. The metastore has already expired or aborted this lock, so 
no later tick can
+      // renew it. Retrying would only log once per interval while the writer 
keeps believing it
+      // holds exclusivity, so report the loss to the owner, which stops the 
schedule and drops
+      // the lock.
+      lockLost = true;
+      onLockLost.accept(e);
     } catch (Exception e) {
       // Do not rethrow. This task is scheduled via 
ScheduledExecutorService.scheduleAtFixedRate,
       // where a thrown exception permanently cancels all subsequent 
executions and is only
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 c83267be5319..91d4062ecc3b 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
@@ -87,7 +87,11 @@ public class HiveMetastoreBasedLockProvider implements 
LockProvider<LockResponse
   @Getter
   private volatile LockResponse lock = null;
   protected LockConfiguration lockConfiguration;
-  private transient ScheduledFuture<?> future = null;
+  // Assigned by the acquiring thread, read and cancelled by the heartbeat 
thread.
+  private transient volatile ScheduledFuture<?> future = null;
+  // Set when the metastore reports the lock as expired or aborted, so that a 
later unlock() can
+  // tell the caller its exclusivity was lost instead of silently doing 
nothing.
+  private volatile boolean lockLostRemotely = false;
   private final transient ScheduledExecutorService executor = 
Executors.newScheduledThreadPool(2);
 
   public HiveMetastoreBasedLockProvider(final LockConfiguration 
lockConfiguration, final StorageConfiguration<?> conf) {
@@ -123,21 +127,35 @@ public class HiveMetastoreBasedLockProvider implements 
LockProvider<LockResponse
     } catch (ExecutionException | InterruptedException | TimeoutException | 
TException e) {
       throw new HoodieLockException(generateLogStatement(FAILED_TO_ACQUIRE, 
generateLogSuffixString()), e);
     }
-    return this.lock != null && this.lock.getState() == LockState.ACQUIRED;
+    return isLockAcquired();
   }
 
+  /**
+   * Releases the lock held at the metastore, if any.
+   *
+   * <p>Unlike most providers, which stay silent when they do not believe they 
hold a lock, this one
+   * deliberately fails when the metastore had already expired or aborted the 
lock: the writer went
+   * on committing without exclusivity and has to hear about it. Note that 
{@code LockManager.unlock()}
+   * skips its metrics and its {@code close()} when the provider throws.
+   *
+   * @throws HoodieLockException if the metastore took the lock away, or if 
releasing it fails.
+   */
   @Override
   public void unlock() {
     try {
       log.info(generateLogStatement(RELEASING, generateLogSuffixString()));
       LockResponse lockResponseLocal = lock;
       if (lockResponseLocal == null) {
+        if (lockLostRemotely) {
+          // The heartbeat already dropped the lock. Unlocking it would fail 
with a bare
+          // NoSuchLockException anyway, so fail with the actual reason 
instead.
+          throw new 
HoodieLockException(generateLogStatement(FAILED_TO_RELEASE, 
generateLogSuffixString())
+              + ", the metastore had already expired or aborted it");
+        }
         return;
       }
       lock = null;
-      if (future != null) {
-        future.cancel(false);
-      }
+      cancelHeartbeat();
       hiveClient.unlock(lockResponseLocal.getLockid());
       log.info(generateLogStatement(RELEASED, generateLogSuffixString()));
     } catch (TException e) {
@@ -159,12 +177,14 @@ public class HiveMetastoreBasedLockProvider implements 
LockProvider<LockResponse
   @Override
   public void close() {
     try {
-      if (lock != null) {
-        hiveClient.unlock(lock.getLockid());
-        lock = null;
-      }
-      if (future != null) {
-        future.cancel(false);
+      // Snapshot the lock, then stop claiming it before releasing it, exactly 
as unlock() does:
+      // the release itself makes an in-flight heartbeat fail, and that must 
not be mistaken for
+      // the metastore taking the lock away.
+      LockResponse lockResponseLocal = lock;
+      lock = null;
+      cancelHeartbeat();
+      if (lockResponseLocal != null) {
+        hiveClient.unlock(lockResponseLocal.getLockid());
       }
       Hive.closeCurrent();
     } catch (Exception e) {
@@ -181,12 +201,22 @@ public class HiveMetastoreBasedLockProvider implements 
LockProvider<LockResponse
       throws InterruptedException, ExecutionException, TimeoutException, 
TException {
     ValidationUtils.checkArgument(this.lock == null, ALREADY_ACQUIRED.name());
     acquireLockInternal(time, unit, component);
-    return this.lock != null && this.lock.getState() == LockState.ACQUIRED;
+    return isLockAcquired();
+  }
+
+  /**
+   * Whether the lock is held right now. Reads {@link #lock} once: the 
heartbeat thread clears it
+   * as soon as the metastore reports the lock as gone, so re-reading the 
field can mix two states.
+   */
+  private boolean isLockAcquired() {
+    LockResponse lockResponseLocal = this.lock;
+    return lockResponseLocal != null && lockResponseLocal.getState() == 
LockState.ACQUIRED;
   }
 
   private void acquireLockInternal(long time, TimeUnit unit, LockComponent 
lockComponent)
       throws InterruptedException, ExecutionException, TimeoutException, 
TException {
     LockRequest lockRequest = null;
+    lockLostRemotely = false;
     try {
       // TODO : FIX:Using the parameterized constructor throws MethodNotFound
       final LockRequestBuilder builder = new LockRequestBuilder();
@@ -210,27 +240,76 @@ public class HiveMetastoreBasedLockProvider implements 
LockProvider<LockResponse
       }
     } finally {
       // it is better to release WAITING lock, otherwise hive lock will hang 
forever
-      if (this.lock != null && this.lock.getState() != LockState.ACQUIRED) {
-        hiveClient.unlock(this.lock.getLockid());
+      // Snapshot the lock: the heartbeat thread clears it as soon as the 
metastore reports it gone.
+      LockResponse lockResponseLocal = this.lock;
+      if (lockResponseLocal != null && lockResponseLocal.getState() != 
LockState.ACQUIRED) {
+        hiveClient.unlock(lockResponseLocal.getLockid());
         lock = null;
-        if (future != null) {
-          future.cancel(false);
-        }
+        cancelHeartbeat();
       }
     }
   }
 
   /**
    * 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.
+   * takes a long time. Does nothing unless {@link #lock} is held right now, 
so that callers may
+   * invoke it without checking the state of the response they just got.
    */
   private void scheduleHeartbeat() {
-    Heartbeat heartbeat = new Heartbeat(hiveClient, lock.getLockid());
+    LockResponse lockResponseLocal = lock;
+    if (lockResponseLocal == null) {
+      // Released while the acquisition was still completing, so there is 
nothing left to renew.
+      return;
+    }
+    if (lockResponseLocal.getState() != LockState.ACQUIRED) {
+      // The metastore only queued the request. The caller releases such a 
lock right away, and
+      // renewing one that was never granted can only latch a loss that never 
happened.
+      return;
+    }
+    // Bind the id into the task and its callback: cancelling a schedule does 
not stop a tick that
+    // is already inside the heartbeat RPC, so a tick can outlive the lock it 
was scheduled for.
+    long lockId = lockResponseLocal.getLockid();
+    Heartbeat heartbeat = new Heartbeat(hiveClient, lockId, cause -> 
onLockLost(lockId, cause));
     long heartbeatIntervalMs = lockConfiguration.getConfig()
         .getLong(LOCK_HEARTBEAT_INTERVAL_MS_KEY, 
DEFAULT_LOCK_HEARTBEAT_INTERVAL_MS);
     future = executor.scheduleAtFixedRate(heartbeat, heartbeatIntervalMs / 2, 
heartbeatIntervalMs, TimeUnit.MILLISECONDS);
   }
 
+  /**
+   * Invoked from the heartbeat thread once the metastore reports the lock as 
expired or aborted.
+   * The lock cannot be renewed anymore, so stop heartbeating it and stop 
claiming it is held.
+   *
+   * @param lockId the lock this heartbeat was scheduled for, which is not 
necessarily the one held
+   *               now: releasing a lock is itself a reason for an in-flight 
heartbeat to fail.
+   */
+  private void onLockLost(long lockId, Exception cause) {
+    LockResponse lockResponseLocal = lock;
+    if (lockResponseLocal == null || lockResponseLocal.getLockid() != lockId) {
+      // We released this lock ourselves while the tick was in flight, which 
is why the metastore
+      // no longer knows about it. Nothing was lost, and any lock held now is 
a different one that
+      // keeps its own heartbeat.
+      log.debug("Ignoring a heartbeat failure for the already released lock 
{}", lockId, cause);
+      return;
+    }
+    // Order matters: the flag is set first, so an unlock() racing with this 
can never find the
+    // lock gone without a reason for it; and the schedule is cancelled before 
the lock is dropped,
+    // so whoever observes the drop is guaranteed to see the renewal already 
stopped.
+    lockLostRemotely = true;
+    cancelHeartbeat();
+    lock = null;
+    log.error("The metastore expired or aborted the lock at{}, heartbeat 
stopped and exclusivity is lost",
+        generateLogSuffixString(), cause);
+  }
+
+  private void cancelHeartbeat() {
+    ScheduledFuture<?> futureLocal = future;
+    if (futureLocal != null) {
+      // Never interrupt: this can run on the heartbeat thread itself, and the 
current tick is
+      // harmless. Cancelling only prevents further executions.
+      futureLocal.cancel(false);
+    }
+  }
+
   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/HiveMetastoreBasedLockProviderTestBase.java
 
b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/transaction/lock/HiveMetastoreBasedLockProviderTestBase.java
new file mode 100644
index 000000000000..0e329e3b2f45
--- /dev/null
+++ 
b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/transaction/lock/HiveMetastoreBasedLockProviderTestBase.java
@@ -0,0 +1,95 @@
+/*
+ * 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.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.junit.jupiter.api.BeforeEach;
+
+import java.lang.reflect.Field;
+
+import static 
org.apache.hudi.common.config.LockConfiguration.DEFAULT_LOCK_HEARTBEAT_INTERVAL_MS;
+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.apache.hudi.common.config.LockConfiguration.LOCK_HEARTBEAT_INTERVAL_MS_KEY;
+
+/**
+ * Shared fixture for the {@link HiveMetastoreBasedLockProvider} unit tests 
that drive the provider
+ * against a mocked {@code IMetaStoreClient}, without a live metastore or 
ZooKeeper.
+ */
+abstract class HiveMetastoreBasedLockProviderTestBase {
+
+  protected static final String DB = "testdb";
+  protected static final String TABLE = "testtable";
+
+  protected LockConfiguration lockConfiguration;
+  protected LockComponent lockComponent;
+
+  @BeforeEach
+  void setUpLockFixture() {
+    TypedProperties props = new TypedProperties();
+    props.setProperty(HIVE_DATABASE_NAME_PROP_KEY, DB);
+    props.setProperty(HIVE_TABLE_NAME_PROP_KEY, TABLE);
+    props.setProperty(LOCK_HEARTBEAT_INTERVAL_MS_KEY, 
String.valueOf(heartbeatIntervalMs()));
+    lockConfiguration = new LockConfiguration(props);
+    lockComponent = new LockComponent(LockType.EXCLUSIVE, LockLevel.TABLE, DB);
+    lockComponent.setTablename(TABLE);
+  }
+
+  /**
+   * The heartbeat interval the provider is configured with, overridden by 
tests that need the
+   * scheduled heartbeat to actually fire while the test runs.
+   */
+  protected long heartbeatIntervalMs() {
+    return DEFAULT_LOCK_HEARTBEAT_INTERVAL_MS;
+  }
+
+  protected static LockResponse acquiredLock(long lockId) {
+    return lockResponse(lockId, LockState.ACQUIRED);
+  }
+
+  protected static LockResponse waitingLock(long lockId) {
+    return lockResponse(lockId, LockState.WAITING);
+  }
+
+  private static LockResponse lockResponse(long lockId, LockState state) {
+    LockResponse response = new LockResponse();
+    response.setLockid(lockId);
+    response.setState(state);
+    return response;
+  }
+
+  /**
+   * Reads a private field of the provider, for the state it does not expose: 
the heartbeat
+   * schedule and the thread pool running it.
+   */
+  @SuppressWarnings("unchecked")
+  protected static <T> T readField(HiveMetastoreBasedLockProvider provider, 
String name) throws Exception {
+    Field field = HiveMetastoreBasedLockProvider.class.getDeclaredField(name);
+    field.setAccessible(true);
+    return (T) field.get(provider);
+  }
+}
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
index ca2386f7a28f..2dbdf827de87 100644
--- 
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
@@ -20,10 +20,22 @@
 package org.apache.hudi.hive.transaction.lock;
 
 import org.apache.hadoop.hive.metastore.IMetaStoreClient;
+import org.apache.hadoop.hive.metastore.api.NoSuchLockException;
+import org.apache.hadoop.hive.metastore.api.NoSuchTxnException;
+import org.apache.hadoop.hive.metastore.api.TxnAbortedException;
 import org.apache.thrift.TException;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Stream;
 
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
@@ -32,24 +44,81 @@ import static org.mockito.Mockito.verify;
 
 class TestHeartbeat {
 
+  private final List<Exception> lockLostCauses = new ArrayList<>();
+
+  /**
+   * Failures that mean the metastore has already expired or aborted the lock. 
They are declared by
+   * {@code IMetaStoreClient.heartbeat(long, long)} and cannot be recovered 
from by retrying.
+   */
+  private static Stream<Exception> terminalFailures() {
+    return Stream.of(
+        new NoSuchLockException("lock does not exist"),
+        new NoSuchTxnException("txn does not exist"),
+        new TxnAbortedException("txn was aborted"));
+  }
+
   @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);
+    Heartbeat heartbeat = new Heartbeat(client, 7L, lockLostCauses::add);
 
     // 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);
+    assertTrue(lockLostCauses.isEmpty(), "a transient failure must not be 
reported as a lost lock");
+  }
+
+  @Test
+  void runKeepsRetryingAfterTransientFailure() throws TException {
+    IMetaStoreClient client = mock(IMetaStoreClient.class);
+    doThrow(new TException("transient 
failure")).when(client).heartbeat(anyLong(), anyLong());
+
+    Heartbeat heartbeat = new Heartbeat(client, 7L, lockLostCauses::add);
+    heartbeat.run();
+    heartbeat.run();
+
+    verify(client, times(2)).heartbeat(0L, 7L);
+    assertTrue(lockLostCauses.isEmpty());
   }
 
   @Test
   void runHeartbeatsTheLockOnSuccess() throws TException {
     IMetaStoreClient client = mock(IMetaStoreClient.class);
 
-    new Heartbeat(client, 99L).run();
+    new Heartbeat(client, 99L, lockLostCauses::add).run();
 
     verify(client, times(1)).heartbeat(0L, 99L);
+    assertTrue(lockLostCauses.isEmpty());
+  }
+
+  @ParameterizedTest
+  @MethodSource("terminalFailures")
+  void runReportsLockLossOnTerminalFailure(Exception terminalFailure) throws 
TException {
+    IMetaStoreClient client = mock(IMetaStoreClient.class);
+    doThrow(terminalFailure).when(client).heartbeat(anyLong(), anyLong());
+
+    Heartbeat heartbeat = new Heartbeat(client, 11L, lockLostCauses::add);
+
+    assertDoesNotThrow(heartbeat::run);
+    assertEquals(1, lockLostCauses.size());
+    assertSame(terminalFailure, lockLostCauses.get(0));
+  }
+
+  @ParameterizedTest
+  @MethodSource("terminalFailures")
+  void runStopsHeartbeatingAfterTerminalFailure(Exception terminalFailure) 
throws TException {
+    IMetaStoreClient client = mock(IMetaStoreClient.class);
+    doThrow(terminalFailure).when(client).heartbeat(anyLong(), anyLong());
+
+    Heartbeat heartbeat = new Heartbeat(client, 11L, lockLostCauses::add);
+    heartbeat.run();
+    // A tick already queued when the schedule was cancelled must not renew a 
lock that is gone,
+    // nor report the loss a second time.
+    heartbeat.run();
+
+    verify(client, times(1)).heartbeat(0L, 11L);
+    assertEquals(1, lockLostCauses.size());
   }
 }
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
index 817c1407f3b9..3d3a8c26c4e8 100644
--- 
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
@@ -19,25 +19,13 @@
 
 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;
@@ -51,23 +39,7 @@ 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);
-  }
+class TestHiveMetastoreBasedLockProviderClose extends 
HiveMetastoreBasedLockProviderTestBase {
 
   @Test
   void closeShutsDownExecutorEvenWhenUnlockThrows() throws Exception {
@@ -80,8 +52,8 @@ class TestHiveMetastoreBasedLockProviderClose {
 
     // 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");
+    ScheduledExecutorService executor = readField(provider, "executor");
+    assertTrue(executor.isShutdown(), "executor must be shut down even when 
unlock() throws");
   }
 
   @Test
@@ -95,19 +67,7 @@ class TestHiveMetastoreBasedLockProviderClose {
     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);
+    ScheduledExecutorService executor = readField(provider, "executor");
+    assertTrue(executor.isShutdown());
   }
 }
diff --git 
a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/transaction/lock/TestHiveMetastoreBasedLockProviderLockLoss.java
 
b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/transaction/lock/TestHiveMetastoreBasedLockProviderLockLoss.java
new file mode 100644
index 000000000000..b485f4b9cc39
--- /dev/null
+++ 
b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/transaction/lock/TestHiveMetastoreBasedLockProviderLockLoss.java
@@ -0,0 +1,362 @@
+/*
+ * 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.exception.HoodieLockException;
+
+import org.apache.hadoop.hive.metastore.IMetaStoreClient;
+import org.apache.hadoop.hive.metastore.api.NoSuchLockException;
+import org.apache.thrift.TException;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.function.BooleanSupplier;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for how {@link HiveMetastoreBasedLockProvider} reacts to the 
metastore reporting its
+ * lock as gone, with a mocked {@link IMetaStoreClient} and no live metastore 
or ZooKeeper.
+ */
+class TestHiveMetastoreBasedLockProviderLockLoss extends 
HiveMetastoreBasedLockProviderTestBase {
+
+  private static final long LOCK_ID = 42L;
+  private static final long OTHER_LOCK_ID = 43L;
+  private static final long HEARTBEAT_INTERVAL_MS = 100L;
+  private static final long AWAIT_TIMEOUT_MS = 30_000L;
+
+  @Override
+  protected long heartbeatIntervalMs() {
+    // Keep the ticks short so the scheduled heartbeat fires within the test.
+    return HEARTBEAT_INTERVAL_MS;
+  }
+
+  @Test
+  void terminalHeartbeatFailureStopsRenewalAndDropsTheLock() throws Exception {
+    IMetaStoreClient client = mock(IMetaStoreClient.class);
+    when(client.lock(any())).thenReturn(acquiredLock(LOCK_ID));
+    doThrow(new NoSuchLockException("lock " + LOCK_ID + " does not exist"))
+        .when(client).heartbeat(anyLong(), anyLong());
+
+    HiveMetastoreBasedLockProvider provider = new 
HiveMetastoreBasedLockProvider(lockConfiguration, client);
+    try {
+      assertTrue(provider.acquireLock(1000L, TimeUnit.MILLISECONDS, 
lockComponent));
+
+      // The metastore has expired the lock, so the provider must stop 
claiming to hold it.
+      awaitUntil(() -> provider.getLock() == null, "the lost lock must be 
dropped by the heartbeat");
+
+      // The heartbeat task latches the failure on its own, so the count below 
would stay at one
+      // even if the schedule were left running. Assert the cancellation 
itself as well.
+      assertTrue(heartbeatFutureOf(provider).isCancelled(), "the heartbeat 
schedule must be cancelled");
+
+      // Give the scheduler several more intervals: no further heartbeat may 
be attempted, since
+      // both the schedule and the heartbeat task itself are stopped after a 
terminal failure.
+      Thread.sleep(HEARTBEAT_INTERVAL_MS * 5);
+      verify(client, times(1)).heartbeat(0L, LOCK_ID);
+
+      // The writer must learn that it no longer holds exclusivity, and the 
provider must not send
+      // a doomed unlock for a lock the metastore has already dropped.
+      assertThrows(HoodieLockException.class, provider::unlock);
+      verify(client, never()).unlock(anyLong());
+    } finally {
+      provider.close();
+    }
+  }
+
+  @Test
+  void lockLostAfterTryLockIsReportedOnUnlock() throws Exception {
+    IMetaStoreClient client = mock(IMetaStoreClient.class);
+    when(client.lock(any())).thenReturn(acquiredLock(LOCK_ID));
+    doThrow(new NoSuchLockException("lock " + LOCK_ID + " does not exist"))
+        .when(client).heartbeat(anyLong(), anyLong());
+
+    HiveMetastoreBasedLockProvider provider = new 
HiveMetastoreBasedLockProvider(lockConfiguration, client);
+    try {
+      // Same loss, but driven through the entry point the LockManager 
actually calls rather than
+      // the test-only acquireLock overload.
+      assertTrue(provider.tryLock(1000L, TimeUnit.MILLISECONDS));
+      awaitUntil(() -> provider.getLock() == null, "the lost lock must be 
dropped by the heartbeat");
+
+      assertThrows(HoodieLockException.class, provider::unlock);
+      verify(client, never()).unlock(anyLong());
+    } finally {
+      provider.close();
+    }
+  }
+
+  @Test
+  void closeAfterALostLockSendsNoUnlockAndShutsDownTheExecutor() throws 
Exception {
+    IMetaStoreClient client = mock(IMetaStoreClient.class);
+    when(client.lock(any())).thenReturn(acquiredLock(LOCK_ID));
+    doThrow(new NoSuchLockException("lock " + LOCK_ID + " does not exist"))
+        .when(client).heartbeat(anyLong(), anyLong());
+
+    HiveMetastoreBasedLockProvider provider = new 
HiveMetastoreBasedLockProvider(lockConfiguration, client);
+    try {
+      assertTrue(provider.acquireLock(1000L, TimeUnit.MILLISECONDS, 
lockComponent));
+      awaitUntil(() -> provider.getLock() == null, "the lost lock must be 
dropped by the heartbeat");
+    } finally {
+      provider.close();
+    }
+
+    // There is nothing left to release at the metastore, but the heartbeat 
pool must still go away.
+    verify(client, never()).unlock(anyLong());
+    ScheduledExecutorService executor = readField(provider, "executor");
+    assertTrue(executor.isShutdown(), "the heartbeat pool must be shut down 
after a lost lock");
+  }
+
+  @Test
+  void transientHeartbeatFailureKeepsRenewingTheLock() throws Exception {
+    IMetaStoreClient client = mock(IMetaStoreClient.class);
+    when(client.lock(any())).thenReturn(acquiredLock(LOCK_ID));
+    CountDownLatch heartbeats = new CountDownLatch(2);
+    doAnswer(invocation -> {
+      heartbeats.countDown();
+      throw new TException("transient failure");
+    }).when(client).heartbeat(anyLong(), anyLong());
+
+    HiveMetastoreBasedLockProvider provider = new 
HiveMetastoreBasedLockProvider(lockConfiguration, client);
+    try {
+      assertTrue(provider.acquireLock(1000L, TimeUnit.MILLISECONDS, 
lockComponent));
+
+      assertTrue(heartbeats.await(AWAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS),
+          "a transient failure must not stop the heartbeat schedule");
+      assertNotNull(provider.getLock(), "a transient failure must not drop the 
lock");
+
+      provider.unlock();
+      verify(client).unlock(LOCK_ID);
+      assertNull(provider.getLock());
+    } finally {
+      provider.close();
+    }
+  }
+
+  @Test
+  void lockCanBeAcquiredAgainAfterItWasLost() throws Exception {
+    IMetaStoreClient client = mock(IMetaStoreClient.class);
+    when(client.lock(any())).thenReturn(acquiredLock(LOCK_ID), 
acquiredLock(OTHER_LOCK_ID));
+    // Only the first lock is expired by the metastore; heartbeating the 
second one succeeds.
+    doThrow(new NoSuchLockException("lock " + LOCK_ID + " does not exist"))
+        .doNothing()
+        .when(client).heartbeat(anyLong(), anyLong());
+
+    HiveMetastoreBasedLockProvider provider = new 
HiveMetastoreBasedLockProvider(lockConfiguration, client);
+    try {
+      assertTrue(provider.acquireLock(1000L, TimeUnit.MILLISECONDS, 
lockComponent));
+      awaitUntil(() -> provider.getLock() == null, "the lost lock must be 
dropped by the heartbeat");
+
+      // Acquiring again must clear the lost-lock state, otherwise the 
provider would keep failing
+      // to release locks it holds perfectly well.
+      assertTrue(provider.acquireLock(1000L, TimeUnit.MILLISECONDS, 
lockComponent));
+      provider.unlock();
+
+      verify(client).unlock(OTHER_LOCK_ID);
+      assertNull(provider.getLock());
+    } finally {
+      provider.close();
+    }
+  }
+
+  @Test
+  void lostLockStateDoesNotLeakIntoTheNextAcquire() throws Exception {
+    IMetaStoreClient client = mock(IMetaStoreClient.class);
+    when(client.lock(any())).thenReturn(acquiredLock(LOCK_ID), 
waitingLock(OTHER_LOCK_ID));
+    doThrow(new NoSuchLockException("lock " + LOCK_ID + " does not exist"))
+        .doNothing()
+        .when(client).heartbeat(anyLong(), anyLong());
+
+    HiveMetastoreBasedLockProvider provider = new 
HiveMetastoreBasedLockProvider(lockConfiguration, client);
+    try {
+      assertTrue(provider.acquireLock(1000L, TimeUnit.MILLISECONDS, 
lockComponent));
+      awaitUntil(() -> provider.getLock() == null, "the lost lock must be 
dropped by the heartbeat");
+
+      // The second attempt only got queued, so it leaves no lock behind and 
nothing was expired
+      // by the metastore this time round.
+      assertFalse(provider.acquireLock(1000L, TimeUnit.MILLISECONDS, 
lockComponent));
+      verify(client).unlock(OTHER_LOCK_ID);
+
+      // Releasing must not report the loss that belonged to the previous lock.
+      assertDoesNotThrow(provider::unlock);
+    } finally {
+      provider.close();
+    }
+  }
+
+  @Test
+  void lockThatWasOnlyQueuedIsNeverHeartbeated() throws Exception {
+    IMetaStoreClient client = mock(IMetaStoreClient.class);
+    when(client.lock(any())).thenReturn(waitingLock(LOCK_ID));
+    doThrow(new NoSuchLockException("lock " + LOCK_ID + " was never granted"))
+        .when(client).heartbeat(anyLong(), anyLong());
+    // Releasing the queued lock is the provider's very next step, and it is 
an RPC: parking inside
+    // it holds open exactly the window in which a heartbeat scheduled for 
that lock would tick.
+    doAnswer(invocation -> {
+      Thread.sleep(HEARTBEAT_INTERVAL_MS * 5);
+      return null;
+    }).when(client).unlock(anyLong());
+
+    HiveMetastoreBasedLockProvider provider = new 
HiveMetastoreBasedLockProvider(lockConfiguration, client);
+    try {
+      assertFalse(provider.acquireLock(1000L, TimeUnit.MILLISECONDS, 
lockComponent));
+
+      // A lock that was never granted must never be renewed: a tick failing 
for it would be read
+      // as the metastore taking away exclusivity that the writer never had in 
the first place.
+      verify(client, never()).heartbeat(anyLong(), anyLong());
+      assertNull(heartbeatFutureOf(provider), "a queued lock must not be given 
a heartbeat schedule");
+      assertDoesNotThrow(provider::unlock);
+    } finally {
+      provider.close();
+    }
+  }
+
+  @Test
+  void unlockStaysSilentWhenNoLockWasEverHeld() {
+    IMetaStoreClient client = mock(IMetaStoreClient.class);
+
+    HiveMetastoreBasedLockProvider provider = new 
HiveMetastoreBasedLockProvider(lockConfiguration, client);
+    try {
+      // Releasing a lock that was never acquired is still a no-op: only a 
lock the metastore took
+      // away is reported as a failure.
+      assertDoesNotThrow(provider::unlock);
+    } finally {
+      provider.close();
+    }
+  }
+
+  @Test
+  void staleHeartbeatFromAReleasedLockDoesNotDropTheNextLock() throws 
Exception {
+    IMetaStoreClient client = mock(IMetaStoreClient.class);
+    when(client.lock(any())).thenReturn(acquiredLock(LOCK_ID), 
acquiredLock(OTHER_LOCK_ID));
+    CountDownLatch staleTickStarted = new CountDownLatch(1);
+    CountDownLatch releaseStaleTick = new CountDownLatch(1);
+    CountDownLatch newLockHeartbeats = new CountDownLatch(3);
+    doAnswer(invocation -> {
+      long heartbeatedLockId = invocation.getArgument(1);
+      if (heartbeatedLockId == LOCK_ID) {
+        // Park this tick inside the RPC until the lock it renews has been 
released and another one
+        // taken, then fail it the way the metastore fails a heartbeat for a 
lock it no longer has.
+        staleTickStarted.countDown();
+        releaseStaleTick.await();
+        throw new NoSuchLockException("lock " + LOCK_ID + " does not exist");
+      }
+      newLockHeartbeats.countDown();
+      return null;
+    }).when(client).heartbeat(anyLong(), anyLong());
+
+    HiveMetastoreBasedLockProvider provider = new 
HiveMetastoreBasedLockProvider(lockConfiguration, client);
+    try {
+      assertTrue(provider.acquireLock(1000L, TimeUnit.MILLISECONDS, 
lockComponent));
+      assertTrue(staleTickStarted.await(AWAIT_TIMEOUT_MS, 
TimeUnit.MILLISECONDS),
+          "the heartbeat for the first lock must be in flight before it is 
released");
+
+      provider.unlock();
+      assertTrue(provider.acquireLock(1000L, TimeUnit.MILLISECONDS, 
lockComponent));
+
+      // Only now does the tick scheduled for the first lock come back, 
failing because that lock
+      // was released here. Cancelling its schedule could never have stopped 
it.
+      releaseStaleTick.countDown();
+
+      assertTrue(newLockHeartbeats.await(AWAIT_TIMEOUT_MS, 
TimeUnit.MILLISECONDS),
+          "the stale tick must not stop the renewal of the lock held now, 
which would let the "
+              + "metastore expire a perfectly healthy lock mid-commit");
+      assertNotNull(provider.getLock(), "the stale tick must not drop the lock 
held now");
+      assertFalse(heartbeatFutureOf(provider).isCancelled(),
+          "the stale tick must not cancel the schedule of the lock held now");
+
+      assertDoesNotThrow(provider::unlock);
+      verify(client).unlock(OTHER_LOCK_ID);
+    } finally {
+      // Free the parked tick even when an assertion above failed: close() 
only shuts the executor
+      // down, which neither interrupts the await nor lets the non-daemon 
thread exit, so leaving
+      // it parked would turn a failing test into a hanging JVM.
+      releaseStaleTick.countDown();
+      provider.close();
+    }
+  }
+
+  @Test
+  void releasingALockNormallyIsNotReportedAsLost() throws Exception {
+    IMetaStoreClient client = mock(IMetaStoreClient.class);
+    when(client.lock(any())).thenReturn(acquiredLock(LOCK_ID));
+    CountDownLatch tickStarted = new CountDownLatch(1);
+    CountDownLatch releaseTick = new CountDownLatch(1);
+    doAnswer(invocation -> {
+      tickStarted.countDown();
+      releaseTick.await();
+      throw new NoSuchLockException("lock " + LOCK_ID + " does not exist");
+    }).when(client).heartbeat(anyLong(), anyLong());
+
+    HiveMetastoreBasedLockProvider provider = new 
HiveMetastoreBasedLockProvider(lockConfiguration, client);
+    try {
+      assertTrue(provider.acquireLock(1000L, TimeUnit.MILLISECONDS, 
lockComponent));
+      assertTrue(tickStarted.await(AWAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS),
+          "the heartbeat must be in flight before the lock is released");
+
+      provider.unlock();
+      verify(client).unlock(LOCK_ID);
+      releaseTick.countDown();
+
+      // Let the released tick finish and run whatever it makes of the failure.
+      Thread.sleep(HEARTBEAT_INTERVAL_MS * 5);
+
+      // The heartbeat failed only because the lock was released here, so 
releasing again stays the
+      // no-op it has always been instead of reporting a loss that never 
happened.
+      assertDoesNotThrow(provider::unlock);
+    } finally {
+      // See the note in 
staleHeartbeatFromAReleasedLockDoesNotDropTheNextLock: a parked tick must
+      // never outlive a failed assertion.
+      releaseTick.countDown();
+      provider.close();
+    }
+  }
+
+  private static ScheduledFuture<?> 
heartbeatFutureOf(HiveMetastoreBasedLockProvider provider) throws Exception {
+    return readField(provider, "future");
+  }
+
+  private static void awaitUntil(BooleanSupplier condition, String message) 
throws InterruptedException {
+    long deadline = System.currentTimeMillis() + AWAIT_TIMEOUT_MS;
+    while (System.currentTimeMillis() < deadline) {
+      if (condition.getAsBoolean()) {
+        return;
+      }
+      Thread.sleep(20L);
+    }
+    fail(message);
+  }
+}


Reply via email to