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

smengcl pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ozone.git


The following commit(s) were added to refs/heads/master by this push:
     new cbe95512541 HDDS-16275. OM checkpoint pause() stops flush thread but 
not apply executor. (#11190)
cbe95512541 is described below

commit cbe95512541bd65be925c60168d05e14873ac24b
Author: Sadanand Shenoy <[email protected]>
AuthorDate: Mon Oct 12 10:01:29 2026 +0530

    HDDS-16275. OM checkpoint pause() stops flush thread but not apply 
executor. (#11190)
---
 .../hadoop/ozone/om/TestOMRatisSnapshots.java      | 107 +++++++++++
 .../TestDirectoryDeletingServiceWithFSO.java       |  18 +-
 .../org/apache/hadoop/ozone/om/OzoneManager.java   |  14 +-
 .../ozone/om/ratis/OzoneManagerStateMachine.java   | 182 +++++++++++++++---
 .../om/ratis/TestOzoneManagerStateMachine.java     | 208 +++++++++++++++++++++
 5 files changed, 487 insertions(+), 42 deletions(-)

diff --git 
a/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/om/TestOMRatisSnapshots.java
 
b/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/om/TestOMRatisSnapshots.java
index 1bfab3c3c6b..fbddb8101fd 100644
--- 
a/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/om/TestOMRatisSnapshots.java
+++ 
b/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/om/TestOMRatisSnapshots.java
@@ -37,8 +37,11 @@
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
 import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
@@ -86,6 +89,7 @@
 import org.rocksdb.RocksDB;
 import org.rocksdb.RocksDBException;
 import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.slf4j.event.Level;
 
 /**
@@ -644,6 +648,82 @@ public void testInstallSnapshotFailedBackupRestoresDbDir() 
throws Exception {
     }
   }
 
+  /**
+   * An apply offered while installCheckpoint has the state machine paused is 
rejected, and Ratis moves
+   * past it. If the install then fails, the old DB is kept and Ratis does not 
reload the state machine,
+   * so the entry would never be applied: the OM must exit instead of resuming 
applies, so that a restart
+   * replays it from the Raft log. A failed install with no such entry must 
not exit.
+   *
+   * The install is called directly on a follower that keeps receiving 
entries, which is wider than a
+   * Ratis driven install: there the follower refuses appendEntries, so only 
entries already committed in
+   * its log but not yet applied can be rejected.
+   */
+  @Test
+  public void testFailedInstallExitsAfterApplyRejectedDuringPause() throws 
Exception {
+    final String leaderOMNodeId = 
OmTestUtil.getCurrentOmProxyNodeId(objectStore);
+    OzoneManager leaderOM = cluster.getOzoneManager(leaderOMNodeId);
+    // The active follower, so it keeps receiving entries while it installs.
+    String followerNodeId = leaderOM.getPeerNodes().get(0).getNodeId();
+    if (!cluster.isOMActive(followerNodeId)) {
+      followerNodeId = leaderOM.getPeerNodes().get(1).getNodeId();
+    }
+    OzoneManager followerOM = cluster.getOzoneManager(followerNodeId);
+
+    writeKeys(1);
+    DBCheckpoint leaderDbCheckpoint = 
leaderOM.getMetadataManager().getStore().getCheckpoint(true);
+    Path leaderCheckpointLocation = leaderDbCheckpoint.getCheckpointLocation();
+    assertNotNull(leaderCheckpointLocation);
+    Path omDbDir = leaderCheckpointLocation.resolve(OM_DB_NAME);
+    Files.createDirectory(omDbDir);
+    moveCheckpointContentsToOmDbDir(leaderCheckpointLocation, omDbDir);
+    TransactionInfo trxnInfo = 
OzoneManagerRatisUtils.getTrxnInfoFromCheckpoint(conf, omDbDir);
+    // The follower is caught up, so claim a newer checkpoint to let the 
replacement start. It fails
+    // at the first backup move, before anything from the checkpoint is 
installed.
+    TransactionInfo aheadTrxnInfo = 
TransactionInfo.valueOf(trxnInfo.getTerm(), trxnInfo.getTransactionIndex() + 
1000);
+
+    DummyExitManager exitManager = new DummyExitManager();
+    followerOM.setExitManagerForTesting(exitManager);
+    LogCapturer updaterLog =
+        
LogCapturer.captureLogs(LoggerFactory.getLogger("org.apache.ratis.server.impl.StateMachineUpdater"));
+    ExecutorService executor = Executors.newSingleThreadExecutor();
+    BlockThenThrowFaultInjector injector = new BlockThenThrowFaultInjector();
+    try {
+      // Nothing left to apply on the follower, so this install fails with no 
entry rejected.
+      long leaderIndex = 
leaderOM.getOmRatisServer().getLastAppliedTermIndex().getIndex();
+      GenericTestUtils.waitFor(
+          () -> 
followerOM.getOmRatisServer().getLastAppliedTermIndex().getIndex() >= 
leaderIndex, 100, 30_000);
+      followerOM.setCheckpointBackupInjector(new 
ThrowOnNthPauseFaultInjector(1, "Simulated backup move failure"));
+      assertNull(followerOM.installCheckpoint(leaderOMNodeId, 
leaderCheckpointLocation, aheadTrxnInfo));
+      assertThat(exitManager.messages).isEmpty();
+
+      followerOM.setCheckpointBackupInjector(injector);
+      Future<TermIndex> install = executor.submit(
+          () -> followerOM.installCheckpoint(leaderOMNodeId, 
leaderCheckpointLocation, aheadTrxnInfo));
+      assertTrue(injector.paused.await(60, TimeUnit.SECONDS));
+      String key = createKey(ozoneBucket);
+      OmKeyArgs keyArgs = new 
OmKeyArgs.Builder().setVolumeName(volumeName).setBucketName(bucketName)
+          .setKeyName(key).build();
+      long keyIndex = leaderOM.lookupKey(keyArgs).getUpdateID();
+      // Ratis logs the rejected apply of the key's commit on the follower.
+      String follower = followerNodeId + "@";
+      String index = "applying txn index=" + keyIndex + ",";
+      GenericTestUtils.waitFor(() -> 
Arrays.stream(updaterLog.getOutput().split("\n"))
+          .anyMatch(line -> line.contains(follower) && line.contains(index)), 
100, 30_000);
+      injector.resume();
+      assertNull(install.get(60, TimeUnit.SECONDS));
+
+      assertThat(exitManager.messages)
+          .as("follower resumed applies on its old DB after the entry at index 
%d was skipped", keyIndex)
+          .singleElement().asString().contains("rejected while the state 
machine was paused");
+    } finally {
+      injector.resume();
+      executor.shutdownNow();
+      updaterLog.stopCapturing();
+      followerOM.setCheckpointBackupInjector(null);
+      cluster.setupExitManagerForTesting();
+    }
+  }
+
   /** Top-level entries of the OM metadata dir, ignoring backup dirs the 
install creates. */
   private static Set<String> topLevelNames(File metaDir) throws IOException {
     try (Stream<Path> list = Files.list(metaDir.toPath())) {
@@ -870,13 +950,40 @@ private void assertLogCapture(LogCapturer logCapture,
   }
 
   private static class DummyExitManager extends ExitManager {
+    private final List<String> messages = new CopyOnWriteArrayList<>();
+
     @Override
     public void exitSystem(int status, String message, Throwable throwable,
         Logger log) {
+      messages.add(throwable == null ? message : message + " " + 
throwable.getMessage());
       log.error("System Exit: " + message, throwable);
     }
   }
 
+  /**
+   * FaultInjector that blocks pause() until resume(), then throws IOException.
+   */
+  private static class BlockThenThrowFaultInjector extends FaultInjector {
+    private final CountDownLatch paused = new CountDownLatch(1);
+    private final CountDownLatch released = new CountDownLatch(1);
+
+    @Override
+    public void pause() throws IOException {
+      paused.countDown();
+      try {
+        released.await();
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+      }
+      throw new IOException("Simulated backup move failure");
+    }
+
+    @Override
+    public void resume() {
+      released.countDown();
+    }
+  }
+
   /**
    * FaultInjector that throws IOException on pause(), simulating a download 
failure
    * after the first part completes. Used to test cleanup on failed download.
diff --git 
a/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/om/service/TestDirectoryDeletingServiceWithFSO.java
 
b/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/om/service/TestDirectoryDeletingServiceWithFSO.java
index 58d816fbabd..7e92e3bc26a 100644
--- 
a/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/om/service/TestDirectoryDeletingServiceWithFSO.java
+++ 
b/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/om/service/TestDirectoryDeletingServiceWithFSO.java
@@ -38,7 +38,6 @@
 import java.io.IOException;
 import java.util.List;
 import java.util.Random;
-import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -465,11 +464,11 @@ public void testDeleteWithMultiLevelsBlockDoubleBuffer() 
throws Exception {
     assertTableRowCount(dirTable, 5);
     assertTableRowCount(keyTable, 3);
 
-    // stop daemon to simulate blocked double buffer
+    // Pause flushing to simulate a blocked double buffer
     OzoneManagerStateMachine omStateMachine = 
cluster.getOzoneManager().getOmRatisServer().getOmStateMachine();
     OzoneManagerDoubleBuffer omDoubleBuffer = 
omStateMachine.getOzoneManagerDoubleBuffer();
     omDoubleBuffer.awaitFlush();
-    omDoubleBuffer.stopDaemon();
+    omDoubleBuffer.pause();
 
     OzoneVolume volume = client.getObjectStore().getVolume(volumeName);
     OzoneBucket bucket = volume.getBucket(bucketName);
@@ -497,18 +496,9 @@ public void testDeleteWithMultiLevelsBlockDoubleBuffer() 
throws Exception {
     // verify bucket used namespace is not going -ve
     assertTrue(volume.getBucket(bucketName).getUsedNamespace() >= 0);
 
-    // re-init double buffer in state machine to resume further processing
-    omStateMachine.pause();
-    omStateMachine.unpause(omStateMachine.getLastAppliedTermIndex().getIndex(),
-        omStateMachine.getLatestSnapshot().getIndex());
-    // flush previous pending transaction manually
-    omDoubleBuffer.resume();
-    CompletableFuture.supplyAsync(() -> {
-      omDoubleBuffer.flushTransactions();
-      return null;
-    });
+    // Resume flushing the pending transactions.
+    omDoubleBuffer.unpause();
     omDoubleBuffer.awaitFlush();
-    omDoubleBuffer.stop();
     // verify deletion progress completion
     assertTableRowCount(deletedDirTable, 0);
     assertTableRowCount(keyTable, 0);
diff --git 
a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java
 
b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java
index 45d4129dcba..5c3e54ebbae 100644
--- 
a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java
+++ 
b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java
@@ -4672,6 +4672,10 @@ public synchronized TermIndex 
installSnapshotFromLeader(String leaderId) throws
       if (checkpointLocation == null) {
         throw new IOException("Cannot install checkpoint from leader " + 
leaderId + ": checkpointLocation is null");
       }
+      // The follower refuses appendEntries while this install is in progress, 
so its commit index is fixed.
+      // Apply up to it before installCheckpoint pauses the state machine, so 
that no committed entry is rejected.
+      long committedIndex = 
omRatisServer.getServerDivision().getRaftLog().getLastCommittedIndex();
+      omRatisServer.getOmStateMachine().awaitApplied(committedIndex);
       termIndex = installCheckpoint(leaderId, checkpointLocation);
     } catch (Exception ex) {
       LOG.error("Failed to install snapshot from Leader OM.", ex);
@@ -4737,9 +4741,7 @@ TermIndex installCheckpoint(String leaderId, Path 
checkpointLocation,
       stopSecretManager();
       stopTrashEmptier();
       omSnapshotManager.invalidateCache();
-      // Pause the State Machine so that no new transactions can be applied.
-      // This action also clears the OM Double Buffer so that if there are any
-      // pending transactions in the buffer, they are discarded.
+      // Pause the State Machine. It flushes the transactions already applied 
and rejects new ones until unpause.
       omRatisServer.getOmStateMachine().pause();
     } catch (Exception e) {
       LOG.error("Failed to stop/ pause the services. Cannot proceed with " +
@@ -4809,6 +4811,8 @@ TermIndex installCheckpoint(String leaderId, Path 
checkpointLocation,
       omSnapshotManager.close();
     }
 
+    // Keeping the old DB returns null below, so Ratis does not reload the 
state machine.
+    boolean oldDbKept = lastAppliedIndex != 
checkpointTrxnInfo.getTransactionIndex();
     // Reload the OM DB store with the new checkpoint.
     // Restart (unpause) the state machine and update its last applied index
     // to the installed checkpoint's snapshot index.
@@ -4820,7 +4824,7 @@ TermIndex installCheckpoint(String leaderId, Path 
checkpointLocation,
         // obtains the last entry. Publish the position used to unpause. After 
a failed
         // DB replacement, these values still identify the restored 
pre-install state.
         setTransactionInfo(TransactionInfo.valueOf(term, lastAppliedIndex));
-        omRatisServer.getOmStateMachine().unpause(lastAppliedIndex, term);
+        omRatisServer.getOmStateMachine().unpause(lastAppliedIndex, term, 
oldDbKept);
         newMetadataManagerStarted = true;
         LOG.info("Reloaded OM state with Term: {} and Index: {}. Spend {} ms",
             term, lastAppliedIndex, Time.monotonicNow() - time);
@@ -4829,7 +4833,7 @@ TermIndex installCheckpoint(String leaderId, Path 
checkpointLocation,
         keyManager.start(configuration);
         startSecretManagerIfNecessary();
         startTrashEmptier(configuration);
-        omRatisServer.getOmStateMachine().unpause(lastAppliedIndex, term);
+        omRatisServer.getOmStateMachine().unpause(lastAppliedIndex, term, 
oldDbKept);
         LOG.info("OM DB is not stopped. Started services with Term: {} and " +
             "Index: {}", term, lastAppliedIndex);
       }
diff --git 
a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/OzoneManagerStateMachine.java
 
b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/OzoneManagerStateMachine.java
index 4abdc1cc683..cb4151a41a4 100644
--- 
a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/OzoneManagerStateMachine.java
+++ 
b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/OzoneManagerStateMachine.java
@@ -98,6 +98,11 @@ public class OzoneManagerStateMachine extends 
BaseStateMachine {
   private final ExecutorService installSnapshotExecutor;
   private final boolean isTracingEnabled;
   private final AtomicInteger statePausedCount = new AtomicInteger(0);
+  private final Object applyTransactionMonitor = new Object();
+  private volatile boolean applyTransactionPaused = false;
+  private int inFlightApplyTransactions = 0;
+  // Highest index whose apply was rejected while paused. Reset when a pause 
starts (not by a nested pause).
+  private long lastRejectedApplyIndex = RaftLog.INVALID_LOG_INDEX;
   private final String threadPrefix;
 
   /**
@@ -434,12 +439,37 @@ public CompletableFuture<Message> 
applyTransaction(TransactionContext trx) {
       // lastAppliedIndex in OzoneManager StateMachine, even if other
       // executor has completed the transactions with id more.
 
-      //if there are too many pending requests, wait for doubleBuffer flushing
-      ozoneManagerDoubleBuffer.acquireUnFlushedTransactions(1);
+      if (!tryEnterApplyTransaction(termIndex.getIndex())) {
+        return completeExceptionally(new IOException("Cannot apply transaction 
while state machine is paused."));
+      }
+      try {
+        //if there are too many pending requests, wait for doubleBuffer 
flushing
+        ozoneManagerDoubleBuffer.acquireUnFlushedTransactions(1);
+      } catch (Exception ex) {
+        exitApplyTransaction();
+        throw ex;
+      }
+
+      final CompletableFuture<OMResponse> runFuture;
+      try {
+        runFuture = CompletableFuture.supplyAsync(() -> {
+          try {
+            return runCommand(request, termIndex);
+          } finally {
+            exitApplyTransaction();
+          }
+        }, executorService);
+      } catch (RuntimeException ex) {
+        ozoneManagerDoubleBuffer.releaseUnFlushedTransactions(1);
+        exitApplyTransaction();
+        throw ex;
+      }
 
-      return CompletableFuture.supplyAsync(() -> runCommand(request, 
termIndex), executorService)
-          .thenApply(this::processResponse);
+      return runFuture.thenApply(this::processResponse);
     } catch (Exception e) {
+      if (e instanceof InterruptedException) {
+        Thread.currentThread().interrupt();
+      }
       return completeExceptionally(e);
     }
   }
@@ -485,19 +515,37 @@ public CompletableFuture<Message> query(Message request) {
   }
 
   @Override
-  public synchronized void pause() {
-    LOG.info("OzoneManagerStateMachine is pausing");
-    statePausedCount.incrementAndGet();
-    final LifeCycle.State state = getLifeCycleState();
-    if (state == LifeCycle.State.PAUSED) {
-      return;
-    }
-    if (state != LifeCycle.State.NEW) {
-      getLifeCycle().transition(LifeCycle.State.PAUSING);
-      getLifeCycle().transition(LifeCycle.State.PAUSED);
+  public void pause() {
+    synchronized (this) {
+      LOG.info("OzoneManagerStateMachine is pausing");
+      statePausedCount.incrementAndGet();
+      final LifeCycle.State state = getLifeCycleState();
+      if (state == LifeCycle.State.PAUSED) {
+        return;
+      }
+      if (state != LifeCycle.State.NEW) {
+        getLifeCycle().transition(LifeCycle.State.PAUSING);
+        getLifeCycle().transition(LifeCycle.State.PAUSED);
+      }
     }
 
-    ozoneManagerDoubleBuffer.stop();
+    pauseApplyTransaction();
+    boolean interrupted = false;
+    while (true) {
+      try {
+        ozoneManagerDoubleBuffer.awaitFlush();
+        break;
+      } catch (InterruptedException ex) {
+        interrupted = true;
+        LOG.warn("Interrupted while waiting for OM double buffer to flush 
during pause.", ex);
+      }
+    }
+    if (interrupted) {
+      Thread.currentThread().interrupt();
+    }
+    synchronized (this) {
+      ozoneManagerDoubleBuffer.stop();
+    }
   }
 
   /**
@@ -507,15 +555,28 @@ public synchronized void pause() {
    */
   public synchronized void unpause(long newLastAppliedSnaphsotIndex,
       long newLastAppliedSnapShotTermIndex) {
+    unpause(newLastAppliedSnaphsotIndex, newLastAppliedSnapShotTermIndex, 
false);
+  }
+
+  /**
+   * Same as {@link #unpause(long, long)}. If oldDbKept, Ratis does not reload 
the state machine, so it never
+   * retries an apply rejected while paused: if one is above the restored 
index, keep rejecting applies and throw.
+   */
+  public synchronized void unpause(long newLastAppliedSnaphsotIndex,
+      long newLastAppliedSnapShotTermIndex, boolean oldDbKept) {
     if (statePausedCount.decrementAndGet() == 0) {
-      getLifeCycle().startAndTransition(() -> {
-        this.ozoneManagerDoubleBuffer = buildDoubleBufferForRatis();
-        restoreLastAppliedTermIndex(TermIndex.valueOf(
-            newLastAppliedSnapShotTermIndex, newLastAppliedSnaphsotIndex));
-        LOG.info("{}: OzoneManagerStateMachine un-pause completed. " +
-            "newLastAppliedSnapshotIndex: {}, newLastAppliedSnapShotTermIndex: 
{}",
-                getId(), newLastAppliedSnaphsotIndex, 
newLastAppliedSnapShotTermIndex);
-      });
+      try {
+        getLifeCycle().startAndTransition(() -> {
+          this.ozoneManagerDoubleBuffer = buildDoubleBufferForRatis();
+          restoreLastAppliedTermIndex(TermIndex.valueOf(
+              newLastAppliedSnapShotTermIndex, newLastAppliedSnaphsotIndex));
+          LOG.info("{}: OzoneManagerStateMachine un-pause completed. " +
+                  "newLastAppliedSnapshotIndex: {}, 
newLastAppliedSnapShotTermIndex: {}",
+              getId(), newLastAppliedSnaphsotIndex, 
newLastAppliedSnapShotTermIndex);
+        });
+      } finally {
+        resumeApplyTransaction(oldDbKept ? newLastAppliedSnaphsotIndex : 
Long.MAX_VALUE);
+      }
     }
   }
 
@@ -729,6 +790,7 @@ public OzoneManagerRequestHandler getHandler() {
   }
 
   public void stop() {
+    resumeApplyTransaction();
     ozoneManagerDoubleBuffer.stop();
     HadoopExecutors.shutdown(executorService, LOG, 5, TimeUnit.SECONDS);
     HadoopExecutors.shutdown(installSnapshotExecutor, LOG, 5, 
TimeUnit.SECONDS);
@@ -746,8 +808,82 @@ public void awaitDoubleBufferFlush() throws 
InterruptedException {
     ozoneManagerDoubleBuffer.awaitFlush();
   }
 
+  /** Wait until the state machine has applied and flushed up to the given 
index. */
+  public void awaitApplied(long index) throws IOException {
+    while (getLastAppliedTermIndex().getIndex() < index) {
+      if (ozoneManager.isStopped()) {
+        throw new IOException("OzoneManager is already stopped: " + 
ozoneManager.getNodeDetails());
+      }
+      try {
+        ozoneManagerDoubleBuffer.awaitFlush();
+      } catch (InterruptedException e) {
+        throw IOUtils.toInterruptedIOException("Interrupted 
ozoneManagerDoubleBuffer.awaitFlush", e);
+      }
+    }
+  }
+
   @VisibleForTesting
   public OzoneManagerDoubleBuffer getOzoneManagerDoubleBuffer() {
     return ozoneManagerDoubleBuffer;
   }
+
+  private void pauseApplyTransaction() {
+    synchronized (applyTransactionMonitor) {
+      if (applyTransactionPaused) {
+        return;
+      }
+      applyTransactionPaused = true;
+      lastRejectedApplyIndex = RaftLog.INVALID_LOG_INDEX;
+      boolean interrupted = false;
+      while (inFlightApplyTransactions > 0) {
+        try {
+          applyTransactionMonitor.wait();
+        } catch (InterruptedException ex) {
+          LOG.warn("Interrupted while waiting for in-flight apply transactions 
to complete.");
+          interrupted = true;
+        }
+      }
+      if (interrupted) {
+        Thread.currentThread().interrupt();
+      }
+    }
+  }
+
+  private void resumeApplyTransaction() {
+    resumeApplyTransaction(Long.MAX_VALUE);
+  }
+
+  private void resumeApplyTransaction(long restoredIndex) {
+    synchronized (applyTransactionMonitor) {
+      if (!applyTransactionPaused) {
+        return;
+      }
+      if (lastRejectedApplyIndex > restoredIndex) {
+        throw new IllegalStateException("Transactions up to index " + 
lastRejectedApplyIndex
+            + " were rejected while the state machine was paused, above the 
restored index " + restoredIndex);
+      }
+      applyTransactionPaused = false;
+      applyTransactionMonitor.notifyAll();
+    }
+  }
+
+  private boolean tryEnterApplyTransaction(long index) {
+    synchronized (applyTransactionMonitor) {
+      if (applyTransactionPaused) {
+        lastRejectedApplyIndex = Math.max(lastRejectedApplyIndex, index);
+        return false;
+      }
+      inFlightApplyTransactions++;
+      return true;
+    }
+  }
+
+  private void exitApplyTransaction() {
+    synchronized (applyTransactionMonitor) {
+      inFlightApplyTransactions--;
+      if (inFlightApplyTransactions == 0) {
+        applyTransactionMonitor.notifyAll();
+      }
+    }
+  }
 }
diff --git 
a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/ratis/TestOzoneManagerStateMachine.java
 
b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/ratis/TestOzoneManagerStateMachine.java
index 7a0657828af..ed94136cf49 100644
--- 
a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/ratis/TestOzoneManagerStateMachine.java
+++ 
b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/ratis/TestOzoneManagerStateMachine.java
@@ -20,6 +20,8 @@
 import static 
org.apache.hadoop.ozone.om.lock.OzoneManagerLock.LeveledResource.VOLUME_LOCK;
 import static 
org.apache.hadoop.security.UserGroupInformation.AuthenticationMethod.SIMPLE;
 import static org.apache.ozone.test.GenericTestUtils.waitFor;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertInstanceOf;
@@ -52,6 +54,7 @@
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
+import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.concurrent.locks.LockSupport;
@@ -271,6 +274,133 @@ public void testApplyTransactionBackpressureInterrupt() 
throws Exception {
     assertInstanceOf(InterruptedException.class, ex.getCause());
   }
 
+  @Test
+  public void testPauseRejectsApplyUntilUnpause() throws Exception {
+    OzoneManagerStateMachine testSm = new OzoneManagerStateMachine(
+        om, doubleBuffer, handler, executor, null) {
+      @Override
+      public OzoneManagerDoubleBuffer buildDoubleBufferForRatis() {
+        return doubleBuffer;
+      }
+    };
+    try {
+      OMRequest request = sampleWriteRequest();
+      TransactionContext trx = mockTrx(request, 1, 5);
+
+      OMResponse expectedResponse = OMResponse.newBuilder()
+          .setCmdType(Type.CreateKey)
+          .setStatus(Status.OK)
+          .setSuccess(true)
+          .build();
+      OMClientResponse clientResponse = mock(OMClientResponse.class);
+      when(clientResponse.getOMResponse()).thenReturn(expectedResponse);
+      when(clientResponse.getOmLockDetails()).thenReturn(null);
+
+      CountDownLatch handlerInvoked = new CountDownLatch(1);
+      doAnswer(invocation -> {
+        handlerInvoked.countDown();
+        return clientResponse;
+      }).when(handler).handleWriteRequest(eq(request), any(), 
eq(doubleBuffer));
+
+      testSm.pause();
+      CompletableFuture<Message> pausedFuture = testSm.applyTransaction(trx);
+      ExecutionException ex = assertThrows(ExecutionException.class, 
pausedFuture::get);
+      assertInstanceOf(IOException.class, ex.getCause());
+      assertFalse(handlerInvoked.await(200, TimeUnit.MILLISECONDS),
+          "apply should be rejected while state machine is paused");
+
+      testSm.unpause(5, 1);
+
+      CompletableFuture<Message> future = testSm.applyTransaction(trx);
+      assertTrue(handlerInvoked.await(2, TimeUnit.SECONDS),
+          "apply should continue after state machine unpause");
+      assertNotNull(future.get(2, TimeUnit.SECONDS));
+    } finally {
+      testSm.stop();
+    }
+  }
+
+  @Test
+  public void testApplyTransactionExecutorRejectDoesNotBlockPause() throws 
Exception {
+    ExecutorService rejectedExecutor = Executors.newSingleThreadExecutor();
+    rejectedExecutor.shutdownNow();
+    OzoneManagerStateMachine testSm = new OzoneManagerStateMachine(
+        om, doubleBuffer, handler, rejectedExecutor, null);
+    try {
+      OMRequest request = sampleWriteRequest();
+      TransactionContext trx = mockTrx(request, 1, 5);
+
+      CompletableFuture<Message> future = testSm.applyTransaction(trx);
+      ExecutionException ex = assertThrows(ExecutionException.class, 
future::get);
+      assertInstanceOf(RejectedExecutionException.class, ex.getCause());
+      verify(doubleBuffer).releaseUnFlushedTransactions(1);
+
+      ExecutorService pauseExecutor = Executors.newSingleThreadExecutor();
+      try {
+        Future<?> pauseFuture = pauseExecutor.submit(testSm::pause);
+        pauseFuture.get(2, TimeUnit.SECONDS);
+      } finally {
+        pauseExecutor.shutdownNow();
+      }
+    } finally {
+      testSm.stop();
+    }
+  }
+
+  @Test
+  public void testUnpauseKeepingOldDbFailsWhenApplyAboveItWasRejected() throws 
Exception {
+    OzoneManagerStateMachine testSm = new OzoneManagerStateMachine(
+        om, doubleBuffer, handler, executor, null) {
+      @Override
+      public OzoneManagerDoubleBuffer buildDoubleBufferForRatis() {
+        return doubleBuffer;
+      }
+    };
+    try {
+      OMRequest request = sampleWriteRequest();
+      // A rejected entry at or below the restored index is in the kept DB, so 
applies resume.
+      testSm.pause();
+      assertRejectedWhilePaused(testSm.applyTransaction(mockTrx(request, 1, 
5)));
+      testSm.unpause(5, 1, true);
+
+      // Ratis never retries an entry above the restored index, so applies 
must stay rejected.
+      testSm.pause();
+      assertRejectedWhilePaused(testSm.applyTransaction(mockTrx(request, 1, 
7)));
+      assertThatThrownBy(() -> testSm.unpause(6, 1, true))
+          .isInstanceOf(IllegalStateException.class)
+          .hasMessageContaining("up to index 7");
+      assertRejectedWhilePaused(testSm.applyTransaction(mockTrx(request, 1, 
8)));
+    } finally {
+      testSm.stop();
+    }
+  }
+
+  private static void assertRejectedWhilePaused(CompletableFuture<Message> 
future) {
+    ExecutionException ex = assertThrows(ExecutionException.class, 
future::get);
+    
assertThat(ex.getCause()).isInstanceOf(IOException.class).hasMessageContaining("while
 state machine is paused");
+  }
+
+  @Test
+  public void testUnpauseChecksAppliesRejectedWhileRebuildingDoubleBuffer() {
+    OzoneManagerStateMachine testSm = new OzoneManagerStateMachine(
+        om, doubleBuffer, handler, executor, null) {
+      @Override
+      public OzoneManagerDoubleBuffer buildDoubleBufferForRatis() {
+        
assertRejectedWhilePaused(applyTransaction(mockTrx(sampleWriteRequest(), 1, 
7)));
+        return doubleBuffer;
+      }
+    };
+    try {
+      testSm.pause();
+      assertThatThrownBy(() -> testSm.unpause(6, 1, true))
+          .isInstanceOf(IllegalStateException.class)
+          .hasMessageContaining("up to index 7");
+      
assertRejectedWhilePaused(testSm.applyTransaction(mockTrx(sampleWriteRequest(), 
1, 8)));
+    } finally {
+      testSm.stop();
+    }
+  }
+
   // --- runCommand tests ---
 
   @Test
@@ -1040,6 +1170,32 @@ public void testTakeSnapshotOmStopped() {
     assertThrows(IOException.class, () -> sm.takeSnapshot());
   }
 
+  @Test
+  public void testAwaitAppliedWaitsForFlush() throws Exception {
+    sm.notifyTermIndexUpdated(1, 0);
+    when(om.isStopped()).thenReturn(false);
+    doAnswer(invocation -> {
+      sm.updateLastAppliedTermIndex(TermIndex.valueOf(1, 2));
+      return null;
+    }).when(doubleBuffer).awaitFlush();
+
+    sm.awaitApplied(2);
+    assertTermIndex(1, 2, sm.getLastAppliedTermIndex());
+    verify(doubleBuffer).awaitFlush();
+
+    // Already applied: returns without waiting.
+    sm.awaitApplied(1);
+    verify(doubleBuffer).awaitFlush();
+  }
+
+  @Test
+  public void testAwaitAppliedOmStopped() {
+    when(om.isStopped()).thenReturn(true);
+    sm.notifyTermIndexUpdated(1, 0);
+
+    assertThrows(IOException.class, () -> sm.awaitApplied(1));
+  }
+
   // --- loadSnapshotInfoFromDB tests ---
 
   @Test
@@ -1281,6 +1437,58 @@ public void testPauseAlreadyPaused() {
     verify(doubleBuffer, times(2)).stop();
   }
 
+  @Test
+  public void testPauseWaitsForSnapshotDuringFlush() throws Exception {
+    initializeStateMachine();
+    OMMetadataManager metadataManager = mock(OMMetadataManager.class);
+    when(om.getMetadataManager()).thenReturn(metadataManager);
+    when(metadataManager.getStore()).thenReturn(mock(DBStore.class));
+
+    CountDownLatch flushWaitReached = new CountDownLatch(1);
+    CountDownLatch releaseFlushWait = new CountDownLatch(1);
+    CountDownLatch snapshotWriteReached = new CountDownLatch(1);
+    CountDownLatch releaseSnapshotWrite = new CountDownLatch(1);
+    doAnswer(invocation -> {
+      flushWaitReached.countDown();
+      awaitUninterruptibly(releaseFlushWait);
+      return null;
+    }).when(doubleBuffer).awaitFlush();
+    
when(doubleBuffer.persistIfNewer(any(TransactionInfo.class))).thenAnswer(invocation
 -> {
+      snapshotWriteReached.countDown();
+      awaitUninterruptibly(releaseSnapshotWrite);
+      return invocation.getArgument(0);
+    });
+
+    AtomicReference<Thread> pauseThread = new AtomicReference<>();
+    ExecutorService pauseExecutor = Executors.newSingleThreadExecutor(runnable 
-> {
+      Thread thread = new Thread(runnable, "test-pause-during-snapshot");
+      pauseThread.set(thread);
+      return thread;
+    });
+    ExecutorService snapshotExecutor = Executors.newSingleThreadExecutor();
+    try {
+      Future<?> pauseFuture = pauseExecutor.submit(sm::pause);
+      assertTrue(flushWaitReached.await(5, TimeUnit.SECONDS));
+      Future<Long> snapshotFuture = snapshotExecutor.submit(sm::takeSnapshot);
+      assertTrue(snapshotWriteReached.await(5, TimeUnit.SECONDS));
+
+      releaseFlushWait.countDown();
+      waitFor(() -> pauseThread.get().getState() == Thread.State.BLOCKED, 10, 
5000);
+      assertFalse(pauseFuture.isDone());
+
+      releaseSnapshotWrite.countDown();
+      snapshotFuture.get(5, TimeUnit.SECONDS);
+      pauseFuture.get(5, TimeUnit.SECONDS);
+    } finally {
+      releaseFlushWait.countDown();
+      releaseSnapshotWrite.countDown();
+      pauseExecutor.shutdownNow();
+      snapshotExecutor.shutdownNow();
+      pauseExecutor.awaitTermination(5, TimeUnit.SECONDS);
+      snapshotExecutor.awaitTermination(5, TimeUnit.SECONDS);
+    }
+  }
+
   /**
    * Reproduces the checkpoint-pause deadlock by holding the double-buffer
    * flush callback immediately before it updates the last-applied index, then


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to