You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@helix.apache.org by jx...@apache.org on 2026/10/19 22:55:55 UTC

(helix) branch master updated: Rewrite the timeout logic to stablize the tests

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

asf-gitbox-commits pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/helix.git


The following commit(s) were added to refs/heads/master by this push:
     new c57a5bc5c Rewrite the timeout logic to stablize the tests
c57a5bc5c is described below

commit c57a5bc5ccf4b721dce1f973ff1583fafae2df2b
Author: Junkai Xue <jx...@apache.org>
AuthorDate: Mon Oct 19 15:55:47 2026 -0700

    Rewrite the timeout logic to stablize the tests
---
 .../manager/TestConsecutiveZkSessionExpiry.java    |  22 ++-
 .../paticipant/TestStateTransitionTimeout.java     |  46 ++++---
 .../TestStateTransitionTimeoutWithResource.java    |  45 +++++--
 .../rebalancer/TestInstanceOperation.java          | 149 +++++++++++----------
 4 files changed, 158 insertions(+), 104 deletions(-)

diff --git a/helix-core/src/test/java/org/apache/helix/integration/manager/TestConsecutiveZkSessionExpiry.java b/helix-core/src/test/java/org/apache/helix/integration/manager/TestConsecutiveZkSessionExpiry.java
index 6d500e815..0254073b7 100644
--- a/helix-core/src/test/java/org/apache/helix/integration/manager/TestConsecutiveZkSessionExpiry.java
+++ b/helix-core/src/test/java/org/apache/helix/integration/manager/TestConsecutiveZkSessionExpiry.java
@@ -121,7 +121,8 @@ public class TestConsecutiveZkSessionExpiry extends ZkUnitTestBase {
     boolean result =
         ClusterStateVerifier
             .verifyByZkCallback(
-                new ClusterStateVerifier.BestPossAndExtViewZkVerifier(ZK_ADDR, clusterName));
+                new ClusterStateVerifier.BestPossAndExtViewZkVerifier(ZK_ADDR, clusterName),
+                TestHelper.WAIT_DURATION);
     Assert.assertTrue(result);
 
     // expire the session of participant
@@ -145,9 +146,22 @@ public class TestConsecutiveZkSessionExpiry extends ZkUnitTestBase {
 
     endCountdown.countDown();
 
+    // Wait for the participant to fully reconnect with a new session before verifying cluster
+    // state. Recovering from consecutive session expiries (re-joining the cluster and carrying
+    // over the current states) can take a while on overloaded machines, and the default 30s
+    // verifier window was not enough.
+    final MockParticipantManager expiredParticipant = participants[0];
+    final String expiredSessionId = oldSessionId;
+    Assert.assertTrue(TestHelper.verify(() -> {
+      String currentSessionId = expiredParticipant.getSessionId();
+      return expiredParticipant.isConnected() && currentSessionId != null
+          && !currentSessionId.equals(expiredSessionId);
+    }, TestHelper.WAIT_DURATION),
+        "Participant should reconnect with a new session after consecutive expiries");
+
     result =
         ClusterStateVerifier.verifyByPolling(new ClusterStateVerifier.BestPossAndExtViewZkVerifier(
-            ZK_ADDR, clusterName));
+            ZK_ADDR, clusterName), 2 * TestHelper.WAIT_DURATION);
     Assert.assertTrue(result);
 
     // clean up
@@ -198,7 +212,7 @@ public class TestConsecutiveZkSessionExpiry extends ZkUnitTestBase {
 
     boolean result =
         ClusterStateVerifier.verifyByZkCallback(new BestPossAndExtViewZkVerifier(ZK_ADDR,
-            clusterName));
+            clusterName), TestHelper.WAIT_DURATION);
     Assert.assertTrue(result);
 
     // expire the session of distributedController
@@ -256,7 +270,7 @@ public class TestConsecutiveZkSessionExpiry extends ZkUnitTestBase {
     // This is needed because handler cleanup is async and may take time in CI
     List<CallbackHandler> handlers = null;
     int expectedHandlerCount = 1;
-    int maxWaitTime = 30000;
+    long maxWaitTime = TestHelper.WAIT_DURATION;
     int pollInterval = 500;
     long startTime = System.currentTimeMillis();
     while (System.currentTimeMillis() - startTime < maxWaitTime) {
diff --git a/helix-core/src/test/java/org/apache/helix/integration/paticipant/TestStateTransitionTimeout.java b/helix-core/src/test/java/org/apache/helix/integration/paticipant/TestStateTransitionTimeout.java
index 8af861f19..7ff8baf22 100644
--- a/helix-core/src/test/java/org/apache/helix/integration/paticipant/TestStateTransitionTimeout.java
+++ b/helix-core/src/test/java/org/apache/helix/integration/paticipant/TestStateTransitionTimeout.java
@@ -54,6 +54,9 @@ import org.testng.annotations.BeforeClass;
 import org.testng.annotations.Test;
 
 public class TestStateTransitionTimeout extends ZkStandAloneCMTestBase {
+  // See beforeClass for why the transition timeout must not be too short.
+  private static final int TRANSITION_TIMEOUT_MS = 3000;
+  private static final int HANDLER_SLEEP_MS = 60000;
   private static Logger LOG = LoggerFactory.getLogger(TestStateTransitionTimeout.class);
 
   @Override
@@ -71,15 +74,17 @@ public class TestStateTransitionTimeout extends ZkStandAloneCMTestBase {
     }
     _gSetupTool.rebalanceStorageCluster(CLUSTER_NAME, TEST_DB, 3);
 
-    // Set the timeout values
-    IdealState idealState =
-        _gSetupTool.getClusterManagementTool().getResourceIdealState(CLUSTER_NAME, TEST_DB);
+    // Set the timeout value. It must be comfortably longer than the time it takes the participant
+    // to merely start executing a state transition task: HelixTaskExecutor arms the timeout timer
+    // right after submitting the task to its thread pool, and if the timer fires before the task
+    // has started, the task is cancelled without ever running, so no ERROR state is produced and
+    // the message is never cleaned up. With a 200ms timeout that happened regularly on slow CI
+    // machines (thread creation, GC pauses, ZK latency). The handler sleep in the test is much
+    // longer than this timeout, so the timeout still always fires before the transition finishes.
     String stateTransition = "SLAVE" + "-" + "MASTER" + "_" + Message.Attributes.TIMEOUT;
-    idealState.getRecord().setSimpleField(stateTransition, "300");
-
     String command =
         "-zkSvr " + ZK_ADDR + " -addResourceProperty " + CLUSTER_NAME + " " + TEST_DB + " "
-            + stateTransition + " 200";
+            + stateTransition + " " + TRANSITION_TIMEOUT_MS;
     ClusterSetup.processCommandLineArgs(command.split(" "));
   }
 
@@ -87,13 +92,20 @@ public class TestStateTransitionTimeout extends ZkStandAloneCMTestBase {
       "MASTER", "SLAVE", "ERROR"
   })
   public static class TimeOutStateModel extends MockMSStateModel {
+    // Only the SLAVE->MASTER transition of the selected partitions sleeps. The transition is kept
+    // out of MockMSStateModel's generic handler on purpose: passing it to the super constructor
+    // would make every transition (including the OFFLINE->SLAVE bootstrap of all partitions on
+    // all nodes) sleep for the full duration, which is not what the test exercises and made the
+    // test take a full sleep per stage.
+    private final MockTransition _sleepTransition;
     boolean _sleep = false;
     // Written by the message handling thread and read by the test thread.
     volatile StateTransitionError _error;
     volatile int _errorCallcount = 0;
 
     public TimeOutStateModel(MockTransition transition, boolean sleep) {
-      super(transition);
+      super(null);
+      _sleepTransition = transition;
       _sleep = sleep;
     }
 
@@ -101,8 +113,8 @@ public class TestStateTransitionTimeout extends ZkStandAloneCMTestBase {
     public void onBecomeMasterFromSlave(Message message, NotificationContext context)
         throws InterruptedException {
       LOG.info("Become MASTER from SLAVE");
-      if (_transition != null && _sleep) {
-        _transition.doTransition(message, context);
+      if (_sleepTransition != null && _sleep) {
+        _sleepTransition.doTransition(message, context);
       }
     }
 
@@ -144,11 +156,11 @@ public class TestStateTransitionTimeout extends ZkStandAloneCMTestBase {
         _gSetupTool.getClusterManagementTool().getResourceIdealState(CLUSTER_NAME, TEST_DB);
     for (int i = 0; i < NODE_NR; i++) {
       String instanceName = PARTICIPANT_PREFIX + "_" + (START_PORT + i);
-      // Sleep much longer than the transition timeout (200ms) so the timeout always fires
-      // before the transition can finish, even if the timeout timer is delayed on a slow
-      // machine. The sleep is interrupted once the timeout fires, so a larger value does not
-      // slow down the timed-out transitions.
-      SleepStateModelFactory factory = new SleepStateModelFactory(5000);
+      // Sleep much longer than the transition timeout so the timeout always fires before the
+      // transition can finish, even if the timeout timer is delayed on a slow machine. The sleep
+      // is interrupted once the timeout fires, so a larger value does not slow down the timed-out
+      // transitions.
+      SleepStateModelFactory factory = new SleepStateModelFactory(HANDLER_SLEEP_MS);
       factories.put(instanceName, factory);
       for (String p : idealState.getPartitionSet()) {
         if (idealState.getPreferenceList(p).get(0).equals(instanceName)) {
@@ -167,13 +179,15 @@ public class TestStateTransitionTimeout extends ZkStandAloneCMTestBase {
 
     boolean result =
         ClusterStateVerifier
-            .verifyByPolling(new MasterNbInExtViewVerifier(ZK_ADDR, CLUSTER_NAME), 60 * 1000);
+            .verifyByPolling(new MasterNbInExtViewVerifier(ZK_ADDR, CLUSTER_NAME),
+                2 * TestHelper.WAIT_DURATION);
     Assert.assertTrue(result);
     HelixDataAccessor accessor = _participants[0].getHelixDataAccessor();
 
     // Keep polling for the expected end state and assert on the polling result, so the test
     // tolerates slow controller pipelines / message handling on overloaded machines.
-    Assert.assertTrue(TestHelper.verify(() -> verify(accessor, idealState, factories), 60 * 1000));
+    Assert.assertTrue(TestHelper.verify(() -> verify(accessor, idealState, factories),
+        2 * TestHelper.WAIT_DURATION));
   }
 
   private boolean verify(HelixDataAccessor accessor, IdealState idealState,
diff --git a/helix-core/src/test/java/org/apache/helix/integration/paticipant/TestStateTransitionTimeoutWithResource.java b/helix-core/src/test/java/org/apache/helix/integration/paticipant/TestStateTransitionTimeoutWithResource.java
index 6d8c849eb..3d1d45332 100644
--- a/helix-core/src/test/java/org/apache/helix/integration/paticipant/TestStateTransitionTimeoutWithResource.java
+++ b/helix-core/src/test/java/org/apache/helix/integration/paticipant/TestStateTransitionTimeoutWithResource.java
@@ -60,6 +60,14 @@ import org.testng.annotations.BeforeClass;
 import org.testng.annotations.Test;
 
 public class TestStateTransitionTimeoutWithResource extends ZkStandAloneCMTestBase {
+  // The transition timeout must be comfortably longer than the time it takes the participant to
+  // merely start executing a state transition task: HelixTaskExecutor arms the timeout timer right
+  // after submitting the task to its thread pool, and if the timer fires before the task has
+  // started, the task is cancelled without ever running, so no ERROR state is produced and the
+  // message is never cleaned up. With a 300ms timeout that happened regularly on slow CI machines.
+  // The handler sleep is much longer than the timeout, so the timeout still always fires first.
+  private static final int TRANSITION_TIMEOUT_MS = 3000;
+  private static final int HANDLER_SLEEP_MS = 60000;
   private static Logger LOG = LoggerFactory.getLogger(TestStateTransitionTimeout.class);
   private Map<String, SleepStateModelFactory> _factories;
   private ConfigAccessor _configAccessor;
@@ -97,13 +105,20 @@ public class TestStateTransitionTimeoutWithResource extends ZkStandAloneCMTestBa
       "MASTER", "SLAVE", "ERROR"
   })
   public static class TimeOutStateModel extends MockMSStateModel {
+    // Only the SLAVE->MASTER transition of the selected partitions sleeps. The transition is kept
+    // out of MockMSStateModel's generic handler on purpose: passing it to the super constructor
+    // would make every transition (including the OFFLINE->SLAVE bootstrap of all partitions on
+    // all nodes) sleep for the full duration, which is not what the test exercises and made the
+    // test take a full sleep per stage.
+    private final MockTransition _sleepTransition;
     boolean _sleep = false;
     // Written by the message handling thread and read by the test thread.
     volatile StateTransitionError _error;
     volatile int _errorCallcount = 0;
 
     public TimeOutStateModel(MockTransition transition, boolean sleep) {
-      super(transition);
+      super(null);
+      _sleepTransition = transition;
       _sleep = sleep;
     }
 
@@ -111,8 +126,8 @@ public class TestStateTransitionTimeoutWithResource extends ZkStandAloneCMTestBa
     public void onBecomeMasterFromSlave(Message message, NotificationContext context)
         throws InterruptedException {
       LOG.info("Become MASTER from SLAVE");
-      if (_transition != null && _sleep) {
-        _transition.doTransition(message, context);
+      if (_sleepTransition != null && _sleep) {
+        _sleepTransition.doTransition(message, context);
       }
     }
 
@@ -156,7 +171,7 @@ public class TestStateTransitionTimeoutWithResource extends ZkStandAloneCMTestBa
     // Set the timeout values
     StateTransitionTimeoutConfig stateTransitionTimeoutConfig =
         new StateTransitionTimeoutConfig(new ZNRecord(TEST_DB));
-    stateTransitionTimeoutConfig.setStateTransitionTimeout("SLAVE", "MASTER", 300);
+    stateTransitionTimeoutConfig.setStateTransitionTimeout("SLAVE", "MASTER", TRANSITION_TIMEOUT_MS);
     ResourceConfig resourceConfig = new ResourceConfig.Builder(TEST_DB)
         .setStateTransitionTimeoutConfig(stateTransitionTimeoutConfig)
         .setRebalanceConfig(new RebalanceConfig(new ZNRecord(TEST_DB)))
@@ -168,12 +183,13 @@ public class TestStateTransitionTimeoutWithResource extends ZkStandAloneCMTestBa
     _gSetupTool.getClusterManagementTool().enableResource(CLUSTER_NAME, TEST_DB, true);
     boolean result =
         ClusterStateVerifier
-            .verifyByPolling(new MasterNbInExtViewVerifier(ZK_ADDR, CLUSTER_NAME), 60 * 1000);
+            .verifyByPolling(new MasterNbInExtViewVerifier(ZK_ADDR, CLUSTER_NAME),
+                2 * TestHelper.WAIT_DURATION);
     Assert.assertTrue(result);
 
     // Keep polling for the expected end state and assert on the polling result, so the test
     // tolerates slow controller pipelines / message handling on overloaded machines.
-    Assert.assertTrue(TestHelper.verify(() -> verify(TEST_DB), 60 * 1000));
+    Assert.assertTrue(TestHelper.verify(() -> verify(TEST_DB), 2 * TestHelper.WAIT_DURATION));
   }
 
   @Test
@@ -184,7 +200,7 @@ public class TestStateTransitionTimeoutWithResource extends ZkStandAloneCMTestBa
 
     StateTransitionTimeoutConfig stateTransitionTimeoutConfig =
         new StateTransitionTimeoutConfig(new ZNRecord(TEST_DB + 1));
-    stateTransitionTimeoutConfig.setStateTransitionTimeout("SLAVE", "MASTER", 300);
+    stateTransitionTimeoutConfig.setStateTransitionTimeout("SLAVE", "MASTER", TRANSITION_TIMEOUT_MS);
     ClusterConfig clusterConfig = _configAccessor.getClusterConfig(CLUSTER_NAME);
     clusterConfig.setStateTransitionTimeoutConfig(stateTransitionTimeoutConfig);
     _configAccessor.setClusterConfig(CLUSTER_NAME, clusterConfig);
@@ -194,12 +210,13 @@ public class TestStateTransitionTimeoutWithResource extends ZkStandAloneCMTestBa
     _gSetupTool.getClusterManagementTool().enableResource(CLUSTER_NAME, TEST_DB + 1, true);
     boolean result =
         ClusterStateVerifier
-            .verifyByPolling(new MasterNbInExtViewVerifier(ZK_ADDR, CLUSTER_NAME), 60 * 1000);
+            .verifyByPolling(new MasterNbInExtViewVerifier(ZK_ADDR, CLUSTER_NAME),
+                2 * TestHelper.WAIT_DURATION);
     Assert.assertTrue(result);
 
     // Keep polling for the expected end state and assert on the polling result, so the test
     // tolerates slow controller pipelines / message handling on overloaded machines.
-    Assert.assertTrue(TestHelper.verify(() -> verify(TEST_DB + 1), 60 * 1000));
+    Assert.assertTrue(TestHelper.verify(() -> verify(TEST_DB + 1), 2 * TestHelper.WAIT_DURATION));
   }
 
   private boolean verify(String dbName) {
@@ -239,11 +256,11 @@ public class TestStateTransitionTimeoutWithResource extends ZkStandAloneCMTestBa
       }
       Thread.sleep(1000);
       String instanceName = PARTICIPANT_PREFIX + "_" + (START_PORT + i);
-      // Sleep much longer than the transition timeout (300ms) so the timeout always fires
-      // before the transition can finish, even if the timeout timer is delayed on a slow
-      // machine. The sleep is interrupted once the timeout fires, so a larger value does not
-      // slow down the timed-out transitions.
-      SleepStateModelFactory factory = new SleepStateModelFactory(5000);
+      // Sleep much longer than the transition timeout so the timeout always fires before the
+      // transition can finish, even if the timeout timer is delayed on a slow machine. The sleep
+      // is interrupted once the timeout fires, so a larger value does not slow down the timed-out
+      // transitions.
+      SleepStateModelFactory factory = new SleepStateModelFactory(HANDLER_SLEEP_MS);
       _factories.put(instanceName, factory);
       for (String p : idealState.getPartitionSet()) {
         if (idealState.getPreferenceList(p).get(0).equals(instanceName)) {
diff --git a/helix-core/src/test/java/org/apache/helix/integration/rebalancer/TestInstanceOperation.java b/helix-core/src/test/java/org/apache/helix/integration/rebalancer/TestInstanceOperation.java
index e06c3a947..35f874958 100644
--- a/helix-core/src/test/java/org/apache/helix/integration/rebalancer/TestInstanceOperation.java
+++ b/helix-core/src/test/java/org/apache/helix/integration/rebalancer/TestInstanceOperation.java
@@ -1,7 +1,6 @@
 package org.apache.helix.integration.rebalancer;
 
 import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Date;
@@ -134,7 +133,8 @@ public class TestInstanceOperation extends ZkTestBase {
   private ZkHelixClusterVerifier _clusterVerifier;
   private BestPossibleExternalViewVerifier _bestPossibleClusterVerifier;
   private ConfigAccessor _configAccessor;
-  private long _stateModelDelay = 3L;
+  // Written by the test thread and read by the participants' state transition threads.
+  private volatile long _stateModelDelay = 3L;
   private final long DEFAULT_RESOURCE_DELAY_TIME = 1800000L;
   private HelixAdmin _admin;
   protected AssignmentMetadataStore _assignmentMetadataStore;
@@ -596,8 +596,8 @@ public class TestInstanceOperation extends ZkTestBase {
         "Swap should be completable");
 
     // Verify routing tables - swap-out in, swap-in out
-    validateRoutingTablesInstance(getEVs(), swapOutName, true);
-    validateRoutingTablesInstance(getEVs(), swapInName, false);
+    validateRoutingTablesInstance(swapOutName, true);
+    validateRoutingTablesInstance(swapInName, false);
 
     // Complete the swap
     Assert.assertTrue(_gSetupTool.getClusterManagementTool()
@@ -609,7 +609,7 @@ public class TestInstanceOperation extends ZkTestBase {
     waitForSwapProcessed(originalEVs, swapOutName, swapInName);
 
     // Verify swap-in is now in routing tables
-    validateRoutingTablesInstance(getEVs(), swapInName, true);
+    validateRoutingTablesInstance(swapInName, true);
 
     // Verify swap-out is disabled with UNKNOWN operation
     InstanceConfig swapOutInstanceConfig = _gSetupTool.getClusterManagementTool()
@@ -862,8 +862,8 @@ public class TestInstanceOperation extends ZkTestBase {
         ImmutableSet.of(swapInName), Collections.emptySet());
 
     // Verify routing tables
-    validateRoutingTablesInstance(getEVs(), swapOutName, true);
-    validateRoutingTablesInstance(getEVs(), swapInName, false);
+    validateRoutingTablesInstance(swapOutName, true);
+    validateRoutingTablesInstance(swapInName, false);
 
     // Complete swap
     Assert.assertTrue(_gSetupTool.getClusterManagementTool()
@@ -876,7 +876,7 @@ public class TestInstanceOperation extends ZkTestBase {
     Assert.assertTrue(_clusterVerifier.verifyByPolling());
 
     // Verify swap-in in routing tables
-    validateRoutingTablesInstance(getEVs(), swapInName, true);
+    validateRoutingTablesInstance(swapInName, true);
 
     // Verify final EV
     verifier(() -> validateEVsCorrect(getEVs(), originalEVs, swapOutToSwapIn,
@@ -954,7 +954,6 @@ public class TestInstanceOperation extends ZkTestBase {
   public void testSwapWithSwapOutGoesOffline() throws Exception {
     LOG.info("START testSwapWithSwapOutGoesOffline");
 
-    Map<String, String> swapOutToSwapIn = new HashMap<>();
     Map<String, ExternalView> originalEVs = getEVs();
 
     String swapOutName = _participants.get(0).getInstanceName();
@@ -966,7 +965,6 @@ public class TestInstanceOperation extends ZkTestBase {
     // Add swap-in
     String swapInName = PARTICIPANT_PREFIX + "_" + _nextStartPort;
     LOG.info("Adding swap-in instance: {}", swapInName);
-    swapOutToSwapIn.put(swapOutName, swapInName);
     addParticipant(swapInName, swapOutConfig.getLogicalId(LOGICAL_ID),
         swapOutConfig.getDomainAsMap().get(ZONE),
         InstanceConstants.InstanceOperation.SWAP_IN, -1);
@@ -987,26 +985,30 @@ public class TestInstanceOperation extends ZkTestBase {
 
     Assert.assertTrue(_bestPossibleClusterVerifier.verifyByPolling());
 
-    // Can complete swap
-    boolean canComplete = _gSetupTool.getClusterManagementTool()
-        .canCompleteSwap(CLUSTER_NAME, swapOutName);
-    LOG.info("canCompleteSwap result: {}", canComplete);
-    Assert.assertTrue(canComplete, "Should be able to complete swap");
-
-    // Complete swap
-    boolean completed = _gSetupTool.getClusterManagementTool()
-        .completeSwapIfPossible(CLUSTER_NAME, swapOutName, false);
-    LOG.info("completeSwapIfPossible result: {}", completed);
-    Assert.assertTrue(completed, "Swap should complete");
+    // Can complete swap. canCompleteSwap also requires that the swap-in has no pending messages,
+    // and a participant deletes a message only after it has written its current state, so the
+    // best-possible verifier above can pass a few milliseconds before the last message is gone.
+    // Poll instead of asserting a one-shot read.
+    Assert.assertTrue(TestHelper.verify(() -> _gSetupTool.getClusterManagementTool()
+        .canCompleteSwap(CLUSTER_NAME, swapOutName), VERIFICATION_TIMEOUT),
+        "Should be able to complete swap");
 
-    // Wait for swap to be fully processed and EV to stabilize
-    // After completeSwapIfPossible, the controller needs time to update the EV
-    waitForSwapProcessed(originalEVs, swapOutName, swapInName);
+    // Complete swap. completeSwapIfPossible re-checks the same preconditions and returns false
+    // without side effects when they do not hold, so it is safe to retry.
+    Assert.assertTrue(TestHelper.verify(() -> _gSetupTool.getClusterManagementTool()
+        .completeSwapIfPossible(CLUSTER_NAME, swapOutName, false), VERIFICATION_TIMEOUT),
+        "Swap should complete");
 
-    // Verify final EV first so the routing tables below are validated against a stable,
-    // converged EV snapshot - use extended timeout for CI stability
-    verifier(() -> validateEVsCorrect(getEVs(), originalEVs, swapOutToSwapIn,
-        Collections.emptySet(), ImmutableSet.of(swapInName)), VERIFICATION_TIMEOUT * 2);
+    // After completeSwapIfPossible the swap-in is a regular ENABLE instance and the swap-out is
+    // UNKNOWN, so the pair is no longer tracked as a swap and the rebalancer recomputes the
+    // assignment from scratch. That recomputation does not have to reproduce the swap-out's exact
+    // assignment: on CI this test repeatedly failed because the swap-in ended up LEADER where the
+    // swap-out had been STANDBY, or membership shifted on partitions not involving the pair.
+    // Verify what the product guarantees instead: the offline swap-out holds nothing, every
+    // partition is fully replicated with one leader, and the swap-in hosts replicas.
+    Assert.assertTrue(_clusterVerifier.verifyByPolling());
+    verifier(() -> validateReplacementAssignment(getEVs(), originalEVs, swapOutName, swapInName),
+        VERIFICATION_TIMEOUT * 2);
 
     // Verify swap-out is disabled
     boolean isEnabled = _gSetupTool.getClusterManagementTool()
@@ -1015,7 +1017,7 @@ public class TestInstanceOperation extends ZkTestBase {
     Assert.assertFalse(isEnabled, "Swap-out should be disabled");
 
     // Verify swap-in in routing tables
-    validateRoutingTablesInstance(getEVs(), swapInName, true, VERIFICATION_TIMEOUT * 2);
+    validateRoutingTablesInstance(swapInName, true, VERIFICATION_TIMEOUT * 2);
 
     LOG.info("END testSwapWithSwapOutGoesOffline");
   }
@@ -1077,7 +1079,7 @@ public class TestInstanceOperation extends ZkTestBase {
     // distribution rounds and preference-list order can legitimately differ once the node set
     // changes (observed on CI as leaders moving on unrelated partitions). Only the SWAP_IN flow
     // (see the testSwap* tests) mirrors the swap-out assignment onto the swap-in instance.
-    verifier(() -> validateEvacuateReplacement(getEVs(), originalEVs, swapOutName, swapInName),
+    verifier(() -> validateReplacementAssignment(getEVs(), originalEVs, swapOutName, swapInName),
         VERIFICATION_TIMEOUT * 2);
 
     // Verify evacuation finished - use verifier to handle timing variations
@@ -1134,7 +1136,7 @@ public class TestInstanceOperation extends ZkTestBase {
 
     // Final validation: the evacuated instance (now UNKNOWN) still holds nothing, every partition
     // is fully replicated and the new instance keeps its load.
-    verifier(() -> validateEvacuateReplacement(getEVs(), originalEVs, swapOutName, swapInName),
+    verifier(() -> validateReplacementAssignment(getEVs(), originalEVs, swapOutName, swapInName),
         VERIFICATION_TIMEOUT * 2);
 
     LOG.info("END testEvacuateWithAddDuringMaintenanceMode");
@@ -1435,11 +1437,15 @@ public class TestInstanceOperation extends ZkTestBase {
     // Let some state transitions start
     Thread.sleep(Math.abs(_stateModelDelay / 100));
 
-    // Check EV before cancel
-    Map<String, ExternalView> assignment = getEVs();
-    for (String resource : _allDBs) {
-      validateAssignmentInEv(assignment.get(resource));
-    }
+    // Check EV before cancel. The EV is being rewritten while the evacuation is in flight, so poll
+    // instead of asserting a single snapshot.
+    verifier(() -> {
+      Map<String, ExternalView> evs = getEVs();
+      for (String resource : _allDBs) {
+        validateAssignmentInEv(evs.get(resource));
+      }
+      return true;
+    }, VERIFICATION_TIMEOUT);
 
     // Cancel evacuation
     _gSetupTool.getClusterManagementTool()
@@ -1451,16 +1457,19 @@ public class TestInstanceOperation extends ZkTestBase {
     _stateModelDelay = 3L;
 
     // Verify all instances still have valid assignments
-    assignment = getEVs();
-    for (String resource : _allDBs) {
-      validateAssignmentInEv(assignment.get(resource));
-    }
+    verifier(() -> {
+      Map<String, ExternalView> evs = getEVs();
+      for (String resource : _allDBs) {
+        validateAssignmentInEv(evs.get(resource));
+      }
+      return true;
+    }, VERIFICATION_TIMEOUT);
 
     // Cluster should converge
     Assert.assertTrue(_clusterVerifier.verifyByPolling());
 
     // All participants should be back
-    assignment = getEVs();
+    Map<String, ExternalView> assignment = getEVs();
     for (String resource : _allDBs) {
       Assert.assertTrue(
           getParticipantsInEv(assignment.get(resource)).containsAll(_participantNames),
@@ -1958,29 +1967,26 @@ public class TestInstanceOperation extends ZkTestBase {
         .collect(Collectors.toSet());
   }
 
-  private void validateRoutingTablesInstance(Map<String, ExternalView> evs,
-      String instanceName, boolean shouldContain) throws Exception {
-    validateRoutingTablesInstance(evs, instanceName, shouldContain, VERIFICATION_TIMEOUT);
+  private void validateRoutingTablesInstance(String instanceName, boolean shouldContain)
+      throws Exception {
+    validateRoutingTablesInstance(instanceName, shouldContain, VERIFICATION_TIMEOUT);
   }
 
-  private void validateRoutingTablesInstance(Map<String, ExternalView> evs,
-      String instanceName, boolean shouldContain, long timeout) throws Exception {
+  /**
+   * Verifies that the routing table providers agree with the external views about the given
+   * instance. The external views are re-read on every poll: comparing the providers against a
+   * single EV snapshot taken before polling made the wait unsatisfiable whenever the EV changed
+   * after the snapshot (routing tables can only ever converge to the current EV).
+   */
+  private void validateRoutingTablesInstance(String instanceName, boolean shouldContain,
+      long timeout) throws Exception {
     RoutingTableProvider[] routingTableProviders = {
         _routingTableProviderDefault, _routingTableProviderEV, _routingTableProviderCS};
 
-    Map<String, Map<String, String>> resourcePartitionState =
-        getResourcePartitionStateOnInstance(evs, instanceName);
-
-    if (resourcePartitionState.isEmpty()) {
-      Arrays.stream(routingTableProviders).forEach(rtp -> {
-        Assert.assertEquals(getInstanceNames(rtp.getInstanceConfigs()).contains(instanceName),
-            shouldContain);
-      });
-      return;
-    }
-
     verifier(() -> {
       try {
+        Map<String, Map<String, String>> resourcePartitionState =
+            getResourcePartitionStateOnInstance(getEVs(), instanceName);
         for (String resource : resourcePartitionState.keySet()) {
           Map<String, String> partitions = resourcePartitionState.get(resource);
           for (String partition : partitions.keySet()) {
@@ -2117,17 +2123,20 @@ public class TestInstanceOperation extends ZkTestBase {
   }
 
   /**
-   * Validates the external views after an instance was evacuated and a replacement instance was
-   * added with ENABLE (not SWAP_IN). Unlike {@link #validateEVsCorrect}, this does not expect an
-   * exact in-place copy of the evacuated instance's assignment, because an ENABLE instance is not
-   * paired with the evacuated one by the controller and the rebalancer may legitimately pick a
-   * different (still valid) assignment and preference-list order. It checks what EVACUATE does
-   * guarantee: the evacuated instance holds no replicas, every original partition still has a
-   * full set of healthy replicas with exactly one top-state replica, and the replacement instance
-   * has taken on load.
+   * Validates the external views after an instance has been fully replaced by another instance
+   * that is now a regular ENABLE node: either an evacuated instance plus a new ENABLE instance, or
+   * a swap-out/swap-in pair after the swap has been completed. Unlike {@link #validateEVsCorrect},
+   * this does not expect an exact in-place copy of the removed instance's assignment. Once the
+   * swap-in is no longer SWAP_IN, the controller does not pair the two instances anymore; the
+   * replacement is just another assignable node and the rebalancer may legitimately pick a
+   * different (still valid) assignment and preference-list order, so leaders and even membership
+   * can move on partitions unrelated to the pair. The exact mirror is only guaranteed while a
+   * SWAP_IN swap is in flight. This checks what the product does guarantee: the removed instance
+   * holds no replicas, every original partition still has a full set of healthy replicas with
+   * exactly one top-state replica, and the replacement instance has taken on load.
    */
-  private boolean validateEvacuateReplacement(Map<String, ExternalView> actuals,
-      Map<String, ExternalView> originals, String evacuatedInstance, String replacementInstance) {
+  private boolean validateReplacementAssignment(Map<String, ExternalView> actuals,
+      Map<String, ExternalView> originals, String removedInstance, String replacementInstance) {
     Assert.assertEquals(actuals.keySet(), originals.keySet(), "Resource key sets should match");
 
     boolean replacementHasReplicas = false;
@@ -2148,9 +2157,9 @@ public class TestInstanceOperation extends ZkTestBase {
         Map<String, String> stateMap = actual.getStateMap(partition);
         Assert.assertNotNull(stateMap,
             String.format("Partition %s in resource %s has no state map", partition, resource));
-        Assert.assertFalse(stateMap.containsKey(evacuatedInstance),
-            String.format("Evacuated instance %s still hosts partition %s in resource %s: %s",
-                evacuatedInstance, partition, resource, stateMap));
+        Assert.assertFalse(stateMap.containsKey(removedInstance),
+            String.format("Removed instance %s still hosts partition %s in resource %s: %s",
+                removedInstance, partition, resource, stateMap));
         Assert.assertEquals(stateMap.size(), REPLICA,
             String.format("Partition %s in resource %s should have %d replicas: %s", partition,
                 resource, REPLICA, stateMap));