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

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


The following commit(s) were added to refs/heads/master by this push:
     new 6d97d3226a7 Make the failure detector probe the channels that carry 
queries (#20022)
6d97d3226a7 is described below

commit 6d97d3226a73d9d43ba3b313da8dc11427861e3c
Author: Xiang Fu <[email protected]>
AuthorDate: Sun Nov 8 01:10:25 2026 -0800

    Make the failure detector probe the channels that carry queries (#20022)
    
    With pings off (the default), the failure detector's retry probe opened the 
OFFLINE channel only and trusted
    isActive(), so a server could be readmitted onto a half-open REALTIME 
channel, and a channel that swallowed every
    query was never replaced.
    
    - QueryRouter#connect probes every channel the server was queried over 
(ServerChannels#probe): a closed channel is
      reconnected and a suspect one is replaced instead of being trusted on 
isActive(). Channels opened only by startup
      pre-connect are left alone.
    - A timed-out query hands each server it reached but that never answered to 
the transport, which judges it
      pinot.broker.failure.detector.unanswered.query.grace.ms (default 2000) 
later, per channel: it counts only if the
      server sent nothing on that channel since the request was written. Any 
response frame resets the count.
    - At pinot.broker.failure.detector.unanswered.queries.threshold consecutive 
unanswered queries (default 5,
      non-positive disables) the channel is marked suspect and the next send 
replaces it with a fresh connection. With
      pings off and 
pinot.broker.failure.detector.report.unanswered.servers=true (default false), 
the server is also
      reported to the failure detector.
    - An unwritable-channel rejection does not mark the channel suspect.
    - A retry round where every retrier answers UNKNOWN still counts as 
recovered, now pinned by a test.
    - Add the NETTY_CONNECTION_SUSPECT_CHANNELS meter.
    
    No wire or serialization change.
---
 .../SingleConnectionBrokerRequestHandler.java      |  18 +-
 ...ionBrokerRequestHandlerFailureDetectorTest.java |  37 +++-
 .../apache/pinot/common/metrics/BrokerMeter.java   |   6 +
 .../ConnectionFailureDetectorTest.java             |  19 ++
 .../pinot/core/transport/AsyncQueryResponse.java   |  20 +-
 .../pinot/core/transport/DataTableHandler.java     |   2 +
 .../apache/pinot/core/transport/QueryRouter.java   |  83 ++++++-
 .../pinot/core/transport/ServerChannels.java       | 242 +++++++++++++++++++--
 .../core/transport/AsyncQueryResponseTest.java     |  45 ++++
 .../pinot/core/transport/QueryRoutingTest.java     | 192 ++++++++++++++++
 .../pinot/core/transport/ServerChannelsTest.java   | 239 +++++++++++++++++++-
 .../apache/pinot/spi/utils/CommonConstants.java    |  27 +++
 12 files changed, 891 insertions(+), 39 deletions(-)

diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/SingleConnectionBrokerRequestHandler.java
 
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/SingleConnectionBrokerRequestHandler.java
index 364751d74d4..68d9af07e9f 100644
--- 
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/SingleConnectionBrokerRequestHandler.java
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/SingleConnectionBrokerRequestHandler.java
@@ -140,7 +140,11 @@ public class SingleConnectionBrokerRequestHandler extends 
BaseSingleStageBrokerR
     super(config, brokerId, requestIdGenerator, routingManager, 
accessControlFactory, queryQuotaManager, tableCache,
         threadAccountant, multiClusterRoutingContext, materializedViewHandler);
     _brokerReduceService = new BrokerReduceService(_config);
-    _queryRouter = new QueryRouter(_brokerId, nettyConfig, tlsConfig, 
serverRoutingStatsManager, threadAccountant);
+    _queryRouter = new QueryRouter(_brokerId, nettyConfig, tlsConfig, 
serverRoutingStatsManager, threadAccountant,
+        
_config.getProperty(CommonConstants.Broker.FailureDetector.CONFIG_OF_UNANSWERED_QUERIES_THRESHOLD,
+            
CommonConstants.Broker.FailureDetector.DEFAULT_UNANSWERED_QUERIES_THRESHOLD),
+        
_config.getProperty(CommonConstants.Broker.FailureDetector.CONFIG_OF_UNANSWERED_QUERY_GRACE_MS,
+            
CommonConstants.Broker.FailureDetector.DEFAULT_UNANSWERED_QUERY_GRACE_MS));
     _failureDetector = failureDetector;
     _pingOnTimeout = 
_config.getProperty(CommonConstants.Broker.FailureDetector.CONFIG_OF_ENABLE_PING_ON_TIMEOUT,
         CommonConstants.Broker.FailureDetector.DEFAULT_ENABLE_PING_ON_TIMEOUT);
@@ -151,6 +155,14 @@ public class SingleConnectionBrokerRequestHandler extends 
BaseSingleStageBrokerR
       Preconditions.checkArgument(_pingTimeoutMs > 0, "'%s' must be positive, 
got: %s",
           CommonConstants.Broker.FailureDetector.CONFIG_OF_PING_TIMEOUT_MS, 
_pingTimeoutMs);
       
_failureDetector.registerServerNotRespondedChecker(this::checkServerNotResponded);
+    } else if 
(_config.getProperty(CommonConstants.Broker.FailureDetector.CONFIG_OF_REPORT_UNANSWERED_SERVERS,
+        
CommonConstants.Broker.FailureDetector.DEFAULT_REPORT_UNANSWERED_SERVERS)) {
+      // Opted in: without pings nothing else acts on a timeout, so a server 
that has gone silent on one of its channels
+      // is reported like a server the transport saw go down, and leaves 
routing until the retry probe re-establishes
+      // the channel. Otherwise, and always with pings (the ping that follows 
every timeout makes that call), the
+      // silent channel is only replaced.
+      _queryRouter.setUnansweredServerReporter(serverRoutingInstance -> 
_failureDetector.markServerUnhealthy(
+          serverRoutingInstance.getInstanceId(), 
serverRoutingInstance.getHostname()));
     }
   }
 
@@ -1070,7 +1082,7 @@ public class SingleConnectionBrokerRequestHandler extends 
BaseSingleStageBrokerR
   }
 
   /// Check if a server that was previously detected as unhealthy is now 
healthy: by pinging it when pings are enabled,
-  /// otherwise by connecting to it.
+  /// otherwise by re-establishing every channel it was queried over (see 
[QueryRouter#connect]).
   public FailureDetector.ServerState retryUnhealthyServer(String instanceId) {
     LOGGER.info("Retrying unhealthy server: {}", instanceId);
     ServerInstance serverInstance = 
_routingManager.getEnabledServerInstanceMap().get(instanceId);
@@ -1100,7 +1112,7 @@ public class SingleConnectionBrokerRequestHandler extends 
BaseSingleStageBrokerR
     }
 
     if (_queryRouter.connect(serverInstance)) {
-      LOGGER.info("Successfully connect to server: {}, marking it healthy", 
instanceId);
+      LOGGER.info("Re-established the channel(s) to server: {}, marking it 
healthy", instanceId);
       return FailureDetector.ServerState.HEALTHY;
     } else {
       LOGGER.warn("Still cannot connect to server: {}, retry later", 
instanceId);
diff --git 
a/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/SingleConnectionBrokerRequestHandlerFailureDetectorTest.java
 
b/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/SingleConnectionBrokerRequestHandlerFailureDetectorTest.java
index 3ff080b1b3e..28e94e36e98 100644
--- 
a/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/SingleConnectionBrokerRequestHandlerFailureDetectorTest.java
+++ 
b/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/SingleConnectionBrokerRequestHandlerFailureDetectorTest.java
@@ -114,10 +114,15 @@ public class 
SingleConnectionBrokerRequestHandlerFailureDetectorTest {
   /// are recorded, mirroring the wiring `BaseBrokerStarter` performs in 
production. Without `pingOnTimeout` the
   /// default applies.
   private void startBroker(boolean pingOnTimeout, long retryInitialDelayMs) {
+    startBroker(pingOnTimeout, retryInitialDelayMs, Map.of());
+  }
+
+  /// As above, with extra broker settings on top.
+  private void startBroker(boolean pingOnTimeout, long retryInitialDelayMs, 
Map<String, Object> extraConfig) {
     // BrokerMetrics is a JVM-wide singleton, so the gauge carries over from 
whatever ran before in this fork.
     BrokerMetrics.get().setValueOfGlobalGauge(BrokerGauge.UNHEALTHY_SERVERS, 
0);
 
-    PinotConfiguration config = new PinotConfiguration();
+    PinotConfiguration config = new PinotConfiguration(new 
HashMap<>(extraConfig));
     config.setProperty(Broker.FailureDetector.CONFIG_OF_TYPE, 
Broker.FailureDetector.Type.CONNECTION.name());
     if (pingOnTimeout) {
       
config.setProperty(Broker.FailureDetector.CONFIG_OF_ENABLE_PING_ON_TIMEOUT, 
true);
@@ -380,6 +385,36 @@ public class 
SingleConnectionBrokerRequestHandlerFailureDetectorTest {
     assertEquals(_excludedServers, List.of());
   }
 
+  /// With pings off, a server that leaves the threshold of queries unanswered 
on its channel is reported to the failure
+  /// detector only when the operator opted in. By default only its channel is 
replaced, so a healthy server whose
+  /// heavy queries overrun their timeout is never taken out of routing for it.
+  @Test(dataProvider = "reportUnanswered")
+  public void testUnansweredServerIsReportedOnlyWhenEnabled(boolean report)
+      throws Exception {
+    stopBroker();
+    startBroker(false, RETRIES_OFF_MS, Map.of(
+        Broker.FailureDetector.CONFIG_OF_UNANSWERED_QUERIES_THRESHOLD, 1,
+        Broker.FailureDetector.CONFIG_OF_UNANSWERED_QUERY_GRACE_MS, 100L,
+        Broker.FailureDetector.CONFIG_OF_REPORT_UNANSWERED_SERVERS, report));
+    ServerInstance frozenServer = startFrozenServer();
+
+    assertTrue(scatter(offlineRouteTo(frozenServer)).isTimedOut());
+    if (report) {
+      awaitUnhealthyServers(frozenServer);
+      assertEquals(_excludedServers, List.of(frozenServer.getInstanceId()));
+    } else {
+      // The judgment runs a grace period after the timeout; give it far 
longer than that before concluding none ran
+      Thread.sleep(1_000L);
+      assertEquals(_failureDetector.getUnhealthyServers(), Set.of());
+      assertEquals(_excludedServers, List.of());
+    }
+  }
+
+  @DataProvider(name = "reportUnanswered")
+  public Object[][] reportUnanswered() {
+    return new Object[][]{{false}, {true}};
+  }
+
   /// With pings off, retries connect to the server, as before this change, 
rather than ping it. A connect succeeds
   /// even against a frozen server, whose kernel still completes the 
handshake, so this one is put back at once.
   @Test
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerMeter.java 
b/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerMeter.java
index b037d73321e..cfd1a6b5cbe 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerMeter.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerMeter.java
@@ -247,6 +247,12 @@ public class BrokerMeter implements AbstractMetrics.Meter {
   // cluster or moved to another address. Entries whose channel had already 
closed are dropped without being counted.
   public static final BrokerMeter NETTY_CONNECTION_CHANNEL_EVICTIONS = create(
       "NETTY_CONNECTION_CHANNEL_EVICTIONS", "nettyConnection", false);
+  // Channels marked suspect because the server left the configured number of 
consecutive queries unanswered on them
+  // (see 
CommonConstants.Broker.FailureDetector.CONFIG_OF_UNANSWERED_QUERIES_THRESHOLD); 
the next query replaces each.
+  // With CONFIG_OF_REPORT_UNANSWERED_SERVERS on and pings off, each is also a 
report to the failure detector. Tagged
+  // with the server's short name.
+  public static final BrokerMeter NETTY_CONNECTION_SUSPECT_CHANNELS = create(
+      "NETTY_CONNECTION_SUSPECT_CHANNELS", "nettyConnection", false);
 
   public static final BrokerMeter PROACTIVE_CLUSTER_CHANGE_CHECK = create(
       "PROACTIVE_CLUSTER_CHANGE_CHECK", "proactiveClusterChangeCheck", true);
diff --git 
a/pinot-common/src/test/java/org/apache/pinot/common/failuredetector/ConnectionFailureDetectorTest.java
 
b/pinot-common/src/test/java/org/apache/pinot/common/failuredetector/ConnectionFailureDetectorTest.java
index 45ab8ea1212..0fe95504605 100644
--- 
a/pinot-common/src/test/java/org/apache/pinot/common/failuredetector/ConnectionFailureDetectorTest.java
+++ 
b/pinot-common/src/test/java/org/apache/pinot/common/failuredetector/ConnectionFailureDetectorTest.java
@@ -453,6 +453,25 @@ public class ConnectionFailureDetectorTest {
     }
   }
 
+  /// When no retrier can vouch for or against a server (every transport 
answers UNKNOWN, e.g. because the server's
+  /// channels were evicted), the server is let go rather than kept unhealthy 
for the whole backoff schedule, so
+  /// that a later failure can flag it again. Pins the semantics the retriers 
rely on.
+  @Test
+  public void testAllRetriersUnknownIsTreatedAsRecovered() {
+    _failureDetector.registerUnhealthyServerRetrier(instanceId -> 
FailureDetector.ServerState.UNKNOWN);
+    _failureDetector.registerUnhealthyServerRetrier(instanceId -> 
FailureDetector.ServerState.UNKNOWN);
+
+    _failureDetector.markServerUnhealthy(INSTANCE_ID, HOST_NAME);
+    verify(Set.of(INSTANCE_ID), 1, 0);
+
+    TestUtils.waitForCondition(aVoid -> 
_failureDetector.getUnhealthyServers().isEmpty()
+        && _healthyServerNotifier._notifyHealthyServerCalled == 1, 5_000L, 
"Server was not let go on all-UNKNOWN");
+
+    // ... and can be flagged again
+    _failureDetector.markServerUnhealthy(INSTANCE_ID, HOST_NAME);
+    verify(Set.of(INSTANCE_ID), 2, 1);
+  }
+
   private void verify(Set<String> expectedUnhealthyServers, int 
expectedNotifyUnhealthyServerCalled,
       int expectedNotifyHealthyServerCalled) {
     assertEquals(_failureDetector.getUnhealthyServers(), 
expectedUnhealthyServers);
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/transport/AsyncQueryResponse.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/transport/AsyncQueryResponse.java
index cb88d8e146d..ce74674e9b2 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/transport/AsyncQueryResponse.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/transport/AsyncQueryResponse.java
@@ -43,6 +43,7 @@ public class AsyncQueryResponse implements QueryResponse {
   private final AtomicInteger _numServersResponded = new AtomicInteger();
   private final ConcurrentHashMap<ServerRoutingInstance, ServerResponse> 
_responseMap;
   private final CountDownLatch _countDownLatch;
+  private final long _startTimeMs;
   private final long _maxEndTimeMs;
   private final long _timeoutMs;
   private final ServerRoutingStatsManager _serverRoutingStatsManager;
@@ -72,6 +73,7 @@ public class AsyncQueryResponse implements QueryResponse {
     }
     _countDownLatch = new CountDownLatch(numServersQueried);
     _timeoutMs = timeoutMs;
+    _startTimeMs = startTimeMs;
     _maxEndTimeMs = startTimeMs + timeoutMs;
   }
 
@@ -95,7 +97,9 @@ public class AsyncQueryResponse implements QueryResponse {
       throws InterruptedException {
     try {
       boolean finish = _countDownLatch.await(_maxEndTimeMs - 
System.currentTimeMillis(), TimeUnit.MILLISECONDS);
-      _status.compareAndSet(Status.IN_PROGRESS, finish ? Status.COMPLETED : 
Status.TIMED_OUT);
+      if (_status.compareAndSet(Status.IN_PROGRESS, finish ? Status.COMPLETED 
: Status.TIMED_OUT) && !finish) {
+        recordUnansweredServers();
+      }
       return _responseMap;
     } finally {
       // Update ServerRoutingStats for query completion. This is done here to 
ensure that the stats are updated for
@@ -117,6 +121,20 @@ public class AsyncQueryResponse implements QueryResponse {
     }
   }
 
+  /// On a timeout, hands every server the request reached but that never 
answered to the transport, with the time
+  /// and the channel the request was written to; the transport judges after a 
grace period whether the server has gone
+  /// silent on that channel (see [ServerChannels#recordUnansweredQuery]).
+  private void recordUnansweredServers() {
+    for (Map.Entry<ServerRoutingInstance, ServerResponse> entry : 
_responseMap.entrySet()) {
+      ServerResponse response = entry.getValue();
+      int submitDelayMs = response.getSubmitDelayMs();
+      // Only servers the request was actually submitted to can be blamed for 
not answering it
+      if (response.getDataTable() == null && submitDelayMs >= 0) {
+        _queryRouter.recordServerTimeout(entry.getKey(), _startTimeMs + 
submitDelayMs, response.getChannel());
+      }
+    }
+  }
+
   private boolean hasServerReturnedExceptions(ServerResponse response) {
     if (response.getDataTable() != null && 
response.getDataTable().getExceptions().size() > 0) {
       DataTable dataTable = response.getDataTable();
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/transport/DataTableHandler.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/transport/DataTableHandler.java
index 8c97c99d22e..da104bb62b0 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/transport/DataTableHandler.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/transport/DataTableHandler.java
@@ -85,6 +85,8 @@ public class DataTableHandler extends 
SimpleChannelInboundHandler<ByteBuf> {
 
   @Override
   protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) {
+    // Before decoding: a frame proves the server alive even if it then fails 
to decode
+    _queryRouter.recordResponse(_serverRoutingInstance);
     int responseSize = msg.readableBytes();
     
_brokerMetrics.addMeteredGlobalValue(BrokerMeter.NETTY_CONNECTION_BYTES_RECEIVED,
 responseSize);
     try {
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/transport/QueryRouter.java 
b/pinot-core/src/main/java/org/apache/pinot/core/transport/QueryRouter.java
index 381def8c5e2..ae719a1629f 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/transport/QueryRouter.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/transport/QueryRouter.java
@@ -28,6 +28,7 @@ import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeoutException;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.function.BiPredicate;
+import java.util.function.Consumer;
 import javax.annotation.Nullable;
 import javax.annotation.concurrent.ThreadSafe;
 import org.apache.pinot.common.config.NettyConfig;
@@ -46,6 +47,7 @@ import 
org.apache.pinot.core.transport.server.routing.stats.ServerRoutingStatsMa
 import org.apache.pinot.spi.accounting.ThreadAccountant;
 import org.apache.pinot.spi.config.table.TableType;
 import org.apache.pinot.spi.env.PinotConfiguration;
+import org.apache.pinot.spi.utils.CommonConstants.Broker;
 import org.apache.pinot.spi.utils.builder.TableNameBuilder;
 import org.apache.pinot.sql.parsers.CalciteSqlCompiler;
 import org.slf4j.Logger;
@@ -76,6 +78,11 @@ public class QueryRouter {
 
   private final BrokerMetrics _brokerMetrics = BrokerMetrics.get();
   private final ConcurrentHashMap<Long, AsyncQueryResponse> 
_asyncQueryResponseMap = new ConcurrentHashMap<>();
+  // Told about a server that stopped answering queries on one of its channels 
(see ServerChannels). The request
+  // handler points it at the failure detector when pings are off and 
reporting is enabled; with or without it, the
+  // suspect channel is replaced by the next query to it, so recovery does not 
depend on the failure detector.
+  private volatile Consumer<ServerRoutingInstance> _unansweredServerReporter = 
serverRoutingInstance -> {
+  };
   /// Ping request ids count down from -1, so they can never collide with 
query request ids, which are non-negative
   /// (see `BrokerRequestIdGenerator`).
   private final AtomicLong _pingRequestIdGenerator = new AtomicLong();
@@ -90,12 +97,31 @@ public class QueryRouter {
   /// @param tlsConfig TLS config
   public QueryRouter(String brokerId, @Nullable NettyConfig nettyConfig, 
@Nullable TlsConfig tlsConfig,
       ServerRoutingStatsManager serverRoutingStatsManager, ThreadAccountant 
threadAccountant) {
+    this(brokerId, nettyConfig, tlsConfig, serverRoutingStatsManager, 
threadAccountant,
+        Broker.FailureDetector.DEFAULT_UNANSWERED_QUERIES_THRESHOLD,
+        Broker.FailureDetector.DEFAULT_UNANSWERED_QUERY_GRACE_MS);
+  }
+
+  /// @param unansweredQueriesThreshold see 
[Broker.FailureDetector#CONFIG_OF_UNANSWERED_QUERIES_THRESHOLD];
+  ///                                   non-positive disables the check
+  /// @param unansweredQueryGraceMs see 
[Broker.FailureDetector#CONFIG_OF_UNANSWERED_QUERY_GRACE_MS]
+  public QueryRouter(String brokerId, @Nullable NettyConfig nettyConfig, 
@Nullable TlsConfig tlsConfig,
+      ServerRoutingStatsManager serverRoutingStatsManager, ThreadAccountant 
threadAccountant,
+      int unansweredQueriesThreshold, long unansweredQueryGraceMs) {
     _brokerId = brokerId;
-    _serverChannels = new ServerChannels(this, nettyConfig, null, 
threadAccountant);
-    _serverChannelsTls = tlsConfig != null ? new ServerChannels(this, 
nettyConfig, tlsConfig, threadAccountant) : null;
+    _serverChannels = new ServerChannels(this, nettyConfig, null, 
threadAccountant, unansweredQueriesThreshold,
+        unansweredQueryGraceMs);
+    _serverChannelsTls = tlsConfig != null ? new ServerChannels(this, 
nettyConfig, tlsConfig, threadAccountant,
+        unansweredQueriesThreshold, unansweredQueryGraceMs) : null;
     _serverRoutingStatsManager = serverRoutingStatsManager;
   }
 
+  /// Registers the consumer told about a server that stopped answering 
queries on one of its channels, typically the
+  /// failure detector. Called from the transport's liveness thread, never 
from an event loop.
+  public void setUnansweredServerReporter(Consumer<ServerRoutingInstance> 
unansweredServerReporter) {
+    _unansweredServerReporter = unansweredServerReporter;
+  }
+
   public AsyncQueryResponse submitQuery(long requestId, String rawTableName,
       @Nullable BrokerRequest offlineBrokerRequest,
       @Nullable Map<ServerInstance, SegmentsToQuery> offlineRoutingTable,
@@ -180,18 +206,19 @@ public class QueryRouter {
     return !channelRoutingInstances(serverInstance).isEmpty();
   }
 
-  /// Connects to the given server, returns `true` if the server is 
successfully connected.
-  ///
-  /// This is the reachability probe the failure detector uses when pings are 
off, opening the OFFLINE channel with a
-  /// TCP connect only. Startup pre-connect uses [#preConnect] instead.
+  /// The reachability probe the failure detector uses when pings are off. 
Re-establishes every channel this broker
+  /// has queried the server over, one per table type: reconnects the ones 
that are down and replaces the ones marked
+  /// suspect (see [ServerChannels#recordUnansweredQuery]) with a fresh 
connection, so that a half-open socket that
+  /// still reports active is never vouched for. Returns `true` if every such 
channel is connected afterwards, which
+  /// includes the vacuous case of a server with no such channel: callers that 
need "reachable" must check
+  /// [#hasChannel] first, as `retryUnhealthyServer` does. It used to open the 
OFFLINE channel only, so on a
+  /// realtime-only cluster a handshake to a freshly restarted process passed 
the probe while the channel that carried
+  /// the queries was dead. Startup pre-connect uses [#preConnect] instead.
   public boolean connect(ServerInstance serverInstance) {
     try {
-      if (_serverChannelsTls != null) {
-        _serverChannelsTls.connect(
-            serverInstance.toServerRoutingInstance(TableType.OFFLINE, 
ServerInstance.RoutingType.NETTY_TLS));
-      } else {
-        _serverChannels.connect(
-            serverInstance.toServerRoutingInstance(TableType.OFFLINE, 
ServerInstance.RoutingType.NETTY));
+      ServerChannels serverChannels = channels();
+      for (ServerRoutingInstance routingInstance : 
channelRoutingInstances(serverInstance)) {
+        serverChannels.probe(routingInstance);
       }
       return true;
     } catch (Exception e) {
@@ -295,6 +322,31 @@ public class QueryRouter {
     return routingInstances;
   }
 
+  /// Records that a query written at `submitTimeMs` to `channel` (when known) 
timed out unanswered; the transport
+  /// judges it after a grace period (see 
[ServerChannels#recordUnansweredQuery]).
+  void recordServerTimeout(ServerRoutingInstance serverRoutingInstance, long 
submitTimeMs, @Nullable Channel channel) {
+    
serverChannelsFor(serverRoutingInstance).recordUnansweredQuery(serverRoutingInstance,
 submitTimeMs, channel);
+  }
+
+  /// Called by the transport once a server has left the configured number of 
consecutive queries unanswered on a
+  /// channel; hands the server to the registered reporter.
+  void reportUnansweredServer(ServerRoutingInstance serverRoutingInstance) {
+    try {
+      _unansweredServerReporter.accept(serverRoutingInstance);
+    } catch (Exception e) {
+      LOGGER.error("Caught exception while reporting unanswered server: {}", 
serverRoutingInstance, e);
+    }
+  }
+
+  @VisibleForTesting
+  int getUnansweredQueries(ServerRoutingInstance serverRoutingInstance) {
+    return 
serverChannelsFor(serverRoutingInstance).getUnansweredQueries(serverRoutingInstance);
+  }
+
+  private ServerChannels serverChannelsFor(ServerRoutingInstance 
serverRoutingInstance) {
+    return serverRoutingInstance.isTlsEnabled() && _serverChannelsTls != null 
? _serverChannelsTls : _serverChannels;
+  }
+
   @VisibleForTesting
   ServerChannels getServerChannels() {
     return _serverChannels;
@@ -390,6 +442,13 @@ public class QueryRouter {
     }
   }
 
+  /// Records that a response frame arrived from the server, before it is 
decoded: any response proves the server and
+  /// its channel alive, whether or not the query it answers is still waiting, 
and whether or not it decodes (a heap
+  /// OOM while deserializing is the broker's problem, not a silent server). 
Called by [DataTableHandler].
+  void recordResponse(ServerRoutingInstance serverRoutingInstance) {
+    
serverChannelsFor(serverRoutingInstance).recordResponse(serverRoutingInstance);
+  }
+
   void receiveDataTable(ServerRoutingInstance serverRoutingInstance, DataTable 
dataTable, int responseSize,
       int deserializationTimeMs) {
     long requestId = 
Long.parseLong(dataTable.getMetadata().get(MetadataKey.REQUEST_ID.getName()));
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/transport/ServerChannels.java 
b/pinot-core/src/main/java/org/apache/pinot/core/transport/ServerChannels.java
index 75992359ffc..9d58f8df08c 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/transport/ServerChannels.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/transport/ServerChannels.java
@@ -19,6 +19,7 @@
 package org.apache.pinot.core.transport;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.util.concurrent.ThreadFactoryBuilder;
 import io.netty.bootstrap.Bootstrap;
 import io.netty.buffer.PooledByteBufAllocator;
 import io.netty.buffer.PooledByteBufAllocatorMetric;
@@ -42,8 +43,12 @@ import io.netty.channel.socket.nio.NioSocketChannel;
 import io.netty.handler.ssl.SslHandler;
 import io.netty.util.concurrent.Future;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executors;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.locks.ReentrantLock;
 import java.util.function.ToLongFunction;
 import javax.annotation.Nullable;
@@ -57,6 +62,7 @@ import org.apache.pinot.common.metrics.BrokerTimer;
 import org.apache.pinot.common.request.InstanceRequest;
 import org.apache.pinot.core.util.OsCheck;
 import org.apache.pinot.spi.accounting.ThreadAccountant;
+import org.apache.pinot.spi.utils.CommonConstants.Broker;
 import org.apache.thrift.TSerializer;
 import org.apache.thrift.protocol.TCompactProtocol;
 import org.apache.thrift.transport.TTransportException;
@@ -79,7 +85,10 @@ import org.slf4j.LoggerFactory;
 ///   - the routing manager evicts the cached channels of a server when it 
enables it, as after a restart, and when it
 ///     sees the server removed from the cluster or moved to another address 
([#evictChannel]); a channel that still
 ///     carries queries when its server is enabled is retired instead: the 
next query opens a fresh connection, and
-///     the old one is closed once the queries on it are past their deadline 
([#evictChannelOfEnabledServer]).
+///     the old one is closed once the queries on it are past their deadline 
([#evictChannelOfEnabledServer]);
+///   - a channel on which the server left 
[Broker.FailureDetector#CONFIG_OF_UNANSWERED_QUERIES_THRESHOLD] consecutive
+///     queries unanswered, each judged a grace period after its timeout, is 
marked suspect: the next query to it, or
+///     the failure detector's retry probe, replaces it with a fresh 
connection ([#recordUnansweredQuery]).
 @ThreadSafe
 public class ServerChannels {
   private static final Logger LOGGER = 
LoggerFactory.getLogger(ServerChannels.class);
@@ -111,6 +120,16 @@ public class ServerChannels {
   private final long _writeTimeoutMs;
   private final int _tcpUserTimeoutMs;
   private final WriteBufferWaterMark _writeBufferWaterMark;
+  private final int _unansweredQueriesThreshold;
+  private final long _unansweredQueryGraceMs;
+  // Bound on the judgments queued per channel at a time. Under a dead server 
at high QPS every timed-out query would
+  // otherwise queue a task for the whole grace period; the first few 
judgments already cross the threshold, so the
+  // rest add nothing but queue depth.
+  private final int _maxPendingJudgmentsPerChannel;
+  // Judges unanswered queries once their grace period is over, and reports 
the server from there; null when the
+  // check is disabled. Its own thread, because reporting ends in a routing 
update that must not run on an event loop.
+  @Nullable
+  private final ScheduledExecutorService _livenessExecutor;
 
   private final BrokerMetrics _brokerMetrics = BrokerMetrics.get();
   private final ConcurrentHashMap<ServerRoutingInstance, ServerChannel> 
_serverToChannelMap = new ConcurrentHashMap<>();
@@ -121,6 +140,16 @@ public class ServerChannels {
   /// @param tlsConfig TLS/SSL config
   public ServerChannels(QueryRouter queryRouter, @Nullable NettyConfig 
nettyConfig, @Nullable TlsConfig tlsConfig,
       ThreadAccountant threadAccountant) {
+    this(queryRouter, nettyConfig, tlsConfig, threadAccountant,
+        Broker.FailureDetector.DEFAULT_UNANSWERED_QUERIES_THRESHOLD,
+        Broker.FailureDetector.DEFAULT_UNANSWERED_QUERY_GRACE_MS);
+  }
+
+  /// @param unansweredQueriesThreshold see 
[Broker.FailureDetector#CONFIG_OF_UNANSWERED_QUERIES_THRESHOLD];
+  ///                                   non-positive disables the check
+  /// @param unansweredQueryGraceMs see 
[Broker.FailureDetector#CONFIG_OF_UNANSWERED_QUERY_GRACE_MS]
+  public ServerChannels(QueryRouter queryRouter, @Nullable NettyConfig 
nettyConfig, @Nullable TlsConfig tlsConfig,
+      ThreadAccountant threadAccountant, int unansweredQueriesThreshold, long 
unansweredQueryGraceMs) {
     if (nettyConfig == null) {
       nettyConfig = new NettyConfig();
     }
@@ -159,9 +188,16 @@ public class ServerChannels {
     _tcpUserTimeoutMs = nettyConfig.getTcpUserTimeoutMs();
     _writeBufferWaterMark = new 
WriteBufferWaterMark(nettyConfig.getWriteBufferLowWaterMarkBytes(),
         nettyConfig.getWriteBufferHighWaterMarkBytes());
+    _unansweredQueriesThreshold = unansweredQueriesThreshold;
+    _unansweredQueryGraceMs = Math.max(0L, unansweredQueryGraceMs);
+    _maxPendingJudgmentsPerChannel = Math.max(16, 2 * 
unansweredQueriesThreshold);
+    _livenessExecutor = unansweredQueriesThreshold > 0 ? 
Executors.newSingleThreadScheduledExecutor(
+        new 
ThreadFactoryBuilder().setNameFormat("server-channel-liveness-%d").setDaemon(true).build())
 : null;
     LOGGER.info("Server channel liveness settings: write timeout: {}ms, TCP 
user timeout: {}ms ({}), write buffer "
-            + "water mark: {}", _writeTimeoutMs, _tcpUserTimeoutMs,
-        isTcpUserTimeoutApplied() ? "applied" : "not applied, requires the 
Epoll transport", _writeBufferWaterMark);
+            + "water mark: {}, unanswered queries threshold: {} ({}), 
unanswered query grace: {}ms", _writeTimeoutMs,
+        _tcpUserTimeoutMs, isTcpUserTimeoutApplied() ? "applied" : "not 
applied, requires the Epoll transport",
+        _writeBufferWaterMark, unansweredQueriesThreshold, _livenessExecutor 
!= null ? "enabled" : "disabled",
+        _unansweredQueryGraceMs);
 
     _bufAllocatorWithLimits = 
PooledByteBufAllocatorWithLimits.getSharedBufferAllocatorWithLimits();
     PooledByteBufAllocatorMetric metric = _bufAllocatorWithLimits.metric();
@@ -309,7 +345,90 @@ public class ServerChannels {
     _serverToChannelMap.computeIfAbsent(serverRoutingInstance, 
ServerChannel::new).preConnect(timeoutMs);
   }
 
+  /// Marks the cached channel to the given routing instance suspect: the 
server stopped answering queries on it
+  /// while the socket still looked active. The next query to it, or the next 
[#probe], replaces it with a fresh
+  /// connection instead of trusting `isActive()`.
+  public void markSuspect(ServerRoutingInstance serverRoutingInstance) {
+    ServerChannel serverChannel = 
_serverToChannelMap.get(serverRoutingInstance);
+    if (serverChannel != null) {
+      serverChannel._suspect = true;
+    }
+  }
+
+  /// The failure detector's retry probe for one channel, when pings are off. 
Does nothing and returns `false` when
+  /// this broker has not queried the server over this channel (see 
[#hasChannel]); otherwise reconnects the channel if
+  /// it is down, replaces it with a fresh connection if it was marked 
suspect, and returns `true`, throwing if the
+  /// connection cannot be established.
+  public boolean probe(ServerRoutingInstance serverRoutingInstance)
+      throws InterruptedException, TimeoutException {
+    ServerChannel serverChannel = 
_serverToChannelMap.get(serverRoutingInstance);
+    if (serverChannel == null || !serverChannel._openedByQuery) {
+      return false;
+    }
+    serverChannel.connect();
+    return true;
+  }
+
+  /// Records a response from the server on the given channel: it is alive, 
whatever it answered.
+  public void recordResponse(ServerRoutingInstance serverRoutingInstance) {
+    ServerChannel serverChannel = 
_serverToChannelMap.get(serverRoutingInstance);
+    if (serverChannel != null) {
+      serverChannel.recordResponse();
+    }
+  }
+
+  /// Records that a query written at `submitTimeMs` to `channel` (when known) 
timed out unanswered. The verdict waits
+  /// out the grace period: the query only counts if the entry still holds 
that channel and the server has still sent
+  /// nothing on it since the query was written. That lets the server's own 
late timeout response through, so a burst
+  /// of slow queries, or a broker pause that delays reading the responses, 
does not look like a dead channel: only a
+  /// channel the server has gone silent on does. At the threshold the channel 
is marked suspect and the server is
+  /// reported through [QueryRouter#reportUnansweredServer]. No-op when the 
check is disabled.
+  public void recordUnansweredQuery(ServerRoutingInstance 
serverRoutingInstance, long submitTimeMs,
+      @Nullable Channel channel) {
+    if (_livenessExecutor == null) {
+      return;
+    }
+    ServerChannel serverChannel = 
_serverToChannelMap.get(serverRoutingInstance);
+    if (serverChannel == null) {
+      return;
+    }
+    if (serverChannel._numPendingJudgments.incrementAndGet() > 
_maxPendingJudgmentsPerChannel) {
+      // Enough judgments queued to cross the threshold already; this query 
adds nothing
+      serverChannel._numPendingJudgments.decrementAndGet();
+      return;
+    }
+    try {
+      _livenessExecutor.schedule(() -> {
+        try {
+          serverChannel.judgeUnansweredQuery(submitTimeMs, channel);
+        } catch (Exception e) {
+          LOGGER.error("Caught exception while judging an unanswered query to 
server: {}", serverRoutingInstance, e);
+        } finally {
+          serverChannel._numPendingJudgments.decrementAndGet();
+        }
+      }, _unansweredQueryGraceMs, TimeUnit.MILLISECONDS);
+    } catch (RejectedExecutionException e) {
+      // Shutting down: nothing left to judge
+      serverChannel._numPendingJudgments.decrementAndGet();
+    }
+  }
+
+  @VisibleForTesting
+  int getUnansweredQueries(ServerRoutingInstance serverRoutingInstance) {
+    ServerChannel serverChannel = 
_serverToChannelMap.get(serverRoutingInstance);
+    return serverChannel != null ? serverChannel.getUnansweredQueries() : 0;
+  }
+
+  @VisibleForTesting
+  int getNumPendingJudgments(ServerRoutingInstance serverRoutingInstance) {
+    ServerChannel serverChannel = 
_serverToChannelMap.get(serverRoutingInstance);
+    return serverChannel != null ? serverChannel._numPendingJudgments.get() : 
0;
+  }
+
   public void shutDown() {
+    if (_livenessExecutor != null) {
+      _livenessExecutor.shutdownNow();
+    }
     // Shut down immediately
     _eventLoopGroup.shutdownGracefully(0, 0, TimeUnit.SECONDS);
   }
@@ -350,6 +469,20 @@ public class ServerChannels {
     // then replaces it with a fresh connection rather than reuse it, and it 
stays open for those queries until it is
     // closed once they are past their deadline. Compared by identity, so it 
never applies to a connection opened since.
     volatile Channel _retiredChannel;
+    // Set when the server stopped answering queries on this channel while the 
socket still looked active (see
+    // judgeUnansweredQuery). connectWithoutLocking() then replaces the 
channel rather than trusting it. A full outbound
+    // queue does not set it: a healthy server draining a large request fills 
it too, and replacing the channel would
+    // fail that request; a stall that lasts is the write timeout's to judge.
+    volatile boolean _suspect;
+    // Liveness bookkeeping, guarded by _livenessLock: when the server last 
sent anything on this channel (the start of
+    // the current connection for a fresh one) and how many consecutive 
queries it has left unanswered since. The lock
+    // is taken briefly on the event loop (per response), on the query thread 
(when a connection is replaced) and on the
+    // liveness thread (judgment), so that a response or a replacement landing 
while a judgment runs is never lost.
+    private final Object _livenessLock = new Object();
+    private long _lastResponseTimeMs;
+    private int _unansweredQueries;
+    // Judgments queued for this channel, bounded by 
ServerChannels#_maxPendingJudgmentsPerChannel
+    final AtomicInteger _numPendingJudgments = new AtomicInteger();
 
     ServerChannel(ServerRoutingInstance serverRoutingInstance) {
       _serverRoutingInstance = serverRoutingInstance;
@@ -400,9 +533,9 @@ public class ServerChannels {
       }
     }
 
-    /// Closes a channel the broker decided to drop (eviction, a retired 
channel once its queries are past their
-    /// deadline): the queries still in flight on it fail through 
`channelInactive` as on any closed channel, but the
-    /// server is not reported for it.
+    /// Closes a channel the broker decided to drop (eviction, replacement of 
a suspect channel, a retired channel
+    /// once its queries are past their deadline): the queries still in flight 
on it fail through `channelInactive` as
+    /// on any closed channel, but the server is not reported for it.
     private void closeByBroker(Channel channel) {
       DataTableHandler handler = 
channel.pipeline().get(DataTableHandler.class);
       if (handler != null) {
@@ -443,6 +576,58 @@ public class ServerChannels {
       }
     }
 
+    void recordResponse() {
+      synchronized (_livenessLock) {
+        _lastResponseTimeMs = System.currentTimeMillis();
+        _unansweredQueries = 0;
+      }
+    }
+
+    int getUnansweredQueries() {
+      synchronized (_livenessLock) {
+        return _unansweredQueries;
+      }
+    }
+
+    /// Starts a clean record for a new connection: not suspect, nothing 
unanswered, counted as answered from now. Done
+    /// before a replacement connects as well as after, so that a judgment of 
a query written to the dropped channel
+    /// that runs while the connect is in progress finds nothing to count.
+    private void resetLiveness() {
+      synchronized (_livenessLock) {
+        _suspect = false;
+        _lastResponseTimeMs = System.currentTimeMillis();
+        _unansweredQueries = 0;
+      }
+    }
+
+    /// Runs on the liveness thread a grace period after a query written at 
`submitTimeMs` to `channel` timed out. See
+    /// [ServerChannels#recordUnansweredQuery].
+    void judgeUnansweredQuery(long submitTimeMs, @Nullable Channel channel) {
+      int unansweredQueries;
+      synchronized (_livenessLock) {
+        // Nothing to add once the channel is already suspect (it is reported 
once, until replaced) or no longer in use,
+        // for a query written to a channel since replaced or closed (it says 
nothing about the current one), or if the
+        // server answered something since the query was written. Strictly 
after: a connect and the first write can
+        // share a millisecond, while a real answer never shares one with its 
request. Checked and acted on under the
+        // lock, so a response or a replacement that lands meanwhile either 
makes this judgment bail or resets the
+        // count right after it.
+        if (_evicted || _suspect || (channel != null && channel != _channel) 
|| _lastResponseTimeMs > submitTimeMs) {
+          return;
+        }
+        unansweredQueries = ++_unansweredQueries;
+        if (unansweredQueries < _unansweredQueriesThreshold) {
+          return;
+        }
+        _unansweredQueries = 0;
+        _suspect = true;
+      }
+      LOGGER.warn("Server: {} has left {} consecutive queries unanswered on 
its channel, each judged {}ms past its "
+          + "timeout; marking the channel suspect", _serverRoutingInstance, 
unansweredQueries, _unansweredQueryGraceMs);
+      
_brokerMetrics.addMeteredValue(BrokerMeter.NETTY_CONNECTION_SUSPECT_CHANNELS, 1,
+          _serverRoutingInstance.getShortName());
+      _queryRouter.reportUnansweredServer(_serverRoutingInstance);
+    }
+
     @VisibleForTesting
     void setChannel(Channel channel) {
       _channel = channel;
@@ -502,25 +687,37 @@ public class ServerChannels {
     ///
     /// Without the bound, a connect to a host that silently drops packets -- 
the very host the failure detector pings
     /// -- blocks for Netty's 30 s default, however long the caller can 
actually wait.
+    ///
+    /// Also replaces a channel marked suspect: the server stopped answering 
on it while the socket still looked
+    /// active, which is what a half-open socket looks like until the kernel 
gives up on it, so `isActive()` is not
+    /// trusted. The suspect channel is dropped before the connect, so a 
failed connect does not leave it in place, and
+    /// whatever is still in flight on it fails without the server being 
reported (nothing on it was being answered).
     void connectWithoutLocking(long timeoutMs)
         throws InterruptedException, TimeoutException {
       Channel channel = _channel;
-      if (channel != null && channel == _retiredChannel) {
+      if (channel != null && channel.isActive() && !_suspect && channel != 
_retiredChannel) {
+        return;
+      }
+      if (channel != null && _suspect) {
+        LOGGER.info("Replacing suspect channel to server: {} with a fresh 
connection", _serverRoutingInstance);
+        _channel = null;
+        closeByBroker(channel);
+      } else if (channel != null && channel == _retiredChannel) {
         // Left open for the queries already on it, and closed once they are 
past their deadline (see retire())
         LOGGER.info("Replacing retired channel to server: {} with a fresh 
connection", _serverRoutingInstance);
         _channel = null;
       }
-      if (_channel == null || !_channel.isActive()) {
-        long startTime = System.currentTimeMillis();
-        ChannelFuture connectFuture = _bootstrap.connect();
-        if (!connectFuture.await(timeoutMs, TimeUnit.MILLISECONDS)) {
-          connectFuture.channel().close();
-          throw new TimeoutException("Timed out connecting to server: " + 
_serverRoutingInstance);
-        }
-        // Completed by now: returns the channel, or rethrows the connect 
failure as sync() always did.
-        _channel = connectFuture.sync().channel();
-        recordConnectTime(System.currentTimeMillis() - startTime);
+      resetLiveness();
+      long startTime = System.currentTimeMillis();
+      ChannelFuture connectFuture = _bootstrap.connect();
+      if (!connectFuture.await(timeoutMs, TimeUnit.MILLISECONDS)) {
+        connectFuture.channel().close();
+        throw new TimeoutException("Timed out connecting to server: " + 
_serverRoutingInstance);
       }
+      // Completed by now: returns the channel, or rethrows the connect 
failure as sync() always did.
+      _channel = connectFuture.sync().channel();
+      resetLiveness();
+      recordConnectTime(System.currentTimeMillis() - startTime);
     }
 
     /// Like [#connectWithoutLocking(long)] but additionally waits out the TLS 
handshake, and bounds both
@@ -560,6 +757,7 @@ public class ServerChannels {
         throw t;
       }
       _channel = channel;
+      resetLiveness();
       recordConnectTime(System.currentTimeMillis() - startTime);
     }
 
@@ -657,10 +855,12 @@ public class ServerChannels {
     }
 
     /// Opens the TCP connection, as the failure detector's reconnect probe
-    /// (`SingleConnectionBrokerRequestHandler#retryUnhealthyServer`) has 
always done. Deliberately does
-    /// **not** await the TLS handshake: this runs at steady state under live 
traffic, and holding
-    /// `_channelLock` across a handshake would make concurrent queries to a 
recovering server queue
-    /// behind it -- the very serialization startup pre-connect exists to 
remove.
+    /// (`SingleConnectionBrokerRequestHandler#retryUnhealthyServer`) has 
always done, replacing the channel when it is
+    /// suspect (see [#connectWithoutLocking(long)]): a probe that only looked 
at `isActive()` would vouch for a
+    /// half-open channel and put the server straight back into routing on it. 
Deliberately does **not** await the TLS
+    /// handshake: this runs at steady state under live traffic, and holding 
`_channelLock` across a handshake would
+    /// make concurrent queries to a recovering server queue behind it -- the 
very serialization startup pre-connect
+    /// exists to remove.
     void connect()
         throws InterruptedException, TimeoutException {
       if (_channelLock.tryLock(TRY_CONNECT_CHANNEL_LOCK_TIMEOUT_MS, 
TimeUnit.MILLISECONDS)) {
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/transport/AsyncQueryResponseTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/transport/AsyncQueryResponseTest.java
index 03488ea37ae..a4305c768e3 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/transport/AsyncQueryResponseTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/transport/AsyncQueryResponseTest.java
@@ -21,13 +21,19 @@ package org.apache.pinot.core.transport;
 import io.netty.channel.Channel;
 import java.util.Map;
 import java.util.Set;
+import org.apache.pinot.common.datatable.DataTable;
 import org.apache.pinot.core.common.datatable.DataTableBuilderFactory;
 import 
org.apache.pinot.core.transport.server.routing.stats.ServerRoutingStatsManager;
 import org.apache.pinot.spi.config.table.TableType;
 import org.apache.pinot.spi.exception.QueryCancelledException;
 import org.testng.annotations.Test;
 
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
 import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertFalse;
 import static org.testng.Assert.assertNotNull;
@@ -216,4 +222,43 @@ public class AsyncQueryResponseTest {
     response.markServerUnavailable(SERVER_2, requestChannel, new 
RuntimeException("inactive"), true);
     assertFalse(response.isAwaitingResponseOn(SERVER_2, requestChannel), 
"Counted down");
   }
+
+  /// A timeout hands every server the request reached but that never answered 
to the transport, with the time the
+  /// request was written, and blames nobody on the spot. Servers the request 
never reached are not handed over.
+  @Test
+  public void testTimeoutRecordsUnansweredServers()
+      throws Exception {
+    QueryRouter queryRouter = mock(QueryRouter.class);
+    long startTimeMs = System.currentTimeMillis();
+    AsyncQueryResponse response =
+        new AsyncQueryResponse(queryRouter, 1L, Set.of(SERVER_1, SERVER_2), 
startTimeMs, 50L,
+            mock(ServerRoutingStatsManager.class), false);
+    // SERVER_2 is never marked submitted: its send failed before anything was 
written
+    response.markRequestSubmitted(SERVER_1);
+
+    response.getFinalResponses();
+
+    assertEquals(response.getStatus(), QueryResponse.Status.TIMED_OUT);
+    assertNull(response.getFailedServer());
+    assertNull(response.getException());
+    verify(queryRouter).recordServerTimeout(eq(SERVER_1), anyLong(), any());
+    verify(queryRouter, never()).recordServerTimeout(eq(SERVER_2), anyLong(), 
any());
+  }
+
+  @Test
+  public void testAnsweredQueryRecordsNoTimeout()
+      throws Exception {
+    QueryRouter queryRouter = mock(QueryRouter.class);
+    AsyncQueryResponse response =
+        new AsyncQueryResponse(queryRouter, 1L, Set.of(SERVER_1), 
System.currentTimeMillis(), 10_000L,
+            mock(ServerRoutingStatsManager.class), false);
+    response.markRequestSubmitted(SERVER_1);
+    response.receiveDataTable(SERVER_1, mock(DataTable.class), 10, 1);
+
+    response.getFinalResponses();
+
+    assertEquals(response.getStatus(), QueryResponse.Status.COMPLETED);
+    verify(queryRouter, never()).recordServerTimeout(eq(SERVER_1), anyLong(), 
any());
+    assertNull(response.getFailedServer());
+  }
 }
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/transport/QueryRoutingTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/transport/QueryRoutingTest.java
index 9d883f68f02..be2ee941d98 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/transport/QueryRoutingTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/transport/QueryRoutingTest.java
@@ -31,6 +31,7 @@ import io.netty.channel.ChannelOutboundHandlerAdapter;
 import io.netty.channel.ChannelPromise;
 import io.netty.channel.SimpleChannelInboundHandler;
 import io.netty.channel.embedded.EmbeddedChannel;
+import io.netty.channel.socket.SocketChannel;
 import io.netty.util.ReferenceCountUtil;
 import java.io.IOException;
 import java.io.InputStream;
@@ -45,6 +46,7 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
@@ -84,6 +86,7 @@ import org.testng.annotations.Test;
 
 import static 
org.apache.pinot.core.transport.QueryServerTestUtils.startAndGetPort;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
@@ -175,6 +178,43 @@ public class QueryRoutingTest {
     return new QueryRouter("testBroker", null, null, statsManager, 
ThreadAccountantUtils.getNoOpAccountant());
   }
 
+  private QueryRouter newIsolatedQueryRouter(int unansweredQueriesThreshold, 
long unansweredQueryGraceMs) {
+    Map<String, Object> properties = new HashMap<>();
+    
properties.put(CommonConstants.Broker.AdaptiveServerSelector.CONFIG_OF_ENABLE_STATS_COLLECTION,
 true);
+    ServerRoutingStatsManager statsManager =
+        new ServerRoutingStatsManager(new PinotConfiguration(properties), 
mock(BrokerMetrics.class));
+    statsManager.init();
+    return new QueryRouter("testBroker", null, null, statsManager, 
ThreadAccountantUtils.getNoOpAccountant(),
+        unansweredQueriesThreshold, unansweredQueryGraceMs);
+  }
+
+  private AsyncQueryResponse submitRealtimeQuery(QueryRouter queryRouter, long 
requestId, long timeoutMs)
+      throws Exception {
+    AsyncQueryResponse asyncQueryResponse =
+        queryRouter.submitQuery(requestId, "testTable", null, null, 
BROKER_REQUEST, _routingTable, timeoutMs);
+    asyncQueryResponse.getFinalResponses();
+    return asyncQueryResponse;
+  }
+
+  private static void waitForUnansweredQueries(QueryRouter queryRouter, 
ServerRoutingInstance instance, int expected) {
+    TestUtils.waitForCondition(aVoid -> 
queryRouter.getUnansweredQueries(instance) == expected, 10L, 5_000L,
+        "Expected " + expected + " unanswered queries counted against " + 
instance);
+  }
+
+  /// A server that answers nothing until the test completes the current gate, 
and then answers every request it has
+  /// queued; the gate can be swapped to hold the next requests again. The 
scheduler returns the gate itself, so the
+  /// server's event loop is never blocked and keeps reading requests (and 
noticing closed connections).
+  private QueryServer 
getGatedQueryServer(AtomicReference<SettableFuture<DataBuffer>> gate) {
+    return QueryServerTestUtils.newQueryServer(queryRequest -> gate.get());
+  }
+
+  private static byte[] responseBytesForRequest(long requestId)
+      throws Exception {
+    DataTable dataTable = DataTableBuilderFactory.getEmptyDataTable();
+    dataTable.getMetadata().put(MetadataKey.REQUEST_ID.getName(), 
Long.toString(requestId));
+    return dataTable.toBytes();
+  }
+
   @Test
   public void testValidResponse()
       throws Exception {
@@ -1010,6 +1050,158 @@ public class QueryRoutingTest {
     }
   }
 
+  /// The retry probe used to look at the OFFLINE channel only. On a 
realtime-only cluster it therefore had no
+  /// channel to check, answered UNKNOWN, and the failure detector readmitted 
the server onto whatever channel was
+  /// cached.
+  @Test
+  public void testRetryProbeCoversRealtimeOnlyChannel()
+      throws Exception {
+    long requestId = 123;
+    _queryServer = getQueryServer(0, responseBytesForRequest(requestId));
+    initializeTestFixtures(startAndGetPort(_queryServer));
+    QueryRouter queryRouter = newIsolatedQueryRouter();
+    try {
+      assertFalse(queryRouter.hasChannel(_serverInstance), "No query sent 
yet");
+
+      AsyncQueryResponse asyncQueryResponse =
+          queryRouter.submitQuery(requestId, "testTable", null, null, 
BROKER_REQUEST, _routingTable, 10_000L);
+      
assertNotNull(asyncQueryResponse.getFinalResponses().get(_realtimeServerRoutingInstance).getDataTable());
+      assertTrue(queryRouter.hasChannel(_serverInstance), "The REALTIME 
channel carries queries");
+      TestUtils.waitForCondition(aVoid -> 
_queryServer.getConnectedChannelCount() == 1, 10L, 5_000L,
+          "Expected the REALTIME channel on the server");
+      SocketChannel healthyChannel = 
_queryServer.getConnectedChannels().iterator().next();
+
+      // A healthy channel passes the probe and is kept, not replaced
+      assertTrue(queryRouter.connect(_serverInstance));
+      assertEquals(_queryServer.getConnectedChannelCount(), 1);
+      assertSame(_queryServer.getConnectedChannels().iterator().next(), 
healthyChannel);
+    } finally {
+      queryRouter.shutDown();
+    }
+  }
+
+  /// A dead channel that still reports active answers nothing. Each 
unanswered query is judged a grace period after
+  /// its timeout; at the threshold the server is reported, and the channel is 
replaced by the next query to it
+  /// (so recovery does not depend on a failure detector being configured) or 
by the detector's probe. Timeouts
+  /// themselves never fail the query or blame the server on the spot.
+  @Test
+  public void testUnansweredQueriesReportServerAndReplaceChannel()
+      throws Exception {
+    long requestId = 123;
+    // Never completed: the server answers nothing for the whole test
+    AtomicReference<SettableFuture<DataBuffer>> gate = new 
AtomicReference<>(SettableFuture.create());
+    _queryServer = getGatedQueryServer(gate);
+    initializeTestFixtures(startAndGetPort(_queryServer));
+    int threshold = 3;
+    long timeoutMs = 300L;
+    long graceMs = 200L;
+    QueryRouter queryRouter = newIsolatedQueryRouter(threshold, graceMs);
+    List<ServerRoutingInstance> reported = new CopyOnWriteArrayList<>();
+    queryRouter.setUnansweredServerReporter(reported::add);
+    try {
+      for (int i = 1; i <= threshold; i++) {
+        AsyncQueryResponse asyncQueryResponse = 
submitRealtimeQuery(queryRouter, requestId, timeoutMs);
+        assertEquals(asyncQueryResponse.getStatus(), 
QueryResponse.Status.TIMED_OUT);
+        assertNull(asyncQueryResponse.getFailedServer(), "A timeout blames 
nobody on the spot");
+        assertNull(asyncQueryResponse.getException(), "A timeout is not a send 
failure");
+        // Judged a grace period later; the count resets once the threshold is 
reached
+        waitForUnansweredQueries(queryRouter, _realtimeServerRoutingInstance, 
i < threshold ? i : 0);
+      }
+      TestUtils.waitForCondition(aVoid -> 
reported.equals(List.of(_realtimeServerRoutingInstance)), 10L, 5_000L,
+          "The server was not reported after " + threshold + " unanswered 
queries");
+      TestUtils.waitForCondition(aVoid -> 
_queryServer.getConnectedChannelCount() == 1, 10L, 5_000L,
+          "Expected the REALTIME channel on the server");
+      SocketChannel suspectChannel = 
_queryServer.getConnectedChannels().iterator().next();
+
+      // The next query is written to a fresh connection and the suspect 
channel is closed, without the server being
+      // reported again for the close
+      AsyncQueryResponse asyncQueryResponse = submitRealtimeQuery(queryRouter, 
requestId, timeoutMs);
+      assertEquals(asyncQueryResponse.getStatus(), 
QueryResponse.Status.TIMED_OUT);
+      TestUtils.waitForCondition(aVoid -> 
_queryServer.getConnectedChannelCount() == 1
+          && _queryServer.getConnectedChannels().iterator().next() != 
suspectChannel, 10L, 5_000L,
+          "The suspect channel was not replaced by the next query");
+      assertEquals(reported.size(), 1);
+      assertTrue(queryRouter.hasChannel(_serverInstance));
+
+      // The probe keeps the fresh channel ...
+      SocketChannel freshChannel = 
_queryServer.getConnectedChannels().iterator().next();
+      assertTrue(queryRouter.connect(_serverInstance));
+      assertEquals(_queryServer.getConnectedChannelCount(), 1);
+      assertSame(_queryServer.getConnectedChannels().iterator().next(), 
freshChannel);
+
+      // ... and replaces it once it is suspect in turn, which is the path the 
failure detector takes after a report
+      // (the first unanswered query above already counted once against the 
fresh channel)
+      for (int i = 2; i <= threshold; i++) {
+        submitRealtimeQuery(queryRouter, requestId, timeoutMs);
+      }
+      TestUtils.waitForCondition(aVoid -> reported.size() == 2, 10L, 5_000L, 
"The server was not reported again");
+      assertTrue(queryRouter.connect(_serverInstance));
+      TestUtils.waitForCondition(aVoid -> 
_queryServer.getConnectedChannelCount() == 1
+          && _queryServer.getConnectedChannels().iterator().next() != 
freshChannel, 10L, 5_000L,
+          "The suspect channel was not replaced by the probe");
+    } finally {
+      gate.get().set(PinotByteBuffer.wrap(new byte[0]));
+      queryRouter.shutDown();
+    }
+  }
+
+  /// Any response from the server within the grace period, even a late one 
for a query that already timed out,
+  /// clears the query: a slow server that still answers is never mistaken for 
a dead channel, and a broker that was
+  /// late reading the responses does not blame the servers.
+  @Test
+  public void testLateResponseClearsUnansweredQuery()
+      throws Exception {
+    long requestId = 123;
+    AtomicReference<SettableFuture<DataBuffer>> gate = new 
AtomicReference<>(SettableFuture.create());
+    _queryServer = getGatedQueryServer(gate);
+    initializeTestFixtures(startAndGetPort(_queryServer));
+    int threshold = 2;
+    long timeoutMs = 300L;
+    long graceMs = 1_000L;
+    QueryRouter queryRouter = newIsolatedQueryRouter(threshold, graceMs);
+    List<ServerRoutingInstance> reported = new CopyOnWriteArrayList<>();
+    queryRouter.setUnansweredServerReporter(reported::add);
+    try {
+      AsyncQueryResponse asyncQueryResponse = submitRealtimeQuery(queryRouter, 
requestId, timeoutMs);
+      assertEquals(asyncQueryResponse.getStatus(), 
QueryResponse.Status.TIMED_OUT);
+      // The server answers the first query late, inside the grace period: 
useless to the query, proof of life for
+      // the channel
+      
gate.getAndSet(SettableFuture.create()).set(PinotByteBuffer.wrap(responseBytesForRequest(requestId)));
+      Thread.sleep(graceMs + 500L);
+      
assertEquals(queryRouter.getUnansweredQueries(_realtimeServerRoutingInstance), 
0,
+          "A query answered within the grace period must not count");
+
+      // Two queries the server never answers do count, and reach the threshold
+      submitRealtimeQuery(queryRouter, requestId, timeoutMs);
+      waitForUnansweredQueries(queryRouter, _realtimeServerRoutingInstance, 1);
+      assertTrue(reported.isEmpty());
+      submitRealtimeQuery(queryRouter, requestId, timeoutMs);
+      TestUtils.waitForCondition(aVoid -> 
reported.equals(List.of(_realtimeServerRoutingInstance)), 10L, 5_000L,
+          "The server was not reported");
+    } finally {
+      gate.get().set(PinotByteBuffer.wrap(new byte[0]));
+      queryRouter.shutDown();
+    }
+  }
+
+  /// A response frame counts as an answer from the server as soon as it 
arrives, before it is decoded: one that fails
+  /// to deserialize, a heap OOM included, still proves the server alive, so 
it must not count towards the unanswered
+  /// queries that get a channel replaced.
+  @Test
+  public void testUndecodableResponseStillCountsAsAnswer() {
+    QueryRouter queryRouter = mock(QueryRouter.class);
+    ServerRoutingInstance routingInstance = new 
ServerRoutingInstance("localhost", 12345, TableType.OFFLINE);
+    EmbeddedChannel channel = new EmbeddedChannel(
+        new DataTableHandler(queryRouter, 
ThreadAccountantUtils.getNoOpAccountant(), routingInstance));
+    try {
+      channel.writeInbound(Unpooled.wrappedBuffer(new byte[]{1, 2, 3}));
+      verify(queryRouter).recordResponse(routingInstance);
+      verify(queryRouter, never()).receiveDataTable(any(), any(), anyInt(), 
anyInt());
+    } finally {
+      channel.finishAndReleaseAll();
+    }
+  }
+
   @Test
   public void testChannelActiveInactiveEmitPerServerTaggedMeters() {
     PinotMetricUtils.init(new PinotConfiguration());
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/transport/ServerChannelsTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/transport/ServerChannelsTest.java
index 587074ef75a..c54ebe4ae92 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/transport/ServerChannelsTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/transport/ServerChannelsTest.java
@@ -499,6 +499,9 @@ public class ServerChannelsTest {
       verify(mockChannel, never()).close();
       verify(asyncQueryResponse, never()).markRequestSent(any(), anyInt());
       verify(asyncQueryResponse, never()).markServerUnavailable(any(), any(), 
any(Exception.class), anyBoolean());
+      // Not marked suspect: a healthy server draining a large request fills 
the queue too, and replacing the channel
+      // would fail that request
+      assertFalse(serverChannel._suspect);
     } finally {
       serverChannels.shutDown();
     }
@@ -1030,9 +1033,10 @@ public class ServerChannelsTest {
       assertTrue(e.getMessage().contains("not writable"), e.getMessage());
       
assertEquals(meterCount(BrokerMeter.NETTY_CONNECTION_UNWRITABLE_REJECTIONS, 
instance), rejectionsBefore + 1);
       verify(rejectedResponse, never()).markRequestSent(any(), anyInt());
-      // Rejecting does not close the channel; the write timeout handler owns 
that decision
+      // Rejecting neither closes nor replaces the channel; the write timeout 
handler owns that decision
       assertTrue(channel.isActive());
       assertSame(serverChannel._channel, channel);
+      assertFalse(serverChannel._suspect);
     } finally {
       serverChannels.shutDown();
     }
@@ -1199,6 +1203,226 @@ public class ServerChannelsTest {
     }
   }
 
+  /// A channel the server stopped answering on is replaced by the next query 
to it, so recovery does not wait for
+  /// a failure detector, and the queries still in flight on it fail without 
blaming the server.
+  @Test
+  public void testNextRequestReplacesSuspectChannel()
+      throws Exception {
+    ServerChannels serverChannels =
+        new ServerChannels(mock(QueryRouter.class), null, null, 
ThreadAccountantUtils.getNoOpAccountant());
+    try (StalledServer server = new StalledServer()) {
+      ServerRoutingInstance instance = new ServerRoutingInstance("localhost", 
server.getPort(), TableType.OFFLINE);
+      AsyncQueryResponse firstResponse = mock(AsyncQueryResponse.class);
+      Channel suspectChannel =
+          serverChannels.sendRequest("testTable", firstResponse, instance, 
smallInstanceRequest(), 10_000L);
+      assertNotNull(suspectChannel);
+      ServerChannels.ServerChannel entry = 
serverChannels.getOrCreateServerChannel(instance);
+      assertSame(entry._channel, suspectChannel);
+
+      serverChannels.markSuspect(instance);
+      AsyncQueryResponse nextResponse = mock(AsyncQueryResponse.class);
+      Channel freshChannel =
+          serverChannels.sendRequest("testTable", nextResponse, instance, 
smallInstanceRequest(), 10_000L);
+      assertNotSame(freshChannel, suspectChannel);
+      assertSame(entry._channel, freshChannel);
+      assertTrue(freshChannel.isActive());
+      TestUtils.waitForCondition(aVoid -> !suspectChannel.isActive(), 10L, 
5_000L, "Suspect channel was not closed");
+      verify(nextResponse, timeout(5_000L)).markRequestSent(eq(instance), 
anyInt());
+    } finally {
+      serverChannels.shutDown();
+    }
+  }
+
+  /// Unanswered queries are judged a grace period after their timeout: a 
response in between clears them, a server
+  /// that stays silent for the threshold gets the channel marked suspect and 
is reported through the router.
+  @Test
+  public void testUnansweredQueriesAreJudgedAfterTheGracePeriod()
+      throws Exception {
+    QueryRouter queryRouter = mock(QueryRouter.class);
+    long graceMs = 200L;
+    ServerChannels serverChannels =
+        new ServerChannels(queryRouter, null, null, 
ThreadAccountantUtils.getNoOpAccountant(), 2, graceMs);
+    try (StalledServer server = new StalledServer()) {
+      ServerRoutingInstance instance = new ServerRoutingInstance("localhost", 
server.getPort(), TableType.REALTIME);
+      long suspectsBefore = 
meterCount(BrokerMeter.NETTY_CONNECTION_SUSPECT_CHANNELS, instance);
+      // Unknown channel: nothing to judge
+      serverChannels.recordUnansweredQuery(instance, 
System.currentTimeMillis(), null);
+      serverChannels.sendRequest("testTable", mock(AsyncQueryResponse.class), 
instance, smallInstanceRequest(),
+          10_000L);
+      ServerChannels.ServerChannel entry = 
serverChannels.getOrCreateServerChannel(instance);
+      Thread.sleep(5L);
+
+      // A query written after the last response, still unanswered a grace 
period after its timeout: counts
+      serverChannels.recordUnansweredQuery(instance, 
System.currentTimeMillis(), null);
+      assertEquals(serverChannels.getUnansweredQueries(instance), 0, "Not 
judged before the grace period");
+      TestUtils.waitForCondition(aVoid -> 
serverChannels.getUnansweredQueries(instance) == 1, 10L, 5_000L,
+          "The unanswered query was not counted after the grace period");
+      assertFalse(entry._suspect);
+      verify(queryRouter, never()).reportUnansweredServer(any());
+      assertEquals(meterCount(BrokerMeter.NETTY_CONNECTION_SUSPECT_CHANNELS, 
instance), suspectsBefore);
+
+      // A response from the server, even to some other query, clears the 
count ...
+      serverChannels.recordResponse(instance);
+      assertEquals(serverChannels.getUnansweredQueries(instance), 0);
+      // ... and a query the server answered within the grace period does not 
count
+      long submitTimeMs = System.currentTimeMillis();
+      Thread.sleep(5L);
+      serverChannels.recordResponse(instance);
+      serverChannels.recordUnansweredQuery(instance, submitTimeMs, null);
+      Thread.sleep(graceMs + 200L);
+      assertEquals(serverChannels.getUnansweredQueries(instance), 0);
+
+      // Silence for the threshold marks the channel suspect and reports the 
server
+      Thread.sleep(5L);
+      serverChannels.recordUnansweredQuery(instance, 
System.currentTimeMillis(), null);
+      serverChannels.recordUnansweredQuery(instance, 
System.currentTimeMillis(), null);
+      verify(queryRouter, timeout(5_000L)).reportUnansweredServer(instance);
+      assertTrue(entry._suspect);
+      assertEquals(serverChannels.getUnansweredQueries(instance), 0, "Reset 
once reported");
+      assertEquals(meterCount(BrokerMeter.NETTY_CONNECTION_SUSPECT_CHANNELS, 
instance), suspectsBefore + 1);
+    } finally {
+      serverChannels.shutDown();
+    }
+  }
+
+  /// Under a dead server at high QPS the judgments queued per channel are 
capped; the ones that do queue are enough
+  /// to cross the threshold, so the outcome is the same with a bounded queue.
+  @Test
+  public void testPendingJudgmentsPerChannelAreCapped()
+      throws Exception {
+    QueryRouter queryRouter = mock(QueryRouter.class);
+    int threshold = 3;
+    ServerChannels serverChannels =
+        new ServerChannels(queryRouter, null, null, 
ThreadAccountantUtils.getNoOpAccountant(), threshold, 300L);
+    try (StalledServer server = new StalledServer()) {
+      ServerRoutingInstance instance = new ServerRoutingInstance("localhost", 
server.getPort(), TableType.REALTIME);
+      serverChannels.sendRequest("testTable", mock(AsyncQueryResponse.class), 
instance, smallInstanceRequest(),
+          10_000L);
+      Thread.sleep(5L);
+      long submitTimeMs = System.currentTimeMillis();
+      for (int i = 0; i < 1_000; i++) {
+        serverChannels.recordUnansweredQuery(instance, submitTimeMs, null);
+      }
+      int numPendingJudgments = 
serverChannels.getNumPendingJudgments(instance);
+      assertTrue(numPendingJudgments >= threshold && numPendingJudgments <= 16,
+          "Expected between the threshold and the cap of pending judgments, 
got " + numPendingJudgments);
+      verify(queryRouter, timeout(5_000L)).reportUnansweredServer(instance);
+      TestUtils.waitForCondition(aVoid -> 
serverChannels.getNumPendingJudgments(instance) == 0, 10L, 5_000L,
+          "Pending judgments were not drained");
+    } finally {
+      serverChannels.shutDown();
+    }
+  }
+
+  /// With the check disabled nothing is scheduled or counted.
+  @Test
+  public void testUnansweredQueryCheckDisabled()
+      throws Exception {
+    QueryRouter queryRouter = mock(QueryRouter.class);
+    ServerChannels serverChannels =
+        new ServerChannels(queryRouter, null, null, 
ThreadAccountantUtils.getNoOpAccountant(), 0, 10L);
+    try (StalledServer server = new StalledServer()) {
+      ServerRoutingInstance instance = new ServerRoutingInstance("localhost", 
server.getPort(), TableType.REALTIME);
+      serverChannels.sendRequest("testTable", mock(AsyncQueryResponse.class), 
instance, smallInstanceRequest(),
+          10_000L);
+      Thread.sleep(5L);
+      serverChannels.recordUnansweredQuery(instance, 
System.currentTimeMillis(), null);
+      Thread.sleep(200L);
+      assertEquals(serverChannels.getUnansweredQueries(instance), 0);
+      assertFalse(serverChannels.getOrCreateServerChannel(instance)._suspect);
+      verify(queryRouter, never()).reportUnansweredServer(any());
+    } finally {
+      serverChannels.shutDown();
+    }
+  }
+
+  /// The failure detector's probe must not vouch for a channel the server 
stopped answering on just because the
+  /// socket still reports active: a suspect channel is replaced, a healthy 
one kept, a closed one reconnected, and
+  /// an unreachable server fails the probe.
+  @Test
+  public void testProbeReplacesSuspectChannelAndKeepsHealthyOne()
+      throws Exception {
+    ServerChannels serverChannels =
+        new ServerChannels(mock(QueryRouter.class), null, null, 
ThreadAccountantUtils.getNoOpAccountant());
+    StalledServer server = new StalledServer();
+    try {
+      ServerRoutingInstance instance = new ServerRoutingInstance("localhost", 
server.getPort(), TableType.REALTIME);
+      // Nothing to vouch for before a query was sent, even once pre-connect 
opened a channel
+      assertFalse(serverChannels.probe(instance));
+      serverChannels.preConnect(instance, 5_000L);
+      assertFalse(serverChannels.probe(instance), "Pre-connected only: not 
query-carrying");
+
+      serverChannels.sendRequest("testTable", mock(AsyncQueryResponse.class), 
instance, smallInstanceRequest(),
+          10_000L);
+      ServerChannels.ServerChannel entry = 
serverChannels.getOrCreateServerChannel(instance);
+      Channel healthyChannel = entry._channel;
+      assertTrue(serverChannels.probe(instance));
+      assertSame(entry._channel, healthyChannel, "A healthy channel is kept");
+      assertTrue(healthyChannel.isActive());
+
+      serverChannels.markSuspect(instance);
+      assertTrue(serverChannels.probe(instance));
+      Channel freshChannel = entry._channel;
+      assertNotSame(freshChannel, healthyChannel, "A suspect channel is 
replaced");
+      assertTrue(freshChannel.isActive());
+      TestUtils.waitForCondition(aVoid -> !healthyChannel.isActive(), 10L, 
5_000L, "Suspect channel was not closed");
+      // No longer suspect: the replacement is kept by the next probe
+      assertTrue(serverChannels.probe(instance));
+      assertSame(entry._channel, freshChannel);
+
+      // A closed channel is reconnected, as the probe always did
+      freshChannel.close().sync();
+      assertTrue(serverChannels.probe(instance));
+      assertNotSame(entry._channel, freshChannel);
+      assertTrue(entry._channel.isActive());
+
+      // An unreachable server fails the probe and leaves no channel behind
+      serverChannels.markSuspect(instance);
+      server.close();
+      assertThrows(Exception.class, () -> serverChannels.probe(instance));
+      assertNull(entry._channel);
+    } finally {
+      server.close();
+      serverChannels.shutDown();
+    }
+  }
+
+  /// A query written to a channel the entry has since replaced says nothing 
about the new channel: its judgment does
+  /// not count, even when it runs after the replacement, and so cannot mark 
the fresh channel suspect.
+  @Test
+  public void testJudgmentOfAQueryOnAReplacedChannelDoesNotCount()
+      throws Exception {
+    QueryRouter queryRouter = mock(QueryRouter.class);
+    ServerChannels serverChannels =
+        new ServerChannels(queryRouter, null, null, 
ThreadAccountantUtils.getNoOpAccountant(), 1, 100L);
+    try (StalledServer server = new StalledServer()) {
+      ServerRoutingInstance instance = new ServerRoutingInstance("localhost", 
server.getPort(), TableType.REALTIME);
+      Channel oldChannel = serverChannels.sendRequest("testTable", 
mock(AsyncQueryResponse.class), instance,
+          smallInstanceRequest(), 10_000L);
+      long oldSubmitTimeMs = System.currentTimeMillis();
+      serverChannels.markSuspect(instance);
+      Channel freshChannel = serverChannels.sendRequest("testTable", 
mock(AsyncQueryResponse.class), instance,
+          smallInstanceRequest(), 10_000L);
+      assertNotSame(freshChannel, oldChannel);
+
+      // With a threshold of 1 a counted judgment would mark the fresh channel 
suspect at once
+      serverChannels.recordUnansweredQuery(instance, oldSubmitTimeMs, 
oldChannel);
+      TestUtils.waitForCondition(aVoid -> 
serverChannels.getNumPendingJudgments(instance) == 0, 10L, 5_000L,
+          "The judgment did not run");
+      assertEquals(serverChannels.getUnansweredQueries(instance), 0);
+      assertFalse(serverChannels.getOrCreateServerChannel(instance)._suspect);
+      verify(queryRouter, never()).reportUnansweredServer(any());
+
+      // A query on the current channel does count
+      Thread.sleep(5L);
+      serverChannels.recordUnansweredQuery(instance, 
System.currentTimeMillis(), freshChannel);
+      verify(queryRouter, timeout(5_000L)).reportUnansweredServer(instance);
+      assertTrue(serverChannels.getOrCreateServerChannel(instance)._suspect);
+    } finally {
+      serverChannels.shutDown();
+    }
+  }
+
   private static long meterCount(BrokerMeter meter) {
     return BrokerMetrics.get().getMeteredValue(meter).count();
   }
@@ -1324,9 +1548,22 @@ public class ServerChannelsTest {
       serverChannel._bootstrap.option(ChannelOption.SO_SNDBUF, 
SOCKET_BUFFER_BYTES);
     }
 
+    /// Returns once the port refuses connections. On Linux a socket closed 
while the acceptor is blocked in
+    /// `accept()` keeps listening until that call returns, so a connect right 
after closing could still succeed.
     @Override
     public void close() {
       IOUtils.closeQuietly(_serverSocket);
+      closeAccepted();
+      try {
+        _acceptor.join(5_000L);
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+      }
+      // Anything the acceptor took in before it returned
+      closeAccepted();
+    }
+
+    private void closeAccepted() {
       for (Socket socket : _accepted) {
         IOUtils.closeQuietly(socket);
       }
diff --git 
a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java 
b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
index 45c947fad6b..271ce143163 100644
--- a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
@@ -1414,6 +1414,33 @@ public class CommonConstants {
       /// stop-the-world GC pause expected on a server, or a pause will 
briefly take that server out of routing.
       public static final String CONFIG_OF_PING_TIMEOUT_MS = 
"pinot.broker.failure.detector.ping.timeout.ms";
       public static final long DEFAULT_PING_TIMEOUT_MS = 5_000L;
+
+      /// Single-stage queries a server leaves unanswered are also counted per 
channel, with or without pings. A query
+      /// counts once its timeout has passed and, a further grace period 
later, the server has still sent nothing on
+      /// the channel since the query was written to it. The grace lets the 
server's own late timeout response through,
+      /// so a burst of slow queries or a broker pause does not look like a 
dead channel; a half-open channel answers
+      /// nothing, ever. At the threshold the channel is marked suspect, so 
the next query to it (or the failure
+      /// detector's retry) replaces it with a fresh connection. That never 
takes the server out of routing; see
+      /// [#CONFIG_OF_REPORT_UNANSWERED_SERVERS] for that. Any response from 
the server, decodable or not, resets the
+      /// count. A non-positive threshold disables the check.
+      public static final String CONFIG_OF_UNANSWERED_QUERIES_THRESHOLD =
+          "pinot.broker.failure.detector.unanswered.queries.threshold";
+      public static final int DEFAULT_UNANSWERED_QUERIES_THRESHOLD = 5;
+      public static final String CONFIG_OF_UNANSWERED_QUERY_GRACE_MS =
+          "pinot.broker.failure.detector.unanswered.query.grace.ms";
+      public static final long DEFAULT_UNANSWERED_QUERY_GRACE_MS = 2_000L;
+
+      /// With pings disabled, whether a server that crosses 
[#CONFIG_OF_UNANSWERED_QUERIES_THRESHOLD] on a channel is
+      /// also reported to the failure detector, which with the `CONNECTION` 
detector takes it out of routing until the
+      /// retry probe re-establishes its channels. Off by default: a tenant 
whose heavy queries routinely overrun their
+      /// timeout, or a long GC pause, can cross the threshold on a healthy 
server. Without pings the retry probe is a
+      /// TCP connect, which a frozen JVM's kernel still completes, so such a 
server is readmitted at every retry and
+      /// can leave up to the threshold of queries per channel unanswered 
again before it is reported once more;
+      /// enabling pings, where every server supports them, is the better way 
to catch it. With pings enabled this
+      /// setting has no effect: the ping that follows every timeout decides.
+      public static final String CONFIG_OF_REPORT_UNANSWERED_SERVERS =
+          "pinot.broker.failure.detector.report.unanswered.servers";
+      public static final boolean DEFAULT_REPORT_UNANSWERED_SERVERS = false;
     }
 
     // Configs related to AdaptiveServerSelection.


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

Reply via email to