xiangfu0 commented on code in PR #19909:
URL: https://github.com/apache/pinot/pull/19909#discussion_r4321116699


##########
pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/realtime/PinotLLCRealtimeSegmentManager.java:
##########
@@ -3020,13 +3020,13 @@ public void repairSegmentsInErrorState(TableConfig 
tableConfig,
       if (status == Status.IN_PROGRESS && 
segmentsInErrorStateInAllReplicas.contains(segmentName)) {
         LOGGER.info("Resetting IN_PROGRESS segment: {} in table: {} since all 
replicas are in ERROR state.",
             segmentName, realtimeTableName);
-        _helixResourceManager.resetSegment(realtimeTableName, segmentName, 
null);
+        resetErrorReplicas(realtimeTableName, segmentName, currentStateMap);
         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);
+        resetErrorReplicas(realtimeTableName, segmentName, currentStateMap);

Review Comment:
   **Unbounded reset loop for stuck partial-ERROR COMMITTING segments, with 
10-minute Helix thread stalls per cycle.** A COMMITTING pauseless segment with 
some (not all) replicas in ERROR lands here and is reset on every validation 
run with no attempt cap or backoff. On the server, OFFLINE->ONLINE for a 
COMMITTING segment enters `RealtimeTableDataManager.downloadSegment`'s 
busy-wait (default `DEFAULT_SEGMENT_DOWNLOAD_TIMEOUT_MS` = 10 min) holding the 
segment lock on the Helix state-transition thread; if the commit is permanently 
stuck (committing server died after COMMIT_START) and no replica is ONLINE to 
peer-download from, it times out back to ERROR and the next run resets it 
again, forever. Since re-ingestion stays gated by default on 
dedup/partial-upsert tables, this state can be permanent — yet it is never 
counted in `PAUSELESS_SEGMENTS_IN_UNRECOVERABLE_ERROR_COUNT` (only the 
all-ERROR skip branch increments it). Suggest skipping (and counting as 
unrecoverable) COMMITTING segm
 ents here, or adding attempt tracking/backoff.



##########
pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/realtime/PinotLLCRealtimeSegmentManager.java:
##########
@@ -3020,13 +3020,13 @@ public void repairSegmentsInErrorState(TableConfig 
tableConfig,
       if (status == Status.IN_PROGRESS && 
segmentsInErrorStateInAllReplicas.contains(segmentName)) {
         LOGGER.info("Resetting IN_PROGRESS segment: {} in table: {} since all 
replicas are in ERROR state.",
             segmentName, realtimeTableName);
-        _helixResourceManager.resetSegment(realtimeTableName, segmentName, 
null);
+        resetErrorReplicas(realtimeTableName, segmentName, currentStateMap);
         continue;
       }
 
       // 3) For all other non-IN_PROGRESS statuses with any ERROR replica, 
trigger a reset to download/refresh
       if (status != Status.IN_PROGRESS) {

Review Comment:
   **Unhandled case: IN_PROGRESS segment with only SOME replicas in ERROR 
matches no branch.** Case 1 needs COMMITTING, case 2 needs all-ERROR, and this 
guard excludes IN_PROGRESS — so a consuming segment with one ERROR replica is 
silently skipped every run, while the new gauges now count it and the header 
doc says a lagging replica can recover. Concretely: one replica of a consuming 
segment errors while the other keeps consuming (the integration test's own 
injected failure, observed before commit); nothing resets it until the segment 
commits (hours on time-based flush), `PAUSELESS_SEGMENTS_IN_ERROR_COUNT` stays 
nonzero with UNRECOVERABLE at 0, and with `enforceConsumptionInOrder` the next 
segment's consumer on that server stays gated. Since `resetErrorReplicas` now 
touches only ERROR instances, the all-ERROR precondition on case 2 (which 
existed to protect healthy replicas from the old untargeted reset) no longer 
protects anything — cases 2 and 3 can collapse into "resetErrorRep
 licas unless COMMITTING && all-ERROR". If skipping live IN_PROGRESS replicas 
is intentional, please log and count that cell explicitly.



##########
pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/realtime/PinotLLCRealtimeSegmentManager.java:
##########
@@ -3020,13 +3020,13 @@ public void repairSegmentsInErrorState(TableConfig 
tableConfig,
       if (status == Status.IN_PROGRESS && 
segmentsInErrorStateInAllReplicas.contains(segmentName)) {
         LOGGER.info("Resetting IN_PROGRESS segment: {} in table: {} since all 
replicas are in ERROR state.",
             segmentName, realtimeTableName);
-        _helixResourceManager.resetSegment(realtimeTableName, segmentName, 
null);
+        resetErrorReplicas(realtimeTableName, segmentName, currentStateMap);

Review Comment:
   **Potential silent data loss on dedup tables (newly enabled by this PR).** 
This reset of an all-ERROR IN_PROGRESS segment re-consumes from the stream — it 
does not "only reload data another replica or the deep store already holds" as 
the case-1 comment claims. Nothing cleans dedup metadata on the ERROR 
transition: `onBecomeOfflineFromError` is a no-op, 
`BaseTableDataManager.doOffloadSegment` unregisters the manager *before* 
`offload()` (and `releaseSegment` is not in a finally), and 
`SegmentDataManager.offload()`'s CAS means a failed offload is never retried. 
So a mid-consumption CONSUMING->OFFLINE failure inside 
`MutableSegmentImpl.offload()` (e.g. dedup `removeSegment` rethrows) leaves 
orphaned PKs registered. After this reset, the fresh consumer's rows hit 
`checkRecordPresentOrUpdate`, which treats the orphaned entries as present, and 
silently drops them -> permanent row loss on all replicas, where pre-PR the 
segment stayed a visible ERROR. Suggest having the reset path offlo
 ad/clean dedup (and upsert) metadata for the segment before re-consuming, or 
gating this branch for dedup tables like re-ingestion is.



##########
pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/realtime/PinotLLCRealtimeSegmentManager.java:
##########
@@ -2956,22 +2958,14 @@ public void repairSegmentsInErrorState(TableConfig 
tableConfig,
     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);
-    }
 
+    // Count segments with any ERROR replica so a single lagging replica is 
visible in the metric
     if (isPauselessTable) {
       _controllerMetrics.setOrUpdateTableGauge(realtimeTableName, 
ControllerGauge.PAUSELESS_SEGMENTS_IN_ERROR_COUNT,
-          segmentsInErrorStateInAllReplicas.size());
+          segmentsInErrorStateInAtLeastOneReplica.size());
     } else {
       _controllerMetrics.setOrUpdateTableGauge(realtimeTableName, 
ControllerGauge.SEGMENTS_IN_ERROR_STATE,

Review Comment:
   **`SEGMENTS_IN_ERROR_STATE` now has two writers with different units.** 
`SegmentStatusChecker` (SegmentStatusChecker.java:575) writes the count of 
(segment, instance) ERROR *pairs* for the same per-table gauge, while this now 
writes the count of *segments* with >=1 ERROR replica. For a non-pauseless 
table with `autoResetErrorSegmentsOnValidation` enabled and 1 segment in ERROR 
on 3 replicas, the gauge flaps between 3 and 1 depending on which periodic task 
ran last. The dual-writer conflict predates this PR, but it was latent (this 
writer almost always wrote 0 since all-replica ERROR is rare); after this 
change both writers are routinely nonzero with different values, breaking 
threshold alerts. Suggest giving the gauge a single owner (SegmentStatusChecker 
always runs) and dropping the write here, or using a separate gauge name for 
the repair-side count.



##########
pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/realtime/PinotLLCRealtimeSegmentManager.java:
##########
@@ -3020,13 +3020,13 @@ public void repairSegmentsInErrorState(TableConfig 
tableConfig,
       if (status == Status.IN_PROGRESS && 
segmentsInErrorStateInAllReplicas.contains(segmentName)) {
         LOGGER.info("Resetting IN_PROGRESS segment: {} in table: {} since all 
replicas are in ERROR state.",

Review Comment:
   **Reset can convert a visible ERROR into a silent ingestion stall when a 
stale segment data manager is still registered.** The ERROR path never 
offloads, so if the original failure happened after `registerSegment` in 
`doAddConsumingSegment` (RealtimeTableDataManager.java:632; e.g. 
`startConsumption`/thread-start failure, or a Helix transition timeout landing 
post-registration), the dead manager stays registered. The reset's 
OFFLINE->CONSUMING then hits the "already exists, skipping adding it as 
CONSUMING segment" early return (RealtimeTableDataManager.java:593-598) and 
*succeeds*: EV shows CONSUMING with no live consumer, this repair loop no 
longer sees the segment (it only scans ERROR states), and with 
`enforceConsumptionInOrder` all later segments of the partition on that server 
block behind it. The ONLINE path recovers from a stale manager (lines 484-496); 
the CONSUMING path has no equivalent. Suggest offloading a stale registered 
manager in `doAddConsumingSegment` (mirroring t
 he ONLINE path) as part of enabling these resets.



##########
pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/realtime/PinotLLCRealtimeSegmentManager.java:
##########
@@ -3036,6 +3036,24 @@ public void repairSegmentsInErrorState(TableConfig 
tableConfig,
     }
   }
 
+  /// Resets only the replicas of the segment that are in ERROR state in the 
external view. Healthy replicas are left
+  /// untouched: resetting a healthy replica takes it through OFFLINE, which 
on dedup / upsert tables briefly drops its
+  /// keys from the metadata manager while other replicas keep consuming, and 
can make the only good copy of a
+  /// segment without a deep-store copy unavailable.
+  private void resetErrorReplicas(String realtimeTableName, String 
segmentName, Map<String, String> currentStateMap) {

Review Comment:
   **ZK read amplification on the repair path.** Each `resetSegment(table, 
segment, instance)` call re-reads the full table IdealState and ExternalView 
from ZK, plus per-instance liveInstance/currentState/pending-message reads in 
`resetPartitionAllState` — and this now runs once per ERROR replica instead of 
once per segment. After a server bounce leaves hundreds of segments x 2-3 ERROR 
replicas, one validation pass performs O(segments x replicas) full IS+EV reads 
on the controller periodic-task thread, loading ZK exactly when the cluster is 
degraded. `resetSegments(table, instance, errorSegmentsOnly=true)` / 
`resetPartitionAllState(instance, table, Set<segments>)` already show the 
batched instance->segments pattern; suggest accumulating an 
instance-to-segments map across the loop and resetting once per instance 
(ideally reusing the IS/EV already fetched at the top of this method).



##########
pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/realtime/PinotLLCRealtimeSegmentManager.java:
##########
@@ -2956,22 +2958,14 @@ public void repairSegmentsInErrorState(TableConfig 
tableConfig,
     boolean repairCommittingSegments =

Review Comment:
   **Misleading WARN on every run, even when nothing is disallowed.** 
`allowRepairOfCommittingSegments` is evaluated eagerly here, so every 
validation pass over a dedup/partial-upsert table that has *any* ERROR replica 
— e.g. one benign, resettable replica this PR happily repairs — logs 
"Disallowing re-ingestion of errored COMMITTING segments ... because dedup is 
enabled" although no COMMITTING segment exists and nothing was disallowed. That 
trains operators to ignore the message before the real COMMITTING-skip case 
occurs. The policy rationale is also now duplicated across three comment blocks 
(method header, case-1 comment, `resetErrorReplicas` doc) that will drift. 
Suggest evaluating the gate lazily inside case 1 (its skip-WARN already covers 
the real event) and keeping the rationale in one place.



##########
pinot-controller/src/test/java/org/apache/pinot/controller/helix/core/realtime/PinotLLCRealtimeSegmentManagerTest.java:
##########
@@ -2315,6 +2318,118 @@ public void 
testShouldRepairErrorSegmentsForPartialUpsertOrDedup() {
         
pinotLLCRealtimeSegmentManager.shouldRepairErrorSegmentsForPartialUpsertOrDedup(DisasterRecoveryMode.ALWAYS));
   }
 
+  @Test
+  public void 
testRepairSegmentsInErrorStateResetsOnlyErrorReplicasForPauselessDedupTable()
+      throws InvalidConfigException {
+    PinotHelixResourceManager mockHelixResourceManager = 
mock(PinotHelixResourceManager.class);
+    ControllerMetrics mockControllerMetrics = mock(ControllerMetrics.class);
+    FakePinotLLCRealtimeSegmentManager segmentManager =
+        new FakePinotLLCRealtimeSegmentManager(mockHelixResourceManager, 
mockControllerMetrics);
+    TableConfig tableConfig = createPauselessDedupTableConfig();
+
+    String server0 = Helix.PREFIX_OF_SERVER_INSTANCE + 0;
+    String server1 = Helix.PREFIX_OF_SERVER_INSTANCE + 1;
+    String committingPartialErrorSegment = new LLCSegmentName(RAW_TABLE_NAME, 
0, 0, CURRENT_TIME_MS).getSegmentName();
+    String committingAllErrorSegment = new LLCSegmentName(RAW_TABLE_NAME, 0, 
1, CURRENT_TIME_MS).getSegmentName();
+    String doneSegment = new LLCSegmentName(RAW_TABLE_NAME, 1, 0, 
CURRENT_TIME_MS).getSegmentName();
+    String inProgressSegment = new LLCSegmentName(RAW_TABLE_NAME, 1, 1, 
CURRENT_TIME_MS).getSegmentName();
+
+    IdealState idealState = new IdealState(REALTIME_TABLE_NAME);
+    ExternalView externalView = new ExternalView(REALTIME_TABLE_NAME);
+    for (String segment : List.of(committingPartialErrorSegment, 
committingAllErrorSegment, doneSegment,
+        inProgressSegment)) {
+      idealState.setPartitionState(segment, server0, SegmentStateModel.ONLINE);
+      idealState.setPartitionState(segment, server1, SegmentStateModel.ONLINE);
+    }
+    externalView.setState(committingPartialErrorSegment, server0, 
SegmentStateModel.ONLINE);
+    externalView.setState(committingPartialErrorSegment, server1, 
SegmentStateModel.ERROR);
+    externalView.setState(committingAllErrorSegment, server0, 
SegmentStateModel.ERROR);
+    externalView.setState(committingAllErrorSegment, server1, 
SegmentStateModel.ERROR);
+    externalView.setState(doneSegment, server0, SegmentStateModel.ONLINE);
+    externalView.setState(doneSegment, server1, SegmentStateModel.ERROR);
+    externalView.setState(inProgressSegment, server0, SegmentStateModel.ERROR);
+    externalView.setState(inProgressSegment, server1, SegmentStateModel.ERROR);
+    segmentManager._idealState = idealState;
+    
when(mockHelixResourceManager.getTableExternalView(REALTIME_TABLE_NAME)).thenReturn(externalView);
+    mockSegmentZKMetadataStatus(mockHelixResourceManager, 
committingPartialErrorSegment, Status.COMMITTING);
+    mockSegmentZKMetadataStatus(mockHelixResourceManager, 
committingAllErrorSegment, Status.COMMITTING);
+    mockSegmentZKMetadataStatus(mockHelixResourceManager, doneSegment, 
Status.DONE);
+    mockSegmentZKMetadataStatus(mockHelixResourceManager, inProgressSegment, 
Status.IN_PROGRESS);
+
+    segmentManager.repairSegmentsInErrorState(tableConfig, false);
+
+    // ERROR replicas are reset even though dedup is enabled, since a reset 
only reloads data a peer or the deep
+    // store already holds
+    verify(mockHelixResourceManager).resetSegment(REALTIME_TABLE_NAME, 
committingPartialErrorSegment, server1);
+    verify(mockHelixResourceManager).resetSegment(REALTIME_TABLE_NAME, 
doneSegment, server1);
+    verify(mockHelixResourceManager).resetSegment(REALTIME_TABLE_NAME, 
inProgressSegment, server0);
+    verify(mockHelixResourceManager).resetSegment(REALTIME_TABLE_NAME, 
inProgressSegment, server1);
+    // Healthy replicas are never reset, and no untargeted (all-replica) reset 
is issued
+    verify(mockHelixResourceManager, 
never()).resetSegment(eq(REALTIME_TABLE_NAME), anyString(), isNull());
+    verify(mockHelixResourceManager, 
never()).resetSegment(REALTIME_TABLE_NAME, committingPartialErrorSegment,
+        server0);
+    verify(mockHelixResourceManager, 
never()).resetSegment(REALTIME_TABLE_NAME, doneSegment, server0);
+    // The COMMITTING segment with all replicas in ERROR is neither reset nor 
re-ingested without the override
+    verify(mockHelixResourceManager, 
never()).resetSegment(eq(REALTIME_TABLE_NAME), eq(committingAllErrorSegment),
+        any());
+    verify(mockHelixResourceManager, 
never()).getDataInstanceAdminEndpoints(any());
+    // Every segment with an ERROR replica is counted, and only the segment 
left unrepaired is unrecoverable
+    verify(mockControllerMetrics).setOrUpdateTableGauge(REALTIME_TABLE_NAME,
+        ControllerGauge.PAUSELESS_SEGMENTS_IN_ERROR_COUNT, 4L);
+    verify(mockControllerMetrics).setOrUpdateTableGauge(REALTIME_TABLE_NAME,
+        ControllerGauge.PAUSELESS_SEGMENTS_IN_UNRECOVERABLE_ERROR_COUNT, 1L);
+  }
+
+  @Test
+  public void 
testRepairSegmentsInErrorStateReingestsCommittingSegmentForDedupTableWithOverride()
+      throws InvalidConfigException {
+    PinotHelixResourceManager mockHelixResourceManager = 
mock(PinotHelixResourceManager.class);
+    FakePinotLLCRealtimeSegmentManager segmentManager =
+        new FakePinotLLCRealtimeSegmentManager(mockHelixResourceManager);
+    TableConfig tableConfig = createPauselessDedupTableConfig();
+
+    String server0 = Helix.PREFIX_OF_SERVER_INSTANCE + 0;
+    String server1 = Helix.PREFIX_OF_SERVER_INSTANCE + 1;
+    String committingAllErrorSegment = new LLCSegmentName(RAW_TABLE_NAME, 0, 
0, CURRENT_TIME_MS).getSegmentName();
+
+    IdealState idealState = new IdealState(REALTIME_TABLE_NAME);
+    idealState.setPartitionState(committingAllErrorSegment, server0, 
SegmentStateModel.ONLINE);
+    idealState.setPartitionState(committingAllErrorSegment, server1, 
SegmentStateModel.ONLINE);
+    ExternalView externalView = new ExternalView(REALTIME_TABLE_NAME);
+    externalView.setState(committingAllErrorSegment, server0, 
SegmentStateModel.ERROR);
+    externalView.setState(committingAllErrorSegment, server1, 
SegmentStateModel.ERROR);
+    segmentManager._idealState = idealState;
+    
when(mockHelixResourceManager.getTableExternalView(REALTIME_TABLE_NAME)).thenReturn(externalView);
+    mockSegmentZKMetadataStatus(mockHelixResourceManager, 
committingAllErrorSegment, Status.COMMITTING);
+
+    segmentManager.repairSegmentsInErrorState(tableConfig, true);
+
+    // With the override, the re-ingestion path is taken (server lookup is 
attempted) and no reset is issued
+    verify(mockHelixResourceManager).getDataInstanceAdminEndpoints(any());

Review Comment:
   **This test never actually exercises re-ingestion.** 
`getDataInstanceAdminEndpoints` is unstubbed, so the mock returns null; the 
resulting NPE is swallowed inside `pickServerToReingest`'s catch, `aliveServer` 
comes back null, and the production code takes the "No alive server found ... 
skipping re-ingestion" path. The test therefore only pins that the endpoint 
lookup happened and no reset was issued — a regression anywhere after the 
lookup (e.g. `triggerReingestion` no longer called, wrong server picked) stays 
green. Suggest stubbing `getDataInstanceAdminEndpoints` to return a populated 
BiMap so the re-ingestion attempt is actually reached, and verifying it (e.g. 
via the HTTP trigger failure log path or a spy).



##########
pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/PauselessDedupErrorReplicaRepairTest.java:
##########
@@ -0,0 +1,219 @@
+/**
+ * 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.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
+  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. 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 (getNumErrorReplicasInEV(realtimeTableName) == 0) {
+        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
+  public void tearDown()

Review Comment:
   **Fragile test lifecycle: one failure leaks the whole cluster into the 
shared surefire fork.** Two issues: (1) `@BeforeClass`/`@AfterClass` lack 
`alwaysRun = true` (the sibling 
`PauselessRealtimeIngestionSegmentCommitFailureTest` has it), so if `setUp` 
throws partway (Kafka/server start failure) TestNG skips this teardown 
entirely; (2) even when it runs, this is an unguarded sequential chain — if 
`dropRealtimeTable` throws (likely when the table is wedged with ERROR replicas 
after a test failure), every subsequent `stop*` call and the temp-dir cleanup 
are skipped, cascading port/state failures into later integration suites in the 
same JVM. Suggest `alwaysRun = true` on both annotations and wrapping each 
teardown step in try/catch as the sibling test does.



##########
pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/realtime/PinotLLCRealtimeSegmentManager.java:
##########
@@ -3036,6 +3036,24 @@ public void repairSegmentsInErrorState(TableConfig 
tableConfig,
     }
   }
 
+  /// Resets only the replicas of the segment that are in ERROR state in the 
external view. Healthy replicas are left
+  /// untouched: resetting a healthy replica takes it through OFFLINE, which 
on dedup / upsert tables briefly drops its
+  /// keys from the metadata manager while other replicas keep consuming, and 
can make the only good copy of a
+  /// segment without a deep-store copy unavailable.
+  private void resetErrorReplicas(String realtimeTableName, String 
segmentName, Map<String, String> currentStateMap) {
+    for (Map.Entry<String, String> entry : currentStateMap.entrySet()) {
+      if (SegmentStateModel.ERROR.equals(entry.getValue())) {
+        String instanceName = entry.getKey();
+        try {

Review Comment:
   **Silent no-op when the EV instance is no longer in IdealState.** 
`resetSegment(table, segment, instance)` goes through `parseInstanceSet`, which 
returns `Set.of()` without any log or exception when the target instance is not 
in the segment's IdealState instance set. After a rebalance moves a segment off 
server A while A holds a stale ERROR replica in the EV, every validation pass 
logs "Found N segments ... repairing them", then no-ops invisibly here (this 
catch never fires), and the error gauges stay elevated indefinitely with no 
actionable log line. Suggest filtering against the already-held 
`segmentToInstanceIdealStateMap` before calling reset, or logging when the 
instance is skipped.



##########
pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/realtime/PinotLLCRealtimeSegmentManager.java:
##########
@@ -2983,14 +2977,20 @@ public void repairSegmentsInErrorState(TableConfig 
tableConfig,
       }
 
       Status status = segmentZKMetadata.getStatus();
+      Map<String, String> currentStateMap = 
segmentToInstanceCurrentStateMap.get(segmentName);
 
-      // 1) For COMMITTING segments where all replicas are in ERROR, try 
re-ingestion (subject to policy)
+      // 1) For COMMITTING segments where all replicas are in ERROR, try 
re-ingestion (subject to policy). Re-ingestion
+      // is the only repair that can admit duplicate or inconsistent rows on 
dedup / partial upsert tables, so it is
+      // the only repair gated on repairCommittingSegments. The resets below 
only reload data another replica or the
+      // deep store already holds, so they stay enabled for such tables.
       if (status == Status.COMMITTING && 
segmentsInErrorStateInAllReplicas.contains(segmentName)) {
         if (!repairCommittingSegments) {
           segmentsInUnRecoverableState += 1;

Review Comment:
   **`PAUSELESS_SEGMENTS_IN_UNRECOVERABLE_ERROR_COUNT` is silently narrowed — 
genuinely unrecoverable segments now read 0.** Pre-PR, the gated 
dedup/partial-upsert path set this gauge to the count of all all-replica-ERROR 
segments; now only this skip branch increments it. Cases that stay broken but 
report 0: (a) a DONE segment with all replicas in ERROR and a peer:// download 
URL whose deep-store upload never completed — every reset fails (no ONLINE 
peer, no deep-store copy) and the replicas return to ERROR forever; (b) a 
COMMITTING all-ERROR segment whose re-ingestion is attempted but finds no alive 
server or whose trigger keeps failing (exception swallowed below); (c) 
partial-ERROR stuck COMMITTING segments. The gauge is documented as "needs 
manual intervention" (ControllerGauge.java:222-224), so alerts that fired 
pre-PR go silent. Also note the gauge is now only written after the whole loop, 
so an uncaught mid-loop exception (e.g. a ZK error from `getSegmentZKMetadata`, 
not in
 side any try/catch) leaves it stale for the run. Suggest counting 
failed/unattemptable repairs as unrecoverable too.



##########
pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/realtime/PinotLLCRealtimeSegmentManager.java:
##########
@@ -2956,22 +2958,14 @@ public void repairSegmentsInErrorState(TableConfig 
tableConfig,
     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);
-    }
 
+    // Count segments with any ERROR replica so a single lagging replica is 
visible in the metric
     if (isPauselessTable) {

Review Comment:
   **Breaking metric-semantics change under the same name.** 
`PAUSELESS_SEGMENTS_IN_ERROR_COUNT` changes from "segments with ALL replicas in 
ERROR" (a serving outage) to "segments with at least one ERROR replica". Alerts 
calibrated to the old meaning now page on routine single-replica blips during 
rolling restarts — the transient condition this PR auto-repairs moments later — 
and conversely no gauge still exposes true full-outage segment counts 
(`segmentsInErrorStateInAllReplicas` no longer feeds any gauge except the gated 
COMMITTING skip). The comment in ControllerGauge.java was not updated either. 
Suggest keeping this gauge's meaning and emitting a new, distinctly named gauge 
(e.g. `SEGMENTS_WITH_ERROR_REPLICAS`) for repair visibility, or at minimum 
calling the semantic change out in docs/release notes.



##########
pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/realtime/PinotLLCRealtimeSegmentManager.java:
##########
@@ -3036,6 +3036,24 @@ public void repairSegmentsInErrorState(TableConfig 
tableConfig,
     }
   }
 
+  /// Resets only the replicas of the segment that are in ERROR state in the 
external view. Healthy replicas are left
+  /// untouched: resetting a healthy replica takes it through OFFLINE, which 
on dedup / upsert tables briefly drops its
+  /// keys from the metadata manager while other replicas keep consuming, and 
can make the only good copy of a
+  /// segment without a deep-store copy unavailable.
+  private void resetErrorReplicas(String realtimeTableName, String 
segmentName, Map<String, String> currentStateMap) {
+    for (Map.Entry<String, String> entry : currentStateMap.entrySet()) {
+      if (SegmentStateModel.ERROR.equals(entry.getValue())) {
+        String instanceName = entry.getKey();
+        try {
+          _helixResourceManager.resetSegment(realtimeTableName, segmentName, 
instanceName);

Review Comment:
   **Race: can reset a replica that recovered to healthy, violating this 
method's own doc.** The ERROR determination comes from the external-view 
snapshot taken at the top of `repairSegmentsInErrorState`, but `resetSegment` 
re-reads the EV and skips only OFFLINE instances 
(`PinotHelixResourceManager.resetSegment`), and `resetPartitionAllState` 
"resets all state regardless". The repair loop can be slow (per-segment ZK 
reads, synchronous re-ingestion HTTP calls), so a replica that recovered 
ERROR->ONLINE/CONSUMING in that window is bounced through OFFLINE anyway — 
briefly dropping its dedup/upsert keys while serving, the exact harm the 
javadoc above says this method avoids. Suggest re-checking that the instance is 
still ERROR against the fresh EV inside the reset, as `resetSegments(table, 
instance, errorSegmentsOnly=true)` already does.



##########
pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/realtime/PinotLLCRealtimeSegmentManager.java:
##########
@@ -3036,6 +3036,24 @@ public void repairSegmentsInErrorState(TableConfig 
tableConfig,
     }
   }
 
+  /// Resets only the replicas of the segment that are in ERROR state in the 
external view. Healthy replicas are left
+  /// untouched: resetting a healthy replica takes it through OFFLINE, which 
on dedup / upsert tables briefly drops its
+  /// keys from the metadata manager while other replicas keep consuming, and 
can make the only good copy of a
+  /// segment without a deep-store copy unavailable.
+  private void resetErrorReplicas(String realtimeTableName, String 
segmentName, Map<String, String> currentStateMap) {
+    for (Map.Entry<String, String> entry : currentStateMap.entrySet()) {
+      if (SegmentStateModel.ERROR.equals(entry.getValue())) {
+        String instanceName = entry.getKey();
+        try {
+          _helixResourceManager.resetSegment(realtimeTableName, segmentName, 
instanceName);
+        } catch (Exception e) {

Review Comment:
   **Swallowing reset failures hides persistent repair breakage.** The old 
untargeted `resetSegment` call let failures (`RuntimeException("Reset segment 
failed...")`, IllegalState preconditions) propagate out of 
`repairSegmentsInErrorState` into the periodic task's error accounting. Now 
every failure — including persistent ones like Helix message sends failing for 
one server, or the segment being deleted between the IS snapshot and the reset 
— is downgraded to an ERROR log, the run completes "successfully", and 
`PAUSELESS_SEGMENTS_IN_UNRECOVERABLE_ERROR_COUNT` is written as 0. Monitoring 
keyed on validation-task failures or that gauge misses the condition entirely. 
Per-instance isolation is good, but consider counting failed resets (e.g. into 
the unrecoverable gauge or a new failure meter) so repeated failures stay 
visible.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to