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 452b237501df fix(lock): name the cause on FAILED_TO_RELEASE in 
StorageBasedLockProvider (#19574)
452b237501df is described below

commit 452b237501df5187f486856bc55369f83f1b6c64
Author: Praveen Gajulapalli <[email protected]>
AuthorDate: Fri Aug 28 09:25:17 2026 +0530

    fix(lock): name the cause on FAILED_TO_RELEASE in StorageBasedLockProvider 
(#19574)
    
    * fix(lock): name the cause on FAILED_TO_RELEASE in StorageBasedLockProvider
    
    Three distinct failures in StorageBasedLockProvider#unlock() threw a
    byte-identical HoodieLockException message, so logs could not tell them
    apart. All three also share a single updateLockReleaseFailureMetric
    counter, leaving no way to attribute a release failure to a cause.
    
    Each throw now names its cause, and each logs the context needed to act
    on it:
    
    - HEARTBEAT_STOP_FAILED: the heartbeat task would not stop, so the lock
      is deliberately left un-expired (the task could still renew it after
      we return). Logs the interrupted flag to separate the two sub-cases in
      LockProviderHeartbeatManager#stopHeartbeat.
    - INTERRUPTED_DURING_THROTTLE_BACKOFF: interrupted mid-backoff. Now also
      passes the InterruptedException so the stack trace survives.
    - THROTTLE_RETRIES_EXHAUSTED vs EXPIRE_WRITE_FAILED: distinguishes an
      exhausted retry budget against a storage rate limit (e.g. the GCS
      1-write/sec per-object limit) from a terminal UNKNOWN_ERROR /
      ACQUIRED_BY_OTHERS outcome.
    
    The four cause strings are declared as constants next to the other lock
    tunables, so the full set is visible in one place and both the call sites
    and the test assertions reference them rather than raw literals.
    
    On ACQUIRED_BY_OTHERS, also log how long ago our lease should have
    ended. A positive value means we overran our own lease, pointing at a
    starved heartbeat (long GC, thread-pool starvation); a negative value
    means the lease had not elapsed by our clock, pointing at clock skew
    between nodes instead. Those two causes are indistinguishable today and
    call for different fixes.
    
    Every new message carries lockFilePath, so a lock left dangling in
    storage can be joined back to the writer that failed to release it.
    
    Behaviour is unchanged: control flow is untouched, and each edit either
    adds a logger.error call or appends ", cause <LABEL>" to an existing
    exception message. Metrics are unchanged.
    
    Adds a test for the interrupted-during-backoff path, which had no
    coverage, and tightens the three existing FAILED_TO_RELEASE assertions
    to pin the specific cause label.
    
    * Include cause in the logs
---
 .../transaction/lock/StorageBasedLockProvider.java | 56 ++++++++++++++++++++--
 .../lock/TestStorageBasedLockProvider.java         | 41 +++++++++++++++-
 2 files changed, 93 insertions(+), 4 deletions(-)

diff --git 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/lock/StorageBasedLockProvider.java
 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/lock/StorageBasedLockProvider.java
index 974ea01aee7a..fc6d5d848aa7 100644
--- 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/lock/StorageBasedLockProvider.java
+++ 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/lock/StorageBasedLockProvider.java
@@ -89,6 +89,21 @@ public class StorageBasedLockProvider implements 
LockProvider<StorageLockFile> {
   @VisibleForTesting
   static final long THROTTLE_INITIAL_RETRY_DELAY_SECONDS = 1;
 
+  // The full set of causes reported alongside FAILED_TO_RELEASE. Several 
distinct failures all
+  // surface as that one lock state, so the cause is what tells them apart in 
production logs.
+  // The heartbeat task would not stop, so the lock is deliberately left 
un-expired.
+  @VisibleForTesting
+  static final String CAUSE_HEARTBEAT_STOP_FAILED = "HEARTBEAT_STOP_FAILED";
+  // Interrupted while backing off between throttled expire-write attempts.
+  @VisibleForTesting
+  static final String CAUSE_INTERRUPTED_DURING_THROTTLE_BACKOFF = 
"INTERRUPTED_DURING_THROTTLE_BACKOFF";
+  // Every expire-write attempt was throttled by storage; the retry budget ran 
out.
+  @VisibleForTesting
+  static final String CAUSE_THROTTLE_RETRIES_EXHAUSTED = 
"THROTTLE_RETRIES_EXHAUSTED";
+  // Terminal expire-write outcome: UNKNOWN_ERROR or ACQUIRED_BY_OTHERS.
+  @VisibleForTesting
+  static final String CAUSE_EXPIRE_WRITE_FAILED = "EXPIRE_WRITE_FAILED";
+
   // Use for testing
   private final Logger logger;
 
@@ -458,8 +473,15 @@ public class StorageBasedLockProvider implements 
LockProvider<StorageLockFile> {
       if (heartbeatManager.hasActiveHeartbeat()) {
         logger.debug("Owner {}: Gracefully shutting down heartbeat.", ownerId);
         if (!heartbeatManager.stopHeartbeat(true)) {
+          // The heartbeat task would not stop, so we must not expire the 
lock: the task could
+          // still renew it after we returned. See 
LockProviderHeartbeatManager#stopHeartbeat for
+          // which of the two sub-cases (interrupted vs. still-inflight) was 
logged.
+          logger.error("Owner {}: Cannot release lock {} - heartbeat failed to 
stop, so the lock is "
+                  + "left un-expired and will be reclaimed only after its 
lease elapses. "
+                  + "interrupted={}", ownerId, lockFilePath, 
Thread.currentThread().isInterrupted());
           
hoodieLockMetrics.ifPresent(HoodieLockMetrics::updateLockReleaseFailureMetric);
-          throw new 
HoodieLockException(generateLockStateMessage(FAILED_TO_RELEASE));
+          throw new HoodieLockException(
+              generateLockStateMessage(FAILED_TO_RELEASE, 
CAUSE_HEARTBEAT_STOP_FAILED));
         }
       }
 
@@ -483,8 +505,12 @@ public class StorageBasedLockProvider implements 
LockProvider<StorageLockFile> {
         // Re-set the interrupt flag and abandon the retry — an interrupted 
thread shouldn't keep
         // doing work. The caller will see FAILED_TO_RELEASE below.
         Thread.currentThread().interrupt();
+        logger.error("Owner {}: Cannot release lock {} - interrupted while 
backing off after "
+                + "throttled expire write (attempt {}/{}); lock left 
un-expired.",
+            ownerId, lockFilePath, attempt, THROTTLE_MAX_RETRIES, ie);
         
hoodieLockMetrics.ifPresent(HoodieLockMetrics::updateLockReleaseFailureMetric);
-        throw new 
HoodieLockException(generateLockStateMessage(FAILED_TO_RELEASE));
+        throw new HoodieLockException(
+            generateLockStateMessage(FAILED_TO_RELEASE, 
CAUSE_INTERRUPTED_DURING_THROTTLE_BACKOFF));
       }
       synchronized (this) {
         // Bail out if the lock was either cleared by another path (e.g. 
shutdown hook,
@@ -498,8 +524,16 @@ public class StorageBasedLockProvider implements 
LockProvider<StorageLockFile> {
     }
 
     if (expireResult != ExpireLockResult.SUCCESS) {
+      // THROTTLED here means the retries above were exhausted; FAILED means 
tryExpireCurrentLock
+      // already logged the specific storage outcome (UNKNOWN_ERROR vs 
ACQUIRED_BY_OTHERS).
+      String cause = expireResult == ExpireLockResult.THROTTLED
+          ? CAUSE_THROTTLE_RETRIES_EXHAUSTED
+          : CAUSE_EXPIRE_WRITE_FAILED;
+      logger.error("Owner {}: Cannot release lock {} - expire write ended as 
{} (cause={}) after {} "
+              + "throttle retries; lock left un-expired and will dangle until 
its lease elapses.",
+          ownerId, lockFilePath, expireResult, cause, THROTTLE_MAX_RETRIES);
       
hoodieLockMetrics.ifPresent(HoodieLockMetrics::updateLockReleaseFailureMetric);
-      throw new 
HoodieLockException(generateLockStateMessage(FAILED_TO_RELEASE));
+      throw new 
HoodieLockException(generateLockStateMessage(FAILED_TO_RELEASE, cause));
     }
   }
 
@@ -566,6 +600,13 @@ public class StorageBasedLockProvider implements 
LockProvider<StorageLockFile> {
         return ExpireLockResult.SUCCESS;
       case ACQUIRED_BY_OTHERS:
         // Lock was acquired by others, indicating heartbeat failure during 
lock hold period.
+        // Log how long ago our lease should have ended: a positive value 
means we overran it,
+        // which distinguishes a starved heartbeat from a premature steal by a 
skewed clock.
+        logger.error("Owner {}: Lock {} was acquired by another owner before 
we could expire it, "
+                + "indicating heartbeat failure during the hold period. Our 
lease validUntil was "
+                + "{} ms ago (negative means the lease had not yet elapsed by 
our clock, which "
+                + "points at clock skew rather than a stalled heartbeat).",
+            ownerId, lockFilePath, getCurrentEpochMs() - 
this.getLock().getValidUntilMs());
         logErrorLockState(FAILED_TO_RELEASE, "lock was acquired by others, 
indicating heartbeat failure.");
         setLock(null);
         
hoodieLockMetrics.ifPresent(HoodieLockMetrics::updateLockAcquiredByOthersErrorMetric);
@@ -678,6 +719,15 @@ public class StorageBasedLockProvider implements 
LockProvider<StorageLockFile> {
         state.toString());
   }
 
+  /**
+   * Same as {@link #generateLockStateMessage(LockState)}, but names the 
specific cause.
+   * Several distinct failures all surface as FAILED_TO_RELEASE; without the 
cause the
+   * exception alone cannot tell them apart in production logs.
+   */
+  private String generateLockStateMessage(LockState state, String cause) {
+    return String.format("%s, cause %s", generateLockStateMessage(state), 
cause);
+  }
+
   private static final String LOCK_STATE_LOGGER_MSG = "Owner {}: Lock file 
path {}, Thread {}, Storage based lock state {}";
   private static final String LOCK_STATE_LOGGER_MSG_WITH_INFO = "Owner {}: 
Lock file path {}, Thread {}, Storage based lock state {}, {}";
 
diff --git 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/lock/TestStorageBasedLockProvider.java
 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/lock/TestStorageBasedLockProvider.java
index 85c21c129d0f..5a9775589dca 100644
--- 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/lock/TestStorageBasedLockProvider.java
+++ 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/lock/TestStorageBasedLockProvider.java
@@ -381,7 +381,10 @@ class TestStorageBasedLockProvider {
     assertTrue(lockProvider.tryLock());
     when(mockHeartbeatManager.stopHeartbeat(true)).thenReturn(false);
     when(mockHeartbeatManager.hasActiveHeartbeat()).thenReturn(true);
-    assertThrows(HoodieLockException.class, () -> lockProvider.unlock());
+    HoodieLockException exception = assertThrows(HoodieLockException.class, () 
-> lockProvider.unlock());
+    assertTrue(exception.getMessage().contains("FAILED_TO_RELEASE"));
+    // The cause must distinguish this from the other FAILED_TO_RELEASE paths.
+    
assertTrue(exception.getMessage().contains(StorageBasedLockProvider.CAUSE_HEARTBEAT_STOP_FAILED),
 exception.getMessage());
     when(mockHeartbeatManager.hasActiveHeartbeat()).thenReturn(false);
   }
 
@@ -404,6 +407,8 @@ class TestStorageBasedLockProvider {
 
     HoodieLockException exception = assertThrows(HoodieLockException.class, () 
-> lockProvider.unlock());
     assertTrue(exception.getMessage().contains("FAILED_TO_RELEASE"));
+    // A steal is a terminal expire-write failure, not an exhausted throttle 
budget.
+    
assertTrue(exception.getMessage().contains(StorageBasedLockProvider.CAUSE_EXPIRE_WRITE_FAILED),
 exception.getMessage());
     when(mockHeartbeatManager.hasActiveHeartbeat()).thenReturn(false);
   }
 
@@ -495,6 +500,8 @@ class TestStorageBasedLockProvider {
 
     HoodieLockException exception = assertThrows(HoodieLockException.class, () 
-> lockProvider.unlock());
     assertTrue(exception.getMessage().contains("FAILED_TO_RELEASE"));
+    // Exhausting the retry budget must be distinguishable from a hard 
expire-write failure.
+    
assertTrue(exception.getMessage().contains(StorageBasedLockProvider.CAUSE_THROTTLE_RETRIES_EXHAUSTED),
 exception.getMessage());
     // 1 initial attempt + THROTTLE_MAX_RETRIES retries.
     verify(mockLockService, times(1 + 
StorageBasedLockProvider.THROTTLE_MAX_RETRIES))
         .tryUpsertLockFile(any(), eq(Option.of(realLockFile)));
@@ -506,6 +513,38 @@ class TestStorageBasedLockProvider {
     when(mockHeartbeatManager.hasActiveHeartbeat()).thenReturn(false);
   }
 
+  @Test
+  void testUnlockThrowsExceptionWhenInterruptedDuringThrottleBackoff() throws 
InterruptedException {
+    // The first expire attempt is THROTTLED, then the backoff sleep is 
interrupted. unlock()
+    // must abandon the retry, re-set the interrupt flag, and report the 
interruption as the
+    // cause rather than an exhausted retry budget.
+    
when(mockLockService.readCurrentLockFile()).thenReturn(Pair.of(LockGetResult.NOT_EXISTS,
 Option.empty()));
+    StorageLockData data = new StorageLockData(false, 
System.currentTimeMillis() + DEFAULT_LOCK_VALIDITY_MS, ownerId);
+    StorageLockFile realLockFile = new StorageLockFile(data, "v1");
+    when(mockLockService.tryUpsertLockFile(any(), eq(Option.empty())))
+        .thenReturn(Pair.of(LockUpsertResult.SUCCESS, 
Option.of(realLockFile)));
+    when(mockHeartbeatManager.startHeartbeatForThread(any())).thenReturn(true);
+    assertTrue(lockProvider.tryLock());
+
+    when(mockHeartbeatManager.stopHeartbeat(true)).thenReturn(true);
+    
when(mockHeartbeatManager.hasActiveHeartbeat()).thenReturn(true).thenReturn(false);
+    when(mockLockService.tryUpsertLockFile(any(), eq(Option.of(realLockFile))))
+        .thenReturn(Pair.of(LockUpsertResult.THROTTLED, Option.empty()));
+    doThrow(new InterruptedException("interrupted while backing off"))
+        .when(lockProvider).sleepForThrottleRetry(anyLong());
+
+    HoodieLockException exception = assertThrows(HoodieLockException.class, () 
-> lockProvider.unlock());
+    assertTrue(exception.getMessage().contains("FAILED_TO_RELEASE"));
+    
assertTrue(exception.getMessage().contains(StorageBasedLockProvider.CAUSE_INTERRUPTED_DURING_THROTTLE_BACKOFF),
+        exception.getMessage());
+    // The interrupt flag must be re-set so callers up the stack still observe 
it. Clear it here
+    // so the flag does not leak into subsequent tests on this thread.
+    assertTrue(Thread.interrupted());
+    // Only the initial attempt ran; the interruption aborted the retry loop.
+    verify(mockLockService, times(1)).tryUpsertLockFile(any(), 
eq(Option.of(realLockFile)));
+    when(mockHeartbeatManager.hasActiveHeartbeat()).thenReturn(false);
+  }
+
   @Test
   void testCloseFailsToStopHeartbeat() {
     
when(mockLockService.readCurrentLockFile()).thenReturn(Pair.of(LockGetResult.NOT_EXISTS,
 Option.empty()));

Reply via email to