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

nsivabalan 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 bd38321d4370 fix(heartbeat): don't fail a durable commit when 
heartbeat cleanup races a refresh (#19867)
bd38321d4370 is described below

commit bd38321d437052c869b47355001a84c8d9c11ebd
Author: vamsikarnika <[email protected]>
AuthorDate: Tue Sep 22 03:39:41 2026 +0530

    fix(heartbeat): don't fail a durable commit when heartbeat cleanup races a 
refresh (#19867)
    
     stop() cancelled the heartbeat scheduler with shutdownNow() and deleted the
      heartbeat file without waiting for an in-flight refresh. The refresh 
could land
      after the delete, recreating the file and bumping the object generation, 
so a
      generation-matched delete was rejected (GCS 412 conditionNotMet).
    
      HoodieHadoopStorage.delete throws unchecked HoodieIOException when the 
object
      still exists after a refused delete, which escaped the IOException-only 
catch in
      deleteHeartbeatFile, propagated out of postCommit and aborted commitStats 
-- after
      commit() had already made the write durable. The caller saw a successful 
commit
      reported as a failure.
    
      - HoodieHeartbeatClient.stopHeartbeatScheduler: shutdown() plus bounded
        awaitTermination() so an in-flight refresh completes before the delete. 
Not
        placed in shutdownHeartbeatScheduler, which the missed-refresh path 
calls from
        the scheduler thread itself and must not await itself.
      - WriterHeartbeatUtils.deleteHeartbeatFile: also catch HoodieIOException. 
The
        commit is durable by postCommit and every caller discards the return 
value, so
        failed cleanup of a transient lease marker must not be fatal. The 
log.error is
        kept, so a leaked file stays visible.
      - updateHeartbeat: guard against a null map entry, since stop() removes 
the entry
        before stopping the scheduler and a refresh can complete inside the new 
await
        window.
---
 .../client/heartbeat/HoodieHeartbeatClient.java    |  37 ++++-
 .../client/heartbeat/WriterHeartbeatUtils.java     |   7 +-
 .../heartbeat/TestHoodieHeartbeatClient.java       | 152 +++++++++++++++++++++
 3 files changed, 190 insertions(+), 6 deletions(-)

diff --git 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/heartbeat/HoodieHeartbeatClient.java
 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/heartbeat/HoodieHeartbeatClient.java
index beeae36a4246..858c36db3b58 100644
--- 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/heartbeat/HoodieHeartbeatClient.java
+++ 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/heartbeat/HoodieHeartbeatClient.java
@@ -196,15 +196,34 @@ public class HoodieHeartbeatClient implements 
AutoCloseable, Serializable {
   private void stopHeartbeatScheduler(Heartbeat heartbeat) {
     log.info("Stopping heartbeat for instant {}", heartbeat.getInstantTime());
     shutdownHeartbeatScheduler(heartbeat);
+    // Callers delete the heartbeat file next. A refresh landing after that 
delete recreates the
+    // file, and on storage that enforces preconditions it also changes the 
object generation, so a
+    // generation-matched delete is rejected (e.g. GCS 412 conditionNotMet).
+    awaitHeartbeatSchedulerTermination(heartbeat);
     heartbeat.setHeartbeatStopped(true);
     log.info("Stopped heartbeat for instant {}", heartbeat.getInstantTime());
   }
 
+  /** Stops further refreshes without waiting; safe to call from the scheduler 
thread itself. */
   private void shutdownHeartbeatScheduler(Heartbeat heartbeat) {
     if (heartbeat.getScheduledFuture() != null) {
       heartbeat.getScheduledFuture().cancel(false);
     }
-    heartbeat.getHeartbeatScheduler().shutdownNow();
+    heartbeat.getHeartbeatScheduler().shutdown();
+  }
+
+  private void awaitHeartbeatSchedulerTermination(Heartbeat heartbeat) {
+    // An in-flight tick can be parked on the bounded write, so allow for that 
plus one interval.
+    long timeoutMs = this.heartbeatWriteTimeoutMs + this.heartbeatIntervalInMs;
+    try {
+      if (!heartbeat.getHeartbeatScheduler().awaitTermination(timeoutMs, 
TimeUnit.MILLISECONDS)) {
+        log.warn("Timed out after {} ms awaiting an in-flight heartbeat 
refresh for instant {}",
+            timeoutMs, heartbeat.getInstantTime());
+      }
+    } catch (InterruptedException e) {
+      Thread.currentThread().interrupt();
+      log.warn("Interrupted awaiting heartbeat scheduler termination for 
instant {}", heartbeat.getInstantTime());
+    }
   }
 
   public static Boolean heartbeatExists(HoodieStorage storage, String 
basePath, String instantTime) throws IOException {
@@ -238,6 +257,10 @@ public class HoodieHeartbeatClient implements 
AutoCloseable, Serializable {
       Long newHeartbeatTime = System.currentTimeMillis();
       writeHeartbeatFile(instantTime);
       Heartbeat heartbeat = instantToHeartbeatMap.get(instantTime);
+      if (heartbeat == null) {
+        // stop() removed the entry while this refresh was in flight.
+        return;
+      }
       if (heartbeat.getLastHeartbeatTime() != null && 
isHeartbeatExpired(instantTime)) {
         // A previous refresh was delayed past the tolerable interval. Stop 
refreshing this heartbeat
         // (cancel the scheduler) and do NOT advance the last heartbeat time, 
so the heartbeat stays expired
@@ -262,8 +285,8 @@ public class HoodieHeartbeatClient implements 
AutoCloseable, Serializable {
       log.warn("Heartbeat file write for instant {} did not complete within {} 
ms; will retry on next tick",
           instantTime, this.heartbeatWriteTimeoutMs);
     } catch (IOException io) {
-      boolean isHeartbeatStopped = 
instantToHeartbeatMap.get(instantTime).isHeartbeatStopped();
-      if (isHeartbeatStopped) {
+      Heartbeat heartbeat = instantToHeartbeatMap.get(instantTime);
+      if (heartbeat == null || heartbeat.isHeartbeatStopped()) {
         log.info("update heart beat failed, because the instant time {} was 
stopped", instantTime);
         return;
       }
@@ -308,9 +331,15 @@ public class HoodieHeartbeatClient implements 
AutoCloseable, Serializable {
   }
 
   @Override
-  public synchronized void close() {
+  public void close() {
+    // Not synchronized: stopHeartbeatTimers() now awaits in-flight refreshes, 
and holding the monitor
+    // across that would block a refresh needing getHeartbeatWriteExecutor() 
until the await expires.
     this.stopHeartbeatTimers();
     this.instantToHeartbeatMap.clear();
+    shutdownHeartbeatWriteExecutor();
+  }
+
+  private synchronized void shutdownHeartbeatWriteExecutor() {
     if (heartbeatWriteExecutor != null) {
       heartbeatWriteExecutor.shutdownNow();
       heartbeatWriteExecutor = null;
diff --git 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/heartbeat/WriterHeartbeatUtils.java
 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/heartbeat/WriterHeartbeatUtils.java
index 9763142628e4..1cd61f4cab36 100644
--- 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/heartbeat/WriterHeartbeatUtils.java
+++ 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/heartbeat/WriterHeartbeatUtils.java
@@ -22,6 +22,7 @@ import org.apache.hudi.common.table.HoodieTableMetaClient;
 import org.apache.hudi.common.util.ValidationUtils;
 import org.apache.hudi.config.HoodieWriteConfig;
 import org.apache.hudi.exception.HoodieException;
+import org.apache.hudi.exception.HoodieIOException;
 import org.apache.hudi.storage.HoodieStorage;
 import org.apache.hudi.storage.StoragePath;
 import org.apache.hudi.table.HoodieTable;
@@ -58,8 +59,10 @@ public class WriterHeartbeatUtils {
       } else {
         log.info("Deleted the heartbeat for instant {}", instantTime);
       }
-    } catch (IOException io) {
-      log.error("Unable to delete heartbeat for instant {}", instantTime, io);
+    } catch (IOException | HoodieIOException e) {
+      // HoodieStorage.deleteFile throws HoodieIOException when the object 
still exists after a
+      // rejected delete. Never fatal: the commit is already durable by 
postCommit.
+      log.error("Unable to delete heartbeat for instant {}", instantTime, e);
     }
     return deleted;
   }
diff --git 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/heartbeat/TestHoodieHeartbeatClient.java
 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/heartbeat/TestHoodieHeartbeatClient.java
index 2df16a1c90c9..eb3f069aa655 100644
--- 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/heartbeat/TestHoodieHeartbeatClient.java
+++ 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/heartbeat/TestHoodieHeartbeatClient.java
@@ -19,6 +19,7 @@
 package org.apache.hudi.client.heartbeat;
 
 import org.apache.hudi.common.testutils.HoodieCommonTestHarness;
+import org.apache.hudi.exception.HoodieIOException;
 import org.apache.hudi.storage.StoragePath;
 import org.apache.hudi.storage.StoragePathInfo;
 import org.apache.hudi.storage.hadoop.HoodieHadoopStorage;
@@ -30,6 +31,7 @@ import org.junit.jupiter.api.Test;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -165,6 +167,156 @@ public class TestHoodieHeartbeatClient extends 
HoodieCommonTestHarness {
     }
   }
 
+  /**
+   * stop() must not delete the heartbeat file while a scheduled refresh is 
still writing it. A
+   * refresh landing after the delete recreates the file, and on storage that 
enforces preconditions
+   * it changes the object generation so a generation-matched delete is 
rejected (e.g. GCS 412).
+   */
+  @Test
+  public void testStopWaitsForInFlightHeartbeatRefresh() throws Exception {
+    // A longer interval also widens the bounded-write timeout, so a slow CI 
box cannot let the write
+    // time out before the test releases it, which would let the delete run 
first.
+    long interval = 5000L;
+    CountDownLatch refreshEntered = new CountDownLatch(1);
+    CountDownLatch releaseRefresh = new CountDownLatch(1);
+    OrderRecordingStorage storage = new OrderRecordingStorage(
+        (FileSystem) metaClient.getStorage().getFileSystem(), refreshEntered, 
releaseRefresh);
+    HoodieHeartbeatClient client = new HoodieHeartbeatClient(
+        storage, metaClient.getBasePath().toString(), interval, 
numTolerableMisses);
+    try {
+      client.start(instantTime1);
+      assertTrue(refreshEntered.await(20, SECONDS), "Scheduled heartbeat 
refresh never started");
+
+      Thread stopper = new Thread(() -> client.stop(instantTime1));
+      stopper.start();
+      // Without awaiting termination, stop() runs straight through and never 
parks here.
+      await().atMost(20, SECONDS).until(() -> stopper.getState() == 
Thread.State.TIMED_WAITING
+          || stopper.getState() == Thread.State.WAITING
+          || stopper.getState() == Thread.State.BLOCKED);
+
+      releaseRefresh.countDown();
+      stopper.join(SECONDS.toMillis(20));
+      assertFalse(stopper.isAlive(), "stop() did not complete after the 
refresh was released");
+
+      assertTrue(storage.events().contains("delete"), "stop() never deleted 
the heartbeat file");
+      assertEquals("delete", storage.events().get(storage.events().size() - 1),
+          "No heartbeat refresh may follow the delete; events were " + 
storage.events());
+    } finally {
+      releaseRefresh.countDown();
+      client.close();
+    }
+  }
+
+  /**
+   * A rejected heartbeat delete must never propagate. 
HoodieStorage.deleteFile throws
+   * HoodieIOException (unchecked) when the object still exists after the 
delete was refused, which is
+   * what a generation-matched delete does on storage that enforces 
preconditions. Callers reach this
+   * from postCommit, where the commit is already durable.
+   */
+  @Test
+  public void testDeleteHeartbeatFileSwallowsHoodieIOException() {
+    ThrowOnDeleteStorage storage =
+        new ThrowOnDeleteStorage((FileSystem) 
metaClient.getStorage().getFileSystem());
+    assertFalse(WriterHeartbeatUtils.deleteHeartbeatFile(storage, basePath, 
instantTime1),
+        "A refused heartbeat delete must be reported via the return value, 
never thrown");
+  }
+
+  /** Interrupting a thread parked in stop() must re-assert the interrupt 
rather than swallow it. */
+  @Test
+  public void testStopReassertsInterruptWhileAwaiting() throws Exception {
+    // A longer interval widens the bounded-write window, so the await is 
still parked when interrupted.
+    long interval = 5000L;
+    CountDownLatch refreshEntered = new CountDownLatch(1);
+    CountDownLatch releaseRefresh = new CountDownLatch(1);
+    OrderRecordingStorage storage = new OrderRecordingStorage(
+        (FileSystem) metaClient.getStorage().getFileSystem(), refreshEntered, 
releaseRefresh);
+    HoodieHeartbeatClient client = new HoodieHeartbeatClient(
+        storage, metaClient.getBasePath().toString(), interval, 
numTolerableMisses);
+    try {
+      client.start(instantTime1);
+      assertTrue(refreshEntered.await(20, SECONDS), "Scheduled heartbeat 
refresh never started");
+
+      AtomicBoolean interruptPreserved = new AtomicBoolean();
+      Thread stopper = new Thread(() -> {
+        client.stop(instantTime1);
+        interruptPreserved.set(Thread.currentThread().isInterrupted());
+      });
+      stopper.start();
+      await().atMost(20, SECONDS).until(() -> stopper.getState() == 
Thread.State.TIMED_WAITING
+          || stopper.getState() == Thread.State.WAITING
+          || stopper.getState() == Thread.State.BLOCKED);
+      stopper.interrupt();
+      stopper.join(SECONDS.toMillis(20));
+
+      assertFalse(stopper.isAlive(), "stop() did not return after being 
interrupted");
+      assertTrue(interruptPreserved.get(), "stop() swallowed the interrupt");
+    } finally {
+      releaseRefresh.countDown();
+      client.close();
+    }
+  }
+
+  /** Refuses every delete with HoodieIOException, as a precondition-enforcing 
store does. */
+  private static class ThrowOnDeleteStorage extends HoodieHadoopStorage {
+
+    ThrowOnDeleteStorage(FileSystem fs) {
+      super(fs);
+    }
+
+    @Override
+    public boolean deleteFile(StoragePath path) {
+      throw new HoodieIOException("Failed to delete invalid data file: " + 
path);
+    }
+  }
+
+  /**
+   * Records create/delete order and blocks the first scheduled refresh until 
released. The block
+   * ignores interruption, as a storage write already in flight would.
+   */
+  private static class OrderRecordingStorage extends HoodieHadoopStorage {
+
+    private final List<String> events = new CopyOnWriteArrayList<>();
+    private final AtomicBoolean gated = new AtomicBoolean(false);
+    private final CountDownLatch refreshEntered;
+    private final CountDownLatch releaseRefresh;
+
+    OrderRecordingStorage(FileSystem fs, CountDownLatch refreshEntered, 
CountDownLatch releaseRefresh) {
+      super(fs);
+      this.refreshEntered = refreshEntered;
+      this.releaseRefresh = releaseRefresh;
+    }
+
+    List<String> events() {
+      return events;
+    }
+
+    @Override
+    public OutputStream create(StoragePath path, boolean overwrite) throws 
IOException {
+      // The first create is start()'s synchronous beat; gate only the first 
scheduled refresh.
+      if (!events.isEmpty() && gated.compareAndSet(false, true)) {
+        refreshEntered.countDown();
+        boolean released = false;
+        while (!released) {
+          try {
+            releaseRefresh.await();
+            released = true;
+          } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+          }
+        }
+      }
+      OutputStream stream = super.create(path, overwrite);
+      events.add("create");
+      return stream;
+    }
+
+    @Override
+    public boolean deleteFile(StoragePath path) throws IOException {
+      events.add("delete");
+      return super.deleteFile(path);
+    }
+  }
+
   /**
    * A storage wrapper whose first {@code create()} call blocks until 
released, simulating a hung
    * storage write. All subsequent calls delegate normally.

Reply via email to