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

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


The following commit(s) were added to refs/heads/master by this push:
     new 2877723da38 Repair ERROR replicas on pauseless dedup and 
partial-upsert tables (#19909)
2877723da38 is described below

commit 2877723da384b783df372fb203fbf9fef0aea740
Author: Suvodeep Pyne <[email protected]>
AuthorDate: Wed Oct 21 16:17:49 2026 -0700

    Repair ERROR replicas on pauseless dedup and partial-upsert tables (#19909)
---
 .../helix/core/PinotHelixResourceManager.java      | 105 +++++++-
 .../realtime/PinotLLCRealtimeSegmentManager.java   | 125 ++++++---
 .../RealtimeSegmentValidationManager.java          |   3 +-
 .../controller/helix/core/SegmentResetTest.java    | 201 +++++++++++++++
 .../PinotLLCRealtimeSegmentManagerTest.java        | 268 +++++++++++++++++++-
 .../PauselessDedupErrorReplicaRepairTest.java      | 281 +++++++++++++++++++++
 .../realtime/utils/PauselessRealtimeTestUtils.java |  28 +-
 7 files changed, 958 insertions(+), 53 deletions(-)

diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/PinotHelixResourceManager.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/PinotHelixResourceManager.java
index 2d536a21b42..67793bec133 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/PinotHelixResourceManager.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/PinotHelixResourceManager.java
@@ -3357,6 +3357,55 @@ public class PinotHelixResourceManager {
     }
   }
 
+  /// Resets the replicas of the given segments that are in ERROR state, 
leaving the other replicas untouched. The ideal
+  /// state and external view are read once, when the resets are sent, to find 
the ERROR replicas; a replica on an
+  /// instance the ideal state no longer assigns the segment to is skipped. 
The resets are sent in one batch per
+  /// instance, only for the segments still in ERROR in the instance's current 
state, and the instance discards a reset
+  /// if the segment has left ERROR by the time it processes it.
+  ///
+  /// @return the segments whose reset failed on at least one instance
+  public Set<String> resetErrorReplicas(String tableNameWithType, Set<String> 
segmentNames) {
+    IdealState idealState = getTableIdealState(tableNameWithType);
+    Preconditions.checkState(idealState != null, "Could not find ideal state 
for table: %s", tableNameWithType);
+    ExternalView externalView = getTableExternalView(tableNameWithType);
+    Preconditions.checkState(externalView != null, "Could not find external 
view for table: %s", tableNameWithType);
+
+    Map<String, Set<String>> instanceToResetSegmentsMap = new HashMap<>();
+    for (String segmentName : segmentNames) {
+      Map<String, String> externalViewStateMap = 
externalView.getStateMap(segmentName);
+      Map<String, String> idealStateMap = 
idealState.getInstanceStateMap(segmentName);
+      if (externalViewStateMap == null || idealStateMap == null) {
+        continue;
+      }
+      for (Map.Entry<String, String> entry : externalViewStateMap.entrySet()) {
+        String instance = entry.getKey();
+        if (!SegmentStateModel.ERROR.equals(entry.getValue())) {
+          continue;
+        }
+        if (idealStateMap.containsKey(instance)) {
+          instanceToResetSegmentsMap.computeIfAbsent(instance, i -> new 
HashSet<>()).add(segmentName);
+        } else {
+          LOGGER.warn("Skipping reset of ERROR segment: {} of table: {} on 
instance: {} which the ideal state no "
+              + "longer assigns it to", segmentName, tableNameWithType, 
instance);
+        }
+      }
+    }
+
+    Set<String> failedSegments = new HashSet<>();
+    for (Map.Entry<String, Set<String>> entry : 
instanceToResetSegmentsMap.entrySet()) {
+      String instance = entry.getKey();
+      Set<String> resetSegments = entry.getValue();
+      try {
+        resetPartitions(instance, tableNameWithType, resetSegments, true);
+      } catch (Exception e) {
+        LOGGER.error("Failed to reset ERROR segments: {} of table: {} on 
instance: {}", resetSegments,
+            tableNameWithType, instance, e);
+        failedSegments.addAll(resetSegments);
+      }
+    }
+    return failedSegments;
+  }
+
   private void resetPartitionAllState(String instance, String 
tableNameWithType, Set<String> segmentNames,
       List<String> failedInstances) {
     try {
@@ -3384,9 +3433,22 @@ public class PinotHelixResourceManager {
   /// However instead of resetting only the ERROR state to its initial state. 
we reset all state regardless.
   @VisibleForTesting
   void resetPartitionAllState(String instanceName, String resourceName, 
Set<String> resetPartitionNames) {
+    resetPartitions(instanceName, resourceName, resetPartitionNames, false);
+  }
+
+  /// Resets the given partitions on the instance to the initial state.
+  ///
+  /// With `errorStateOnly`, like [HelixAdmin#resetPartition(String, String, 
String, List)], only the partitions in
+  /// ERROR state in the instance's current state are reset, and each message 
is sent from ERROR, so the instance
+  /// discards it if the partition has left ERROR by the time it processes it. 
A partition with a pending state
+  /// transition message is skipped. Otherwise every partition is reset from 
its current state, and a pending state
+  /// transition message for any of them fails the call.
+  @VisibleForTesting
+  void resetPartitions(String instanceName, String resourceName, Set<String> 
resetPartitionNames,
+      boolean errorStateOnly) {
     LOGGER.info("Resetting partitions: {} for resource: {} on instance: {}", 
resetPartitionNames, resourceName,
         instanceName);
-    HelixDataAccessor accessor = _helixZkManager.getHelixDataAccessor();
+    HelixDataAccessor accessor = getHelixZkManager().getHelixDataAccessor();
     PropertyKey.Builder keyBuilder = accessor.keyBuilder();
 
     // check the instance is alive
@@ -3409,6 +3471,7 @@ public class PinotHelixResourceManager {
     CurrentState curState = 
accessor.getProperty(keyBuilder.currentState(instanceName, sessionId, 
resourceName));
 
     // check there is no pending messages for the partitions exist
+    Set<String> partitionsWithPendingMessage = new HashSet<>();
     List<Message> messages = 
accessor.getChildValues(keyBuilder.messages(instanceName), true);
     for (Message message : messages) {
       if 
(!Message.MessageType.STATE_TRANSITION.name().equalsIgnoreCase(message.getMsgType())
 || !sessionId.equals(
@@ -3416,9 +3479,12 @@ public class PinotHelixResourceManager {
           || !resetPartitionNames.contains(message.getPartitionName())) {
         continue;
       }
-      throw new RuntimeException(
-          "Can't reset state for " + resourceName + "." + resetPartitionNames 
+ " on " + instanceName
-              + ", because a pending message " + message + " exists for 
resource " + message.getResourceName());
+      if (!errorStateOnly) {
+        throw new RuntimeException(
+            "Can't reset state for " + resourceName + "." + 
resetPartitionNames + " on " + instanceName
+                + ", because a pending message " + message + " exists for 
resource " + message.getResourceName());
+      }
+      partitionsWithPendingMessage.add(message.getPartitionName());
     }
 
     String adminName;
@@ -3433,6 +3499,19 @@ public class PinotHelixResourceManager {
     List<Message> resetMessages = new ArrayList<>();
     List<PropertyKey> messageKeys = new ArrayList<>();
     for (String partitionName : resetPartitionNames) {
+      String fromState = curState.getState(partitionName);
+      if (errorStateOnly) {
+        if (!SegmentStateModel.ERROR.equals(fromState)) {
+          LOGGER.info("Skipping reset of partition: {} for resource: {} on 
instance: {} in state: {}", partitionName,
+              resourceName, instanceName, fromState);
+          continue;
+        }
+        if (partitionsWithPendingMessage.contains(partitionName)) {
+          LOGGER.info("Skipping reset of partition: {} for resource: {} on 
instance: {} with a pending message",
+              partitionName, resourceName, instanceName);
+          continue;
+        }
+      }
       // send currentState to initialState message
       String msgId = UUID.randomUUID().toString();
       Message message = new Message(Message.MessageType.STATE_TRANSITION, 
msgId);
@@ -3443,7 +3522,7 @@ public class PinotHelixResourceManager {
       message.setResourceName(resourceName);
       message.setTgtSessionId(sessionId);
       message.setStateModelDef(stateModelDef);
-      message.setFromState(curState.getState(partitionName));
+      message.setFromState(fromState);
       message.setToState(stateModel.getInitialState());
       message.setStateModelFactoryName(idealState.getStateModelFactoryName());
 
@@ -3458,7 +3537,21 @@ public class PinotHelixResourceManager {
       messageKeys.add(keyBuilder.message(instanceName, message.getId()));
     }
 
-    accessor.setChildren(messageKeys, resetMessages);
+    if (resetMessages.isEmpty()) {
+      return;
+    }
+    boolean[] results = accessor.setChildren(messageKeys, resetMessages);
+    List<String> failedPartitions = new ArrayList<>();
+    for (int i = 0; i < results.length; i++) {
+      if (!results[i]) {
+        failedPartitions.add(resetMessages.get(i).getPartitionName());
+      }
+    }
+    if (!failedPartitions.isEmpty()) {
+      throw new RuntimeException(
+          "Failed to send reset messages for partitions: " + failedPartitions 
+ " of resource: " + resourceName
+              + " on instance: " + instanceName);
+    }
   }
 
   /// Sends a segment refresh message to:
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/realtime/PinotLLCRealtimeSegmentManager.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/realtime/PinotLLCRealtimeSegmentManager.java
index d3211b55feb..56d8ce41dd3 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/realtime/PinotLLCRealtimeSegmentManager.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/realtime/PinotLLCRealtimeSegmentManager.java
@@ -22,6 +22,7 @@ import com.fasterxml.jackson.core.JsonProcessingException;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Function;
 import com.google.common.base.Preconditions;
+import com.google.common.base.Suppliers;
 import com.google.common.collect.BiMap;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
@@ -3103,7 +3104,22 @@ public class PinotLLCRealtimeSegmentManager implements 
PinotClusterConfigChangeL
   ///
   /// API signature: POST http://\[serverURL\]/reingestSegment/\[segmentName\] 
Request body (JSON):
   ///
-  /// If segment is in ERROR state in only few replicas but has download URL, 
we instead trigger a segment reset
+  /// Otherwise the ERROR replicas are reset:
+  /// - A segment still CONSUMING in the ideal state (IN_PROGRESS, or 
COMMITTING before its commit updates the ideal
+  ///   state) re-consumes when reset. It is reset only when all its replicas 
are in ERROR; otherwise it is left alone
+  ///   until it commits, so its ERROR replicas then reload it instead.
+  /// - Any other segment reloads when reset, from the replica's local copy, 
the deep store or an ONLINE replica.
+  ///
+  /// Consuming from the stream again is unsafe on dedup and partial upsert 
tables: re-ingestion can admit duplicate
+  /// or inconsistent rows, and a re-consumed segment can drop rows as 
duplicates of keys left by the failed attempt,
+  /// or merge partial upsert rows against previous values that no replica has 
loaded. So unless
+  /// [#allowRepairOfCommittingSegments] allows it, such tables skip 
re-ingestion and, when pauseless, the reset that
+  /// re-consumes an IN_PROGRESS segment. Resets that reload a segment run for 
every table.
+  ///
+  /// Segments counted as unrecoverable are those this pass cannot repair or 
cannot issue a repair for: skipped by the
+  /// policy above, with no replica left consuming, with nothing but a 
possible local copy to load from, with no server
+  /// to re-ingest on, or whose re-ingestion trigger or reset fails. A failed 
reset fails the call once every segment
+  /// is processed.
   ///
   /// @param tableConfig The table config
   public void repairSegmentsInErrorState(TableConfig tableConfig,
@@ -3177,18 +3193,11 @@ public class PinotLLCRealtimeSegmentManager implements 
PinotClusterConfigChangeL
         segmentsInErrorStateInAtLeastOneReplica.size(), 
segmentsInErrorStateInAtLeastOneReplica,
         segmentsInErrorStateInAllReplicas.size(), 
segmentsInErrorStateInAllReplicas, realtimeTableName);
 
-    boolean repairCommittingSegments =
-        
allowRepairOfCommittingSegments(repairErrorSegmentsForPartialUpsertOrDedup, 
tableConfig);
-    int segmentsInUnRecoverableState = 0;
-    if (isPauselessTable && !repairCommittingSegments) {
-      // We do not run reingestion for dedup and partial upsert tables in 
pauseless as it can
-      // lead to data inconsistencies
-      _controllerMetrics.setOrUpdateTableGauge(realtimeTableName,
-          ControllerGauge.PAUSELESS_SEGMENTS_IN_UNRECOVERABLE_ERROR_COUNT, 
segmentsInErrorStateInAllReplicas.size());
-      return;
-    } else {
-      LOGGER.info("Repairing error segments in table: {}.", realtimeTableName);
-    }
+    // Evaluated lazily, since it logs why repair is disallowed
+    Supplier<Boolean> repairCommittingSegments = Suppliers.memoize(
+        () -> 
allowRepairOfCommittingSegments(repairErrorSegmentsForPartialUpsertOrDedup, 
tableConfig));
+    Set<String> unrecoverableSegments = new HashSet<>();
+    Set<String> segmentsToReset = new HashSet<>();
 
     if (isPauselessTable) {
       _controllerMetrics.setOrUpdateTableGauge(realtimeTableName, 
ControllerGauge.PAUSELESS_SEGMENTS_IN_ERROR_COUNT,
@@ -3207,14 +3216,19 @@ public class PinotLLCRealtimeSegmentManager implements 
PinotClusterConfigChangeL
       }
 
       Status status = segmentZKMetadata.getStatus();
+      boolean allReplicasInError = 
segmentsInErrorStateInAllReplicas.contains(segmentName);
+      Map<String, String> idealStateMap = 
segmentToInstanceIdealStateMap.get(segmentName);
+      Map<String, String> currentStateMap = 
segmentToInstanceCurrentStateMap.get(segmentName);
 
       // 1) For COMMITTING segments where all replicas are in ERROR, try 
re-ingestion (subject to policy)
-      if (status == Status.COMMITTING && 
segmentsInErrorStateInAllReplicas.contains(segmentName)) {
-        if (!repairCommittingSegments) {
-          segmentsInUnRecoverableState += 1;
-          LOGGER.info(
-              "Segment: {} in table: {} is COMMITTING with all replicas in 
ERROR state. Skipping re-ingestion since "
-                  + "repairErrorSegments is false.",
+      if (status == Status.COMMITTING && allReplicasInError) {
+        if (!repairCommittingSegments.get()) {
+          unrecoverableSegments.add(segmentName);
+          LOGGER.warn(
+              "Segment: {} in table: {} is COMMITTING with all replicas in 
ERROR state. Skipping re-ingestion because "
+                  + "repair of committing segments is disabled for dedup and 
partial upsert tables. Set "
+                  + "disasterRecoveryMode=ALWAYS or run the validation task 
with "
+                  + "repairErrorSegmentsForPartialUpsertOrDedup=true to repair 
it.",
               segmentName, realtimeTableName);
           continue;
         }
@@ -3223,10 +3237,9 @@ public class PinotLLCRealtimeSegmentManager implements 
PinotClusterConfigChangeL
             segmentName, realtimeTableName);
 
         // Find at least one server that should host this segment and is alive
-        Map<String, String> idealStateMap = 
segmentToInstanceIdealStateMap.get(segmentName);
-        assert idealStateMap != null;
         String aliveServer = pickServerToReingest(idealStateMap.keySet());
         if (aliveServer == null) {
+          unrecoverableSegments.add(segmentName);
           LOGGER.warn("No alive server found to re-ingest segment: {} in 
table: {}, skipping re-ingestion", segmentName,
               realtimeTableName);
           continue;
@@ -3235,28 +3248,70 @@ public class PinotLLCRealtimeSegmentManager implements 
PinotClusterConfigChangeL
           triggerReingestion(aliveServer, segmentName);
           LOGGER.info("Successfully triggered re-ingestion for segment: {} on 
server: {}", segmentName, aliveServer);
         } catch (Exception e) {
+          unrecoverableSegments.add(segmentName);
           LOGGER.error("Failed to trigger re-ingestion for segment: {} on 
server: {}", segmentName, aliveServer, e);
         }
         continue;
       }
 
-      // 2) For IN_PROGRESS segments where all replicas are in ERROR, issue a 
reset to allow ERROR->OFFLINE and retry
-      if (status == Status.IN_PROGRESS && 
segmentsInErrorStateInAllReplicas.contains(segmentName)) {
+      // 2) A reset re-consumes a segment that is still CONSUMING in the ideal 
state: an IN_PROGRESS segment, or a
+      // COMMITTING one whose commit has not updated the ideal state yet
+      if (status == Status.IN_PROGRESS || (status == Status.COMMITTING && 
idealStateMap.containsValue(
+          SegmentStateModel.CONSUMING))) {
+        // With a replica not in ERROR, wait for the segment to commit, so its 
ERROR replicas then reload it instead
+        if (!allReplicasInError) {
+          if (currentStateMap.containsValue(SegmentStateModel.CONSUMING)) {
+            LOGGER.info("Segment: {} in table: {} is still consuming with some 
replicas in ERROR state. Leaving them "
+                + "to be reset once the segment commits.", segmentName, 
realtimeTableName);
+          } else {
+            unrecoverableSegments.add(segmentName);
+            LOGGER.warn("Segment: {} in table: {} is still CONSUMING in the 
ideal state, but no replica is consuming "
+                + "it and the others are in ERROR state. Leaving them to be 
reset once the segment commits.",
+                segmentName, realtimeTableName);
+          }
+          continue;
+        }
+        if (isPauselessTable && !repairCommittingSegments.get()) {
+          unrecoverableSegments.add(segmentName);
+          LOGGER.warn(
+              "Segment: {} in table: {} is IN_PROGRESS with all replicas in 
ERROR state. Skipping the reset, which "
+                  + "re-consumes it, because repair by re-consuming is 
disabled for pauseless dedup and partial upsert "
+                  + "tables. Set disasterRecoveryMode=ALWAYS or run the 
validation task with "
+                  + "repairErrorSegmentsForPartialUpsertOrDedup=true to repair 
it.",
+              segmentName, realtimeTableName);
+          continue;
+        }
         LOGGER.info("Resetting IN_PROGRESS segment: {} in table: {} since all 
replicas are in ERROR state.",
             segmentName, realtimeTableName);
-        _helixResourceManager.resetSegment(realtimeTableName, segmentName, 
null);
+        segmentsToReset.add(segmentName);
         continue;
       }
 
-      // 3) For all other non-IN_PROGRESS statuses with any ERROR replica, 
trigger a reset to download/refresh
-      if (status != Status.IN_PROGRESS) {
-        _helixResourceManager.resetSegment(realtimeTableName, segmentName, 
null);
+      // 3) Otherwise a reset reloads the segment: from the replica's local 
copy, the deep store, or an ONLINE replica.
+      // A COMMITTING segment, or one committed with a peer download URL, has 
no deep-store copy. With no ONLINE or
+      // CONSUMING replica either, only a local copy can still load it, which 
only the reset can tell.
+      segmentsToReset.add(segmentName);
+      boolean inDeepStore = status != Status.COMMITTING
+          && 
!CommonConstants.Segment.METADATA_URI_FOR_PEER_DOWNLOAD.equals(segmentZKMetadata.getDownloadUrl());
+      if (!inDeepStore && 
!currentStateMap.containsValue(SegmentStateModel.ONLINE)
+          && !currentStateMap.containsValue(SegmentStateModel.CONSUMING)) {
+        unrecoverableSegments.add(segmentName);
+        LOGGER.warn("Segment: {} in table: {} has no deep-store copy and no 
ONLINE or CONSUMING replica. Resetting its "
+            + "ERROR replicas, which can only load a local copy.", 
segmentName, realtimeTableName);
       }
     }
 
+    Set<String> segmentsFailedToReset =
+        segmentsToReset.isEmpty() ? Set.of() : 
_helixResourceManager.resetErrorReplicas(realtimeTableName,
+            segmentsToReset);
+    unrecoverableSegments.addAll(segmentsFailedToReset);
     if (isPauselessTable) {
       _controllerMetrics.setOrUpdateTableGauge(realtimeTableName,
-          ControllerGauge.PAUSELESS_SEGMENTS_IN_UNRECOVERABLE_ERROR_COUNT, 
segmentsInUnRecoverableState);
+          ControllerGauge.PAUSELESS_SEGMENTS_IN_UNRECOVERABLE_ERROR_COUNT, 
unrecoverableSegments.size());
+    }
+    if (!segmentsFailedToReset.isEmpty()) {
+      throw new RuntimeException(
+          "Failed to reset ERROR replicas of segments: " + 
segmentsFailedToReset + " in table: " + realtimeTableName);
     }
   }
 
@@ -3264,9 +3319,10 @@ public class PinotLLCRealtimeSegmentManager implements 
PinotClusterConfigChangeL
     return disasterRecoveryMode == DisasterRecoveryMode.ALWAYS;
   }
 
-  /// Whether to allow repairing the ERROR segments with ZK status: COMMITTING
-  /// This method is only useful for pauseless ingestion with features like 
dedup enabled (Since repairing committing
-  /// segments for such tables can lead to incorrect data).
+  /// Whether to allow the repairs of ERROR segments that consume from the 
stream again: re-ingesting a COMMITTING
+  /// segment, and on pauseless tables, resetting an IN_PROGRESS segment
+  /// This method is only useful for tables with features like dedup enabled 
(Since such repairs can lead to incorrect
+  /// data for them).
   /// @param repairErrorSegmentsForPartialUpsertOrDedup API context flag, if 
true then always allow repair
   /// @param tableConfig tableConfig
   /// @return Returns true if repair is allowed
@@ -3290,7 +3346,7 @@ public class PinotLLCRealtimeSegmentManager implements 
PinotClusterConfigChangeL
         == UpsertConfig.Mode.PARTIAL);
     if (isPartialUpsertEnabled) {
       // If isPartialUpsert is enabled, do not allow repair.
-      LOGGER.warn("Skipping repair for errored segments in table: {} because 
partialUpsert is enabled",
+      LOGGER.warn("Disallowing repairs that re-consume errored segments in 
table: {} because partialUpsert is enabled",
           tableConfig.getTableName());
       return false;
     }
@@ -3299,7 +3355,7 @@ public class PinotLLCRealtimeSegmentManager implements 
PinotClusterConfigChangeL
     boolean isDedupEnabled = (dedupConfig != null) && 
(dedupConfig.isDedupEnabled());
     if (isDedupEnabled) {
       // If dedup is enabled, do not allow repair of error segment.
-      LOGGER.warn("Skipping repair for errored segments in table: {} because 
dedup is enabled",
+      LOGGER.warn("Disallowing repairs that re-consume errored segments in 
table: {} because dedup is enabled",
           tableConfig.getTableName());
       return false;
     }
@@ -3311,7 +3367,8 @@ public class PinotLLCRealtimeSegmentManager implements 
PinotClusterConfigChangeL
   /// using Simple HTTP APIs.
   ///
   /// POST http://\[serverURL\]/reingestSegment/\[segmentName\]
-  private void triggerReingestion(String serverHostPort, String segmentName)
+  @VisibleForTesting
+  void triggerReingestion(String serverHostPort, String segmentName)
       throws IOException, URISyntaxException, HttpErrorStatusException {
     String scheme = CommonConstants.HTTP_PROTOCOL;
     if (serverHostPort.contains(CommonConstants.HTTPS_PROTOCOL)) {
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/validation/RealtimeSegmentValidationManager.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/validation/RealtimeSegmentValidationManager.java
index e44706588db..fc7fdceed5a 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/validation/RealtimeSegmentValidationManager.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/validation/RealtimeSegmentValidationManager.java
@@ -134,7 +134,8 @@ public class RealtimeSegmentValidationManager extends 
ControllerPeriodicTask<Rea
 
     boolean isPauselessTable = 
PauselessConsumptionUtils.isPauselessEnabled(tableConfig);
     if (isPauselessTable || _segmentAutoResetOnErrorAtValidation) {
-      // For realtime tables without dedup or partial upsert, repair segments 
in error state.
+      // Repair segments in error state. Dedup and partial upsert tables skip 
the repairs that consume from the stream
+      // again unless overridden (see repairSegmentsInErrorState).
       // When pauseless consumption is disabled, this behavior remains gated by
       // _segmentAutoResetOnErrorAtValidation to preserve legacy semantics.
       _llcRealtimeSegmentManager.repairSegmentsInErrorState(tableConfig,
diff --git 
a/pinot-controller/src/test/java/org/apache/pinot/controller/helix/core/SegmentResetTest.java
 
b/pinot-controller/src/test/java/org/apache/pinot/controller/helix/core/SegmentResetTest.java
index 335255f5762..e15054a69a0 100644
--- 
a/pinot-controller/src/test/java/org/apache/pinot/controller/helix/core/SegmentResetTest.java
+++ 
b/pinot-controller/src/test/java/org/apache/pinot/controller/helix/core/SegmentResetTest.java
@@ -18,24 +18,43 @@
  */
 package org.apache.pinot.controller.helix.core;
 
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.apache.helix.HelixDataAccessor;
+import org.apache.helix.HelixManager;
+import org.apache.helix.PropertyKey;
+import org.apache.helix.model.CurrentState;
 import org.apache.helix.model.ExternalView;
 import org.apache.helix.model.IdealState;
+import org.apache.helix.model.LiveInstance;
+import org.apache.helix.model.Message;
+import org.apache.helix.model.StateModelDefinition;
 import org.apache.pinot.controller.ControllerConf;
 import org.apache.pinot.spi.utils.CommonConstants;
+import 
org.apache.pinot.spi.utils.CommonConstants.Helix.StateModel.SegmentStateModel;
+import org.mockito.ArgumentCaptor;
 import org.mockito.Mockito;
 import org.testng.Assert;
 import org.testng.annotations.Test;
 
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.expectThrows;
 
 
 public class SegmentResetTest {
+  private static final String SESSION_ID = "session_1";
 
   private final String _tableName = "myTable_OFFLINE";
   private final String _segmentName = "segment_1";
@@ -110,4 +129,186 @@ public class SegmentResetTest {
     pinotHelixManager.resetSegment(_tableName, _segmentName, null);
     pinotHelixManager.resetSegments(_tableName, null, false);
   }
+
+  @Test
+  public void testResetErrorReplicasResetsOnlyReplicasStillInError() {
+    IdealState idealState = new IdealState(_tableName);
+    ExternalView externalView = new ExternalView(_tableName);
+    for (String segment : List.of("segment_1", "segment_2", "segment_3", 
"segment_4")) {
+      idealState.setPartitionState(segment, _instance1, 
SegmentStateModel.ONLINE);
+      idealState.setPartitionState(segment, _instance2, 
SegmentStateModel.ONLINE);
+    }
+    externalView.setState("segment_1", _instance1, SegmentStateModel.ERROR);
+    externalView.setState("segment_1", _instance2, SegmentStateModel.ONLINE);
+    // An ERROR replica on an instance the ideal state no longer assigns the 
segment to
+    externalView.setState("segment_1", "instance_3", SegmentStateModel.ERROR);
+    externalView.setState("segment_2", _instance1, SegmentStateModel.ERROR);
+    externalView.setState("segment_2", _instance2, SegmentStateModel.ERROR);
+    // Recovered after the caller saw it in ERROR
+    externalView.setState("segment_3", _instance1, SegmentStateModel.ONLINE);
+    externalView.setState("segment_3", _instance2, SegmentStateModel.ONLINE);
+    // In ERROR but not requested
+    externalView.setState("segment_4", _instance1, SegmentStateModel.ERROR);
+    ErrorReplicaResetHelixResourceManager pinotHelixManager =
+        new ErrorReplicaResetHelixResourceManager(idealState, externalView);
+
+    // segment_5 is in neither the ideal state nor the external view, e.g. 
deleted after the caller looked
+    Set<String> failedSegments = 
pinotHelixManager.resetErrorReplicas(_tableName,
+        Set.of("segment_1", "segment_2", "segment_3", "segment_5"));
+
+    assertEquals(failedSegments, Set.of());
+    assertEquals(pinotHelixManager._resetSegmentsPerInstance,
+        Map.of(_instance1, Set.of("segment_1", "segment_2"), _instance2, 
Set.of("segment_2")));
+  }
+
+  @Test
+  public void testResetErrorReplicasReturnsSegmentsOfFailedInstance() {
+    IdealState idealState = new IdealState(_tableName);
+    ExternalView externalView = new ExternalView(_tableName);
+    for (String segment : List.of("segment_1", "segment_2")) {
+      idealState.setPartitionState(segment, _instance1, 
SegmentStateModel.ONLINE);
+      idealState.setPartitionState(segment, _instance2, 
SegmentStateModel.ONLINE);
+    }
+    externalView.setState("segment_1", _instance1, SegmentStateModel.ERROR);
+    externalView.setState("segment_1", _instance2, SegmentStateModel.ERROR);
+    externalView.setState("segment_2", _instance1, SegmentStateModel.ONLINE);
+    externalView.setState("segment_2", _instance2, SegmentStateModel.ERROR);
+    ErrorReplicaResetHelixResourceManager pinotHelixManager =
+        new ErrorReplicaResetHelixResourceManager(idealState, externalView);
+    pinotHelixManager._instancesToFail.add(_instance1);
+
+    Set<String> failedSegments = 
pinotHelixManager.resetErrorReplicas(_tableName, Set.of("segment_1", 
"segment_2"));
+
+    // The other instance is still reset
+    assertEquals(failedSegments, Set.of("segment_1"));
+    assertEquals(pinotHelixManager._resetSegmentsPerInstance,
+        Map.of(_instance2, Set.of("segment_1", "segment_2")));
+  }
+
+  /// Serves the given ideal state and external view, and records the resets 
per instance instead of sending them.
+  private static class ErrorReplicaResetHelixResourceManager extends 
PinotHelixResourceManager {
+    private final IdealState _idealState;
+    private final ExternalView _externalView;
+    private final Set<String> _instancesToFail = new HashSet<>();
+    private final Map<String, Set<String>> _resetSegmentsPerInstance = new 
HashMap<>();
+
+    ErrorReplicaResetHelixResourceManager(IdealState idealState, ExternalView 
externalView) {
+      super(createControllerConf());
+      _idealState = idealState;
+      _externalView = externalView;
+    }
+
+    private static ControllerConf createControllerConf() {
+      ControllerConf controllerConf = new ControllerConf();
+      controllerConf.setZkStr("localhost:2181");
+      controllerConf.setHelixClusterName("cluster01");
+      return controllerConf;
+    }
+
+    @Override
+    public IdealState getTableIdealState(String tableNameWithType) {
+      return _idealState;
+    }
+
+    @Override
+    public ExternalView getTableExternalView(String tableNameWithType) {
+      return _externalView;
+    }
+
+    @Override
+    void resetPartitions(String instanceName, String resourceName, Set<String> 
resetPartitionNames,
+        boolean errorStateOnly) {
+      assertTrue(errorStateOnly);
+      if (_instancesToFail.contains(instanceName)) {
+        throw new RuntimeException("Test: Fail reset for " + instanceName);
+      }
+      _resetSegmentsPerInstance.put(instanceName, resetPartitionNames);
+    }
+  }
+
+  @Test
+  @SuppressWarnings("unchecked")
+  public void 
testResetPartitionsErrorStateOnlySendsResetsFromErrorForPartitionsInError() {
+    CurrentState currentState = new CurrentState(_tableName);
+    currentState.setState("segment_1", SegmentStateModel.ERROR);
+    // Recovered after the external view was read
+    currentState.setState("segment_2", SegmentStateModel.ONLINE);
+    // A reset from an earlier pass is still pending
+    currentState.setState("segment_3", SegmentStateModel.ERROR);
+    Message pendingMessage = new Message(Message.MessageType.STATE_TRANSITION, 
"pending");
+    pendingMessage.setTgtSessionId(SESSION_ID);
+    pendingMessage.setResourceName(_tableName);
+    pendingMessage.setPartitionName("segment_3");
+    HelixDataAccessor accessor = mockHelixDataAccessor(currentState, 
List.of(pendingMessage));
+    when(accessor.setChildren(anyList(), anyList())).thenReturn(new 
boolean[]{true});
+
+    new HelixDataAccessorResourceManager(accessor).resetPartitions(_instance1, 
_tableName,
+        Set.of("segment_1", "segment_2", "segment_3"), true);
+
+    ArgumentCaptor<List<Message>> messagesCaptor = 
ArgumentCaptor.forClass(List.class);
+    verify(accessor).setChildren(anyList(), messagesCaptor.capture());
+    List<Message> messages = messagesCaptor.getValue();
+    assertEquals(messages.size(), 1);
+    assertEquals(messages.get(0).getPartitionName(), "segment_1");
+    // Sent from ERROR, so the instance discards it if the segment leaves 
ERROR before processing it
+    assertEquals(messages.get(0).getFromState(), SegmentStateModel.ERROR);
+    assertEquals(messages.get(0).getToState(), SegmentStateModel.OFFLINE);
+  }
+
+  @Test
+  public void testResetPartitionsFailsWhenResetMessageIsNotWritten() {
+    CurrentState currentState = new CurrentState(_tableName);
+    currentState.setState("segment_1", SegmentStateModel.ERROR);
+    HelixDataAccessor accessor = mockHelixDataAccessor(currentState, 
List.of());
+    when(accessor.setChildren(anyList(), anyList())).thenReturn(new 
boolean[]{false});
+
+    RuntimeException exception = expectThrows(RuntimeException.class,
+        () -> new 
HelixDataAccessorResourceManager(accessor).resetPartitions(_instance1, 
_tableName,
+            Set.of("segment_1"), true));
+    assertTrue(exception.getMessage().contains("segment_1"), 
exception.getMessage());
+  }
+
+  private HelixDataAccessor mockHelixDataAccessor(CurrentState currentState, 
List<Message> messages) {
+    LiveInstance liveInstance = mock(LiveInstance.class);
+    when(liveInstance.getEphemeralOwner()).thenReturn(SESSION_ID);
+    IdealState idealState = new IdealState(_tableName);
+    idealState.setStateModelDefRef("SegmentOnlineOfflineStateModel");
+    StateModelDefinition stateModelDefinition = 
mock(StateModelDefinition.class);
+    
when(stateModelDefinition.getInitialState()).thenReturn(SegmentStateModel.OFFLINE);
+    HelixDataAccessor accessor = mock(HelixDataAccessor.class);
+    when(accessor.keyBuilder()).thenReturn(new 
PropertyKey.Builder("cluster01"));
+    when(accessor.getProperty(any(PropertyKey.class))).thenAnswer(invocation 
-> {
+      PropertyKey key = invocation.getArgument(0);
+      switch (key.getType()) {
+        case LIVEINSTANCES:
+          return liveInstance;
+        case IDEALSTATES:
+          return idealState;
+        case STATEMODELDEFS:
+          return stateModelDefinition;
+        case CURRENTSTATES:
+          return currentState;
+        default:
+          throw new IllegalArgumentException("Unexpected property key: " + 
key);
+      }
+    });
+    when(accessor.getChildValues(any(PropertyKey.class), 
anyBoolean())).thenReturn(new ArrayList<>(messages));
+    return accessor;
+  }
+
+  /// Sends the resets through the given Helix data accessor.
+  private static class HelixDataAccessorResourceManager extends 
PinotHelixResourceManager {
+    private final HelixManager _helixManager;
+
+    HelixDataAccessorResourceManager(HelixDataAccessor accessor) {
+      super(ErrorReplicaResetHelixResourceManager.createControllerConf());
+      _helixManager = mock(HelixManager.class);
+      when(_helixManager.getHelixDataAccessor()).thenReturn(accessor);
+    }
+
+    @Override
+    public HelixManager getHelixZkManager() {
+      return _helixManager;
+    }
+  }
 }
diff --git 
a/pinot-controller/src/test/java/org/apache/pinot/controller/helix/core/realtime/PinotLLCRealtimeSegmentManagerTest.java
 
b/pinot-controller/src/test/java/org/apache/pinot/controller/helix/core/realtime/PinotLLCRealtimeSegmentManagerTest.java
index 4eeef13372e..9216dddf860 100644
--- 
a/pinot-controller/src/test/java/org/apache/pinot/controller/helix/core/realtime/PinotLLCRealtimeSegmentManagerTest.java
+++ 
b/pinot-controller/src/test/java/org/apache/pinot/controller/helix/core/realtime/PinotLLCRealtimeSegmentManagerTest.java
@@ -19,6 +19,8 @@
 package org.apache.pinot.controller.helix.core.realtime;
 
 import com.google.common.base.Preconditions;
+import com.google.common.collect.BiMap;
+import com.google.common.collect.HashBiMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
 import java.io.File;
@@ -62,9 +64,11 @@ import org.apache.helix.store.zk.ZkHelixPropertyStore;
 import org.apache.helix.zookeeper.datamodel.ZNRecord;
 import org.apache.pinot.common.assignment.InstancePartitions;
 import org.apache.pinot.common.exception.HttpErrorStatusException;
+import org.apache.pinot.common.exception.InvalidConfigException;
 import org.apache.pinot.common.metadata.ZKMetadataProvider;
 import org.apache.pinot.common.metadata.segment.SegmentPartitionMetadata;
 import org.apache.pinot.common.metadata.segment.SegmentZKMetadata;
+import org.apache.pinot.common.metrics.ControllerGauge;
 import org.apache.pinot.common.metrics.ControllerMeter;
 import org.apache.pinot.common.metrics.ControllerMetrics;
 import org.apache.pinot.common.restlet.resources.BatchConfig;
@@ -85,12 +89,14 @@ import 
org.apache.pinot.segment.spi.index.metadata.SegmentMetadataImpl;
 import org.apache.pinot.segment.spi.partition.metadata.ColumnPartitionMetadata;
 import org.apache.pinot.spi.auth.AuthProvider;
 import org.apache.pinot.spi.config.table.ColumnPartitionConfig;
+import org.apache.pinot.spi.config.table.DedupConfig;
 import org.apache.pinot.spi.config.table.DisasterRecoveryMode;
 import org.apache.pinot.spi.config.table.PauseState;
 import org.apache.pinot.spi.config.table.SegmentPartitionConfig;
 import org.apache.pinot.spi.config.table.SegmentsValidationAndRetentionConfig;
 import org.apache.pinot.spi.config.table.TableConfig;
 import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.config.table.UpsertConfig;
 import org.apache.pinot.spi.config.table.assignment.InstancePartitionsType;
 import org.apache.pinot.spi.config.table.ingestion.IngestionConfig;
 import org.apache.pinot.spi.config.table.ingestion.StreamIngestionConfig;
@@ -2896,6 +2902,249 @@ public class PinotLLCRealtimeSegmentManagerTest {
         
pinotLLCRealtimeSegmentManager.shouldRepairErrorSegmentsForPartialUpsertOrDedup(DisasterRecoveryMode.ALWAYS));
   }
 
+  @Test
+  public void 
testRepairSegmentsInErrorStateResetsCommittedSegmentsOnPauselessDedupTable()
+      throws InvalidConfigException {
+    PinotHelixResourceManager mockHelixResourceManager = 
mock(PinotHelixResourceManager.class);
+    ControllerMetrics mockControllerMetrics = mock(ControllerMetrics.class);
+    FakePinotLLCRealtimeSegmentManager segmentManager =
+        new FakePinotLLCRealtimeSegmentManager(mockHelixResourceManager, 
mockControllerMetrics);
+    IdealState idealState = new IdealState(REALTIME_TABLE_NAME);
+    ExternalView externalView = new ExternalView(REALTIME_TABLE_NAME);
+    String committingPartialErrorSegment = 
addSegmentForErrorRepair(mockHelixResourceManager, idealState,
+        externalView, 0, 0, Status.COMMITTING, SegmentStateModel.ONLINE, 
SegmentStateModel.ERROR);
+    addSegmentForErrorRepair(mockHelixResourceManager, idealState, 
externalView, 0, 1, Status.COMMITTING,
+        SegmentStateModel.ERROR, SegmentStateModel.ERROR);
+    // The next segment of the partition whose COMMITTING segment stays in 
ERROR
+    addSegmentForErrorRepair(mockHelixResourceManager, idealState, 
externalView, 0, 2, Status.IN_PROGRESS,
+        SegmentStateModel.ERROR, SegmentStateModel.ERROR);
+    String donePartialErrorSegment = 
addSegmentForErrorRepair(mockHelixResourceManager, idealState, externalView, 1,
+        0, Status.DONE, SegmentStateModel.ONLINE, SegmentStateModel.ERROR);
+    String doneAllErrorSegment = 
addSegmentForErrorRepair(mockHelixResourceManager, idealState, externalView, 1, 
1,
+        Status.DONE, SegmentStateModel.ERROR, SegmentStateModel.ERROR);
+    addSegmentForErrorRepair(mockHelixResourceManager, idealState, 
externalView, 1, 2, Status.IN_PROGRESS,
+        SegmentStateModel.CONSUMING, SegmentStateModel.ERROR);
+    segmentManager._idealState = idealState;
+    
when(mockHelixResourceManager.getTableExternalView(REALTIME_TABLE_NAME)).thenReturn(externalView);
+
+    segmentManager.repairSegmentsInErrorState(createPauselessTableConfig(new 
DedupConfig(), null), false);
+
+    // Only the committed segments are reset: their ERROR replicas reload from 
a peer or the deep store. Resetting an
+    // IN_PROGRESS segment would re-consume it, and the COMMITTING segment 
with every replica in ERROR needs
+    // re-ingestion, both of which stay disallowed on a dedup table without 
the override.
+    verify(mockHelixResourceManager).resetErrorReplicas(REALTIME_TABLE_NAME,
+        Set.of(committingPartialErrorSegment, donePartialErrorSegment, 
doneAllErrorSegment));
+    verify(mockHelixResourceManager, never()).resetSegment(any(), any(), 
any());
+    verify(mockHelixResourceManager, 
never()).getDataInstanceAdminEndpoints(any());
+    // The error count keeps counting segments with every replica in ERROR. 
The two left unrepaired are unrecoverable;
+    // the IN_PROGRESS segment with a healthy replica is not, since its ERROR 
replica is reset once it commits.
+    verify(mockControllerMetrics).setOrUpdateTableGauge(REALTIME_TABLE_NAME,
+        ControllerGauge.PAUSELESS_SEGMENTS_IN_ERROR_COUNT, 3L);
+    verify(mockControllerMetrics).setOrUpdateTableGauge(REALTIME_TABLE_NAME,
+        ControllerGauge.PAUSELESS_SEGMENTS_IN_UNRECOVERABLE_ERROR_COUNT, 2L);
+  }
+
+  @Test
+  public void 
testRepairSegmentsInErrorStateDoesNotReconsumeAfterErrorSegmentOnPauselessPartialUpsertTable()
+      throws InvalidConfigException {
+    PinotHelixResourceManager mockHelixResourceManager = 
mock(PinotHelixResourceManager.class);
+    ControllerMetrics mockControllerMetrics = mock(ControllerMetrics.class);
+    FakePinotLLCRealtimeSegmentManager segmentManager =
+        new FakePinotLLCRealtimeSegmentManager(mockHelixResourceManager, 
mockControllerMetrics);
+    IdealState idealState = new IdealState(REALTIME_TABLE_NAME);
+    ExternalView externalView = new ExternalView(REALTIME_TABLE_NAME);
+    // Re-consuming the IN_PROGRESS segment would merge its rows against the 
previous values held by the COMMITTING
+    // segment before it, which no replica has loaded
+    addSegmentForErrorRepair(mockHelixResourceManager, idealState, 
externalView, 0, 0, Status.COMMITTING,
+        SegmentStateModel.ERROR, SegmentStateModel.ERROR);
+    addSegmentForErrorRepair(mockHelixResourceManager, idealState, 
externalView, 0, 1, Status.IN_PROGRESS,
+        SegmentStateModel.ERROR, SegmentStateModel.ERROR);
+    segmentManager._idealState = idealState;
+    
when(mockHelixResourceManager.getTableExternalView(REALTIME_TABLE_NAME)).thenReturn(externalView);
+
+    segmentManager.repairSegmentsInErrorState(
+        createPauselessTableConfig(null, new 
UpsertConfig(UpsertConfig.Mode.PARTIAL)), false);
+
+    verify(mockHelixResourceManager, never()).resetErrorReplicas(any(), any());
+    verify(mockHelixResourceManager, never()).resetSegment(any(), any(), 
any());
+    verify(mockHelixResourceManager, 
never()).getDataInstanceAdminEndpoints(any());
+    verify(mockControllerMetrics).setOrUpdateTableGauge(REALTIME_TABLE_NAME,
+        ControllerGauge.PAUSELESS_SEGMENTS_IN_UNRECOVERABLE_ERROR_COUNT, 2L);
+  }
+
+  @Test
+  public void 
testRepairSegmentsInErrorStateReingestsAndReconsumesOnPauselessDedupTableWithOverride()
+      throws InvalidConfigException {
+    PinotHelixResourceManager mockHelixResourceManager = 
mock(PinotHelixResourceManager.class);
+    ControllerMetrics mockControllerMetrics = mock(ControllerMetrics.class);
+    FakePinotLLCRealtimeSegmentManager segmentManager =
+        new FakePinotLLCRealtimeSegmentManager(mockHelixResourceManager, 
mockControllerMetrics);
+    IdealState idealState = new IdealState(REALTIME_TABLE_NAME);
+    ExternalView externalView = new ExternalView(REALTIME_TABLE_NAME);
+    String committingAllErrorSegment = 
addSegmentForErrorRepair(mockHelixResourceManager, idealState, externalView,
+        0, 0, Status.COMMITTING, SegmentStateModel.ERROR, 
SegmentStateModel.ERROR);
+    String inProgressAllErrorSegment = 
addSegmentForErrorRepair(mockHelixResourceManager, idealState, externalView,
+        0, 1, Status.IN_PROGRESS, SegmentStateModel.ERROR, 
SegmentStateModel.ERROR);
+    segmentManager._idealState = idealState;
+    
when(mockHelixResourceManager.getTableExternalView(REALTIME_TABLE_NAME)).thenReturn(externalView);
+    BiMap<String, String> serverEndpoints = HashBiMap.create(
+        Map.of(Helix.PREFIX_OF_SERVER_INSTANCE + 0, "http://server0:8097";, 
Helix.PREFIX_OF_SERVER_INSTANCE + 1,
+            "http://server1:8097";));
+    
when(mockHelixResourceManager.getDataInstanceAdminEndpoints(serverEndpoints.keySet())).thenReturn(
+        serverEndpoints);
+
+    segmentManager.repairSegmentsInErrorState(createPauselessTableConfig(new 
DedupConfig(), null), true);
+
+    // With the override, the COMMITTING segment is re-ingested on one of the 
servers hosting it, and the IN_PROGRESS
+    // segment is reset so it re-consumes
+    assertEquals(segmentManager._reingestionRequests.keySet(), 
Set.of(committingAllErrorSegment));
+    
assertTrue(serverEndpoints.containsValue(segmentManager._reingestionRequests.get(committingAllErrorSegment)));
+    verify(mockHelixResourceManager).resetErrorReplicas(REALTIME_TABLE_NAME, 
Set.of(inProgressAllErrorSegment));
+    verify(mockControllerMetrics).setOrUpdateTableGauge(REALTIME_TABLE_NAME,
+        ControllerGauge.PAUSELESS_SEGMENTS_IN_UNRECOVERABLE_ERROR_COUNT, 0L);
+  }
+
+  @Test
+  public void 
testRepairSegmentsInErrorStateCountsFailedRepairsAsUnrecoverable()
+      throws InvalidConfigException {
+    PinotHelixResourceManager mockHelixResourceManager = 
mock(PinotHelixResourceManager.class);
+    ControllerMetrics mockControllerMetrics = mock(ControllerMetrics.class);
+    FakePinotLLCRealtimeSegmentManager segmentManager =
+        new FakePinotLLCRealtimeSegmentManager(mockHelixResourceManager, 
mockControllerMetrics);
+    IdealState idealState = new IdealState(REALTIME_TABLE_NAME);
+    ExternalView externalView = new ExternalView(REALTIME_TABLE_NAME);
+    addSegmentForErrorRepair(mockHelixResourceManager, idealState, 
externalView, 0, 0, Status.COMMITTING,
+        SegmentStateModel.ERROR, SegmentStateModel.ERROR);
+    String donePartialErrorSegment = 
addSegmentForErrorRepair(mockHelixResourceManager, idealState, externalView, 1,
+        0, Status.DONE, SegmentStateModel.ONLINE, SegmentStateModel.ERROR);
+    segmentManager._idealState = idealState;
+    
when(mockHelixResourceManager.getTableExternalView(REALTIME_TABLE_NAME)).thenReturn(externalView);
+    
when(mockHelixResourceManager.getDataInstanceAdminEndpoints(any())).thenReturn(HashBiMap.create(
+        Map.of(Helix.PREFIX_OF_SERVER_INSTANCE + 0, "http://server0:8097";, 
Helix.PREFIX_OF_SERVER_INSTANCE + 1,
+            "http://server1:8097";)));
+    segmentManager._failReingestion = true;
+    when(mockHelixResourceManager.resetErrorReplicas(REALTIME_TABLE_NAME, 
Set.of(donePartialErrorSegment)))
+        .thenReturn(Set.of(donePartialErrorSegment));
+
+    // A failed reset fails the pass, as it did before resets were isolated 
per segment, after the gauge is set
+    RuntimeException exception = expectThrows(RuntimeException.class,
+        () -> 
segmentManager.repairSegmentsInErrorState(createPauselessTableConfig(null, 
null), false));
+    assertTrue(exception.getMessage().contains(donePartialErrorSegment), 
exception.getMessage());
+    verify(mockControllerMetrics).setOrUpdateTableGauge(REALTIME_TABLE_NAME,
+        ControllerGauge.PAUSELESS_SEGMENTS_IN_UNRECOVERABLE_ERROR_COUNT, 2L);
+  }
+
+  @Test
+  public void 
testRepairSegmentsInErrorStateCountsCommittingSegmentWithoutServerEndpointAsUnrecoverable()
+      throws InvalidConfigException {
+    PinotHelixResourceManager mockHelixResourceManager = 
mock(PinotHelixResourceManager.class);
+    ControllerMetrics mockControllerMetrics = mock(ControllerMetrics.class);
+    FakePinotLLCRealtimeSegmentManager segmentManager =
+        new FakePinotLLCRealtimeSegmentManager(mockHelixResourceManager, 
mockControllerMetrics);
+    IdealState idealState = new IdealState(REALTIME_TABLE_NAME);
+    ExternalView externalView = new ExternalView(REALTIME_TABLE_NAME);
+    addSegmentForErrorRepair(mockHelixResourceManager, idealState, 
externalView, 0, 0, Status.COMMITTING,
+        SegmentStateModel.ERROR, SegmentStateModel.ERROR);
+    segmentManager._idealState = idealState;
+    
when(mockHelixResourceManager.getTableExternalView(REALTIME_TABLE_NAME)).thenReturn(externalView);
+    
when(mockHelixResourceManager.getDataInstanceAdminEndpoints(any())).thenReturn(HashBiMap.create());
+
+    segmentManager.repairSegmentsInErrorState(createPauselessTableConfig(null, 
null), false);
+
+    assertTrue(segmentManager._reingestionRequests.isEmpty());
+    verify(mockControllerMetrics).setOrUpdateTableGauge(REALTIME_TABLE_NAME,
+        ControllerGauge.PAUSELESS_SEGMENTS_IN_UNRECOVERABLE_ERROR_COUNT, 1L);
+  }
+
+  @Test
+  public void testRepairSegmentsInErrorStateCountsSegmentsWithNoOtherSource()
+      throws InvalidConfigException {
+    PinotHelixResourceManager mockHelixResourceManager = 
mock(PinotHelixResourceManager.class);
+    ControllerMetrics mockControllerMetrics = mock(ControllerMetrics.class);
+    FakePinotLLCRealtimeSegmentManager segmentManager =
+        new FakePinotLLCRealtimeSegmentManager(mockHelixResourceManager, 
mockControllerMetrics);
+    IdealState idealState = new IdealState(REALTIME_TABLE_NAME);
+    ExternalView externalView = new ExternalView(REALTIME_TABLE_NAME);
+    // The commit has not updated the ideal state yet, so a reset would 
re-consume these segments. They wait for the
+    // commit, which the second one has no replica left to finish.
+    String committingStillConsumingSegment = 
addSegmentForErrorRepair(mockHelixResourceManager, idealState,
+        externalView, 0, 0, Status.COMMITTING, SegmentStateModel.CONSUMING, 
SegmentStateModel.ERROR);
+    String committingStillConsumingWithoutConsumerSegment = 
addSegmentForErrorRepair(mockHelixResourceManager,
+        idealState, externalView, 0, 1, Status.COMMITTING, 
SegmentStateModel.OFFLINE, SegmentStateModel.ERROR);
+    for (String segmentName : List.of(committingStillConsumingSegment,
+        committingStillConsumingWithoutConsumerSegment)) {
+      idealState.setPartitionState(segmentName, 
Helix.PREFIX_OF_SERVER_INSTANCE + 0, SegmentStateModel.CONSUMING);
+      idealState.setPartitionState(segmentName, 
Helix.PREFIX_OF_SERVER_INSTANCE + 1, SegmentStateModel.CONSUMING);
+    }
+    // Not in the deep store yet: reset, since the ERROR replica may load a 
local copy or wait for the other replica.
+    // With no ONLINE or CONSUMING replica, only a local copy is left.
+    String committingWithConsumingReplicaSegment = 
addSegmentForErrorRepair(mockHelixResourceManager, idealState,
+        externalView, 1, 0, Status.COMMITTING, SegmentStateModel.CONSUMING, 
SegmentStateModel.ERROR);
+    String committingWithOfflineReplicaSegment = 
addSegmentForErrorRepair(mockHelixResourceManager, idealState,
+        externalView, 2, 0, Status.COMMITTING, SegmentStateModel.OFFLINE, 
SegmentStateModel.ERROR);
+    // Committed with a peer download URL
+    String peerDownloadOnlineSegment = 
addSegmentForErrorRepair(mockHelixResourceManager, idealState, externalView, 3,
+        0, Status.DONE, SegmentStateModel.ONLINE, SegmentStateModel.ERROR);
+    String peerDownloadAllErrorSegment = 
addSegmentForErrorRepair(mockHelixResourceManager, idealState, externalView,
+        3, 1, Status.DONE, SegmentStateModel.ERROR, SegmentStateModel.ERROR);
+    for (String segmentName : List.of(peerDownloadOnlineSegment, 
peerDownloadAllErrorSegment)) {
+      SegmentZKMetadata segmentZKMetadata = new SegmentZKMetadata(segmentName);
+      segmentZKMetadata.setStatus(Status.DONE);
+      segmentZKMetadata.setDownloadUrl(METADATA_URI_FOR_PEER_DOWNLOAD);
+      when(mockHelixResourceManager.getSegmentZKMetadata(REALTIME_TABLE_NAME, 
segmentName)).thenReturn(
+          segmentZKMetadata);
+    }
+    segmentManager._idealState = idealState;
+    
when(mockHelixResourceManager.getTableExternalView(REALTIME_TABLE_NAME)).thenReturn(externalView);
+    Set<String> segmentsToReset = 
Set.of(committingWithConsumingReplicaSegment, 
committingWithOfflineReplicaSegment,
+        peerDownloadOnlineSegment, peerDownloadAllErrorSegment);
+    // A segment whose reset also fails is counted once
+    when(mockHelixResourceManager.resetErrorReplicas(REALTIME_TABLE_NAME, 
segmentsToReset)).thenReturn(
+        Set.of(peerDownloadAllErrorSegment));
+
+    expectThrows(RuntimeException.class,
+        () -> 
segmentManager.repairSegmentsInErrorState(createPauselessTableConfig(null, 
null), false));
+
+    verify(mockHelixResourceManager).resetErrorReplicas(REALTIME_TABLE_NAME, 
segmentsToReset);
+    verify(mockControllerMetrics).setOrUpdateTableGauge(REALTIME_TABLE_NAME,
+        ControllerGauge.PAUSELESS_SEGMENTS_IN_UNRECOVERABLE_ERROR_COUNT, 3L);
+  }
+
+  private static TableConfig createPauselessTableConfig(@Nullable DedupConfig 
dedupConfig,
+      @Nullable UpsertConfig upsertConfig) {
+    IngestionConfig ingestionConfig = new IngestionConfig();
+    StreamIngestionConfig streamIngestionConfig = new StreamIngestionConfig(
+        
List.of(FakeStreamConfigUtils.getDefaultLowLevelStreamConfigs().getStreamConfigsMap()));
+    streamIngestionConfig.setPauselessConsumptionEnabled(true);
+    ingestionConfig.setStreamIngestionConfig(streamIngestionConfig);
+    return new 
TableConfigBuilder(TableType.REALTIME).setTableName(RAW_TABLE_NAME)
+        .setNumReplicas(2)
+        .setIngestionConfig(ingestionConfig)
+        .setDedupConfig(dedupConfig)
+        .setUpsertConfig(upsertConfig)
+        .build();
+  }
+
+  /// Adds a segment with one replica per given external view state, hosted on 
servers 0, 1, ..., and mocks its ZK
+  /// metadata status. Returns the segment name.
+  private static String addSegmentForErrorRepair(PinotHelixResourceManager 
mockHelixResourceManager,
+      IdealState idealState, ExternalView externalView, int partitionId, int 
sequenceNumber, Status status,
+      String... replicaStates) {
+    String segmentName = new LLCSegmentName(RAW_TABLE_NAME, partitionId, 
sequenceNumber, CURRENT_TIME_MS)
+        .getSegmentName();
+    String idealStateState = status == Status.IN_PROGRESS ? 
SegmentStateModel.CONSUMING : SegmentStateModel.ONLINE;
+    for (int i = 0; i < replicaStates.length; i++) {
+      String server = Helix.PREFIX_OF_SERVER_INSTANCE + i;
+      idealState.setPartitionState(segmentName, server, idealStateState);
+      externalView.setState(segmentName, server, replicaStates[i]);
+    }
+    SegmentZKMetadata segmentZKMetadata = new SegmentZKMetadata(segmentName);
+    segmentZKMetadata.setStatus(status);
+    when(mockHelixResourceManager.getSegmentZKMetadata(REALTIME_TABLE_NAME, 
segmentName)).thenReturn(
+        segmentZKMetadata);
+    return segmentName;
+  }
+
   @Test
   public void testOnChangeUpdatesMaxSegmentCompletionTime() {
     FakePinotLLCRealtimeSegmentManager segmentManager = new 
FakePinotLLCRealtimeSegmentManager();
@@ -3043,6 +3292,9 @@ public class PinotLLCRealtimeSegmentManagerTest {
     };
     FileUploadDownloadClient _mockedFileUploadDownloadClient;
     PinotHelixResourceManager _mockResourceManager;
+    // Segment name to the server endpoint re-ingestion was triggered on
+    Map<String, String> _reingestionRequests = new HashMap<>();
+    boolean _failReingestion = false;
 
     FakePinotLLCRealtimeSegmentManager() {
       this(createMockedResourceManager());
@@ -3054,7 +3306,12 @@ public class PinotLLCRealtimeSegmentManagerTest {
     }
 
     FakePinotLLCRealtimeSegmentManager(PinotHelixResourceManager 
pinotHelixResourceManager) {
-      super(pinotHelixResourceManager, CONTROLLER_CONF, 
mock(ControllerMetrics.class));
+      this(pinotHelixResourceManager, mock(ControllerMetrics.class));
+    }
+
+    FakePinotLLCRealtimeSegmentManager(PinotHelixResourceManager 
pinotHelixResourceManager,
+        ControllerMetrics controllerMetrics) {
+      super(pinotHelixResourceManager, CONTROLLER_CONF, controllerMetrics);
       _mockResourceManager = pinotHelixResourceManager;
     }
 
@@ -3285,6 +3542,15 @@ public class PinotLLCRealtimeSegmentManagerTest {
     long getCurrentTimeMs() {
       return _currentTimeMs;
     }
+
+    @Override
+    void triggerReingestion(String serverHostPort, String segmentName)
+        throws IOException {
+      if (_failReingestion) {
+        throw new IOException("Injected re-ingestion failure for segment: " + 
segmentName);
+      }
+      _reingestionRequests.put(segmentName, serverHostPort);
+    }
   }
 
   private static class FakePinotLLCRealtimeSegmentManagerII extends 
FakePinotLLCRealtimeSegmentManager {
diff --git 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/PauselessDedupErrorReplicaRepairTest.java
 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/PauselessDedupErrorReplicaRepairTest.java
new file mode 100644
index 00000000000..578bb549c06
--- /dev/null
+++ 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/PauselessDedupErrorReplicaRepairTest.java
@@ -0,0 +1,281 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.integration.tests;
+
+import java.io.File;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import org.apache.commons.io.FileUtils;
+import org.apache.helix.model.ExternalView;
+import org.apache.pinot.controller.ControllerConf;
+import 
org.apache.pinot.controller.helix.core.periodictask.ControllerPeriodicTask;
+import 
org.apache.pinot.integration.tests.realtime.utils.FailureInjectingTableConfig;
+import 
org.apache.pinot.integration.tests.realtime.utils.FailureInjectingTableDataManagerProvider;
+import 
org.apache.pinot.integration.tests.realtime.utils.PauselessRealtimeTestUtils;
+import org.apache.pinot.server.starter.helix.HelixInstanceDataManagerConfig;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.ingestion.IngestionConfig;
+import 
org.apache.pinot.spi.config.table.ingestion.ParallelSegmentConsumptionPolicy;
+import org.apache.pinot.spi.config.table.ingestion.StreamIngestionConfig;
+import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.env.PinotConfiguration;
+import org.apache.pinot.spi.stream.StreamConfigProperties;
+import org.apache.pinot.spi.utils.CommonConstants;
+import 
org.apache.pinot.spi.utils.CommonConstants.Helix.StateModel.SegmentStateModel;
+import org.apache.pinot.spi.utils.builder.TableNameBuilder;
+import org.apache.pinot.util.TestUtils;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertNotNull;
+
+
+/// Verifies that on a pauseless dedup table WITHOUT the disaster recovery 
override, the segment-level validation
+/// repairs ERROR replicas whose segment another replica holds, by resetting 
only those replicas so they reload from
+/// a peer or the deep store.
+///
+/// The consuming transition is made to fail on one server only, so every 
affected segment keeps one healthy replica
+/// and one ERROR replica. Re-ingestion is never needed for this repair, so it 
must run even though re-ingestion is
+/// disallowed for dedup tables. The dedup consume gate also blocks the 
failing server's consumers while any of its
+/// replicas is in ERROR, so without this repair the lagging server never 
catches up.
+public class PauselessDedupErrorReplicaRepairTest extends 
BaseClusterIntegrationTest {
+  private static final int NUM_PARTITIONS = 2;
+  private static final long VALIDATION_RERUN_INTERVAL_MS = 10_000L;
+
+  @Override
+  protected String getAvroTarFileName() {
+    return "dedupPauselessIngestionTestData.tar.gz";
+  }
+
+  @Override
+  protected int getRealtimeSegmentFlushSize() {
+    return 2;
+  }
+
+  @Override
+  protected String getSchemaFileName() {
+    return "dedupIngestionTestSchema.schema";
+  }
+
+  @Override
+  protected long getCountStarResult() {
+    // Two distinct records are expected with pk values of 0, 1.
+    return 2;
+  }
+
+  @Override
+  protected String getPartitionColumn() {
+    return "id";
+  }
+
+  @Override
+  protected int getNumReplicas() {
+    return 2;
+  }
+
+  @Override
+  protected IngestionConfig getIngestionConfig() {
+    IngestionConfig ingestionConfig = new IngestionConfig();
+    Map<String, String> streamConfigs = getStreamConfigs();
+    
streamConfigs.put(StreamConfigProperties.PAUSELESS_SEGMENT_DOWNLOAD_TIMEOUT_SECONDS,
 "10");
+    StreamIngestionConfig streamIngestionConfig = new 
StreamIngestionConfig(List.of(streamConfigs));
+    streamIngestionConfig.setPauselessConsumptionEnabled(true);
+    
streamIngestionConfig.setParallelSegmentConsumptionPolicy(ParallelSegmentConsumptionPolicy.ALLOW_DURING_BUILD_ONLY);
+    streamIngestionConfig.setEnforceConsumptionInOrder(true);
+    // Intentionally no disasterRecoveryMode: repair of COMMITTING segments 
stays disallowed for this dedup table,
+    // which must not stop the reset of ERROR replicas that have a healthy 
copy elsewhere.
+    ingestionConfig.setStreamIngestionConfig(streamIngestionConfig);
+    return ingestionConfig;
+  }
+
+  @Override
+  protected void overrideControllerConf(Map<String, Object> properties) {
+    
properties.put(ControllerConf.ControllerPeriodicTasksConf.ENABLE_DEEP_STORE_RETRY_UPLOAD_LLC_SEGMENT,
 true);
+    // Recovery is triggered explicitly by the test; keep the scheduled 
validator from racing it.
+    
properties.put(ControllerConf.ControllerPeriodicTasksConf.REALTIME_SEGMENT_VALIDATION_INITIAL_DELAY_IN_SECONDS,
+        3600);
+  }
+
+  @Override
+  protected PinotConfiguration getServerConf(int serverId) {
+    PinotConfiguration serverConf = super.getServerConf(serverId);
+    serverConf.setProperty("pinot.server.instance.segment.store.uri", "file:" 
+ _controllerConfig.getDataDir());
+    serverConf.setProperty("pinot.server.instance." + 
HelixInstanceDataManagerConfig.UPLOAD_SEGMENT_TO_DEEP_STORE,
+        "true");
+    serverConf.setProperty("pinot.server.instance." + 
CommonConstants.Server.TABLE_DATA_MANAGER_PROVIDER_CLASS,
+        FailureInjectingTableDataManagerProvider.class.getName());
+    if (serverId == 1) {
+      // Fail the consuming transition of the first segment of each partition 
on this server only, so each affected
+      // segment has one ERROR replica here and one healthy replica on the 
other server.
+      serverConf.setProperty(
+          "pinot.server.instance." + 
FailureInjectingTableDataManagerProvider.FAILURE_CONFIG_KEY + "."
+              + getTableName(), new FailureInjectingTableConfig(false, true, 
NUM_PARTITIONS).toJson());
+    }
+    return serverConf;
+  }
+
+  @BeforeClass(alwaysRun = true)
+  public void setUp()
+      throws Exception {
+    TestUtils.ensureDirectoriesExistAndEmpty(_tempDir, _segmentDir, _tarDir);
+    startZk();
+    startKafka();
+    startController();
+    startBroker();
+    startServers(2);
+
+    List<File> avroFiles = unpackAvroData(_tempDir);
+    pushAvroIntoKafka(avroFiles);
+
+    Schema schema = createSchema();
+    addSchema(schema);
+    TableConfig tableConfig = createDedupTableConfig(avroFiles.get(0), 
getPartitionColumn(), NUM_PARTITIONS);
+    assertNotNull(tableConfig.getDedupConfig());
+    tableConfig.getValidationConfig().setRetentionTimeUnit("DAYS");
+    tableConfig.getValidationConfig().setRetentionTimeValue("100000");
+    
tableConfig.getValidationConfig().setPeerSegmentDownloadScheme(CommonConstants.HTTP_PROTOCOL);
+    addTableConfig(tableConfig);
+  }
+
+  @Test
+  public void testErrorReplicasAreRepairedWithoutDisasterRecoveryOverride()
+      throws Exception {
+    String realtimeTableName = 
TableNameBuilder.REALTIME.tableNameWithType(getTableName());
+
+    TestUtils.waitForCondition(aVoid -> 
getNumErrorReplicasInEV(realtimeTableName) > 0, 600_000L,
+        "Injected failures did not put any replica in ERROR state");
+
+    // Drive the segment-level validation periodically (as it runs in 
production) until every ERROR replica is
+    // repaired, i.e. reaches its ideal state: leaving ERROR is not enough, 
since a reset replica goes through OFFLINE.
+    // The runs are spaced out so a pass does not re-trigger resets still in 
flight from the previous pass.
+    long[] lastValidationRunMs = new long[1];
+    TestUtils.waitForCondition(aVoid -> {
+      if 
(PauselessRealtimeTestUtils.isExternalViewConverged(_helixResourceManager, 
realtimeTableName)) {
+        return true;
+      }
+      long nowMs = System.currentTimeMillis();
+      if (nowMs - lastValidationRunMs[0] >= VALIDATION_RERUN_INTERVAL_MS) {
+        lastValidationRunMs[0] = nowMs;
+        runSegmentLevelValidation();
+      }
+      return false;
+    }, 1000L, 600_000L, "ERROR replicas were not repaired without the disaster 
recovery override");
+
+    // With all its replicas repaired, the lagging server passes the dedup 
consume gate and catches up, so queries
+    // return the deduplicated count from either replica group.
+    waitForAllDocsLoaded(600_000L);
+  }
+
+  private int getNumErrorReplicasInEV(String realtimeTableName) {
+    ExternalView externalView = _helixResourceManager.getHelixAdmin()
+        .getResourceExternalView(_helixResourceManager.getHelixClusterName(), 
realtimeTableName);
+    if (externalView == null) {
+      return 0;
+    }
+    int numErrorReplicas = 0;
+    for (Map<String, String> instanceStateMap : 
externalView.getRecord().getMapFields().values()) {
+      for (String state : instanceStateMap.values()) {
+        if (state.equals(SegmentStateModel.ERROR)) {
+          numErrorReplicas++;
+        }
+      }
+    }
+    return numErrorReplicas;
+  }
+
+  private void runSegmentLevelValidation() {
+    Properties periodicTaskProperties = new Properties();
+    
periodicTaskProperties.setProperty(ControllerPeriodicTask.RUN_SEGMENT_LEVEL_VALIDATION,
 Boolean.TRUE.toString());
+    
_controllerStarter.getRealtimeSegmentValidationManager().run(periodicTaskProperties);
+  }
+
+  @AfterClass(alwaysRun = true)
+  public void tearDown()
+      throws Exception {
+    // Run every step even if an earlier one fails, so a failed test does not 
leak the cluster into later suites
+    Throwable cleanupFailure = null;
+    try {
+      if (_helixResourceManager != null && 
_helixResourceManager.getRealtimeTableConfig(getTableName()) != null) {
+        dropRealtimeTable(getTableName());
+      }
+    } catch (Throwable t) {
+      cleanupFailure = addCleanupFailure(cleanupFailure, t);
+    }
+    try {
+      if (!_serverStarters.isEmpty()) {
+        stopServer();
+      }
+    } catch (Throwable t) {
+      cleanupFailure = addCleanupFailure(cleanupFailure, t);
+    }
+    try {
+      if (!_brokerStarters.isEmpty()) {
+        stopBroker();
+      }
+    } catch (Throwable t) {
+      cleanupFailure = addCleanupFailure(cleanupFailure, t);
+    }
+    try {
+      if (_controllerStarter != null) {
+        stopController();
+      }
+    } catch (Throwable t) {
+      cleanupFailure = addCleanupFailure(cleanupFailure, t);
+    }
+    try {
+      stopKafka();
+    } catch (Throwable t) {
+      cleanupFailure = addCleanupFailure(cleanupFailure, t);
+    }
+    try {
+      stopZk();
+    } catch (Throwable t) {
+      cleanupFailure = addCleanupFailure(cleanupFailure, t);
+    }
+    try {
+      FileUtils.deleteDirectory(_tempDir);
+    } catch (Throwable t) {
+      cleanupFailure = addCleanupFailure(cleanupFailure, t);
+    }
+    if (cleanupFailure != null) {
+      rethrowCleanupFailure(cleanupFailure);
+    }
+  }
+
+  private static Throwable addCleanupFailure(Throwable cleanupFailure, 
Throwable failure) {
+    if (cleanupFailure == null) {
+      return failure;
+    }
+    cleanupFailure.addSuppressed(failure);
+    return cleanupFailure;
+  }
+
+  private static void rethrowCleanupFailure(Throwable cleanupFailure)
+      throws Exception {
+    if (cleanupFailure instanceof Error) {
+      throw (Error) cleanupFailure;
+    }
+    if (cleanupFailure instanceof Exception) {
+      throw (Exception) cleanupFailure;
+    }
+    throw new RuntimeException(cleanupFailure);
+  }
+}
diff --git 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/realtime/utils/PauselessRealtimeTestUtils.java
 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/realtime/utils/PauselessRealtimeTestUtils.java
index 4b117234f01..783fbc2f156 100644
--- 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/realtime/utils/PauselessRealtimeTestUtils.java
+++ 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/realtime/utils/PauselessRealtimeTestUtils.java
@@ -55,19 +55,25 @@ public class PauselessRealtimeTestUtils {
   /// `CONSUMING -> ONLINE` transition would make the one-shot repair 
permanently miss the segment.
   public static void waitForExternalViewToConverge(PinotHelixResourceManager 
helixResourceManager,
       String tableNameWithType, long timeoutMs) {
-    TestUtils.waitForCondition(aVoid -> {
-      IdealState idealState = 
helixResourceManager.getTableIdealState(tableNameWithType);
-      ExternalView externalView = 
helixResourceManager.getTableExternalView(tableNameWithType);
-      if (idealState == null || externalView == null) {
+    TestUtils.waitForCondition(aVoid -> 
isExternalViewConverged(helixResourceManager, tableNameWithType), 1000,
+        timeoutMs, "External view failed to converge to ideal state for table: 
" + tableNameWithType);
+  }
+
+  /// Returns whether every instance of every segment in the ideal state of 
the given table is in its prescribed state
+  /// in the external view.
+  public static boolean isExternalViewConverged(PinotHelixResourceManager 
helixResourceManager,
+      String tableNameWithType) {
+    IdealState idealState = 
helixResourceManager.getTableIdealState(tableNameWithType);
+    ExternalView externalView = 
helixResourceManager.getTableExternalView(tableNameWithType);
+    if (idealState == null || externalView == null) {
+      return false;
+    }
+    for (Map.Entry<String, Map<String, String>> entry : 
idealState.getRecord().getMapFields().entrySet()) {
+      if (!entry.getValue().equals(externalView.getStateMap(entry.getKey()))) {
         return false;
       }
-      for (Map.Entry<String, Map<String, String>> entry : 
idealState.getRecord().getMapFields().entrySet()) {
-        if 
(!entry.getValue().equals(externalView.getStateMap(entry.getKey()))) {
-          return false;
-        }
-      }
-      return true;
-    }, 1000, timeoutMs, "External view failed to converge to ideal state for 
table: " + tableNameWithType);
+    }
+    return true;
   }
 
   /// Marks all current segments of the given table as exceeding the max 
segment completion time, making them


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

Reply via email to