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

yashmayya 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 ca9e7a3d6ce Add broker-side percent of replica metrics (#19221)
ca9e7a3d6ce is described below

commit ca9e7a3d6ceb4b8035e88d8491c41552329769f3
Author: Jhow <[email protected]>
AuthorDate: Tue Aug 18 03:16:35 2026 +0800

    Add broker-side percent of replica metrics (#19221)
---
 .../instanceselector/BaseInstanceSelector.java     |  78 +++-
 .../routing/instanceselector/InstanceSelector.java |  16 +
 .../ReplicaGroupInstanceSelector.java              |   5 +-
 .../instanceselector/TableReplicaHealth.java       |  84 ++++
 .../routing/manager/BaseBrokerRoutingManager.java  |  84 +++-
 .../instanceselector/InstanceSelectorTest.java     | 467 +++++++++++++++++++++
 .../routing/manager/BrokerRoutingManagerTest.java  | 271 +++++++++++-
 .../apache/pinot/common/metrics/BrokerGauge.java   |  25 +-
 8 files changed, 1020 insertions(+), 10 deletions(-)

diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/instanceselector/BaseInstanceSelector.java
 
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/instanceselector/BaseInstanceSelector.java
index 6fe60b5411b..f322325519d 100644
--- 
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/instanceselector/BaseInstanceSelector.java
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/instanceselector/BaseInstanceSelector.java
@@ -80,6 +80,15 @@ import static 
org.apache.pinot.spi.utils.CommonConstants.Broker.FALLBACK_POOL_ID
 /// 2) When there is no state update from helix, new segments won't be retired 
because of the time passing (those with
 /// creation time more than 5 minutes ago).
 /// TODO: refresh new/old segment state where there is no update from helix 
for long time.
+///
+/// Alongside the selection state, this class maintains a [TableReplicaHealth] 
describing how well the
+/// table's segments are replicated across the instances it can route to, 
exposed through
+/// [#getReplicaHealth()]. Because it is derived from what routing can 
actually use, it accounts for both
+/// external view divergence and disabled instances, and for the replica 
groups that
+/// [ReplicaGroupInstanceSelector] takes out of service wholesale. Measuring 
it here and reporting it in
+/// [org.apache.pinot.broker.routing.manager.BaseBrokerRoutingManager] is 
deliberate: a selector cannot
+/// tell whether it covers the whole table or a sampled subset of it, and only 
the routing manager knows
+/// which one owns the table's gauges.
 public abstract class BaseInstanceSelector implements InstanceSelector {
   private static final Logger LOGGER = 
LoggerFactory.getLogger(BaseInstanceSelector.class);
   // To prevent int overflow, reset the request id once it reaches this value
@@ -104,9 +113,19 @@ public abstract class BaseInstanceSelector implements 
InstanceSelector {
   // Reduce this map to reduce garbage
   protected final Map<String, List<SegmentInstanceCandidate>> 
_oldSegmentCandidatesMap = new HashMap<>();
   protected Map<String, NewSegmentState> _newSegmentStateMap;
+  /// Number of ONLINE/CONSUMING instances in the ideal state, for old 
segments that have fewer
+  /// candidates than that. Only used for metrics
+  ///
+  /// Kept sparse deliberately: an absent entry means "as many candidates as 
the ideal state assigns",
+  /// so a healthy table stores nothing here. Read it through 
[#getExpectedReplicas].
+  protected final Map<String, Integer> _oldSegmentExpectedReplicasMap = new 
HashMap<>();
 
   // _segmentStates is needed for instance selection (multi-threaded), so it 
is made volatile.
   protected volatile SegmentStates _segmentStates;
+  // Published together with _segmentStates and read back by the routing 
manager to report the table's
+  // gauges. Volatile so that a reader on another thread cannot see it lagging 
the segment states it was
+  // computed alongside.
+  protected volatile TableReplicaHealth _replicaHealth;
   protected Map<String, ServerInstance> _enabledServerStore;
 
   @Override
@@ -143,6 +162,43 @@ public abstract class BaseInstanceSelector implements 
InstanceSelector {
     refreshSegmentStates();
   }
 
+  /// Returns how well the table's segments are currently replicated across 
the instances this selector
+  /// can route to. Never null once [#init] has run.
+  @Override
+  public TableReplicaHealth getReplicaHealth() {
+    return _replicaHealth;
+  }
+
+  /// Returns the number of instances the ideal state assigns to the given old 
segment in an
+  /// ONLINE/CONSUMING state. See [#_oldSegmentExpectedReplicasMap] for why 
this is stored sparsely.
+  private int getExpectedReplicas(String segment, int numCandidates) {
+    return Math.max(1, _oldSegmentExpectedReplicasMap.getOrDefault(segment, 
numCandidates));
+  }
+
+  /// Records an old segment's candidates, along with the ideal-state replica 
count the replica health needs
+  /// that the candidate list does not preserve. The single entry point for 
both structures, so that they
+  /// cannot fall out of step.
+  protected void putOldSegment(String segment, List<SegmentInstanceCandidate> 
candidates,
+      Map<String, String> idealStateInstanceStateMap) {
+    _oldSegmentCandidatesMap.put(segment, candidates);
+    // Stored only when it differs from the candidate count, see 
_oldSegmentExpectedReplicasMap
+    int numIdealStateReplicas = 
getNumInstancesOnlineForRouting(idealStateInstanceStateMap);
+    if (numIdealStateReplicas > candidates.size()) {
+      _oldSegmentExpectedReplicasMap.put(segment, numIdealStateReplicas);
+    }
+  }
+
+  /// Returns the number of instances in the given ideal state assignment that 
are ONLINE/CONSUMING.
+  protected static int getNumInstancesOnlineForRouting(Map<String, String> 
idealStateInstanceStateMap) {
+    int numOnlineForRouting = 0;
+    for (String state : idealStateInstanceStateMap.values()) {
+      if (isOnlineForRouting(state)) {
+        numOnlineForRouting++;
+      }
+    }
+    return numOnlineForRouting;
+  }
+
   /// Returns whether the instance state is online for routing purpose 
(ONLINE/CONSUMING).
   static boolean isOnlineForRouting(@Nullable String state) {
     return SegmentStateModel.ONLINE.equals(state) || 
SegmentStateModel.CONSUMING.equals(state);
@@ -241,6 +297,7 @@ public abstract class BaseInstanceSelector implements 
InstanceSelector {
   void updateSegmentMaps(IdealState idealState, ExternalView externalView, 
Set<String> onlineSegments,
       Map<String, Long> newSegmentCreationTimeMap) {
     _oldSegmentCandidatesMap.clear();
+    _oldSegmentExpectedReplicasMap.clear();
     _newSegmentStateMap = new 
HashMap<>(HashUtil.getHashMapCapacity(newSegmentCreationTimeMap.size()));
 
     Map<String, Map<String, String>> idealStateAssignment = 
idealState.getRecord().getMapFields();
@@ -269,7 +326,7 @@ public abstract class BaseInstanceSelector implements 
InstanceSelector {
           _newSegmentStateMap.put(segment, new 
NewSegmentState(newSegmentCreationTimeMs, candidates));
         } else {
           // Old segment
-          _oldSegmentCandidatesMap.put(segment, List.of());
+          putOldSegment(segment, List.of(), idealStateInstanceStateMap);
         }
       } else {
         TreeSet<String> onlineInstances = 
getOnlineInstances(idealStateInstanceStateMap, externalViewInstanceStateMap);
@@ -297,7 +354,7 @@ public abstract class BaseInstanceSelector implements 
InstanceSelector {
             }
             idealStateReplicaId++;
           }
-          _oldSegmentCandidatesMap.put(segment, candidates);
+          putOldSegment(segment, candidates, idealStateInstanceStateMap);
         }
       }
       if (_emitSinglePoolSegmentsMetric) {
@@ -326,12 +383,27 @@ public abstract class BaseInstanceSelector implements 
InstanceSelector {
         new 
HashMap<>(HashUtil.getHashMapCapacity(_oldSegmentCandidatesMap.size() + 
_newSegmentStateMap.size()));
     Set<String> servingInstances = new HashSet<>();
     Set<String> unavailableSegments = new HashSet<>();
+    int minPercentOfReplicas = TableReplicaHealth.FULLY_REPLICATED_PERCENT;
+    // Segments seen at exactly minPercentOfReplicas so far. Reset whenever a 
lower percentage displaces the
+    // minimum, so that the pair published below always describes the same 
population.
+    int numSegmentsAtMinPercentOfReplicas = 0;
 
     for (Map.Entry<String, List<SegmentInstanceCandidate>> entry : 
_oldSegmentCandidatesMap.entrySet()) {
       String segment = entry.getKey();
       List<SegmentInstanceCandidate> candidates = entry.getValue();
       List<SegmentInstanceCandidate> enabledCandidates =
           getEnabledCandidatesAndAddToServingInstances(candidates, 
servingInstances);
+      int expectedReplicas = getExpectedReplicas(segment, candidates.size());
+      int servingReplicas = enabledCandidates.size();
+      if (TableReplicaHealth.shouldMeasure(expectedReplicas)) {
+        int percentOfReplicas = TableReplicaHealth.toPercent(servingReplicas, 
expectedReplicas);
+        if (percentOfReplicas < minPercentOfReplicas) {
+          minPercentOfReplicas = percentOfReplicas;
+          numSegmentsAtMinPercentOfReplicas = 1;
+        } else if (percentOfReplicas == minPercentOfReplicas) {
+          numSegmentsAtMinPercentOfReplicas++;
+        }
+      }
       if (!enabledCandidates.isEmpty()) {
         instanceCandidatesMap.put(segment, enabledCandidates);
       } else {
@@ -368,6 +440,8 @@ public abstract class BaseInstanceSelector implements 
InstanceSelector {
     }
 
     _segmentStates = new SegmentStates(instanceCandidatesMap, 
servingInstances, unavailableSegments);
+    _replicaHealth = new TableReplicaHealth(minPercentOfReplicas, 
numSegmentsAtMinPercentOfReplicas,
+        unavailableSegments.size());
   }
 
   private List<SegmentInstanceCandidate> 
getEnabledCandidatesAndAddToServingInstances(
diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/instanceselector/InstanceSelector.java
 
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/instanceselector/InstanceSelector.java
index b9b1434d223..06491ebd90d 100644
--- 
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/instanceselector/InstanceSelector.java
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/instanceselector/InstanceSelector.java
@@ -72,6 +72,22 @@ public interface InstanceSelector {
   /// Returns the enabled server instances currently serving the table.
   Set<String> getServingInstances();
 
+  /// Returns how well the table's segments are replicated across the 
instances this selector can route to,
+  /// or `null` if this selector does not measure it. The routing manager 
reads this after each assignment
+  /// and instance change and publishes it as the table's replica health 
gauges.
+  ///
+  /// Implementations must:
+  /// - Measure the table's whole segment set, not a subset. The values are 
published under table level
+  ///   gauges, so numbers derived from a subset would be read as the table's 
own.
+  /// - Return an immutable snapshot, recomputed in [#onAssignmentChange] and 
[#onInstancesChange] and
+  ///   published so that the manager's subsequent read observes it, rather 
than a live view.
+  /// - Return `null` consistently rather than only sometimes: a selector that 
stops measuring has its
+  ///   gauges dropped, so alternating would make the series flicker.
+  @Nullable
+  default TableReplicaHealth getReplicaHealth() {
+    return null;
+  }
+
   class SelectionResult {
     private final Pair<Map<String, String>, Map<String, String>/*optional 
segments*/> _segmentToInstanceMap;
     private final List<String> _unavailableSegments;
diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/instanceselector/ReplicaGroupInstanceSelector.java
 
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/instanceselector/ReplicaGroupInstanceSelector.java
index de15a5006c0..debc04a1912 100644
--- 
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/instanceselector/ReplicaGroupInstanceSelector.java
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/instanceselector/ReplicaGroupInstanceSelector.java
@@ -191,6 +191,7 @@ public class ReplicaGroupInstanceSelector extends 
BaseInstanceSelector {
   void updateSegmentMapsForUpsertTable(IdealState idealState, ExternalView 
externalView, Set<String> onlineSegments,
       Map<String, Long> newSegmentCreationTimeMap) {
     _oldSegmentCandidatesMap.clear();
+    _oldSegmentExpectedReplicasMap.clear();
     int newSegmentMapCapacity = 
HashUtil.getHashMapCapacity(newSegmentCreationTimeMap.size());
     _newSegmentStateMap = new HashMap<>(newSegmentMapCapacity);
 
@@ -256,7 +257,9 @@ public class ReplicaGroupInstanceSelector extends 
BaseInstanceSelector {
         }
         idealStateReplicaId++;
       }
-      _oldSegmentCandidatesMap.put(segment, candidates);
+      // Instances taken out of service for the whole replica group are 
excluded above, so measuring against
+      // the ideal state count is what makes the replica health metrics 
reflect a group-wide knockout.
+      putOldSegment(segment, candidates, idealStateInstanceStateMap);
     }
 
     for (Map.Entry<String, Set<String>> entry : 
newSegmentToOnlineInstancesMap.entrySet()) {
diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/instanceselector/TableReplicaHealth.java
 
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/instanceselector/TableReplicaHealth.java
new file mode 100644
index 00000000000..0fff10df054
--- /dev/null
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/instanceselector/TableReplicaHealth.java
@@ -0,0 +1,84 @@
+/**
+ * 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.broker.routing.instanceselector;
+
+import javax.annotation.concurrent.Immutable;
+
+
+/// Table-level view of how well a table's segments are replicated across the 
servers this broker can
+/// actually route to: one set of numbers per table, aggregated over its 
segments. Computed alongside
+/// [SegmentStates] and published as a whole so the values stay mutually 
consistent. The percentage is
+/// `servingReplicas * 100 / expectedReplicas` evaluated per segment and then 
minimised, so that a table
+/// whose segments are not uniformly replicated still reads correctly, and 
counted so the minimum comes with
+/// the number of segments behind it.
+///
+/// A snapshot, not a history: a segment is reported the moment it drops, and 
debouncing belongs in the
+/// alert. Only two populations are left out - single-replica segments, and 
segments routing still calls new
+/// (it does not serve those either, so a stuck push is invisible here by 
design). Consuming segments count.
+@Immutable
+public class TableReplicaHealth {
+  /// Value reported when a table has no segments to measure.
+  public static final int FULLY_REPLICATED_PERCENT = 100;
+
+  /// Assigned replicas below which a segment is not measured: a 
single-replica segment has no redundancy even
+  /// when healthy, so measuring it would report the replication level rather 
than an incident.
+  private static final int MIN_MEASURED_REPLICAS = 2;
+
+  private final int _minPercentOfReplicas;
+  private final int _numSegmentsAtMinPercentOfReplicas;
+  private final int _numUnavailableSegments;
+
+  public TableReplicaHealth(int minPercentOfReplicas, int 
numSegmentsAtMinPercentOfReplicas,
+      int numUnavailableSegments) {
+    _minPercentOfReplicas = minPercentOfReplicas;
+    _numSegmentsAtMinPercentOfReplicas = numSegmentsAtMinPercentOfReplicas;
+    _numUnavailableSegments = numUnavailableSegments;
+  }
+
+  /// Returns the worst measured segment's replica percentage, or 
[#FULLY_REPLICATED_PERCENT] if there are
+  /// none. The minimum, so one unservable segment is not diluted by a large 
healthy table.
+  public int getMinPercentOfReplicas() {
+    return _minPercentOfReplicas;
+  }
+
+  /// Returns how many measured segments sit at [#getMinPercentOfReplicas] - 
the blast radius behind it, which
+  /// the minimum alone cannot distinguish: one straggler and a whole table 
down read the same. `0` only when
+  /// there is nothing to measure, since otherwise some segment is always the 
worst one.
+  public int getNumSegmentsAtMinPercentOfReplicas() {
+    return _numSegmentsAtMinPercentOfReplicas;
+  }
+
+  /// Returns how many segments cannot be routed anywhere, whatever their 
replication. Matches
+  /// [SegmentStates#getUnavailableSegments()], i.e. what the query path 
refuses to serve, so unlike the two
+  /// above it includes single-replica segments. When 
[#getMinPercentOfReplicas] is `0`, this is how much of
+  /// the data is already gone rather than merely degraded.
+  public int getNumUnavailableSegments() {
+    return _numUnavailableSegments;
+  }
+
+  /// Returns whether a segment's replication is high enough to measure at all.
+  static boolean shouldMeasure(int expectedReplicas) {
+    return expectedReplicas >= MIN_MEASURED_REPLICAS;
+  }
+
+  /// Returns the percentage of assigned replicas that are serving, truncated 
and capped at 100.
+  static int toPercent(int servingReplicas, int expectedReplicas) {
+    return Math.min(FULLY_REPLICATED_PERCENT, servingReplicas * 
FULLY_REPLICATED_PERCENT / expectedReplicas);
+  }
+}
diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/manager/BaseBrokerRoutingManager.java
 
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/manager/BaseBrokerRoutingManager.java
index e0ccad18e36..631b67f63cc 100644
--- 
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/manager/BaseBrokerRoutingManager.java
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/manager/BaseBrokerRoutingManager.java
@@ -60,6 +60,7 @@ import 
org.apache.pinot.broker.routing.adaptiveserverselector.AdaptiveServerSele
 import 
org.apache.pinot.broker.routing.adaptiveserverselector.AdaptiveServerSelectorFactory;
 import org.apache.pinot.broker.routing.instanceselector.InstanceSelector;
 import 
org.apache.pinot.broker.routing.instanceselector.InstanceSelectorFactory;
+import org.apache.pinot.broker.routing.instanceselector.TableReplicaHealth;
 import 
org.apache.pinot.broker.routing.segmentmetadata.SegmentZkMetadataFetchListener;
 import 
org.apache.pinot.broker.routing.segmentmetadata.SegmentZkMetadataFetcher;
 import 
org.apache.pinot.broker.routing.segmentpartition.SegmentPartitionMetadataManager;
@@ -73,6 +74,7 @@ import 
org.apache.pinot.broker.routing.tablesampler.TableSampler;
 import org.apache.pinot.broker.routing.tablesampler.TableSamplerFactory;
 import org.apache.pinot.broker.routing.timeboundary.TimeBoundaryManager;
 import org.apache.pinot.common.metadata.ZKMetadataProvider;
+import org.apache.pinot.common.metrics.BrokerGauge;
 import org.apache.pinot.common.metrics.BrokerMeter;
 import org.apache.pinot.common.metrics.BrokerMetrics;
 import org.apache.pinot.common.request.BrokerRequest;
@@ -381,6 +383,7 @@ public abstract class BaseBrokerRoutingManager implements 
RoutingManager, Cluste
         LOGGER.error("Caught unexpected exception while updating routing entry 
on segment assignment change for "
             + "table: {}", tableNameWithType, e);
       }
+      updateReplicaHealthMetrics(routingEntry);
       return true;
     }
     return false;
@@ -478,7 +481,7 @@ public abstract class BaseBrokerRoutingManager implements 
RoutingManager, Cluste
       try {
         Object tableLock = getRoutingTableBuildLock(tableNameWithType);
         synchronized (tableLock) {
-          routingEntry.onInstancesChange(_routableServerInstanceMap.keySet(), 
changedServers);
+          updateRoutingEntryOnInstancesChange(routingEntry, 
_routableServerInstanceMap.keySet(), changedServers);
         }
       } catch (Exception e) {
         LOGGER.error("Caught unexpected exception while updating routing entry 
on instances change for table: {}",
@@ -553,7 +556,7 @@ public abstract class BaseBrokerRoutingManager implements 
RoutingManager, Cluste
       try {
         Object tableLock = getRoutingTableBuildLock(tableNameWithType);
         synchronized (tableLock) {
-          routingEntry.onInstancesChange(_routableServerInstanceMap.keySet(), 
changedServers);
+          updateRoutingEntryOnInstancesChange(routingEntry, 
_routableServerInstanceMap.keySet(), changedServers);
         }
       } catch (Exception e) {
         LOGGER.error("Caught unexpected exception while updating routing entry 
when excluding server: {} for table: {}",
@@ -600,7 +603,7 @@ public abstract class BaseBrokerRoutingManager implements 
RoutingManager, Cluste
       try {
         Object tableLock = getRoutingTableBuildLock(tableNameWithType);
         synchronized (tableLock) {
-          routingEntry.onInstancesChange(_routableServerInstanceMap.keySet(), 
changedServers);
+          updateRoutingEntryOnInstancesChange(routingEntry, 
_routableServerInstanceMap.keySet(), changedServers);
         }
       } catch (Exception e) {
         LOGGER.error("Caught unexpected exception while updating routing entry 
when including server: {} for table: {}",
@@ -698,6 +701,68 @@ public abstract class BaseBrokerRoutingManager implements 
RoutingManager, Cluste
     }
   }
 
+  /// Applies an instance change to the routing entry and re-reports its 
replica health gauges.
+  ///
+  /// The single entry point for the instance change paths, so that none of 
them can apply the change without
+  /// refreshing the gauges it moves. Callers must hold the table's routing 
build lock.
+  private void updateRoutingEntryOnInstancesChange(RoutingEntry routingEntry, 
Set<String> enabledInstances,
+      List<String> changedInstances) {
+    try {
+      routingEntry.onInstancesChange(enabledInstances, changedInstances);
+    } finally {
+      // See processAssignmentChangeForTable for why the report is in a 
finally block. It cannot be folded into
+      // the callers' own try/catch the way it is there: theirs wraps the 
synchronized block rather than sitting
+      // inside it, and this must run under the table lock.
+      updateReplicaHealthMetrics(routingEntry);
+    }
+  }
+
+  /// Reports the table's replica health gauges from what its own instance 
selector currently measures.
+  /// Called after every change that can move those numbers, so the gauges 
track the routing rather than a
+  /// sampling interval.
+  ///
+  /// Only the routing entry's own selector is asked. The per-sampler 
selectors see a subset of the table's
+  /// segments and share its name, so reporting from them would overwrite the 
table's real values with
+  /// numbers measured over that subset. They still measure their own subset - 
the wasted work is a counter
+  /// per sampled segment, cheap enough not to be worth a switch that could be 
set wrong.
+  ///
+  /// Callers must hold the table's routing build lock: that is what makes the 
`_disabled` read below see the
+  /// value written by the assignment change, and what keeps two changes from 
interleaving a report with a
+  /// removal.
+  private void updateReplicaHealthMetrics(RoutingEntry routingEntry) {
+    String tableNameWithType = routingEntry.getTableNameWithType();
+    if (routingEntry.isDisabled()) {
+      // A disabled table has every replica driven OFFLINE on purpose, so 
reporting it as unavailable would
+      // be a false alarm. Drop the gauges instead, so the series disappears 
rather than reading as an
+      // outage, and reappears when the table is enabled again.
+      removeReplicaHealthMetrics(tableNameWithType);
+      return;
+    }
+    TableReplicaHealth replicaHealth = 
routingEntry._instanceSelector.getReplicaHealth();
+    if (replicaHealth == null) {
+      // A custom instance selector that does not measure replica health. Drop 
rather than leave whatever a
+      // previous selector reported, so that swapping the table to such a 
selector ends the series instead of
+      // freezing it.
+      removeReplicaHealthMetrics(tableNameWithType);
+      return;
+    }
+    // All three are plain values: nothing here depends on the clock, so a 
snapshot is the whole truth
+    _brokerMetrics.setValueOfTableGauge(tableNameWithType, 
BrokerGauge.PERCENT_OF_REPLICAS,
+        replicaHealth.getMinPercentOfReplicas());
+    _brokerMetrics.setValueOfTableGauge(tableNameWithType, 
BrokerGauge.SEGMENTS_AT_MIN_PERCENT_OF_REPLICAS,
+        replicaHealth.getNumSegmentsAtMinPercentOfReplicas());
+    _brokerMetrics.setValueOfTableGauge(tableNameWithType, 
BrokerGauge.UNAVAILABLE_SEGMENTS,
+        replicaHealth.getNumUnavailableSegments());
+  }
+
+  /// Stops reporting the table's replica health gauges, so that they do not 
keep being exported frozen at a
+  /// value that no longer describes the table.
+  private void removeReplicaHealthMetrics(String tableNameWithType) {
+    _brokerMetrics.removeTableGauge(tableNameWithType, 
BrokerGauge.PERCENT_OF_REPLICAS);
+    _brokerMetrics.removeTableGauge(tableNameWithType, 
BrokerGauge.UNAVAILABLE_SEGMENTS);
+    _brokerMetrics.removeTableGauge(tableNameWithType, 
BrokerGauge.SEGMENTS_AT_MIN_PERCENT_OF_REPLICAS);
+  }
+
   private void buildRoutingInternal(String tableNameWithType) {
     long buildStartTimeMs = System.currentTimeMillis();
     Object tableLock = getRoutingTableBuildLock(tableNameWithType);
@@ -892,6 +957,10 @@ public abstract class BaseBrokerRoutingManager implements 
RoutingManager, Cluste
       } else {
         LOGGER.info("Rebuilt routing for table: {}", tableNameWithType);
       }
+      // Reported only once the entry is stored, so that a build that failed 
earlier cannot leave gauges
+      // behind with no routing entry to ever clean them up. The IS / EV 
re-check below reports again if it
+      // ends up updating the entry.
+      updateReplicaHealthMetrics(routingEntry);
 
       // Check for updates to the IS / EV after adding the routing entry, as 
it is possible that the
       // processSegmentAssignmentChange() may have run and missed updating 
this newly added entry. Only update
@@ -973,6 +1042,10 @@ public abstract class BaseBrokerRoutingManager implements 
RoutingManager, Cluste
       if (_routingEntryMap.remove(tableNameWithType) != null) {
         LOGGER.info("Removed routing for table: {}", tableNameWithType);
 
+        // Stop reporting the table level gauges owned by the routing, 
otherwise they keep being exported
+        // for a table this broker no longer serves
+        removeReplicaHealthMetrics(tableNameWithType);
+
         // Remove time boundary manager for the offline part routing if the 
removed routing is the real-time part of a
         // hybrid table
         if (TableNameBuilder.isRealtimeTableResource(tableNameWithType)) {
@@ -1404,6 +1477,10 @@ public abstract class BaseBrokerRoutingManager 
implements RoutingManager, Cluste
     // inconsistency between components, which is fine because the 
inconsistency only exists for the newly changed
     // segments and only lasts for a very short time.
     void onAssignmentChange(IdealState idealState, ExternalView externalView) {
+      // Derived purely from the ideal state, so it is set up front: a 
component failing partway through the
+      // update must not leave this reading the previous ideal state, since 
the replica health reporting
+      // decides from it whether the table's gauges are reported or dropped
+      _disabled = !idealState.isEnabled();
       Set<String> onlineSegments = getOnlineSegments(idealState);
       Set<String> preSelectedOnlineSegments = 
_segmentPreSelector.preSelect(onlineSegments);
       _segmentZkMetadataFetcher.onAssignmentChange(idealState, externalView, 
preSelectedOnlineSegments);
@@ -1415,7 +1492,6 @@ public abstract class BaseBrokerRoutingManager implements 
RoutingManager, Cluste
       updateSamplerInfos(idealState, externalView, preSelectedOnlineSegments);
       _lastUpdateIdealStateVersion = idealState.getStat().getVersion();
       _lastUpdateExternalViewVersion = externalView.getStat().getVersion();
-      _disabled = !idealState.isEnabled();
     }
 
     void onInstancesChange(Set<String> enabledInstances, List<String> 
changedInstances) {
diff --git 
a/pinot-broker/src/test/java/org/apache/pinot/broker/routing/instanceselector/InstanceSelectorTest.java
 
b/pinot-broker/src/test/java/org/apache/pinot/broker/routing/instanceselector/InstanceSelectorTest.java
index 03e35d251da..796ee992b1d 100644
--- 
a/pinot-broker/src/test/java/org/apache/pinot/broker/routing/instanceselector/InstanceSelectorTest.java
+++ 
b/pinot-broker/src/test/java/org/apache/pinot/broker/routing/instanceselector/InstanceSelectorTest.java
@@ -33,6 +33,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.TreeMap;
+import java.util.TreeSet;
 import java.util.concurrent.TimeUnit;
 import org.apache.commons.lang3.tuple.ImmutablePair;
 import org.apache.commons.lang3.tuple.Pair;
@@ -43,6 +44,7 @@ import org.apache.helix.zookeeper.datamodel.ZNRecord;
 import org.apache.pinot.broker.routing.adaptiveserverselector.HybridSelector;
 import org.apache.pinot.common.metadata.ZKMetadataProvider;
 import org.apache.pinot.common.metadata.segment.SegmentZKMetadata;
+import org.apache.pinot.common.metrics.BrokerGauge;
 import org.apache.pinot.common.metrics.BrokerMetrics;
 import org.apache.pinot.common.request.BrokerRequest;
 import org.apache.pinot.common.request.PinotQuery;
@@ -68,9 +70,12 @@ import static 
org.apache.pinot.spi.utils.CommonConstants.Helix.StateModel.Segmen
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyBoolean;
 import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.clearInvocations;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertTrue;
@@ -1927,4 +1932,466 @@ public class InstanceSelectorTest {
 
     assertEquals(selectedResult.getLeft(), expectedSelection);
   }
+
+  // Replica health metrics
+  //
+  // The scenarios below all use the same three instances and assert on the 
TableReplicaHealth the
+  // selector derives, since that is what the gauges are emitted from. Each 
segment's percentage is
+  // measured against the replicas its own ideal state assigns, so segments do 
not have to be uniformly
+  // replicated for the numbers to make sense.
+
+  private static final String REPLICA_INSTANCE_0 = "instance0";
+  private static final String REPLICA_INSTANCE_1 = "instance1";
+  private static final String REPLICA_INSTANCE_2 = "instance2";
+  private static final Set<String> REPLICA_INSTANCES =
+      ImmutableSet.of(REPLICA_INSTANCE_0, REPLICA_INSTANCE_1, 
REPLICA_INSTANCE_2);
+
+  /// Returns the ideal state assignment placing the segment on all three 
instances as ONLINE.
+  private static List<Pair<String, String>> allOnline() {
+    return List.of(new ImmutablePair<>(REPLICA_INSTANCE_0, ONLINE), new 
ImmutablePair<>(REPLICA_INSTANCE_1, ONLINE),
+        new ImmutablePair<>(REPLICA_INSTANCE_2, ONLINE));
+  }
+
+  /// Returns an assignment placing the segment on two of the three instances 
as ONLINE.
+  private static List<Pair<String, String>> twoReplicas() {
+    return List.of(new ImmutablePair<>(REPLICA_INSTANCE_0, ONLINE), new 
ImmutablePair<>(REPLICA_INSTANCE_1, ONLINE));
+  }
+
+  /// Returns an external view assignment where the first `numOnline` of the 
three instances are ONLINE
+  /// and the rest are OFFLINE, so the segment looks partially loaded.
+  private static List<Pair<String, String>> partiallyOnline(int numOnline) {
+    List<String> instances = List.of(REPLICA_INSTANCE_0, REPLICA_INSTANCE_1, 
REPLICA_INSTANCE_2);
+    List<Pair<String, String>> assignment = new ArrayList<>(instances.size());
+    for (int i = 0; i < instances.size(); i++) {
+      assignment.add(new ImmutablePair<>(instances.get(i), i < numOnline ? 
ONLINE : OFFLINE));
+    }
+    return assignment;
+  }
+
+  /// Returns an external view assignment where every instance but 
`offlineInstance` is ONLINE, so that
+  /// different segments can be made to lose different replicas.
+  private static List<Pair<String, String>> onlineExcept(String 
offlineInstance) {
+    List<Pair<String, String>> assignment = new 
ArrayList<>(REPLICA_INSTANCES.size());
+    for (String instance : List.of(REPLICA_INSTANCE_0, REPLICA_INSTANCE_1, 
REPLICA_INSTANCE_2)) {
+      assignment.add(new ImmutablePair<>(instance, 
instance.equals(offlineInstance) ? OFFLINE : ONLINE));
+    }
+    return assignment;
+  }
+
+  private BaseInstanceSelector createReplicaHealthSelector(String 
selectorType, Set<String> enabledInstances,
+      Map<String, List<Pair<String, String>>> idealStateAssignment,
+      Map<String, List<Pair<String, String>>> externalViewAssignment) {
+    // Sorted so that the order the selector looks up segment metadata in is 
deterministic, which is what
+    // the stub set up by createSegmentCreationTimes matches on
+    return (BaseInstanceSelector) createTestInstanceSelector(selectorType, 
enabledInstances,
+        createIdealState(idealStateAssignment), 
createExternalView(externalViewAssignment),
+        new TreeSet<>(externalViewAssignment.keySet()));
+  }
+
+  /// Stubs the segment metadata lookup with the given creation times, in the 
order the selector reads them.
+  private void createSegmentCreationTimes(Map<String, Long> 
creationTimeMsBySegment) {
+    List<Pair<String, Long>> creationTimes = new 
ArrayList<>(creationTimeMsBySegment.size());
+    for (String segment : new TreeSet<>(creationTimeMsBySegment.keySet())) {
+      creationTimes.add(new ImmutablePair<>(segment, 
creationTimeMsBySegment.get(segment)));
+    }
+    createSegments(creationTimes);
+  }
+
+  /// Marks the given segments as created long enough ago that they are no 
longer treated as new, so that
+  /// they count towards the replica health even though their external view 
has not converged.
+  private void createOldSegments(List<String> segments) {
+    long creationTimeMs = _mutableClock.millis() - 
NEW_SEGMENT_EXPIRATION_MILLIS - 1;
+    Map<String, Long> creationTimes = new HashMap<>();
+    for (String segment : segments) {
+      creationTimes.put(segment, creationTimeMs);
+    }
+    createSegmentCreationTimes(creationTimes);
+  }
+
+  @Test(dataProvider = "selectorType")
+  public void testReplicaHealthFullyReplicated(String selectorType) {
+    // Every segment is ONLINE everywhere the ideal state assigns it
+    BaseInstanceSelector selector = createReplicaHealthSelector(selectorType, 
REPLICA_INSTANCES,
+        Map.of("segment0", allOnline(), "segment1", allOnline()),
+        Map.of("segment0", allOnline(), "segment1", allOnline()));
+
+    TableReplicaHealth replicaHealth = selector.getReplicaHealth();
+    assertEquals(replicaHealth.getMinPercentOfReplicas(), 100);
+    assertEquals(replicaHealth.getNumUnavailableSegments(), 0);
+    // Every measured segment sits at the minimum when the minimum is 100, so 
the count is the measured
+    // population rather than 0 - it counts what the percentage speaks for, 
not what is wrong
+    assertEquals(replicaHealth.getNumSegmentsAtMinPercentOfReplicas(), 2);
+    // Nothing is degraded, so no expected replica count has to be remembered
+    assertTrue(selector._oldSegmentExpectedReplicasMap.isEmpty());
+  }
+
+  /// Returns an external view assignment with the first `numOnline` of the 
three instances ONLINE and the
+  /// rest in ERROR, i.e. replicas that failed their state transition rather 
than merely being offline.
+  private static List<Pair<String, String>> partiallyOnlineRestInError(int 
numOnline) {
+    List<String> instances = List.of(REPLICA_INSTANCE_0, REPLICA_INSTANCE_1, 
REPLICA_INSTANCE_2);
+    List<Pair<String, String>> assignment = new ArrayList<>(instances.size());
+    for (int i = 0; i < instances.size(); i++) {
+      assignment.add(new ImmutablePair<>(instances.get(i), i < numOnline ? 
ONLINE : ERROR));
+    }
+    return assignment;
+  }
+
+  private static List<Pair<String, String>> singleReplica(String state) {
+    return List.of(new ImmutablePair<>(REPLICA_INSTANCE_0, state));
+  }
+
+  /// Returns an ideal state assignment placing the segment on all three 
instances as CONSUMING, i.e. a
+  /// segment the controller still considers in progress.
+  private static List<Pair<String, String>> allConsuming() {
+    return List.of(new ImmutablePair<>(REPLICA_INSTANCE_0, CONSUMING),
+        new ImmutablePair<>(REPLICA_INSTANCE_1, CONSUMING), new 
ImmutablePair<>(REPLICA_INSTANCE_2, CONSUMING));
+  }
+
+  @Test(dataProvider = "selectorType")
+  public void testConsumingSegmentMeasuredLikeAnyOther(String selectorType) {
+    // Consuming segments are deliberately not special-cased. A partition 
whose replicas are all gone will also
+    // raise an ingestion alert, and reconciling that overlap belongs in the 
alerting pipeline rather than in a
+    // metric that would otherwise stop meaning what its name says.
+    createOldSegments(List.of("segment0"));
+    BaseInstanceSelector selector = createReplicaHealthSelector(selectorType, 
REPLICA_INSTANCES,
+        Map.of("segment0", allConsuming()), Map.of("segment0", 
partiallyOnline(0)));
+
+    TableReplicaHealth replicaHealth = selector.getReplicaHealth();
+    assertEquals(replicaHealth.getMinPercentOfReplicas(), 0);
+    assertEquals(replicaHealth.getNumSegmentsAtMinPercentOfReplicas(), 1);
+    assertEquals(replicaHealth.getNumUnavailableSegments(), 1);
+  }
+
+  @Test(dataProvider = "selectorType")
+  public void testHealthyConsumingSegmentReportsFullyReplicated(String 
selectorType) {
+    // The flip side of not excluding them: a partition consuming normally on 
every replica must read 100%, or
+    // every real-time table would look permanently degraded. CONSUMING counts 
as serving for routing.
+    createOldSegments(List.of("segment0"));
+    BaseInstanceSelector selector = createReplicaHealthSelector(selectorType, 
REPLICA_INSTANCES,
+        Map.of("segment0", allConsuming()), Map.of("segment0", 
allConsuming()));
+
+    TableReplicaHealth replicaHealth = selector.getReplicaHealth();
+    assertEquals(replicaHealth.getMinPercentOfReplicas(), 100);
+    assertEquals(replicaHealth.getNumSegmentsAtMinPercentOfReplicas(), 1);
+    assertEquals(replicaHealth.getNumUnavailableSegments(), 0);
+  }
+
+  @Test(dataProvider = "selectorType")
+  public void testCommittedSegmentCountedWhilePeersStillDownloading(String 
selectorType) {
+    // The exclusion has to end at the commit, not when the last replica 
finishes downloading. The ideal state
+    // turns ONLINE at commit while peers still report CONSUMING, and that 
segment is an ordinary immutable
+    // one whose replicas are genuinely missing.
+    createOldSegments(List.of("segment0"));
+    BaseInstanceSelector selector = createReplicaHealthSelector(selectorType, 
REPLICA_INSTANCES,
+        // Committed: ideal state ONLINE everywhere
+        Map.of("segment0", allOnline()),
+        // Only the committer has it; the peers have dropped out rather than 
reporting CONSUMING
+        Map.of("segment0", partiallyOnline(1)));
+
+    _mutableClock.fastForward(Duration.ofMillis(NEW_SEGMENT_EXPIRATION_MILLIS 
+ 1));
+    TableReplicaHealth replicaHealth = selector.getReplicaHealth();
+    assertEquals(replicaHealth.getMinPercentOfReplicas(), 33);
+    assertEquals(replicaHealth.getNumSegmentsAtMinPercentOfReplicas(), 1);
+  }
+
+  @Test(dataProvider = "selectorType")
+  public void 
testCommittingSegmentStillRoutableFromPeersReportingConsuming(String 
selectorType) {
+    // The normal commit window: ideal state ONLINE, peers still CONSUMING in 
the external view. They remain
+    // routable, so nothing is short of replicas and no clock starts.
+    BaseInstanceSelector selector = createReplicaHealthSelector(selectorType, 
REPLICA_INSTANCES,
+        Map.of("segment0", allOnline()),
+        Map.of("segment0", List.of(new ImmutablePair<>(REPLICA_INSTANCE_0, 
ONLINE),
+            new ImmutablePair<>(REPLICA_INSTANCE_1, CONSUMING), new 
ImmutablePair<>(REPLICA_INSTANCE_2, CONSUMING))));
+
+    TableReplicaHealth replicaHealth = selector.getReplicaHealth();
+    assertEquals(replicaHealth.getMinPercentOfReplicas(), 100);
+    assertEquals(replicaHealth.getNumSegmentsAtMinPercentOfReplicas(), 1);
+  }
+
+  @Test(dataProvider = "selectorType")
+  public void testSegmentsAtMinPercentIgnoresSingleReplicaSegments(String 
selectorType) {
+    // The count is drawn from the same population as the percentage, so a 
segment the ideal state assigns one
+    // replica is left out of it too - otherwise a table that is 
single-replica by design would report every
+    // one of its segments as sitting at the worst level.
+    createOldSegments(List.of("segment0"));
+    BaseInstanceSelector selector = createReplicaHealthSelector(selectorType, 
REPLICA_INSTANCES,
+        Map.of("segment0", singleReplica(ONLINE)), Map.of("segment0", 
singleReplica(OFFLINE)));
+
+    _mutableClock.fastForward(Duration.ofMillis(NEW_SEGMENT_EXPIRATION_MILLIS 
* 2));
+    TableReplicaHealth replicaHealth = selector.getReplicaHealth();
+    assertEquals(replicaHealth.getMinPercentOfReplicas(), 100);
+    // Nothing measured at all, which is the only way the count reaches 0
+    assertEquals(replicaHealth.getNumSegmentsAtMinPercentOfReplicas(), 0);
+  }
+
+  @Test
+  public void testSegmentsAtMinPercentCountsOnlyTheWorstLevel() {
+    // segment0 is at 2 of 3 and segment1 at 1 of 3. The count belongs to the 
percentage that is reported, so
+    // only segment1 is in it - a segment that is degraded but better off than 
the worst must not inflate the
+    // blast radius the minimum is describing.
+    // Balanced routing only: under strict replica groups segment1's gaps 
would exclude those groups for
+    // segment0 as well, taking both segments to 1 of 3 and hiding the 
distinction this asserts on.
+    createOldSegments(List.of("segment0", "segment1"));
+    BaseInstanceSelector selector = 
createReplicaHealthSelector(BALANCED_INSTANCE_SELECTOR, REPLICA_INSTANCES,
+        Map.of("segment0", allOnline(), "segment1", allOnline()),
+        Map.of("segment0", partiallyOnline(2), "segment1", 
partiallyOnline(1)));
+
+    _mutableClock.fastForward(Duration.ofMillis(NEW_SEGMENT_EXPIRATION_MILLIS 
* 2));
+    TableReplicaHealth replicaHealth = selector.getReplicaHealth();
+    assertEquals(replicaHealth.getMinPercentOfReplicas(), 33);
+    assertEquals(replicaHealth.getNumSegmentsAtMinPercentOfReplicas(), 1);
+  }
+
+  @Test(dataProvider = "selectorType")
+  public void 
testSegmentsAtMinPercentComparesUnevenlyReplicatedSegments(String selectorType) 
{
+    // segment0 is down to 1 of its 2 assigned replicas and segment1 to 1 of 
its 3. Both have lost all but one
+    // replica, yet the percentages differ - 50 against 33 - and it is the 
percentage that decides membership,
+    // so only segment1 is counted. Measuring each segment against its own 
assignment is what makes the two
+    // comparable in the first place.
+    createOldSegments(List.of("segment0", "segment1"));
+    BaseInstanceSelector selector = createReplicaHealthSelector(selectorType, 
REPLICA_INSTANCES,
+        Map.of("segment0", twoReplicas(), "segment1", allOnline()),
+        Map.of("segment0", List.of(new ImmutablePair<>(REPLICA_INSTANCE_0, 
ONLINE),
+            new ImmutablePair<>(REPLICA_INSTANCE_1, OFFLINE)), "segment1", 
partiallyOnline(1)));
+
+    _mutableClock.fastForward(Duration.ofMillis(NEW_SEGMENT_EXPIRATION_MILLIS 
* 2));
+    TableReplicaHealth replicaHealth = selector.getReplicaHealth();
+    // The percentage reports the worse of the two, which is the 1-of-3 segment
+    assertEquals(replicaHealth.getMinPercentOfReplicas(), 33);
+    assertEquals(replicaHealth.getNumSegmentsAtMinPercentOfReplicas(), 1);
+  }
+
+  @Test(dataProvider = "selectorType")
+  public void 
testSegmentsAtMinPercentCountsTwoReplicaSegmentWithNoReplicaLeft(String 
selectorType) {
+    // A two-replica segment is measured like any other, so losing both takes 
it to 0% and counts it. A rule
+    // keyed on "three or more assigned" would miss a total loss on a 
two-replica table entirely.
+    createOldSegments(List.of("segment0"));
+    BaseInstanceSelector selector = createReplicaHealthSelector(selectorType, 
REPLICA_INSTANCES,
+        Map.of("segment0", twoReplicas()),
+        Map.of("segment0", List.of(new ImmutablePair<>(REPLICA_INSTANCE_0, 
OFFLINE),
+            new ImmutablePair<>(REPLICA_INSTANCE_1, OFFLINE))));
+
+    _mutableClock.fastForward(Duration.ofMillis(NEW_SEGMENT_EXPIRATION_MILLIS 
* 2));
+    TableReplicaHealth replicaHealth = selector.getReplicaHealth();
+    assertEquals(replicaHealth.getMinPercentOfReplicas(), 0);
+    assertEquals(replicaHealth.getNumSegmentsAtMinPercentOfReplicas(), 1);
+  }
+
+  @Test
+  public void testSegmentsAtMinPercentWatchesReplicatedPartOfMixedTable() {
+    // A real-time table whose consuming segment lives on one replica while 
its completed segments live on
+    // three. Losing two replicas of the completed segment has to be reported, 
and the thinly replicated
+    // consuming segment must neither stop that from happening nor be counted 
alongside it.
+    createOldSegments(List.of("completed"));
+    BaseInstanceSelector selector = 
createReplicaHealthSelector(BALANCED_INSTANCE_SELECTOR, REPLICA_INSTANCES,
+        Map.of("consuming", List.of(new ImmutablePair<>(REPLICA_INSTANCE_0, 
CONSUMING)), "completed", allOnline()),
+        Map.of("consuming", List.of(new ImmutablePair<>(REPLICA_INSTANCE_0, 
CONSUMING)), "completed",
+            partiallyOnline(1)));
+
+    _mutableClock.fastForward(Duration.ofMillis(NEW_SEGMENT_EXPIRATION_MILLIS 
* 2));
+    TableReplicaHealth replicaHealth = selector.getReplicaHealth();
+    assertEquals(replicaHealth.getMinPercentOfReplicas(), 33);
+    assertEquals(replicaHealth.getNumSegmentsAtMinPercentOfReplicas(), 1);
+  }
+
+  @Test(dataProvider = "selectorType")
+  public void testReplicaHealthPartiallyReplicated(String selectorType) {
+    // segment0 is only loaded on 1 of its 3 replicas, which is the threshold 
the low replica alert fires on
+    createOldSegments(List.of("segment0"));
+    BaseInstanceSelector selector = createReplicaHealthSelector(selectorType, 
REPLICA_INSTANCES,
+        Map.of("segment0", allOnline()), Map.of("segment0", 
partiallyOnline(1)));
+
+    TableReplicaHealth replicaHealth = selector.getReplicaHealth();
+    assertEquals(replicaHealth.getMinPercentOfReplicas(), 33);
+    assertEquals(replicaHealth.getNumUnavailableSegments(), 0);
+  }
+
+  @Test(dataProvider = "selectorType")
+  public void testReplicaHealthUnavailableSegment(String selectorType) {
+    // segment0 is loaded nowhere
+    createOldSegments(List.of("segment0"));
+    BaseInstanceSelector selector = createReplicaHealthSelector(selectorType, 
REPLICA_INSTANCES,
+        Map.of("segment0", allOnline()), Map.of("segment0", 
partiallyOnline(0)));
+
+    TableReplicaHealth replicaHealth = selector.getReplicaHealth();
+    assertEquals(replicaHealth.getMinPercentOfReplicas(), 0);
+    assertEquals(replicaHealth.getNumUnavailableSegments(), 1);
+    // A replicated segment that is unavailable is at 0%, which is as low as 
the minimum goes, so it is
+    // counted by both gauges rather than moving from one to the other
+    assertEquals(replicaHealth.getNumSegmentsAtMinPercentOfReplicas(), 1);
+  }
+
+  @Test
+  public void testReplicaHealthReportsWorstSegmentNotAverage() {
+    // segment0 is loaded nowhere while the other two are fully loaded. 
Reporting the minimum is what keeps
+    // a single unservable segment from being diluted by a large healthy 
table, and the count says how much
+    // of the table that minimum is speaking for - here one segment out of the 
three measured.
+    createOldSegments(List.of("segment0"));
+    BaseInstanceSelector selector = 
createReplicaHealthSelector(BALANCED_INSTANCE_SELECTOR, REPLICA_INSTANCES,
+        Map.of("segment0", allOnline(), "segment1", allOnline(), "segment2", 
allOnline()),
+        Map.of("segment0", partiallyOnline(0), "segment1", allOnline(), 
"segment2", allOnline()));
+
+    TableReplicaHealth replicaHealth = selector.getReplicaHealth();
+    assertEquals(replicaHealth.getMinPercentOfReplicas(), 0);
+    assertEquals(replicaHealth.getNumSegmentsAtMinPercentOfReplicas(), 1);
+    assertEquals(replicaHealth.getNumUnavailableSegments(), 1);
+  }
+
+  @Test(dataProvider = "selectorType")
+  public void testReplicaHealthIgnoresNewSegments(String selectorType) {
+    // segment0 was just created and is only loaded on 1 replica. That is 
expected right after a push, so
+    // it must not drag the percentage down.
+    createSegmentCreationTimes(Map.of("segment0", _mutableClock.millis()));
+    BaseInstanceSelector selector = createReplicaHealthSelector(selectorType, 
REPLICA_INSTANCES,
+        Map.of("segment0", allOnline()), Map.of("segment0", 
partiallyOnline(1)));
+
+    TableReplicaHealth replicaHealth = selector.getReplicaHealth();
+    assertEquals(replicaHealth.getMinPercentOfReplicas(), 100);
+  }
+
+  @Test(dataProvider = "selectorType")
+  public void testReplicaHealthAccountsForDisabledInstance(String 
selectorType) {
+    // A disabled instance reduces the replicas the broker can route to just 
as much as a missing external
+    // view entry does, and it arrives through onInstancesChange rather than 
an assignment change.
+    BaseInstanceSelector selector = createReplicaHealthSelector(selectorType, 
REPLICA_INSTANCES,
+        Map.of("segment0", allOnline()), Map.of("segment0", allOnline()));
+    assertEquals(selector.getReplicaHealth().getMinPercentOfReplicas(), 100);
+
+    selector.onInstancesChange(ImmutableSet.of(REPLICA_INSTANCE_0, 
REPLICA_INSTANCE_1),
+        List.of(REPLICA_INSTANCE_2));
+
+    TableReplicaHealth replicaHealth = selector.getReplicaHealth();
+    assertEquals(replicaHealth.getMinPercentOfReplicas(), 66);
+    assertEquals(replicaHealth.getNumUnavailableSegments(), 0);
+  }
+
+  @Test
+  public void testReplicaHealthReflectsStrictReplicaGroupKnockout() {
+    // The two segments share an ideal state assignment but are each missing a 
different instance: segment0
+    // is not loaded on instance2, segment1 is not loaded on instance1. Strict 
replica group routing takes
+    // both instances out of service for every segment in the group, so each 
segment is left with instance0
+    // alone. The external view on its own only ever shows two of three 
replicas missing per segment, so the
+    // group-wide knockout is degradation a metric computed from the external 
view cannot see.
+    createOldSegments(List.of("segment0", "segment1"));
+    Map<String, List<Pair<String, String>>> idealStateAssignment =
+        Map.of("segment0", allOnline(), "segment1", allOnline());
+    Map<String, List<Pair<String, String>>> externalViewAssignment =
+        Map.of("segment0", onlineExcept(REPLICA_INSTANCE_2), "segment1", 
onlineExcept(REPLICA_INSTANCE_1));
+
+    BaseInstanceSelector strictSelector =
+        
createReplicaHealthSelector(STRICT_REPLICA_GROUP_INSTANCE_SELECTOR_TYPE, 
REPLICA_INSTANCES,
+            idealStateAssignment, externalViewAssignment);
+    TableReplicaHealth strictReplicaHealth = strictSelector.getReplicaHealth();
+    // 1 of 3, not the 2 of 3 the external view would suggest, and both 
segments are down to a single replica
+    assertEquals(strictReplicaHealth.getMinPercentOfReplicas(), 33);
+    assertEquals(strictReplicaHealth.getNumSegmentsAtMinPercentOfReplicas(), 
2);
+
+    // Without the strict guarantee each segment only loses the replica its 
own external view is missing
+    BaseInstanceSelector balancedSelector =
+        createReplicaHealthSelector(BALANCED_INSTANCE_SELECTOR, 
REPLICA_INSTANCES, idealStateAssignment,
+            externalViewAssignment);
+    TableReplicaHealth balancedReplicaHealth = 
balancedSelector.getReplicaHealth();
+    assertEquals(balancedReplicaHealth.getMinPercentOfReplicas(), 66);
+    // Both segments tie at the milder minimum, so the count stays 2 while the 
percentage improves: it tracks
+    // whatever level is currently worst, and is only ever read together with 
that level
+    assertEquals(balancedReplicaHealth.getNumSegmentsAtMinPercentOfReplicas(), 
2);
+  }
+
+  @Test(dataProvider = "selectorType")
+  public void testReplicaHealthWithUnevenlyReplicatedSegments(String 
selectorType) {
+    // segment0 is deliberately assigned a single replica, as happens when 
only part of a table's data sits
+    // on a tier with fewer replicas. Measuring it against its own ideal state 
keeps it at 100 instead of
+    // permanently reporting it as under-replicated.
+    BaseInstanceSelector selector = createReplicaHealthSelector(selectorType, 
REPLICA_INSTANCES,
+        Map.of("segment0", List.of(new ImmutablePair<>(REPLICA_INSTANCE_0, 
ONLINE)), "segment1", allOnline()),
+        Map.of("segment0", List.of(new ImmutablePair<>(REPLICA_INSTANCE_0, 
ONLINE)), "segment1", allOnline()));
+
+    TableReplicaHealth replicaHealth = selector.getReplicaHealth();
+    assertEquals(replicaHealth.getMinPercentOfReplicas(), 100);
+  }
+
+  @Test(dataProvider = "selectorType")
+  public void testReplicaHealthIgnoresIdealStateOfflineReplicas(String 
selectorType) {
+    // An instance the ideal state marks OFFLINE is not expected to serve the 
segment, so it must not count
+    // against the segment either.
+    BaseInstanceSelector selector = createReplicaHealthSelector(selectorType, 
REPLICA_INSTANCES,
+        Map.of("segment0",
+            List.of(new ImmutablePair<>(REPLICA_INSTANCE_0, ONLINE), new 
ImmutablePair<>(REPLICA_INSTANCE_1, ONLINE),
+                new ImmutablePair<>(REPLICA_INSTANCE_2, OFFLINE))),
+        Map.of("segment0", allOnline()));
+
+    TableReplicaHealth replicaHealth = selector.getReplicaHealth();
+    assertEquals(replicaHealth.getMinPercentOfReplicas(), 100);
+  }
+
+  @Test(dataProvider = "selectorType")
+  public void testReplicaHealthWithNoOldSegments(String selectorType) {
+    // The state right after a table is created: every segment is new, so 
there is nothing to measure. It
+    // has to read as fully replicated, otherwise it would trip the very alert 
the gauge exists for.
+    createSegmentCreationTimes(Map.of("segment0", _mutableClock.millis()));
+    BaseInstanceSelector selector = createReplicaHealthSelector(selectorType, 
REPLICA_INSTANCES,
+        Map.of("segment0", allOnline()), Map.of("segment0", 
partiallyOnline(0)));
+
+    TableReplicaHealth replicaHealth = selector.getReplicaHealth();
+    assertEquals(replicaHealth.getMinPercentOfReplicas(), 100);
+    // The 100 above is the "nothing to measure" default rather than a 
measurement, and the count is what
+    // tells the two apart
+    assertEquals(replicaHealth.getNumSegmentsAtMinPercentOfReplicas(), 0);
+    assertEquals(replicaHealth.getNumUnavailableSegments(), 0);
+  }
+
+  @Test(dataProvider = "selectorType")
+  public void testReplicaHealthRecoversOnAssignmentChange(String selectorType) 
{
+    // The expected replica counts are cached until the next assignment change 
rebuilds them, so a segment
+    // that converges has to stop being reported as degraded.
+    createOldSegments(List.of("segment0"));
+    Map<String, List<Pair<String, String>>> idealStateAssignment = 
Map.of("segment0", allOnline());
+    BaseInstanceSelector selector = createReplicaHealthSelector(selectorType, 
REPLICA_INSTANCES,
+        idealStateAssignment, Map.of("segment0", partiallyOnline(1)));
+    assertEquals(selector.getReplicaHealth().getMinPercentOfReplicas(), 33);
+
+    selector.onAssignmentChange(createIdealState(idealStateAssignment),
+        createExternalView(Map.of("segment0", allOnline())), 
Set.of("segment0"));
+
+    TableReplicaHealth replicaHealth = selector.getReplicaHealth();
+    assertEquals(replicaHealth.getMinPercentOfReplicas(), 100);
+    // The cached counts have to be dropped on rebuild, or the segment stays 
degraded forever
+    assertEquals(selector._oldSegmentExpectedReplicasMap, Map.of());
+  }
+
+  @Test
+  public void testReplicaHealthWithUpsertTableAppliesStrictReplicaGroupRules() 
{
+    // An upsert table gets the strict replica-group treatment even under the 
plain replica-group selector,
+    // so the group-wide knockout has to be reflected there too.
+    when(_tableConfig.isUpsertEnabled()).thenReturn(true);
+    createOldSegments(List.of("segment0", "segment1"));
+    // Same setup as testReplicaHealthReflectsStrictReplicaGroupKnockout: a 
different instance missing per
+    // segment, so 33 can only come from the group-wide knockout and not from 
either segment's own view
+    BaseInstanceSelector selector =
+        createReplicaHealthSelector(REPLICA_GROUP_INSTANCE_SELECTOR_TYPE, 
REPLICA_INSTANCES,
+            Map.of("segment0", allOnline(), "segment1", allOnline()),
+            Map.of("segment0", onlineExcept(REPLICA_INSTANCE_2), "segment1", 
onlineExcept(REPLICA_INSTANCE_1)));
+
+    TableReplicaHealth replicaHealth = selector.getReplicaHealth();
+    assertEquals(replicaHealth.getMinPercentOfReplicas(), 33);
+    assertEquals(replicaHealth.getNumSegmentsAtMinPercentOfReplicas(), 2);
+  }
+
+  @Test
+  public void testReplicaHealthMeasuredForDisabledTable() {
+    // The selector measures a disabled table like any other - it cannot tell 
the difference, and it is the
+    // routing manager that decides a disabled table's gauges should be 
dropped rather than reported.
+    createOldSegments(List.of("segment0"));
+    IdealState disabledIdealState = createIdealState(Map.of("segment0", 
allOnline()));
+    disabledIdealState.enable(false);
+    BalancedInstanceSelector selector = new BalancedInstanceSelector();
+    selector.init(_tableConfig, _propertyStore, _brokerMetrics, null, 
_mutableClock, INSTANCE_SELECTOR_CONFIG,
+        REPLICA_INSTANCES, EMPTY_SERVER_MAP, disabledIdealState,
+        createExternalView(Map.of("segment0", partiallyOnline(0))), 
Set.of("segment0"));
+
+    assertEquals(selector.getReplicaHealth().getNumUnavailableSegments(), 1);
+    // The selector never touches the replica health gauges itself
+    verify(_brokerMetrics, never()).setValueOfTableGauge(eq(TABLE_NAME), 
any(BrokerGauge.class), anyLong());
+    verify(_brokerMetrics, never()).removeTableGauge(eq(TABLE_NAME), 
any(BrokerGauge.class));
+  }
 }
diff --git 
a/pinot-broker/src/test/java/org/apache/pinot/broker/routing/manager/BrokerRoutingManagerTest.java
 
b/pinot-broker/src/test/java/org/apache/pinot/broker/routing/manager/BrokerRoutingManagerTest.java
index bc4134ebb61..32be5f024cf 100644
--- 
a/pinot-broker/src/test/java/org/apache/pinot/broker/routing/manager/BrokerRoutingManagerTest.java
+++ 
b/pinot-broker/src/test/java/org/apache/pinot/broker/routing/manager/BrokerRoutingManagerTest.java
@@ -29,23 +29,34 @@ import org.apache.helix.HelixConstants.ChangeType;
 import org.apache.helix.HelixDataAccessor;
 import org.apache.helix.HelixManager;
 import org.apache.helix.PropertyKey;
+import org.apache.helix.model.ExternalView;
+import org.apache.helix.model.IdealState;
 import org.apache.helix.model.InstanceConfig;
 import org.apache.helix.store.zk.ZkHelixPropertyStore;
 import org.apache.helix.zookeeper.datamodel.ZNRecord;
 import org.apache.pinot.broker.routing.instanceselector.InstanceSelector;
+import org.apache.pinot.broker.routing.instanceselector.TableReplicaHealth;
 import 
org.apache.pinot.broker.routing.segmentmetadata.SegmentZkMetadataFetcher;
 import 
org.apache.pinot.broker.routing.segmentpartition.SegmentPartitionMetadataManager;
 import org.apache.pinot.broker.routing.segmentpreselector.SegmentPreSelector;
 import org.apache.pinot.broker.routing.segmentpruner.SegmentPruner;
 import org.apache.pinot.broker.routing.segmentselector.SegmentSelector;
+import org.apache.pinot.broker.routing.tablesampler.TableSampler;
 import org.apache.pinot.broker.routing.timeboundary.TimeBoundaryManager;
+import org.apache.pinot.common.metrics.BrokerGauge;
 import org.apache.pinot.common.metrics.BrokerMetrics;
+import org.apache.pinot.common.utils.config.TableConfigSerDeUtils;
 import org.apache.pinot.core.routing.TablePartitionInfo;
 import org.apache.pinot.core.routing.TablePartitionReplicatedServersInfo;
 import org.apache.pinot.core.routing.timeboundary.TimeBoundaryInfo;
 import org.apache.pinot.core.transport.ServerInstance;
 import 
org.apache.pinot.core.transport.server.routing.stats.ServerRoutingStatsManager;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.TableType;
 import org.apache.pinot.spi.env.PinotConfiguration;
+import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
+import org.apache.pinot.spi.utils.builder.TableNameBuilder;
+import org.apache.zookeeper.data.Stat;
 import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
@@ -55,7 +66,10 @@ import org.testng.annotations.Test;
 
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
@@ -72,6 +86,9 @@ public class BrokerRoutingManagerTest {
   private static final int SERVER_PORT = 8000;
   private static final String INSTANCE_CONFIGS_PATH = "/CONFIGS/PARTICIPANT";
   private static final String TEST_TABLE = "testTable_OFFLINE";
+  private static final List<BrokerGauge> REPLICA_HEALTH_GAUGES =
+      List.of(BrokerGauge.PERCENT_OF_REPLICAS, 
BrokerGauge.UNAVAILABLE_SEGMENTS,
+          BrokerGauge.SEGMENTS_AT_MIN_PERCENT_OF_REPLICAS);
 
   private AutoCloseable _mocks;
 
@@ -244,6 +261,14 @@ public class BrokerRoutingManagerTest {
   private static Object createRoutingEntry(String tableNameWithType, 
TimeBoundaryManager timeBoundaryManager,
       SegmentPartitionMetadataManager partitionMetadataManager, Map<String, ?> 
samplerInfos)
       throws Exception {
+    return createRoutingEntry(tableNameWithType, timeBoundaryManager, 
partitionMetadataManager, samplerInfos,
+        mock(InstanceSelector.class), false);
+  }
+
+  private static Object createRoutingEntry(String tableNameWithType, 
TimeBoundaryManager timeBoundaryManager,
+      SegmentPartitionMetadataManager partitionMetadataManager, Map<String, ?> 
samplerInfos,
+      InstanceSelector instanceSelector, boolean disabled)
+      throws Exception {
     Class<?> routingEntryClass = 
Class.forName(BaseBrokerRoutingManager.class.getName() + "$RoutingEntry");
     Constructor<?> constructor = 
routingEntryClass.getDeclaredConstructor(String.class, String.class, 
String.class,
         SegmentPreSelector.class, SegmentSelector.class, List.class, 
InstanceSelector.class, int.class, int.class,
@@ -252,9 +277,251 @@ public class BrokerRoutingManagerTest {
     constructor.setAccessible(true);
     return constructor.newInstance(tableNameWithType, "/IDEALSTATES/" + 
tableNameWithType,
         "/EXTERNALVIEW/" + tableNameWithType, mock(SegmentPreSelector.class), 
mock(SegmentSelector.class),
-        Collections.<SegmentPruner>emptyList(), mock(InstanceSelector.class), 
1, 1,
+        Collections.<SegmentPruner>emptyList(), instanceSelector, 1, 1,
         mock(SegmentZkMetadataFetcher.class), timeBoundaryManager, 
partitionMetadataManager, null, samplerInfos,
-        false);
+        disabled);
+  }
+
+  private static Object createSamplerInfo(InstanceSelector instanceSelector)
+      throws Exception {
+    Class<?> samplerInfoClass = 
Class.forName(BaseBrokerRoutingManager.class.getName() + "$SamplerInfo");
+    Constructor<?> constructor =
+        samplerInfoClass.getDeclaredConstructor(TableSampler.class, 
SegmentSelector.class, InstanceSelector.class);
+    constructor.setAccessible(true);
+    return constructor.newInstance(mock(TableSampler.class), 
mock(SegmentSelector.class), instanceSelector);
+  }
+
+  /// Registers the test server as routable, so that the exclude/include paths 
actually walk the routing
+  /// entries instead of short-circuiting.
+  private void enableTestServer() {
+    when(_zkDataAccessor.getChildren(eq(INSTANCE_CONFIGS_PATH), any(), 
eq(AccessOption.PERSISTENT), anyInt(), anyInt()))
+        .thenReturn(List.of(createEnabledServerZNRecord(SERVER_INSTANCE_ID)));
+    _routingManager.processClusterChange(ChangeType.INSTANCE_CONFIG);
+  }
+
+  private void verifyReplicaHealthGaugesRemoved() {
+    for (BrokerGauge gauge : REPLICA_HEALTH_GAUGES) {
+      verify(_brokerMetrics).removeTableGauge(TEST_TABLE, gauge);
+    }
+  }
+
+  private void verifyNoReplicaHealthGaugesReported() {
+    for (BrokerGauge gauge : REPLICA_HEALTH_GAUGES) {
+      verify(_brokerMetrics, never()).setValueOfTableGauge(eq(TEST_TABLE), 
eq(gauge), anyLong());
+    }
+  }
+
+  @Test
+  public void testRemoveRoutingRemovesReplicaHealthMetrics()
+      throws Exception {
+    // The routing owns the table's replica health gauges, so tearing it down 
has to stop them - otherwise
+    // they keep being exported for a table this broker no longer serves
+    putRoutingEntry(TEST_TABLE, createRoutingEntry(TEST_TABLE, null, null, 
Map.of()));
+
+    _routingManager.removeRouting(TEST_TABLE);
+
+    verifyReplicaHealthGaugesRemoved();
+  }
+
+  @Test
+  public void testInstanceChangeReportsReplicaHealthMetrics()
+      throws Exception {
+    // The gauges have to track the routing, so an instance change re-reports 
what the selector now measures
+    enableTestServer();
+    InstanceSelector instanceSelector = mock(InstanceSelector.class);
+    when(instanceSelector.getReplicaHealth()).thenReturn(new 
TableReplicaHealth(33, 2, 1));
+    putRoutingEntry(TEST_TABLE, createRoutingEntry(TEST_TABLE, null, null, 
Map.of(), instanceSelector, false));
+
+    _routingManager.excludeServerFromRouting(SERVER_INSTANCE_ID);
+
+    verify(_brokerMetrics).setValueOfTableGauge(TEST_TABLE, 
BrokerGauge.PERCENT_OF_REPLICAS, 33);
+    verify(_brokerMetrics).setValueOfTableGauge(TEST_TABLE, 
BrokerGauge.SEGMENTS_AT_MIN_PERCENT_OF_REPLICAS, 2);
+    verify(_brokerMetrics).setValueOfTableGauge(TEST_TABLE, 
BrokerGauge.UNAVAILABLE_SEGMENTS, 1);
+  }
+
+  @Test
+  public void testReplicaHealthMetricsDroppedForDisabledTable()
+      throws Exception {
+    // Disabling a table drives every replica OFFLINE on purpose, so its 
segments look unavailable. Reporting
+    // that would fire the alert for a table nobody expects to be queryable. 
Drop the gauges instead, so the
+    // series disappears rather than reading as an outage.
+    enableTestServer();
+    InstanceSelector instanceSelector = mock(InstanceSelector.class);
+    when(instanceSelector.getReplicaHealth()).thenReturn(new 
TableReplicaHealth(0, 1, 1));
+    putRoutingEntry(TEST_TABLE, createRoutingEntry(TEST_TABLE, null, null, 
Map.of(), instanceSelector, true));
+
+    _routingManager.excludeServerFromRouting(SERVER_INSTANCE_ID);
+
+    verifyReplicaHealthGaugesRemoved();
+    verifyNoReplicaHealthGaugesReported();
+  }
+
+  /// Stubs the ZK reads that `processSegmentAssignmentChangeInternal` makes 
for the test table, so that the
+  /// real assignment change path runs against the given ideal state.
+  private void stubSegmentAssignmentChange(IdealState idealState) {
+    String idealStatePath = "/IDEALSTATES/" + TEST_TABLE;
+    String externalViewPath = "/EXTERNALVIEW/" + TEST_TABLE;
+    // Any version other than the one the routing entry currently holds, so 
that the change is not skipped
+    Stat changedStat = new Stat();
+    changedStat.setVersion(2);
+    when(_zkDataAccessor.getStats(eq(List.of(idealStatePath)), 
eq(AccessOption.PERSISTENT)))
+        .thenReturn(new Stat[]{changedStat});
+    when(_zkDataAccessor.getStats(eq(List.of(externalViewPath)), 
eq(AccessOption.PERSISTENT)))
+        .thenReturn(new Stat[]{changedStat});
+    when(_zkDataAccessor.get(eq(idealStatePath), any(), 
eq(AccessOption.PERSISTENT)))
+        .thenReturn(idealState.getRecord());
+    when(_zkDataAccessor.get(eq(externalViewPath), any(), 
eq(AccessOption.PERSISTENT)))
+        .thenReturn(new ExternalView(TEST_TABLE).getRecord());
+  }
+
+  private static IdealState createIdealState(boolean enabled) {
+    IdealState idealState = new IdealState(TEST_TABLE);
+    idealState.enable(enabled);
+    return idealState;
+  }
+
+  @Test
+  public void testBuildRoutingReportsReplicaHealthGauges()
+      throws Exception {
+    // Building the routing is the only path by which a newly served table's 
gauges first appear. Without a
+    // report here they would only materialize at the next unrelated cluster 
change, and a table that was
+    // never rebuilt would stay missing from the dashboard entirely.
+    enableTestServer();
+    TableConfig tableConfig =
+        new 
TableConfigBuilder(TableType.OFFLINE).setTableName(TableNameBuilder.extractRawTableName(TEST_TABLE))
+            .build();
+    when(_propertyStore.get(eq("/CONFIGS/TABLE/" + TEST_TABLE), any(), 
eq(AccessOption.PERSISTENT)))
+        .thenReturn(TableConfigSerDeUtils.toZNRecord(tableConfig));
+    when(_zkDataAccessor.get(eq("/IDEALSTATES/" + TEST_TABLE), any(), 
eq(AccessOption.PERSISTENT)))
+        .thenReturn(createIdealState(true).getRecord());
+    when(_zkDataAccessor.get(eq("/EXTERNALVIEW/" + TEST_TABLE), any(), 
eq(AccessOption.PERSISTENT)))
+        .thenReturn(new ExternalView(TEST_TABLE).getRecord());
+
+    _routingManager.buildRouting(TEST_TABLE);
+
+    assertTrue(_routingManager.routingExists(TEST_TABLE));
+    // A table with no segments has nothing to measure, so it reads as fully 
replicated rather than as an
+    // outage - see TableReplicaHealth
+    verify(_brokerMetrics).setValueOfTableGauge(TEST_TABLE, 
BrokerGauge.PERCENT_OF_REPLICAS, 100);
+    verify(_brokerMetrics).setValueOfTableGauge(TEST_TABLE, 
BrokerGauge.SEGMENTS_AT_MIN_PERCENT_OF_REPLICAS, 0);
+    verify(_brokerMetrics).setValueOfTableGauge(TEST_TABLE, 
BrokerGauge.UNAVAILABLE_SEGMENTS, 0);
+  }
+
+  @Test
+  public void 
testAssignmentChangeDropsThenRestoresGaugesAcrossDisableAndEnable()
+      throws Exception {
+    // Drives the real assignment change path rather than injecting the 
disabled flag, since that is the only
+    // thing that ever flips it. Both directions matter: dropping the gauges 
of a disabled table is only
+    // correct if enabling it brings them back, otherwise the table stays 
invisible for good.
+    InstanceSelector instanceSelector = mock(InstanceSelector.class);
+    when(instanceSelector.getReplicaHealth()).thenReturn(new 
TableReplicaHealth(100, 0, 0));
+    putRoutingEntry(TEST_TABLE, createRoutingEntry(TEST_TABLE, null, null, 
Map.of(), instanceSelector, false));
+
+    // Disabling a table drives every replica OFFLINE on purpose, so reporting 
it would be a false alarm
+    stubSegmentAssignmentChange(createIdealState(false));
+    _routingManager.processSegmentAssignmentChangeInternal();
+
+    verifyReplicaHealthGaugesRemoved();
+    verifyNoReplicaHealthGaugesReported();
+
+    clearInvocations(_brokerMetrics);
+    stubSegmentAssignmentChange(createIdealState(true));
+    _routingManager.processSegmentAssignmentChangeInternal();
+
+    verify(_brokerMetrics).setValueOfTableGauge(TEST_TABLE, 
BrokerGauge.PERCENT_OF_REPLICAS, 100);
+    verify(_brokerMetrics).setValueOfTableGauge(TEST_TABLE, 
BrokerGauge.SEGMENTS_AT_MIN_PERCENT_OF_REPLICAS, 0);
+    verify(_brokerMetrics).setValueOfTableGauge(TEST_TABLE, 
BrokerGauge.UNAVAILABLE_SEGMENTS, 0);
+  }
+
+  @Test
+  public void testSamplerInstanceSelectorNeverReportsReplicaHealth()
+      throws Exception {
+    // A sampler's selector sees only a sampled subset of the table's segments 
and shares the table name with
+    // the selector covering the whole table, so reporting from it would 
overwrite the table's real values.
+    // Only the routing entry's own selector is ever asked.
+    enableTestServer();
+    InstanceSelector samplerInstanceSelector = mock(InstanceSelector.class);
+    when(samplerInstanceSelector.getReplicaHealth()).thenReturn(new 
TableReplicaHealth(0, 5, 5));
+    InstanceSelector instanceSelector = mock(InstanceSelector.class);
+    when(instanceSelector.getReplicaHealth()).thenReturn(new 
TableReplicaHealth(100, 0, 0));
+    putRoutingEntry(TEST_TABLE,
+        createRoutingEntry(TEST_TABLE, null, null, Map.of("sampler", 
createSamplerInfo(samplerInstanceSelector)),
+            instanceSelector, false));
+
+    _routingManager.excludeServerFromRouting(SERVER_INSTANCE_ID);
+
+    // The sampler's selector is driven by the change like any other...
+    verify(samplerInstanceSelector).onInstancesChange(any(), any());
+    // ...but is never a source of the table's gauges
+    verify(samplerInstanceSelector, never()).getReplicaHealth();
+    verify(_brokerMetrics).setValueOfTableGauge(TEST_TABLE, 
BrokerGauge.PERCENT_OF_REPLICAS, 100);
+  }
+
+  @Test
+  public void testSelectorThatDoesNotMeasureReplicaHealthDropsTheGauges()
+      throws Exception {
+    // A custom instance selector that does not extend BaseInstanceSelector 
returns null replica health. Its
+    // gauges are dropped rather than left at whatever a previous selector 
reported, so swapping a table to
+    // such a selector ends the series instead of freezing it.
+    enableTestServer();
+    InstanceSelector instanceSelector = mock(InstanceSelector.class);
+    when(instanceSelector.getReplicaHealth()).thenReturn(null);
+    putRoutingEntry(TEST_TABLE, createRoutingEntry(TEST_TABLE, null, null, 
Map.of(), instanceSelector, false));
+
+    _routingManager.excludeServerFromRouting(SERVER_INSTANCE_ID);
+
+    // Asserted positively first, so the never() checks below cannot pass just 
because the reporting path
+    // was never reached at all
+    verify(instanceSelector).getReplicaHealth();
+    verifyReplicaHealthGaugesRemoved();
+    verifyNoReplicaHealthGaugesReported();
+  }
+
+  @Test
+  public void testReplicaHealthStillReportedWhenTheAssignmentChangeThrows()
+      throws Exception {
+    // RoutingEntry.onAssignmentChange updates the instance selector before 
the time boundary manager, so by
+    // the time this throws the replica health is already fresh but 
unreported. That is the window the
+    // finally block exists for. Real callers reach it through 
MultiStageReplicaGroupSelector, whose own
+    // onAssignmentChange recomputes the health via super and then throws from 
getInstancePartitions() when
+    // the instance partitions ZNode is missing.
+    InstanceSelector instanceSelector = mock(InstanceSelector.class);
+    when(instanceSelector.getReplicaHealth()).thenReturn(new 
TableReplicaHealth(0, 4, 4));
+    TimeBoundaryManager timeBoundaryManager = mock(TimeBoundaryManager.class);
+    doThrow(new RuntimeException("simulated time boundary manager 
failure")).when(timeBoundaryManager)
+        .onAssignmentChange(any(), any(), any());
+    putRoutingEntry(TEST_TABLE,
+        createRoutingEntry(TEST_TABLE, timeBoundaryManager, null, Map.of(), 
instanceSelector, false));
+    stubSegmentAssignmentChange(createIdealState(true));
+
+    _routingManager.processSegmentAssignmentChangeInternal();
+
+    verify(_brokerMetrics).setValueOfTableGauge(TEST_TABLE, 
BrokerGauge.PERCENT_OF_REPLICAS, 0);
+    verify(_brokerMetrics).setValueOfTableGauge(TEST_TABLE, 
BrokerGauge.SEGMENTS_AT_MIN_PERCENT_OF_REPLICAS, 4);
+    verify(_brokerMetrics).setValueOfTableGauge(TEST_TABLE, 
BrokerGauge.UNAVAILABLE_SEGMENTS, 4);
+  }
+
+  @Test
+  public void testReplicaHealthStillReportedWhenTheInstanceChangeThrows()
+      throws Exception {
+    // Same window on the instance change path: RoutingEntry.onInstancesChange 
updates the table's own
+    // selector before fanning out to the samplers', and unlike 
updateSamplerInfos it does not catch per
+    // sampler, so a failing sampler selector aborts the update after the 
health is already fresh.
+    enableTestServer();
+    InstanceSelector instanceSelector = mock(InstanceSelector.class);
+    when(instanceSelector.getReplicaHealth()).thenReturn(new 
TableReplicaHealth(0, 3, 3));
+    InstanceSelector samplerInstanceSelector = mock(InstanceSelector.class);
+    doThrow(new RuntimeException("simulated instance selector 
failure")).when(samplerInstanceSelector)
+        .onInstancesChange(any(), any());
+    putRoutingEntry(TEST_TABLE,
+        createRoutingEntry(TEST_TABLE, null, null, Map.of("sampler", 
createSamplerInfo(samplerInstanceSelector)),
+            instanceSelector, false));
+
+    _routingManager.excludeServerFromRouting(SERVER_INSTANCE_ID);
+
+    verify(_brokerMetrics).setValueOfTableGauge(TEST_TABLE, 
BrokerGauge.PERCENT_OF_REPLICAS, 0);
+    verify(_brokerMetrics).setValueOfTableGauge(TEST_TABLE, 
BrokerGauge.SEGMENTS_AT_MIN_PERCENT_OF_REPLICAS, 3);
+    verify(_brokerMetrics).setValueOfTableGauge(TEST_TABLE, 
BrokerGauge.UNAVAILABLE_SEGMENTS, 3);
   }
 
   @SuppressWarnings({"rawtypes", "unchecked"})
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerGauge.java 
b/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerGauge.java
index bd9374aacc9..9387ac13f8d 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerGauge.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerGauge.java
@@ -107,7 +107,30 @@ public enum BrokerGauge implements AbstractMetrics.Gauge {
   /// signals a leak in the ZK listener / drop path.
   MATERIALIZED_VIEW_CACHE_ENTRY_COUNT("materializedViewCacheEntries", true),
   // Workload config fetch status: 1 = success, 0 = failure
-  WORKLOAD_CONFIG_FETCH_STATUS("status", true);
+  WORKLOAD_CONFIG_FETCH_STATUS("status", true),
+
+  /// Replica availability of a table as observed by this broker's routing: 
the smallest percentage of
+  /// assigned replicas that are actually routable, across all the table's 
measured segments. `100` means every
+  /// measured segment can be served from every replica the ideal state 
assigns to it; `0` means at least one measured
+  /// segment cannot be served at all.
+  ///
+  /// Two populations are left out of the measurement, so this gauge can read 
`100` while such a segment is
+  /// unavailable - watch [#UNAVAILABLE_SEGMENTS] for those:
+  /// - Segments assigned a single replica, which have no redundancy to report 
on and would otherwise pin a table of
+  ///   mixed replication (e.g. a tier assigned fewer replicas) to `0` for the 
length of any restart or rebalance.
+  /// - Segments still classified new (see
+  ///   
[org.apache.pinot.spi.utils.CommonConstants.Broker#CONFIG_OF_NEW_SEGMENT_EXPIRATION_SECONDS]),
 which are
+  ///   commonly not yet loaded everywhere.
+  PERCENT_OF_REPLICAS("percent", false),
+
+  /// Number of the table's segments that this broker currently cannot route 
to any server. Segments assigned a
+  /// single replica are included
+  UNAVAILABLE_SEGMENTS("segments", false),
+
+  /// Number of the table's measured segments that are replicated as poorly as 
[#PERCENT_OF_REPLICAS] reports,
+  /// i.e. how many segments that percentage speaks for. The same populations 
are excluded, so this reads `0`
+  /// exactly when the table has nothing to measure.
+  SEGMENTS_AT_MIN_PERCENT_OF_REPLICAS("segments", false);
 
   private final String _brokerGaugeName;
   private final String _unit;


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

Reply via email to