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 bcf9463253e HDDS-16132. Retry follower SCM DN server start after 
catch-up (#10999)
bcf9463253e is described below

commit bcf9463253eb504457ee819a5de802f131fb1e6d
Author: Siyao Meng <[email protected]>
AuthorDate: Sat Oct 10 00:41:16 2026 -0700

    HDDS-16132. Retry follower SCM DN server start after catch-up (#10999)
    
    Generated-by: Codex (GPT-5.6 Sol)
---
 .../apache/hadoop/hdds/scm/ha/SCMStateMachine.java | 129 +++++++++-
 .../hdds/scm/server/StorageContainerManager.java   |   7 +
 .../hadoop/hdds/scm/ha/TestSCMStateMachine.java    | 280 +++++++++++++++++++--
 .../TestSCMFollowerCatchupWithContainerReport.java |   6 +-
 4 files changed, 397 insertions(+), 25 deletions(-)

diff --git 
a/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/ha/SCMStateMachine.java
 
b/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/ha/SCMStateMachine.java
index eb1b8ec0577..546f5b63dfe 100644
--- 
a/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/ha/SCMStateMachine.java
+++ 
b/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/ha/SCMStateMachine.java
@@ -23,6 +23,7 @@
 import com.google.common.base.Preconditions;
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
 import java.io.IOException;
+import java.time.Duration;
 import java.util.Collection;
 import java.util.EnumMap;
 import java.util.List;
@@ -30,8 +31,11 @@
 import java.util.Optional;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutorService;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.LongSupplier;
 import org.apache.hadoop.hdds.protocol.proto.SCMRatisProtocol.RequestType;
 import org.apache.hadoop.hdds.scm.block.DeletedBlockLog;
 import org.apache.hadoop.hdds.scm.block.DeletedBlockLogImpl;
@@ -82,6 +86,24 @@ public class SCMStateMachine extends BaseStateMachine {
   private final boolean isInitialized;
   private ExecutorService installSnapshotExecutor;
 
+  // Interval for the fallback retry that starts the deferred datanode-server 
on
+  // a follower (see retryStartDNServerUntilReady).
+  private static final Duration DN_SERVER_START_RETRY_INTERVAL =
+      Duration.ofSeconds(1);
+  private static final Duration DN_SERVER_START_INITIAL_WARNING_DELAY = 
Duration.ofSeconds(30);
+  private static final Duration DN_SERVER_START_WARNING_INTERVAL = 
Duration.ofMinutes(5);
+  private final Duration dnServerStartRetryInterval;
+  private final LongSupplier monotonicClock;
+
+  // Periodically retries the deferred datanode-server start on a follower so 
an
+  // idle cluster (no new transactions, only Ratis heartbeats) still exits safe
+  // mode once catch-up becomes observable.
+  private ScheduledExecutorService dnServerStartRetryExecutor;
+  private ScheduledFuture<?> dnServerStartRetryFuture;
+  private long dnServerStartRetryAttempts;
+  private long dnServerStartRetryStartedAt;
+  private long dnServerStartNextWarningAt;
+
   private DBCheckpoint installingDBCheckpoint = null;
   private List<ManagedSecretKey> installingSecretKeys = null;
 
@@ -95,10 +117,18 @@ public class SCMStateMachine extends BaseStateMachine {
 
   public SCMStateMachine(final StorageContainerManager scm,
       SCMHADBTransactionBuffer buffer) {
+    this(scm, buffer, DN_SERVER_START_RETRY_INTERVAL, Time::monotonicNow);
+  }
+
+  @VisibleForTesting
+  SCMStateMachine(final StorageContainerManager scm,
+      SCMHADBTransactionBuffer buffer, Duration retryInterval, LongSupplier 
monotonicClock) {
     this.scm = scm;
     this.invokers = new EnumMap<>(RequestType.class);
     this.transactionBuffer = buffer;
     this.metrics = scm.getMetrics();
+    this.dnServerStartRetryInterval = retryInterval;
+    this.monotonicClock = monotonicClock;
     TransactionInfo latestTrxInfo = this.transactionBuffer.getLatestTrxInfo();
     if (!latestTrxInfo.isDefault()) {
       updateLastAppliedTermIndex(latestTrxInfo.getTerm(),
@@ -111,12 +141,21 @@ public SCMStateMachine(final StorageContainerManager scm,
             .setNameFormat(scm.threadNamePrefix() + "SCMInstallSnapshot-%d")
             .build()
     );
+    this.dnServerStartRetryExecutor = HadoopExecutors.newScheduledThreadPool(1,
+        new ThreadFactoryBuilder()
+            .setNameFormat(scm.threadNamePrefix() + "SCMDNServerStartRetry-%d")
+            .setDaemon(true)
+            .build());
     isInitialized = true;
   }
 
+  // Used only by SCMRatisServerImpl.initialize() to bootstrap Ratis storage;
+  // there is no SCM instance or background work to start or stop.
   public SCMStateMachine() {
     isInitialized = false;
     this.metrics = null;
+    this.dnServerStartRetryInterval = DN_SERVER_START_RETRY_INTERVAL;
+    this.monotonicClock = Time::monotonicNow;
   }
 
   private void addRatisEvent(String message) {
@@ -303,6 +342,7 @@ public void notifyLeaderChanged(RaftGroupMemberId 
groupMemberId,
         leaderCommitIndexOnStart = getLeaderCommitIndex();
       }
       tryStartDNServerAndRefreshSafeMode();
+      scheduleDNServerStartRetry();
       String message = "Leader changed to " + newLeaderId +
           ", current SCM " + scm.getScmId() + " is still follower.";
       LOG.info(message);
@@ -416,18 +456,102 @@ public void notifyTermIndexUpdated(long term, long 
index) {
    * container reports are processed against the up-to-date container/pipeline
    * state rather than a stale, mid-replay snapshot.
    */
-  private void tryStartDNServerAndRefreshSafeMode() {
-    if (isStateMachineReady.get()) {
+  private synchronized void tryStartDNServerAndRefreshSafeMode() {
+    if (scm.isStopped() || isStateMachineReady.get()) {
       return;
     }
     if (scm.getScmContext().isLeader() || isFollowerCaughtUp()) {
       if (isStateMachineReady.compareAndSet(false, true)) {
+        // No further retry is needed. shutdown() lets the current retry, if
+        // this method was called by it, finish normally without cancellation.
+        dnServerStartRetryExecutor.shutdown();
         scm.getDatanodeProtocolServer().start();
         scm.getScmSafeModeManager().refreshAndValidate();
       }
     }
   }
 
+  /**
+   * Periodic fallback for the deferred datanode-server start on a follower. 
The
+   * Ratis callbacks ({@code applyTransaction}, {@code notifyTermIndexUpdated},
+   * {@code notifyLeaderChanged}) only fire on new activity, so on an idle
+   * cluster a restarted follower can miss the moment the leader's committed
+   * index becomes observable (it is not yet known when {@code 
notifyLeaderChanged}
+   * fires) and never start its datanode server, leaving it stuck in safe mode.
+   * This re-checks until the server starts. Starting stays
+   * gated by the same {@link #tryStartDNServerAndRefreshSafeMode()} 
predicate, so
+   * it cannot start the server before genuine catch-up.
+   */
+  private void retryStartDNServerUntilReady() {
+    try {
+      synchronized (this) {
+        if (!scm.isStopped() && !isStateMachineReady.get()) {
+          dnServerStartRetryAttempts++;
+          tryStartDNServerAndRefreshSafeMode();
+          logPendingDNServerStart();
+        }
+      }
+    } catch (Exception e) {
+      // Do not let a failed readiness check terminate the fallback. The
+      // finally block only schedules another attempt if startup is pending.
+      LOG.warn("Deferred datanode-server start retry failed", e);
+    } finally {
+      synchronized (this) {
+        dnServerStartRetryFuture = null;
+        scheduleDNServerStartRetry();
+      }
+    }
+  }
+
+  private void logPendingDNServerStart() {
+    if (scm.isStopped() || isStateMachineReady.get()) {
+      return;
+    }
+    long now = monotonicClock.getAsLong();
+    if (now >= dnServerStartNextWarningAt) {
+      long lastAppliedIndex = 
scm.getScmHAManager().getRatisServer().getDivision().getInfo().getLastAppliedIndex();
+      LOG.warn("Deferred datanode-server start is still waiting for follower 
catch-up: " +
+          "attempts={}, elapsed={}ms, lastAppliedIndex={}, 
leaderCommitIndexOnStart={}",
+          dnServerStartRetryAttempts, now - dnServerStartRetryStartedAt, 
lastAppliedIndex, leaderCommitIndexOnStart);
+      dnServerStartNextWarningAt = now + 
DN_SERVER_START_WARNING_INTERVAL.toMillis();
+    }
+  }
+
+  private synchronized void scheduleDNServerStartRetry() {
+    if (scm.isStopped() || isStateMachineReady.get() ||
+        dnServerStartRetryExecutor.isShutdown() || dnServerStartRetryFuture != 
null) {
+      return;
+    }
+    if (dnServerStartRetryAttempts == 0) {
+      dnServerStartRetryStartedAt = monotonicClock.getAsLong();
+      dnServerStartNextWarningAt = dnServerStartRetryStartedAt + 
DN_SERVER_START_INITIAL_WARNING_DELAY.toMillis();
+    }
+    dnServerStartRetryFuture = dnServerStartRetryExecutor.schedule(
+        this::retryStartDNServerUntilReady,
+        dnServerStartRetryInterval.toMillis(), TimeUnit.MILLISECONDS);
+  }
+
+  public void stopDNServerStartRetry() {
+    if (!isInitialized) {
+      return;
+    }
+    synchronized (this) {
+      dnServerStartRetryExecutor.shutdownNow();
+      dnServerStartRetryFuture = null;
+    }
+    HadoopExecutors.shutdown(dnServerStartRetryExecutor, LOG, 5, 
TimeUnit.SECONDS);
+  }
+
+  @VisibleForTesting
+  boolean isDNServerStartRetryStopped() {
+    return dnServerStartRetryExecutor.isShutdown();
+  }
+
+  @VisibleForTesting
+  boolean isDNServerStartRetryTerminated() {
+    return dnServerStartRetryExecutor.isTerminated();
+  }
+
   /**
    * @return true if this follower's last applied index has reached the 
leader's
    * committed index captured when it (re)joined, i.e. all transactions the
@@ -570,6 +694,7 @@ public void close() throws IOException {
       transactionBuffer.close();
       HadoopExecutors.
           shutdown(installSnapshotExecutor, LOG, 5, TimeUnit.SECONDS);
+      stopDNServerStartRetry();
     } else if (!scm.isStopped()) {
       scm.shutDown("scm statemachine is closed by ratis, terminate SCM");
     }
diff --git 
a/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/server/StorageContainerManager.java
 
b/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/server/StorageContainerManager.java
index 0fa0e38bfa7..fa32b0b9705 100644
--- 
a/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/server/StorageContainerManager.java
+++ 
b/hadoop-hdds/server-scm/src/main/java/org/apache/hadoop/hdds/scm/server/StorageContainerManager.java
@@ -117,6 +117,7 @@
 import org.apache.hadoop.hdds.scm.ha.SCMRatisServerImpl;
 import org.apache.hadoop.hdds.scm.ha.SCMServiceException;
 import org.apache.hadoop.hdds.scm.ha.SCMServiceManager;
+import org.apache.hadoop.hdds.scm.ha.SCMStateMachine;
 import org.apache.hadoop.hdds.scm.ha.SequenceIdGenerator;
 import org.apache.hadoop.hdds.scm.ha.StatefulServiceStateManager;
 import org.apache.hadoop.hdds.scm.ha.StatefulServiceStateManagerImpl;
@@ -1627,6 +1628,12 @@ public void stop() {
       stopReplicationManager(); // started eagerly
       return;
     }
+
+    SCMStateMachine stateMachine = getScmHAManager().getRatisServer()
+        .getSCMStateMachine();
+    if (stateMachine != null) {
+      stateMachine.stopDNServerStartRetry();
+    }
     try {
       if (containerBalancer.isBalancerRunning()) {
         LOG.info("Stopping Container Balancer service.");
diff --git 
a/hadoop-hdds/server-scm/src/test/java/org/apache/hadoop/hdds/scm/ha/TestSCMStateMachine.java
 
b/hadoop-hdds/server-scm/src/test/java/org/apache/hadoop/hdds/scm/ha/TestSCMStateMachine.java
index 0bf82bad2b5..075e6f10223 100644
--- 
a/hadoop-hdds/server-scm/src/test/java/org/apache/hadoop/hdds/scm/ha/TestSCMStateMachine.java
+++ 
b/hadoop-hdds/server-scm/src/test/java/org/apache/hadoop/hdds/scm/ha/TestSCMStateMachine.java
@@ -17,18 +17,30 @@
 
 package org.apache.hadoop.hdds.scm.ha;
 
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.after;
+import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.timeout;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+import java.time.Duration;
+import java.util.Collections;
 import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
 import org.apache.hadoop.hdds.protocol.proto.SCMRatisProtocol.RequestType;
 import org.apache.hadoop.hdds.scm.container.placement.metrics.SCMMetrics;
 import org.apache.hadoop.hdds.scm.exceptions.SCMException;
@@ -39,37 +51,265 @@
 import org.apache.hadoop.hdds.scm.server.StorageContainerManager;
 import org.apache.hadoop.hdds.utils.TransactionInfo;
 import org.apache.hadoop.ozone.upgrade.UpgradeException;
+import org.apache.hadoop.util.concurrent.ExecutorHelper;
+import org.apache.ozone.test.GenericTestUtils;
+import org.apache.ozone.test.GenericTestUtils.LogCapturer;
 import org.apache.ratis.proto.RaftProtos;
 import org.apache.ratis.proto.RaftProtos.LogEntryProto;
 import org.apache.ratis.proto.RaftProtos.StateMachineLogEntryProto;
 import org.apache.ratis.protocol.Message;
+import org.apache.ratis.protocol.RaftGroupId;
+import org.apache.ratis.protocol.RaftGroupMemberId;
+import org.apache.ratis.protocol.RaftPeerId;
+import org.apache.ratis.server.DivisionInfo;
+import org.apache.ratis.server.RaftServer;
 import org.apache.ratis.server.protocol.TermIndex;
 import org.apache.ratis.statemachine.TransactionContext;
 import org.apache.ratis.util.ExitUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.InOrder;
 
 /**
- * Test SCMStateMachine events recording.
+ * Tests SCMStateMachine events and deferred datanode-server startup.
  */
 public class TestSCMStateMachine {
+  private static final Duration RETRY_INTERVAL = Duration.ofMillis(100);
 
-  @Test
-  public void testRatisEventsRecording() throws Exception {
+  private final AtomicBoolean commitIndexAvailable = new AtomicBoolean();
+  private final AtomicBoolean scmStopped = new AtomicBoolean();
+  private final AtomicLong lastAppliedIndex = new AtomicLong(5L);
+  private final AtomicLong leaderCommitIndex = new AtomicLong(5L);
+  private final AtomicLong retryClock = new AtomicLong(1000L);
+  private final RaftPeerId followerId = RaftPeerId.valueOf("follower");
+  private final RaftPeerId leaderId = RaftPeerId.valueOf("leader");
+
+  private SCMMetrics metrics;
+  private SCMDatanodeProtocolServer datanodeProtocolServer;
+  private SCMSafeModeManager safeModeManager;
+  private SCMStateMachine stateMachine;
+
+  @BeforeEach
+  void setUp() {
     StorageContainerManager scm = mock(StorageContainerManager.class);
-    SCMMetrics metrics = SCMMetrics.create();
+    metrics = SCMMetrics.create();
+    SCMHADBTransactionBuffer buffer = mock(SCMHADBTransactionBuffer.class);
+    datanodeProtocolServer = mock(SCMDatanodeProtocolServer.class);
+    safeModeManager = mock(SCMSafeModeManager.class);
+
+    SCMContext scmContext = mock(SCMContext.class);
+    SCMHAManager haManager = mock(SCMHAManager.class);
+    SCMRatisServer ratisServer = mock(SCMRatisServer.class);
+    RaftServer.Division division = mock(RaftServer.Division.class);
+    DivisionInfo divisionInfo = mock(DivisionInfo.class);
+
     when(scm.getMetrics()).thenReturn(metrics);
+    when(scm.isStopped()).thenAnswer(invocation -> scmStopped.get());
+    when(scm.getScmContext()).thenReturn(scmContext);
+    when(scm.getScmHAManager()).thenReturn(haManager);
+    when(scm.getDatanodeProtocolServer()).thenReturn(datanodeProtocolServer);
+    when(scm.getScmSafeModeManager()).thenReturn(safeModeManager);
+    when(haManager.getRatisServer()).thenReturn(ratisServer);
+    when(ratisServer.getDivision()).thenReturn(division);
+    when(division.getInfo()).thenReturn(divisionInfo);
+    when(divisionInfo.getLeaderId()).thenReturn(leaderId);
+    when(divisionInfo.getLastAppliedIndex()).thenAnswer(invocation -> 
lastAppliedIndex.get());
+    when(division.getCommitInfos()).thenAnswer(invocation -> 
commitIndexAvailable.get()
+        ? Collections.singletonList(RaftProtos.CommitInfoProto.newBuilder()
+            
.setServer(RaftProtos.RaftPeerProto.newBuilder().setId(leaderId.toByteString()))
+            .setCommitIndex(leaderCommitIndex.get())
+            .build())
+        : Collections.emptyList());
+    when(buffer.getLatestTrxInfo()).thenReturn(
+        TransactionInfo.valueOf(TermIndex.valueOf(0, 0)));
 
-    SCMHADBTransactionBuffer buffer = mock(SCMHADBTransactionBuffer.class);
-    
when(buffer.getLatestTrxInfo()).thenReturn(TransactionInfo.valueOf(TermIndex.valueOf(0,
 0)));
+    stateMachine = new SCMStateMachine(scm, buffer, RETRY_INTERVAL, 
retryClock::get);
+  }
 
-    // The contents of the state machine are mocked, so SCMStateMachine#close 
is a no-op.
-    SCMStateMachine stateMachine = new SCMStateMachine(scm, buffer);
-    stateMachine.notifyConfigurationChanged(1, 1, 
RaftProtos.RaftConfigurationProto.getDefaultInstance());
+  @AfterEach
+  void tearDown() {
+    stateMachine.stopDNServerStartRetry();
+    metrics.unRegister();
+  }
 
+  @Test
+  void testRatisEventsRecording() {
+    stateMachine.notifyConfigurationChanged(1, 1, 
RaftProtos.RaftConfigurationProto.getDefaultInstance());
     assertTrue(metrics.getRatisEvents().contains("Configuration changed at 
term index"));
+  }
 
-    metrics.unRegister();
+  @Test
+  void testRetryStartsDNServerWhenLeaderCommitIndexBecomesAvailable() throws 
Exception {
+    LogCapturer executorLogs = LogCapturer.captureLogs(ExecutorHelper.class);
+    try {
+      stateMachine.notifyLeaderChanged(memberId(), leaderId);
+      verify(datanodeProtocolServer, never()).start();
+
+      commitIndexAvailable.set(true);
+
+      verify(datanodeProtocolServer, timeout(2000)).start();
+      verify(safeModeManager, timeout(2000)).refreshAndValidate();
+      assertThat(stateMachine.getIsStateMachineReady()).isTrue();
+      assertThat(stateMachine.isDNServerStartRetryStopped()).isTrue();
+      GenericTestUtils.waitFor(stateMachine::isDNServerStartRetryTerminated, 
10, 2000);
+      
assertThat(executorLogs.getOutput()).doesNotContain("CancellationException");
+    } finally {
+      executorLogs.stopCapturing();
+    }
+  }
+
+  @Test
+  void testRetryWaitsUntilFollowerCatchesUp() {
+    commitIndexAvailable.set(true);
+    lastAppliedIndex.set(4L);
+
+    stateMachine.notifyLeaderChanged(memberId(), leaderId);
+
+    verify(datanodeProtocolServer, after(RETRY_INTERVAL.toMillis() * 
3).never()).start();
+    lastAppliedIndex.set(5L);
+    verify(datanodeProtocolServer, timeout(2000)).start();
+    verify(safeModeManager, timeout(2000)).refreshAndValidate();
+  }
+
+  @Test
+  void testRetryUsesCapturedCommitIndexWhenLeaderAdvances() {
+    commitIndexAvailable.set(true);
+    lastAppliedIndex.set(4L);
+    stateMachine.notifyLeaderChanged(memberId(), leaderId);
+
+    leaderCommitIndex.set(100L);
+    lastAppliedIndex.set(5L);
+
+    verify(datanodeProtocolServer, timeout(2000).times(1)).start();
+    verify(safeModeManager, timeout(2000).times(1)).refreshAndValidate();
+    assertThat(stateMachine.getIsStateMachineReady()).isTrue();
+  }
+
+  @Test
+  void testPendingRetryWarningsAreThrottledAndStopWhenReady() throws Exception 
{
+    commitIndexAvailable.set(true);
+    lastAppliedIndex.set(4L);
+    LogCapturer logs = LogCapturer.captureLogs(SCMStateMachine.class);
+    try {
+      stateMachine.notifyLeaderChanged(memberId(), leaderId);
+      retryClock.set(30999L);
+      verify(datanodeProtocolServer, after(RETRY_INTERVAL.toMillis() * 
2).never()).start();
+      assertThat(logs.getOutput()).doesNotContain("still waiting for follower 
catch-up");
+
+      retryClock.set(31000L);
+      GenericTestUtils.waitFor(() -> 
logs.getOutput().contains("elapsed=30000ms"), 10, 2000);
+      assertThat(logs.getOutput()).containsPattern(
+          "attempts=[1-9][0-9]*, elapsed=30000ms, lastAppliedIndex=4, 
leaderCommitIndexOnStart=5");
+
+      retryClock.set(330999L);
+      verify(datanodeProtocolServer, after(RETRY_INTERVAL.toMillis() * 
2).never()).start();
+      assertThat(logs.getOutput()).containsOnlyOnce("still waiting for 
follower catch-up");
+
+      retryClock.set(331000L);
+      GenericTestUtils.waitFor(() -> 
logs.getOutput().contains("elapsed=330000ms"), 10, 2000);
+      assertThat(logs.getOutput()).containsOnlyOnce("elapsed=330000ms");
+
+      lastAppliedIndex.set(5L);
+      verify(datanodeProtocolServer, timeout(2000)).start();
+      GenericTestUtils.waitFor(stateMachine::isDNServerStartRetryTerminated, 
10, 2000);
+      logs.clearOutput();
+      retryClock.set(1000000L);
+      stateMachine.notifyLeaderChanged(memberId(), leaderId);
+      assertThat(logs.getOutput()).doesNotContain("still waiting for follower 
catch-up");
+    } finally {
+      logs.stopCapturing();
+    }
+  }
+
+  @Test
+  void testPendingRetryWarningIncludesUnavailableLeaderCommitIndex() throws 
Exception {
+    LogCapturer logs = LogCapturer.captureLogs(SCMStateMachine.class);
+    try {
+      stateMachine.notifyLeaderChanged(memberId(), leaderId);
+      retryClock.set(31000L);
+
+      GenericTestUtils.waitFor(() -> 
logs.getOutput().contains("elapsed=30000ms"), 10, 2000);
+      assertThat(logs.getOutput()).contains("lastAppliedIndex=5, 
leaderCommitIndexOnStart=-1");
+      verify(datanodeProtocolServer, never()).start();
+
+      scmStopped.set(true);
+      stateMachine.stopDNServerStartRetry();
+      logs.clearOutput();
+      retryClock.set(1000000L);
+      assertThat(stateMachine.isDNServerStartRetryTerminated()).isTrue();
+      assertThat(logs.getOutput()).doesNotContain("still waiting for follower 
catch-up");
+    } finally {
+      logs.stopCapturing();
+    }
+  }
+
+  @Test
+  void testBootstrapStateMachineSkipsSCMCallbacksAndRetryStop() throws 
Exception {
+    try (SCMStateMachine bootstrap = new SCMStateMachine()) {
+      bootstrap.notifyLeaderChanged(memberId(), leaderId);
+      bootstrap.notifyLeaderChanged(memberId(), followerId);
+      bootstrap.notifyTermIndexUpdated(1, 1);
+      bootstrap.notifyLeaderReady();
+      bootstrap.notifyNotLeader(Collections.emptyList());
+      bootstrap.stopDNServerStartRetry();
+      bootstrap.stopDNServerStartRetry();
+
+      assertThat(bootstrap.getIsStateMachineReady()).isFalse();
+      assertThat(bootstrap.getLatestSnapshot()).isNull();
+    }
+  }
+
+  @Test
+  void testAlreadyCaughtUpStartsDNServerExactlyOnce() throws Exception {
+    commitIndexAvailable.set(true);
+
+    stateMachine.notifyLeaderChanged(memberId(), leaderId);
+    stateMachine.notifyLeaderChanged(memberId(), leaderId);
+
+    verify(datanodeProtocolServer, times(1)).start();
+    verify(safeModeManager, times(1)).refreshAndValidate();
+    assertThat(stateMachine.getIsStateMachineReady()).isTrue();
+    GenericTestUtils.waitFor(stateMachine::isDNServerStartRetryTerminated, 10, 
2000);
+  }
+
+  @Test
+  void testStopPreventsPendingRetryFromStartingDNServer() {
+    stateMachine.notifyLeaderChanged(memberId(), leaderId);
+    scmStopped.set(true);
+
+    stateMachine.stopDNServerStartRetry();
+
+    assertThat(stateMachine.isDNServerStartRetryStopped()).isTrue();
+    commitIndexAvailable.set(true);
+    verify(datanodeProtocolServer, after(RETRY_INTERVAL.toMillis() * 
2).never()).start();
+  }
+
+  @Test
+  void testStopWaitsForRunningRetry() throws Exception {
+    CountDownLatch startEntered = new CountDownLatch(1);
+    CountDownLatch allowStart = new CountDownLatch(1);
+    doAnswer(invocation -> {
+      startEntered.countDown();
+      assertTrue(allowStart.await(2, TimeUnit.SECONDS));
+      return null;
+    }).when(datanodeProtocolServer).start();
+    stateMachine.notifyLeaderChanged(memberId(), leaderId);
+    commitIndexAvailable.set(true);
+    assertTrue(startEntered.await(2, TimeUnit.SECONDS));
+
+    scmStopped.set(true);
+    CompletableFuture<Void> stopFuture = 
CompletableFuture.runAsync(stateMachine::stopDNServerStartRetry);
+    assertThrows(TimeoutException.class, () -> stopFuture.get(100, 
TimeUnit.MILLISECONDS));
+
+    allowStart.countDown();
+    stopFuture.get(2, TimeUnit.SECONDS);
+    assertThat(stateMachine.isDNServerStartRetryTerminated()).isTrue();
+    verify(datanodeProtocolServer, times(1)).start();
+  }
+
+  private RaftGroupMemberId memberId() {
+    return RaftGroupMemberId.valueOf(followerId, RaftGroupId.randomId());
   }
 
   /**
@@ -85,11 +325,11 @@ public void testUpgradeExceptionDuringApplyTerminates() 
throws Exception {
       ScmInvoker<?> invoker = mock(ScmInvoker.class);
       when(invoker.invokeLocal(any(), any())).thenThrow(
           new 
UpgradeException(UpgradeException.ResultCodes.FINALIZE_UPGRADE_ACTION_FAILED));
-      SCMStateMachine stateMachine = newStateMachine(buffer, 
RequestType.FINALIZE, invoker);
+      SCMStateMachine applyingStateMachine = newStateMachine(buffer, 
RequestType.FINALIZE, invoker);
 
       // terminate throws ExitException when system exit is disabled
       assertThrows(ExitUtils.ExitException.class,
-          () -> 
stateMachine.applyTransaction(newTransaction(RequestType.FINALIZE, 1)));
+          () -> 
applyingStateMachine.applyTransaction(newTransaction(RequestType.FINALIZE, 1)));
     } finally {
       ExitUtils.clear();
     }
@@ -98,9 +338,9 @@ public void testUpgradeExceptionDuringApplyTerminates() 
throws Exception {
   @Test
   public void testApplyTransactionFlushesAfterRecordingTransactionInfo() 
throws Exception {
     SCMHADBTransactionBuffer buffer = mock(SCMHADBTransactionBuffer.class);
-    SCMStateMachine stateMachine = newStateMachine(buffer, 
RequestType.PIPELINE, succeedingInvoker());
+    SCMStateMachine applyingStateMachine = newStateMachine(buffer, 
RequestType.PIPELINE, succeedingInvoker());
 
-    CompletableFuture<Message> result = 
stateMachine.applyTransaction(newTransaction(RequestType.PIPELINE, 7));
+    CompletableFuture<Message> result = 
applyingStateMachine.applyTransaction(newTransaction(RequestType.PIPELINE, 7));
 
     assertTrue(result.isDone() && !result.isCompletedExceptionally());
     InOrder order = inOrder(buffer);
@@ -113,10 +353,10 @@ public void 
testApplyTransactionFlushesAfterRecordingTransactionInfo() throws Ex
   @Test
   public void testFlushCheckedForEveryAppliedTransaction() throws Exception {
     SCMHADBTransactionBuffer buffer = mock(SCMHADBTransactionBuffer.class);
-    SCMStateMachine stateMachine = newStateMachine(buffer, 
RequestType.PIPELINE, succeedingInvoker());
+    SCMStateMachine applyingStateMachine = newStateMachine(buffer, 
RequestType.PIPELINE, succeedingInvoker());
 
     for (int i = 1; i <= 5; i++) {
-      stateMachine.applyTransaction(newTransaction(RequestType.PIPELINE, i));
+      
applyingStateMachine.applyTransaction(newTransaction(RequestType.PIPELINE, i));
     }
 
     verify(buffer, times(5)).flushIfPendingLimitReached();
@@ -129,9 +369,9 @@ public void 
testFlushStillCheckedWhenTransactionIsLogicallyRejected() throws Exc
     ScmInvoker<?> invoker = mock(ScmInvoker.class);
     when(invoker.invokeLocal(anyString(), any()))
         .thenThrow(new SCMException("rejected", 
ResultCodes.FAILED_TO_FIND_CONTAINER));
-    SCMStateMachine stateMachine = newStateMachine(buffer, 
RequestType.PIPELINE, invoker);
+    SCMStateMachine applyingStateMachine = newStateMachine(buffer, 
RequestType.PIPELINE, invoker);
 
-    CompletableFuture<Message> result = 
stateMachine.applyTransaction(newTransaction(RequestType.PIPELINE, 3));
+    CompletableFuture<Message> result = 
applyingStateMachine.applyTransaction(newTransaction(RequestType.PIPELINE, 3));
 
     assertTrue(result.isCompletedExceptionally());
     InOrder order = inOrder(buffer);
@@ -146,10 +386,10 @@ public void 
testFlushFailureTerminatesAndClosesApplyingWindow() throws Exception
     try {
       SCMHADBTransactionBuffer buffer = mock(SCMHADBTransactionBuffer.class);
       doThrow(new IllegalStateException("injected flush 
failure")).when(buffer).flushIfPendingLimitReached();
-      SCMStateMachine stateMachine = newStateMachine(buffer, 
RequestType.PIPELINE, succeedingInvoker());
+      SCMStateMachine applyingStateMachine = newStateMachine(buffer, 
RequestType.PIPELINE, succeedingInvoker());
 
       assertThrows(ExitUtils.ExitException.class,
-          () -> 
stateMachine.applyTransaction(newTransaction(RequestType.PIPELINE, 9)));
+          () -> 
applyingStateMachine.applyTransaction(newTransaction(RequestType.PIPELINE, 9)));
 
       verify(buffer).endApplyingTransaction();
     } finally {
diff --git 
a/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/hdds/scm/TestSCMFollowerCatchupWithContainerReport.java
 
b/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/hdds/scm/TestSCMFollowerCatchupWithContainerReport.java
index 8ab1f6ae8ed..faf602ee3e8 100644
--- 
a/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/hdds/scm/TestSCMFollowerCatchupWithContainerReport.java
+++ 
b/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/hdds/scm/TestSCMFollowerCatchupWithContainerReport.java
@@ -223,10 +223,10 @@ void testFollowerCatchupAfterContainerCreate() throws 
Exception {
   }
 
   /**
-   * Edge case for removing the background polling loop: on an otherwise idle
+   * Edge case for the fallback retry: on an otherwise idle
    * cluster a restarted follower must still start its datanode server (exit 
safe
-   * mode) and serve replicas after promotion, driven by Ratis heartbeats /
-   * notifyLeaderChanged rather than a steady stream of new transactions.
+   * mode) and serve replicas after promotion without a steady stream of new
+   * transactions.
    */
   @Test
   void testFollowerCatchupOnIdleCluster() throws Exception {


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

Reply via email to