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 aea5680a40b Detect servers that stop answering queries but keep their 
connection open (#19452)
aea5680a40b is described below

commit aea5680a40b15a03414876bcad7c5a15ce95eef4
Author: Yash Mayya <[email protected]>
AuthorDate: Sat Oct 24 15:24:11 2026 -0700

    Detect servers that stop answering queries but keep their connection open 
(#19452)
---
 .../SingleConnectionBrokerRequestHandler.java      |  73 +++-
 ...ionBrokerRequestHandlerFailureDetectorTest.java | 450 +++++++++++++++++++++
 ...BaseExponentialBackoffRetryFailureDetector.java | 132 +++++-
 .../failuredetector/ConnectionFailureDetector.java |   9 +-
 .../common/failuredetector/FailureDetector.java    |  23 ++
 .../pinot/common/request/InstanceRequest.java      | 106 ++++-
 .../ConnectionFailureDetectorTest.java             | 284 ++++++++++++-
 pinot-common/src/thrift/request.thrift             |   4 +
 .../core/transport/InstanceRequestHandler.java     |  19 +
 .../apache/pinot/core/transport/QueryRouter.java   | 149 ++++++-
 .../pinot/core/transport/ServerChannels.java       |  47 ++-
 .../core/transport/InstanceRequestHandlerTest.java |  56 +++
 .../pinot/core/transport/QueryRoutingTest.java     | 270 +++++++++++++
 .../pinot/core/transport/QueryServerTestUtils.java |  69 ++++
 .../pinot/core/transport/ServerChannelsTest.java   |  39 +-
 .../apache/pinot/spi/utils/CommonConstants.java    |  20 +-
 16 files changed, 1693 insertions(+), 57 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 ea9364d8af4..cceced717cc 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
@@ -19,6 +19,7 @@
 package org.apache.pinot.broker.requesthandler;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
 import com.google.common.collect.Maps;
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
 import java.util.ArrayList;
@@ -113,6 +114,8 @@ public class SingleConnectionBrokerRequestHandler extends 
BaseSingleStageBrokerR
   protected final BrokerReduceService _brokerReduceService;
   protected final QueryRouter _queryRouter;
   protected final FailureDetector _failureDetector;
+  private final boolean _pingOnTimeout;
+  private final long _pingTimeoutMs;
 
   /// Legacy constructor without an MV handler — see 
[BaseSingleStageBrokerRequestHandler]'s
   /// legacy ctor for the rationale.  Delegates with `materializedViewHandler 
= null`.
@@ -139,7 +142,16 @@ public class SingleConnectionBrokerRequestHandler extends 
BaseSingleStageBrokerR
     _brokerReduceService = new BrokerReduceService(_config);
     _queryRouter = new QueryRouter(_brokerId, nettyConfig, tlsConfig, 
serverRoutingStatsManager, threadAccountant);
     _failureDetector = failureDetector;
+    _pingOnTimeout = 
_config.getProperty(CommonConstants.Broker.FailureDetector.CONFIG_OF_ENABLE_PING_ON_TIMEOUT,
+        CommonConstants.Broker.FailureDetector.DEFAULT_ENABLE_PING_ON_TIMEOUT);
+    _pingTimeoutMs = 
_config.getProperty(CommonConstants.Broker.FailureDetector.CONFIG_OF_PING_TIMEOUT_MS,
+        CommonConstants.Broker.FailureDetector.DEFAULT_PING_TIMEOUT_MS);
     
_failureDetector.registerUnhealthyServerRetrier(this::retryUnhealthyServer);
+    if (_pingOnTimeout) {
+      Preconditions.checkArgument(_pingTimeoutMs > 0, "'%s' must be positive, 
got: %s",
+          CommonConstants.Broker.FailureDetector.CONFIG_OF_PING_TIMEOUT_MS, 
_pingTimeoutMs);
+      
_failureDetector.registerServerNotRespondedChecker(this::checkServerNotResponded);
+    }
   }
 
   @Override
@@ -739,11 +751,34 @@ public class SingleConnectionBrokerRequestHandler extends 
BaseSingleStageBrokerR
         serversNotResponded.add(entry.getKey());
       }
     }
+    reportServersNotResponded(asyncQueryResponse, finalResponses);
+
     ScatterResultStats stats = new ScatterResultStats(
         dataTableMap.size() + serversNotResponded.size(), dataTableMap.size(), 
totalResponseSize);
     return new ScatterResult(dataTableMap, serversNotResponded, stats, 
timedOut, asyncQueryResponse.getException());
   }
 
+  /// Reports the servers that left a timed-out query unanswered to the 
failure detector, which, with pings enabled,
+  /// pings each of them and marks the ones that do not answer unhealthy. This 
is how a server whose node is gone or
+  /// whose JVM is frozen is detected at all -- see 
[FailureDetector#notifyServerNotResponded].
+  ///
+  /// Nothing is reported unless the query timed out: a `FAILED` response was 
aborted early by a send failure or a
+  /// channel teardown, both already reported through 
[AsyncQueryResponse#getFailedServer()], and aborting leaves
+  /// servers that were never given a chance to answer without a response. For 
the same reason the split
+  /// materialized-view path reports each of its two responses on its own: one 
can time out while the other aborts.
+  private void reportServersNotResponded(AsyncQueryResponse asyncQueryResponse,
+      Map<ServerRoutingInstance, ServerResponse> finalResponses) {
+    if (asyncQueryResponse.getStatus() == QueryResponse.Status.TIMED_OUT) {
+      for (Map.Entry<ServerRoutingInstance, ServerResponse> entry : 
finalResponses.entrySet()) {
+        if (entry.getValue().getDataTable() == null) {
+          ServerRoutingInstance serverRoutingInstance = entry.getKey();
+          
_failureDetector.notifyServerNotResponded(serverRoutingInstance.getInstanceId(),
+              serverRoutingInstance.getHostname());
+        }
+      }
+    }
+  }
+
   /// Executes the reduce step on the scatter result and populates the 
response with server stats.
   /// Subclasses may override to perform custom reduce logic, or construct a 
[ScatterResult]
   /// with a substituted data table map using [ScatterResultStats] to preserve 
server stats.
@@ -855,6 +890,9 @@ public class SingleConnectionBrokerRequestHandler extends 
BaseSingleStageBrokerR
     long totalResponseSize = totalResponseSizeHolder[0];
     int numServersResponded = dataTableMap.size();
 
+    reportServersNotResponded(baseAsyncResponse, baseFinalResponses);
+    reportServersNotResponded(materializedViewAsyncResponse, 
viewFinalResponses);
+
     /// On a SPLIT query, base and MV scatter-gathers cover DISJOINT halves of 
the timeline
     /// (base covers `ts < boundary`, MV covers `ts >= boundary`).  Partial 
failure on either
     /// side therefore leaves whole time-ranges missing — semantically 
distinct from a non-split
@@ -1004,7 +1042,8 @@ public class SingleConnectionBrokerRequestHandler extends 
BaseSingleStageBrokerR
     }
   }
 
-  /// Check if a server that was previously detected as unhealthy is now 
healthy.
+  /// 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.
   public FailureDetector.ServerState retryUnhealthyServer(String instanceId) {
     LOGGER.info("Retrying unhealthy server: {}", instanceId);
     ServerInstance serverInstance = 
_routingManager.getEnabledServerInstanceMap().get(instanceId);
@@ -1019,6 +1058,18 @@ public class SingleConnectionBrokerRequestHandler 
extends BaseSingleStageBrokerR
       return FailureDetector.ServerState.UNKNOWN;
     }
 
+    if (_pingOnTimeout) {
+      // Not a connect: a frozen JVM's kernel still completes the handshake, 
which would put a server taken out for an
+      // unanswered ping straight back into routing.
+      if (_queryRouter.ping(serverInstance, _pingTimeoutMs)) {
+        LOGGER.info("Server: {} answered a ping, marking it healthy", 
instanceId);
+        return FailureDetector.ServerState.HEALTHY;
+      } else {
+        LOGGER.warn("Server: {} did not answer a ping within {}ms, retry 
later", instanceId, _pingTimeoutMs);
+        return FailureDetector.ServerState.UNHEALTHY;
+      }
+    }
+
     if (_queryRouter.connect(serverInstance)) {
       LOGGER.info("Successfully connect to server: {}, marking it healthy", 
instanceId);
       return FailureDetector.ServerState.HEALTHY;
@@ -1028,6 +1079,26 @@ public class SingleConnectionBrokerRequestHandler 
extends BaseSingleStageBrokerR
     }
   }
 
+  /// Checks a server that left a query unanswered, by pinging it. Registered 
with the failure detector only when pings
+  /// are enabled, as the only check a timeout runs -- see 
[FailureDetector#registerServerNotRespondedChecker].
+  private FailureDetector.ServerState checkServerNotResponded(String 
instanceId) {
+    ServerInstance serverInstance = 
_routingManager.getEnabledServerInstanceMap().get(instanceId);
+    if (serverInstance == null) {
+      // Not routed by this broker -- a server of another cluster, say, or one 
Helix has just disabled -- so there is
+      // nothing to take out of routing.
+      return FailureDetector.ServerState.UNKNOWN;
+    }
+    if (!_queryRouter.hasChannel(serverInstance)) {
+      // Nothing to ping over, which says nothing about the server.
+      return FailureDetector.ServerState.UNKNOWN;
+    }
+    if (_queryRouter.ping(serverInstance, _pingTimeoutMs)) {
+      LOGGER.debug("Server: {} left a query unanswered but answered a ping", 
instanceId);
+      return FailureDetector.ServerState.HEALTHY;
+    }
+    return FailureDetector.ServerState.UNHEALTHY;
+  }
+
   /// Counts responses that successfully returned a DataTable. Production 
callers use this in the
   /// MV-split path to detect "all of one side's servers failed" — if 
`viewFinalResponses` is
   /// non-empty AND the count is zero, the split would silently undercount the 
historical half
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
new file mode 100644
index 00000000000..7574bc25d94
--- /dev/null
+++ 
b/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/SingleConnectionBrokerRequestHandlerFailureDetectorTest.java
@@ -0,0 +1,450 @@
+/**
+ * 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.requesthandler;
+
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.SettableFuture;
+import java.io.IOException;
+import java.net.ServerSocket;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.apache.pinot.broker.broker.AllowAllAccessControlFactory;
+import org.apache.pinot.broker.queryquota.QueryQuotaManager;
+import org.apache.pinot.broker.routing.manager.BrokerRoutingManager;
+import org.apache.pinot.common.config.provider.TableCache;
+import org.apache.pinot.common.datatable.DataTable;
+import org.apache.pinot.common.datatable.DataTable.MetadataKey;
+import org.apache.pinot.common.failuredetector.FailureDetector;
+import org.apache.pinot.common.failuredetector.FailureDetectorFactory;
+import org.apache.pinot.common.metrics.BrokerGauge;
+import org.apache.pinot.common.metrics.BrokerMetrics;
+import org.apache.pinot.common.metrics.MetricValueUtils;
+import org.apache.pinot.common.request.BrokerRequest;
+import org.apache.pinot.core.common.datatable.DataTableBuilderFactory;
+import org.apache.pinot.core.routing.ImplicitHybridTableRouteInfo;
+import org.apache.pinot.core.routing.MultiClusterRoutingContext;
+import org.apache.pinot.core.routing.SegmentsToQuery;
+import org.apache.pinot.core.routing.TableRouteInfo;
+import org.apache.pinot.core.transport.QueryServer;
+import org.apache.pinot.core.transport.QueryServerTestUtils;
+import org.apache.pinot.core.transport.ServerInstance;
+import 
org.apache.pinot.core.transport.server.routing.stats.ServerRoutingStatsManager;
+import org.apache.pinot.spi.accounting.ThreadAccountantUtils;
+import org.apache.pinot.spi.env.PinotConfiguration;
+import 
org.apache.pinot.spi.eventlistener.query.BrokerQueryEventListenerFactory;
+import org.apache.pinot.spi.utils.CommonConstants.Broker;
+import org.apache.pinot.sql.parsers.CalciteSqlCompiler;
+import org.apache.pinot.util.TestUtils;
+import org.mockito.stubbing.Answer;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
+
+
+/// End-to-end coverage, over real sockets, of the broker removing a server 
whose node is gone or whose JVM is frozen.
+///
+/// Such a server keeps its TCP connection open and never answers, so it 
produces query timeouts and nothing else: no
+/// send exception, no channel teardown. Before this change the broker kept 
routing queries to it until Helix noticed,
+/// minutes later, while healthy replicas sat idle. Now a timed-out query 
makes the broker ping the server, and a server
+/// that does not answer the ping is taken out of routing.
+///
+/// These tests drive the real [SingleConnectionBrokerRequestHandler] and a 
real [FailureDetector] against real
+/// servers, so the whole chain -- timeout, ping, routing exclusion, retry -- 
runs over real sockets.
+public class SingleConnectionBrokerRequestHandlerFailureDetectorTest {
+  private static final BrokerRequest BROKER_REQUEST =
+      CalciteSqlCompiler.compileToBrokerRequest("SELECT * FROM testTable");
+  private static final String RAW_TABLE_NAME = "testTable";
+  private static final long REQUEST_ID = 123L;
+  private static final long QUERY_TIMEOUT_MS = 1_000L;
+  private static final long PING_TIMEOUT_MS = 500L;
+  /// Long enough that the retry loop never runs during a test that is not 
about it.
+  private static final long RETRIES_OFF_MS = 600_000L;
+  private static final long FAST_RETRY_DELAY_MS = 100L;
+
+  private final List<QueryServer> _queryServers = new ArrayList<>();
+  private final List<ServerSocket> _frozenServers = new ArrayList<>();
+  /// What the routing manager reports as enabled. The ping-based check treats 
a server missing from here as
+  /// unhealthy, so every server a test starts must be in it.
+  private final Map<String, ServerInstance> _enabledServers = new 
ConcurrentHashMap<>();
+
+  private BrokerRoutingManager _routingManager;
+  private SingleConnectionBrokerRequestHandler _requestHandler;
+  private FailureDetector _failureDetector;
+  private List<String> _excludedServers;
+  private List<String> _reincludedServers;
+
+  @BeforeMethod
+  public void setUp() {
+    startBroker(true, RETRIES_OFF_MS);
+  }
+
+  /// Builds the broker-side stack under test: a real request handler wired to 
a real failure detector whose notifiers
+  /// are recorded, mirroring the wiring `BaseBrokerStarter` performs in 
production. Without `pingOnTimeout` the
+  /// default applies.
+  private void startBroker(boolean pingOnTimeout, long retryInitialDelayMs) {
+    // 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();
+    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);
+    }
+    config.setProperty(Broker.FailureDetector.CONFIG_OF_PING_TIMEOUT_MS, 
PING_TIMEOUT_MS);
+    
config.setProperty(Broker.FailureDetector.CONFIG_OF_RETRY_INITIAL_DELAY_MS, 
retryInitialDelayMs);
+    config.setProperty(Broker.FailureDetector.CONFIG_OF_RETRY_DELAY_FACTOR, 1);
+    // Past the max retries a server is put back regardless, which would let a 
recovery test pass with no ping answered.
+    config.setProperty(Broker.FailureDetector.CONFIG_OF_MAX_RETRIES, 
Integer.MAX_VALUE);
+
+    _failureDetector = FailureDetectorFactory.getFailureDetector(config, 
BrokerMetrics.get());
+    _excludedServers = new CopyOnWriteArrayList<>();
+    _reincludedServers = new CopyOnWriteArrayList<>();
+    _failureDetector.registerUnhealthyServerNotifier(instanceId -> 
_excludedServers.add(instanceId));
+    _failureDetector.registerHealthyServerNotifier(instanceId -> 
_reincludedServers.add(instanceId));
+    _failureDetector.start();
+
+    BrokerQueryEventListenerFactory.init(new PinotConfiguration());
+    ServerRoutingStatsManager serverRoutingStatsManager =
+        new ServerRoutingStatsManager(new PinotConfiguration(), 
BrokerMetrics.get());
+    serverRoutingStatsManager.init();
+
+    _routingManager = mock(BrokerRoutingManager.class);
+    
when(_routingManager.getEnabledServerInstanceMap()).thenReturn(_enabledServers);
+    _requestHandler =
+        new SingleConnectionBrokerRequestHandler(config, "testBroker", new 
BrokerRequestIdGenerator(), _routingManager,
+            new AllowAllAccessControlFactory(), mock(QueryQuotaManager.class), 
mock(TableCache.class), null, null,
+            serverRoutingStatsManager, _failureDetector, 
ThreadAccountantUtils.getNoOpAccountant(),
+            mock(MultiClusterRoutingContext.class));
+  }
+
+  @AfterMethod
+  public void tearDown()
+      throws IOException {
+    stopBroker();
+    for (QueryServer queryServer : _queryServers) {
+      queryServer.shutDown();
+    }
+    _queryServers.clear();
+    for (ServerSocket frozenServer : _frozenServers) {
+      frozenServer.close();
+    }
+    _frozenServers.clear();
+    _enabledServers.clear();
+  }
+
+  private void stopBroker() {
+    if (_requestHandler != null) {
+      _requestHandler.shutDown();
+      _requestHandler = null;
+    }
+    if (_failureDetector != null) {
+      _failureDetector.stop();
+      _failureDetector = null;
+    }
+    // The gauge is a JVM-wide singleton; do not leave it dirty for the next 
test.
+    BrokerMetrics.get().setValueOfGlobalGauge(BrokerGauge.UNHEALTHY_SERVERS, 
0);
+  }
+
+  // 
---------------------------------------------------------------------------------------------------------------
+  // Fixtures
+  // 
---------------------------------------------------------------------------------------------------------------
+
+  /// Starts a server whose node is gone or whose JVM is frozen: a listening 
socket that is never accepted from. The
+  /// kernel completes the TCP handshake and buffers whatever is sent, but 
nothing ever reads it or replies -- neither
+  /// queries nor pings. Shutting a server down would NOT model this: that 
closes the socket, which takes the
+  /// already-working connection-failure path instead.
+  private ServerInstance startFrozenServer()
+      throws IOException {
+    ServerSocket frozenServer = new ServerSocket(0, 100);
+    _frozenServers.add(frozenServer);
+    return enable(QueryServerTestUtils.serverInstance("localhost", 
frozenServer.getLocalPort()));
+  }
+
+  /// Starts a server whose JVM is alive but whose query engine never finishes 
anything -- stuck, or so busy that every
+  /// query times out. Its network thread still answers pings, because they do 
not go through the query engine.
+  private ServerInstance startStuckQueryEngineServer() {
+    return startQueryServer(0, invocation -> SettableFuture.<byte[]>create());
+  }
+
+  /// Starts a server that answers queries straight away.
+  private ServerInstance startRespondingServer(int port) {
+    DataTable dataTable = DataTableBuilderFactory.getEmptyDataTable();
+    dataTable.getMetadata().put(MetadataKey.REQUEST_ID.getName(), 
Long.toString(REQUEST_ID));
+    byte[] responseBytes;
+    try {
+      responseBytes = dataTable.toBytes();
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+    return startQueryServer(port, invocation -> 
Futures.immediateFuture(responseBytes));
+  }
+
+  private ServerInstance startQueryServer(int port, Answer<?> submitAnswer) {
+    QueryServer queryServer = QueryServerTestUtils.newQueryServer(port, 
submitAnswer);
+    _queryServers.add(queryServer);
+    return enable(QueryServerTestUtils.serverInstance("localhost", 
QueryServerTestUtils.startAndGetPort(queryServer)));
+  }
+
+  private ServerInstance enable(ServerInstance serverInstance) {
+    _enabledServers.put(serverInstance.getInstanceId(), serverInstance);
+    return serverInstance;
+  }
+
+  /// Routes a query to the given servers for the given table types; both for 
a hybrid table.
+  private static TableRouteInfo routeTo(boolean offline, boolean realtime, 
ServerInstance... servers) {
+    Map<ServerInstance, SegmentsToQuery> routingTable = new HashMap<>();
+    for (ServerInstance server : servers) {
+      routingTable.put(server, new SegmentsToQuery(List.of("segment0"), 
List.of()));
+    }
+    return new ImplicitHybridTableRouteInfo(offline ? BROKER_REQUEST : null, 
realtime ? BROKER_REQUEST : null,
+        offline ? routingTable : null, realtime ? routingTable : null);
+  }
+
+  private static TableRouteInfo offlineRouteTo(ServerInstance... servers) {
+    return routeTo(true, false, servers);
+  }
+
+  private SingleConnectionBrokerRequestHandler.ScatterResult 
scatter(TableRouteInfo route)
+      throws Exception {
+    return _requestHandler.doScatter(REQUEST_ID, RAW_TABLE_NAME, route, 
QUERY_TIMEOUT_MS,
+        new BaseSingleStageBrokerRequestHandler.ServerStats());
+  }
+
+  /// Waits until exactly `servers` are unhealthy, gauge included. Waits on 
that final state rather than on the
+  /// notifier, which runs before the detector records the change.
+  private void awaitUnhealthyServers(ServerInstance... servers) {
+    Set<String> expected = new HashSet<>();
+    for (ServerInstance server : servers) {
+      expected.add(server.getInstanceId());
+    }
+    TestUtils.waitForCondition(
+        aVoid -> _failureDetector.getUnhealthyServers().equals(expected) && 
unhealthyServerGauge() == expected.size(),
+        10_000L, "Unhealthy servers did not become: " + expected);
+  }
+
+  private static int unhealthyServerGauge() {
+    return (int) MetricValueUtils.getGlobalGaugeValue(BrokerMetrics.get(), 
BrokerGauge.UNHEALTHY_SERVERS);
+  }
+
+  /// Waits until the check of `server` after a timeout has finished, not 
merely started, given the count of its
+  /// lookups. A server is checked again only once its previous check is over, 
so a second check proves the first
+  /// finished. Stops early if the server got marked unhealthy, which ends its 
checks, and leaves that to the caller's
+  /// assertions.
+  private void awaitCheckFinished(ServerInstance server, AtomicInteger 
numLookups) {
+    TestUtils.waitForCondition(aVoid -> {
+      _failureDetector.notifyServerNotResponded(server.getInstanceId(), 
server.getHostname());
+      return numLookups.get() >= 2 || 
!_failureDetector.getUnhealthyServers().isEmpty();
+    }, 10_000L, "Check did not finish");
+  }
+
+  /// Counts lookups of enabled servers. The handler looks a server up once 
per check or retry, so this counts those.
+  private AtomicInteger countServerLookups() {
+    AtomicInteger numLookups = new AtomicInteger();
+    when(_routingManager.getEnabledServerInstanceMap()).thenAnswer(invocation 
-> {
+      numLookups.incrementAndGet();
+      return _enabledServers;
+    });
+    return numLookups;
+  }
+
+  // 
---------------------------------------------------------------------------------------------------------------
+  // Tests
+  // 
---------------------------------------------------------------------------------------------------------------
+
+  /// THE INCIDENT. A query to a server whose node is gone times out; the 
broker pings the server, the ping goes
+  /// unanswered too, and the server is taken out of routing -- about one 
query timeout plus one ping timeout after the
+  /// first failure, instead of minutes later.
+  ///
+  /// Covered for every table type: the broker keeps a separate channel per 
table type, and the incident's tables were
+  /// REALTIME. Checking only the OFFLINE channel would never have pinged this 
server at all.
+  @Test(dataProvider = "tableTypes")
+  public void testServerWhoseNodeIsGoneIsTakenOutOfRouting(boolean offline, 
boolean realtime)
+      throws Exception {
+    ServerInstance frozenServer = startFrozenServer();
+
+    long startTimeMs = System.currentTimeMillis();
+    SingleConnectionBrokerRequestHandler.ScatterResult result = 
scatter(routeTo(offline, realtime, frozenServer));
+    assertTrue(result.isTimedOut());
+    awaitUnhealthyServers(frozenServer);
+    long elapsedMs = System.currentTimeMillis() - startTimeMs;
+
+    assertEquals(_excludedServers, List.of(frozenServer.getInstanceId()));
+    assertTrue(elapsedMs < QUERY_TIMEOUT_MS + PING_TIMEOUT_MS + 3_000L,
+        "Server should be out of routing about one query timeout plus one ping 
timeout in; took " + elapsedMs + "ms");
+  }
+
+  @DataProvider(name = "tableTypes")
+  public Object[][] tableTypes() {
+    return new Object[][]{{true, false}, {false, true}, {true, true}};
+  }
+
+  /// A server whose JVM is alive but whose queries time out -- stuck or 
merely busy -- answers the ping and stays in
+  /// routing. This is the control for the test above: the same timeout and 
the same check, but here the ping is
+  /// answered. It also pins the accepted gap: a live JVM with a stuck query 
engine is not detected.
+  ///
+  /// A retrier that calls every server unhealthy is registered too, as the 
multi-stage one does for an idle gRPC
+  /// channel: only the ping may decide whether a server that left a query 
unanswered leaves routing.
+  @Test
+  public void testServerWhoseQueryEngineIsStuckStaysInRouting()
+      throws Exception {
+    ServerInstance stuckServer = startStuckQueryEngineServer();
+    _failureDetector.registerUnhealthyServerRetrier(instanceId -> 
FailureDetector.ServerState.UNHEALTHY);
+    AtomicInteger numChecks = countServerLookups();
+
+    assertTrue(scatter(offlineRouteTo(stuckServer)).isTimedOut());
+    awaitCheckFinished(stuckServer, numChecks);
+
+    assertEquals(_failureDetector.getUnhealthyServers(), Set.of());
+    assertEquals(_excludedServers, List.of());
+  }
+
+  /// A server outside this broker's routing -- another cluster's, say, or one 
Helix has just disabled -- is left
+  /// alone after a timeout: there is nothing to take out of routing.
+  @Test
+  public void testServerOutsideThisBrokersRoutingIsLeftAlone()
+      throws Exception {
+    ServerInstance frozenServer = startFrozenServer();
+    _enabledServers.remove(frozenServer.getInstanceId());
+    AtomicInteger numChecks = countServerLookups();
+
+    assertTrue(scatter(offlineRouteTo(frozenServer)).isTimedOut());
+    awaitCheckFinished(frozenServer, numChecks);
+
+    assertEquals(_failureDetector.getUnhealthyServers(), Set.of());
+    assertEquals(_excludedServers, List.of());
+  }
+
+  /// A server the broker has no query channel to has nothing to ping over, 
which says nothing about the server, so it
+  /// is left alone too.
+  @Test
+  public void testServerWithoutAQueryChannelIsLeftAlone() {
+    ServerInstance respondingServer = startRespondingServer(0);
+    AtomicInteger numChecks = countServerLookups();
+
+    awaitCheckFinished(respondingServer, numChecks);
+
+    assertEquals(_failureDetector.getUnhealthyServers(), Set.of());
+    assertEquals(_excludedServers, List.of());
+  }
+
+  /// Pings are off by default, which leaves a timeout without consequence, as 
before this change. Old servers queue a
+  /// ping behind their queries, so a busy one could miss it; the feature is 
for clusters whose servers all answer
+  /// pings.
+  @Test
+  public void testTimeoutLeavesServerInRoutingWhenPingsAreOff()
+      throws Exception {
+    stopBroker();
+    startBroker(false, RETRIES_OFF_MS);
+    ServerInstance frozenServer = startFrozenServer();
+    AtomicInteger numLookups = countServerLookups();
+
+    assertTrue(scatter(offlineRouteTo(frozenServer)).isTimedOut());
+    // A check would look the server up within milliseconds; give one far 
longer than that before concluding none ran.
+    Thread.sleep(PING_TIMEOUT_MS + 500L);
+
+    assertEquals(numLookups.get(), 0);
+    assertEquals(_failureDetector.getUnhealthyServers(), Set.of());
+    assertEquals(_excludedServers, List.of());
+  }
+
+  /// 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
+  public void testRetriesConnectWhenPingsAreOff()
+      throws Exception {
+    stopBroker();
+    startBroker(false, FAST_RETRY_DELAY_MS);
+    ServerInstance frozenServer = startFrozenServer();
+    // Opens the channel a retry looks for; without one the retrier has no 
opinion.
+    assertTrue(scatter(offlineRouteTo(frozenServer)).isTimedOut());
+
+    _failureDetector.markServerUnhealthy(frozenServer.getInstanceId(), 
frozenServer.getHostname());
+    awaitUnhealthyServers();
+    assertEquals(_reincludedServers, List.of(frozenServer.getInstanceId()));
+  }
+
+  /// Several servers dying together are all taken out of routing, while their 
healthy peer is left alone.
+  @Test
+  public void testEveryFrozenServerIsTakenOutOfRouting()
+      throws Exception {
+    ServerInstance frozenServer1 = startFrozenServer();
+    ServerInstance frozenServer2 = startFrozenServer();
+    ServerInstance respondingServer = startRespondingServer(0);
+
+    SingleConnectionBrokerRequestHandler.ScatterResult result =
+        scatter(offlineRouteTo(frozenServer1, frozenServer2, 
respondingServer));
+    assertTrue(result.isTimedOut());
+    assertEquals(result.getServersNotResponded().size(), 2);
+    awaitUnhealthyServers(frozenServer1, frozenServer2);
+  }
+
+  /// A server that keeps failing its pings stays out of routing, retry after 
retry. Each retry reconnects -- the
+  /// previous channel was closed when its ping failed -- and pings over the 
new channel.
+  @Test
+  public void testFrozenServerStaysOutOfRoutingWhileItKeepsFailingPings()
+      throws Exception {
+    stopBroker();
+    startBroker(true, FAST_RETRY_DELAY_MS);
+    ServerInstance frozenServer = startFrozenServer();
+    AtomicInteger numChecks = countServerLookups();
+
+    scatter(offlineRouteTo(frozenServer));
+    awaitUnhealthyServers(frozenServer);
+    // The first check marked it unhealthy; wait for retries on top of it, so 
this cannot pass vacuously.
+    TestUtils.waitForCondition(aVoid -> numChecks.get() >= 4, 10_000L, "Server 
was not retried");
+
+    assertEquals(_failureDetector.getUnhealthyServers(), 
Set.of(frozenServer.getInstanceId()));
+    assertEquals(_reincludedServers, List.of());
+  }
+
+  /// A server that comes back -- after a long GC pause, or a restart in place 
-- is put back into routing by the
+  /// retrier once it answers a ping again. Its old channel was closed when 
the first ping failed, so the retry has to
+  /// reconnect to reach it.
+  @Test
+  public void testServerIsPutBackOnceItAnswersAgain()
+      throws Exception {
+    stopBroker();
+    startBroker(true, FAST_RETRY_DELAY_MS);
+    ServerInstance frozenServer = startFrozenServer();
+    int port = frozenServer.getPort();
+
+    scatter(offlineRouteTo(frozenServer));
+    awaitUnhealthyServers(frozenServer);
+
+    // The server recovers on the same port.
+    _frozenServers.remove(0).close();
+    startRespondingServer(port);
+
+    awaitUnhealthyServers();
+    assertEquals(_reincludedServers, List.of(frozenServer.getInstanceId()));
+  }
+}
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/failuredetector/BaseExponentialBackoffRetryFailureDetector.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/failuredetector/BaseExponentialBackoffRetryFailureDetector.java
index 90b6dcf33c0..a6d2245eb77 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/failuredetector/BaseExponentialBackoffRetryFailureDetector.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/failuredetector/BaseExponentialBackoffRetryFailureDetector.java
@@ -18,13 +18,17 @@
  */
 package org.apache.pinot.common.failuredetector;
 
-import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.DelayQueue;
 import java.util.concurrent.Delayed;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.Consumer;
 import java.util.function.Function;
 import javax.annotation.Nullable;
@@ -39,15 +43,26 @@ import org.slf4j.LoggerFactory;
 
 /// The `BaseExponentialBackoffRetryFailureDetector` is a base failure 
detector implementation that retries the
 /// unhealthy servers with exponential increasing delays.
+///
+/// Every check of a server -- a retry, or a check after a query to it timed 
out -- runs on a small pool rather than on
+/// the retry thread, because a check can wait out a ping timeout and one dead 
server must not hold up the checks of the
+/// others. A retry runs the registered retriers; a check after a timeout runs 
the registered checkers only.
 @ThreadSafe
 public abstract class BaseExponentialBackoffRetryFailureDetector implements 
FailureDetector {
   private static final Logger LOGGER = 
LoggerFactory.getLogger(BaseExponentialBackoffRetryFailureDetector.class);
+  private static final int MAX_CONCURRENT_CHECKS = 16;
 
   protected final String _name = getClass().getSimpleName();
   protected final ConcurrentHashMap<String, RetryInfo> 
_unhealthyServerRetryInfoMap = new ConcurrentHashMap<>();
   protected final DelayQueue<RetryInfo> _retryInfoDelayQueue = new 
DelayQueue<>();
+  /// Servers with a check after a timeout in flight, so a burst of timeouts 
against one server checks it once.
+  protected final Set<String> _serversBeingChecked = 
ConcurrentHashMap.newKeySet();
+  /// Both iterated concurrently by the checks.
+  protected final List<Function<String, ServerState>> _unhealthyServerRetriers 
= new CopyOnWriteArrayList<>();
+  protected final List<Function<String, ServerState>> 
_serverNotRespondedCheckers = new CopyOnWriteArrayList<>();
+  protected final ThreadPoolExecutor _checkExecutor = createCheckExecutor();
+  private final Object _unhealthyServerGaugeLock = new Object();
 
-  protected final List<Function<String, ServerState>> _unhealthyServerRetriers 
= new ArrayList<>();
   protected Consumer<String> _healthyServerNotifier;
   protected Consumer<String> _unhealthyServerNotifier;
   protected BrokerMetrics _brokerMetrics;
@@ -77,6 +92,11 @@ public abstract class 
BaseExponentialBackoffRetryFailureDetector implements Fail
     _unhealthyServerRetriers.add(unhealthyServerRetrier);
   }
 
+  @Override
+  public void registerServerNotRespondedChecker(Function<String, ServerState> 
serverNotRespondedChecker) {
+    _serverNotRespondedCheckers.add(serverNotRespondedChecker);
+  }
+
   @Override
   public void registerHealthyServerNotifier(Consumer<String> 
healthyServerNotifier) {
     _healthyServerNotifier = healthyServerNotifier;
@@ -107,24 +127,7 @@ public abstract class 
BaseExponentialBackoffRetryFailureDetector implements Fail
             markServerHealthy(instanceId, retryInfo._hostName);
             continue;
           }
-          LOGGER.info("Retry unhealthy server: {}", instanceId);
-          boolean recovered = true;
-          for (Function<String, ServerState> unhealthyServerRetrier : 
_unhealthyServerRetriers) {
-            ServerState serverState = unhealthyServerRetrier.apply(instanceId);
-            if (serverState == ServerState.UNHEALTHY) {
-              recovered = false;
-              break;
-            }
-          }
-          if (recovered) {
-            markServerHealthy(instanceId, retryInfo._hostName);
-          } else {
-            // Update the retry info and add it back to the delay queue
-            retryInfo._retryDelayNs = (long) (retryInfo._retryDelayNs * 
_retryDelayFactor);
-            retryInfo._retryTimeNs = System.nanoTime() + 
retryInfo._retryDelayNs;
-            retryInfo._numRetries++;
-            _retryInfoDelayQueue.offer(retryInfo);
-          }
+          _checkExecutor.execute(() -> retry(retryInfo));
         } catch (Exception e) {
           if (_running) {
             LOGGER.error("Caught exception in the retry thread, continuing 
with errors", e);
@@ -137,26 +140,96 @@ public abstract class 
BaseExponentialBackoffRetryFailureDetector implements Fail
     _retryThread.start();
   }
 
+  /// Retries one unhealthy server: marks it healthy if it checks out, 
otherwise requeues it with a longer delay. A
+  /// retrier that throws counts as a failed check, so the server is retried 
again rather than stranded as unhealthy.
+  private void retry(RetryInfo retryInfo) {
+    String instanceId = retryInfo._instanceId;
+    LOGGER.info("Retry unhealthy server: {}", instanceId);
+    boolean recovered = false;
+    try {
+      recovered = !isReportedUnhealthy(instanceId, _unhealthyServerRetriers);
+    } catch (Exception e) {
+      LOGGER.error("Caught exception while retrying unhealthy server: {}, 
retrying again later", instanceId, e);
+    }
+    if (recovered) {
+      markServerHealthy(instanceId, retryInfo._hostName);
+    } else {
+      retryInfo._retryDelayNs = (long) (retryInfo._retryDelayNs * 
_retryDelayFactor);
+      retryInfo._retryTimeNs = System.nanoTime() + retryInfo._retryDelayNs;
+      retryInfo._numRetries++;
+      _retryInfoDelayQueue.offer(retryInfo);
+    }
+  }
+
+  /// Returns whether any of the given retriers or checkers reports the server 
unhealthy.
+  private static boolean isReportedUnhealthy(String instanceId, 
List<Function<String, ServerState>> checks) {
+    for (Function<String, ServerState> check : checks) {
+      if (check.apply(instanceId) == ServerState.UNHEALTHY) {
+        return true;
+      }
+    }
+    return false;
+  }
+
   @Override
   public void markServerHealthy(String instanceId, @Nullable String hostName) {
     _unhealthyServerRetryInfoMap.computeIfPresent(instanceId, (id, retryInfo) 
-> {
       LOGGER.info("Mark server: {} {} as healthy", instanceId, hostName);
-      _brokerMetrics.setValueOfGlobalGauge(BrokerGauge.UNHEALTHY_SERVERS, 
_unhealthyServerRetryInfoMap.size() - 1);
       _healthyServerNotifier.accept(instanceId);
       return null;
     });
+    updateUnhealthyServerGauge();
   }
 
   @Override
   public void markServerUnhealthy(String instanceId, @Nullable String 
hostName) {
     _unhealthyServerRetryInfoMap.computeIfAbsent(instanceId, id -> {
       LOGGER.warn("Mark server: {} {} as unhealthy", instanceId, hostName);
-      _brokerMetrics.setValueOfGlobalGauge(BrokerGauge.UNHEALTHY_SERVERS, 
_unhealthyServerRetryInfoMap.size() + 1);
       _unhealthyServerNotifier.accept(instanceId);
       RetryInfo retryInfo = new RetryInfo(id, hostName);
       _retryInfoDelayQueue.offer(retryInfo);
       return retryInfo;
     });
+    updateUnhealthyServerGauge();
+  }
+
+  /// Sets the gauge from the map's size, rather than adjusting it by one 
inside the compute: two servers changing state
+  /// at once would both read the same size there and leave the gauge off by 
one. Under the lock the last writer reads
+  /// a size that already includes every change made before it.
+  private void updateUnhealthyServerGauge() {
+    synchronized (_unhealthyServerGaugeLock) {
+      _brokerMetrics.setValueOfGlobalGauge(BrokerGauge.UNHEALTHY_SERVERS, 
_unhealthyServerRetryInfoMap.size());
+    }
+  }
+
+  /// {@inheritDoc}
+  ///
+  /// Checks the server with the registered checkers, off the caller's thread, 
and marks it unhealthy if any of them
+  /// reports it unhealthy. A server already unhealthy, or already being 
checked, is left alone.
+  @Override
+  public void notifyServerNotResponded(String instanceId, @Nullable String 
hostName) {
+    if (!_running || _serverNotRespondedCheckers.isEmpty() || 
_unhealthyServerRetryInfoMap.containsKey(instanceId)
+        || !_serversBeingChecked.add(instanceId)) {
+      return;
+    }
+    try {
+      _checkExecutor.execute(() -> {
+        try {
+          // Skip marking once stopping: shutdownNow() interrupts the check, 
which then looks like a failed one.
+          if (isReportedUnhealthy(instanceId, _serverNotRespondedCheckers) && 
_running) {
+            LOGGER.warn("Server: {} {} left a query unanswered and failed the 
follow-up check", instanceId, hostName);
+            markServerUnhealthy(instanceId, hostName);
+          }
+        } catch (Exception e) {
+          LOGGER.error("Caught exception while checking server: {} after a 
query timed out", instanceId, e);
+        } finally {
+          _serversBeingChecked.remove(instanceId);
+        }
+      });
+    } catch (RejectedExecutionException e) {
+      // Only when stopping.
+      _serversBeingChecked.remove(instanceId);
+    }
   }
 
   @Override
@@ -168,6 +241,7 @@ public abstract class 
BaseExponentialBackoffRetryFailureDetector implements Fail
   public void stop() {
     LOGGER.info("Stopping {}", _name);
     _running = false;
+    _checkExecutor.shutdownNow();
 
     try {
       _retryThread.interrupt();
@@ -177,6 +251,20 @@ public abstract class 
BaseExponentialBackoffRetryFailureDetector implements Fail
     }
   }
 
+  /// Bounded, with its threads created on demand and reclaimed when idle, so 
an idle broker holds none.
+  private static ThreadPoolExecutor createCheckExecutor() {
+    AtomicInteger threadIndex = new AtomicInteger();
+    ThreadPoolExecutor executor =
+        new ThreadPoolExecutor(MAX_CONCURRENT_CHECKS, MAX_CONCURRENT_CHECKS, 
60L, TimeUnit.SECONDS,
+            new LinkedBlockingQueue<>(), runnable -> {
+          Thread thread = new Thread(runnable, "failure-detector-check-" + 
threadIndex.getAndIncrement());
+          thread.setDaemon(true);
+          return thread;
+        });
+    executor.allowCoreThreadTimeOut(true);
+    return executor;
+  }
+
   /// Encapsulates the retry related information.
   protected class RetryInfo implements Delayed {
     final String _instanceId;
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/failuredetector/ConnectionFailureDetector.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/failuredetector/ConnectionFailureDetector.java
index 7daeaf45324..4f046f4324d 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/failuredetector/ConnectionFailureDetector.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/failuredetector/ConnectionFailureDetector.java
@@ -21,8 +21,13 @@ package org.apache.pinot.common.failuredetector;
 import javax.annotation.concurrent.ThreadSafe;
 
 
-/// The `ConnectionFailureDetector` marks failed server (connection failure) 
from query response as unhealthy, and
-/// retries the unhealthy servers with exponential increasing delays.
+/// The `ConnectionFailureDetector` marks a server as unhealthy when a query 
response reports it as failed
+/// (connection failure), or, with pings enabled, when a query to it times out 
and the server then fails to answer a
+/// ping (see [FailureDetector#notifyServerNotResponded]). It retries the 
unhealthy servers with exponentially
+/// increasing delays.
+///
+/// Only the single-stage Netty query path reports timeouts today; the gRPC 
and multi-stage paths report connection
+/// failures only.
 ///
 /// This class doesn't currently implement any additional logic over 
BaseExponentialBackoffRetryFailureDetector and is
 /// retained for backward compatibility.
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/failuredetector/FailureDetector.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/failuredetector/FailureDetector.java
index bc230c94acd..2344e685978 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/failuredetector/FailureDetector.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/failuredetector/FailureDetector.java
@@ -46,6 +46,16 @@ public interface FailureDetector {
   /// does not know about this server.
   void registerUnhealthyServerRetrier(Function<String, ServerState> 
unhealthyServerRetrier);
 
+  /// Registers a function that checks a server which left a query unanswered 
(see [#notifyServerNotResponded]). It is
+  /// called with the instanceId of the server and should return 
[ServerState#UNHEALTHY] only if the server is not
+  /// answering at all, and [ServerState#HEALTHY] or [ServerState#UNKNOWN] 
otherwise.
+  ///
+  /// Kept apart from the retriers on purpose. A retrier answers whether an 
unhealthy server has recovered, and may err
+  /// towards keeping it out -- the multi-stage one reports an idle gRPC 
channel as unhealthy. A checker decides whether
+  /// a server that is in routing comes out, so it must only report a server 
that verifiably failed to answer.
+  default void registerServerNotRespondedChecker(Function<String, ServerState> 
serverNotRespondedChecker) {
+  }
+
   /// Registers a consumer that will be called with the instanceId of a server 
that is detected as healthy.
   void registerHealthyServerNotifier(Consumer<String> healthyServerNotifier);
 
@@ -73,6 +83,19 @@ public interface FailureDetector {
   /// Marks a server as unhealthy.
   void markServerUnhealthy(String instanceId, @Nullable String hostName);
 
+  /// Notifies the detector that a server left a query unanswered until the 
query deadline, without its connection
+  /// ever breaking.
+  ///
+  /// A server whose node is gone, whose network path is blackholed, or whose 
JVM is frozen produces no other signal.
+  /// Writing into an open socket succeeds even when the peer is gone, so 
there is no send exception, the channel is
+  /// never torn down, and connection-failure detection cannot see it. The 
query simply times out.
+  ///
+  /// A timeout alone cannot tell such a server from one that is merely busy, 
so implementations must check the server
+  /// with the registered checkers (see [#registerServerNotRespondedChecker]) 
before acting on it, and do nothing when
+  /// none is registered.
+  default void notifyServerNotResponded(String instanceId, @Nullable String 
hostName) {
+  }
+
   /// Returns all the unhealthy servers.
   Set<String> getUnhealthyServers();
 
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/request/InstanceRequest.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/request/InstanceRequest.java
index 79ac876ba11..e673825ed16 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/request/InstanceRequest.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/request/InstanceRequest.java
@@ -37,6 +37,7 @@ public class InstanceRequest implements 
org.apache.thrift.TBase<InstanceRequest,
   private static final org.apache.thrift.protocol.TField 
OPTIONAL_SEGMENTS_FIELD_DESC = new 
org.apache.thrift.protocol.TField("optionalSegments", 
org.apache.thrift.protocol.TType.LIST, (short)6);
   private static final org.apache.thrift.protocol.TField CID_FIELD_DESC = new 
org.apache.thrift.protocol.TField("cid", 
org.apache.thrift.protocol.TType.STRING, (short)7);
   private static final org.apache.thrift.protocol.TField 
TABLE_SEGMENTS_INFO_LIST_FIELD_DESC = new 
org.apache.thrift.protocol.TField("tableSegmentsInfoList", 
org.apache.thrift.protocol.TType.LIST, (short)8);
+  private static final org.apache.thrift.protocol.TField PING_FIELD_DESC = new 
org.apache.thrift.protocol.TField("ping", 
org.apache.thrift.protocol.TType.BOOL, (short)9);
 
   private static final org.apache.thrift.scheme.SchemeFactory 
STANDARD_SCHEME_FACTORY = new InstanceRequestStandardSchemeFactory();
   private static final org.apache.thrift.scheme.SchemeFactory 
TUPLE_SCHEME_FACTORY = new InstanceRequestTupleSchemeFactory();
@@ -49,6 +50,7 @@ public class InstanceRequest implements 
org.apache.thrift.TBase<InstanceRequest,
   private @org.apache.thrift.annotation.Nullable 
java.util.List<java.lang.String> optionalSegments; // optional
   private @org.apache.thrift.annotation.Nullable java.lang.String cid; // 
optional
   private @org.apache.thrift.annotation.Nullable 
java.util.List<TableSegmentsInfo> tableSegmentsInfoList; // optional
+  private boolean ping; // optional
 
   /** The set of fields this struct contains, along with convenience methods 
for finding and manipulating them. */
   public enum _Fields implements org.apache.thrift.TFieldIdEnum {
@@ -59,7 +61,8 @@ public class InstanceRequest implements 
org.apache.thrift.TBase<InstanceRequest,
     BROKER_ID((short)5, "brokerId"),
     OPTIONAL_SEGMENTS((short)6, "optionalSegments"),
     CID((short)7, "cid"),
-    TABLE_SEGMENTS_INFO_LIST((short)8, "tableSegmentsInfoList");
+    TABLE_SEGMENTS_INFO_LIST((short)8, "tableSegmentsInfoList"),
+    PING((short)9, "ping");
 
     private static final java.util.Map<java.lang.String, _Fields> byName = new 
java.util.HashMap<java.lang.String, _Fields>();
 
@@ -91,6 +94,8 @@ public class InstanceRequest implements 
org.apache.thrift.TBase<InstanceRequest,
           return CID;
         case 8: // TABLE_SEGMENTS_INFO_LIST
           return TABLE_SEGMENTS_INFO_LIST;
+        case 9: // PING
+          return PING;
         default:
           return null;
       }
@@ -136,8 +141,9 @@ public class InstanceRequest implements 
org.apache.thrift.TBase<InstanceRequest,
   // isset id assignments
   private static final int __REQUESTID_ISSET_ID = 0;
   private static final int __ENABLETRACE_ISSET_ID = 1;
+  private static final int __PING_ISSET_ID = 2;
   private byte __isset_bitfield = 0;
-  private static final _Fields optionals[] = 
{_Fields.SEARCH_SEGMENTS,_Fields.ENABLE_TRACE,_Fields.BROKER_ID,_Fields.OPTIONAL_SEGMENTS,_Fields.CID,_Fields.TABLE_SEGMENTS_INFO_LIST};
+  private static final _Fields optionals[] = 
{_Fields.SEARCH_SEGMENTS,_Fields.ENABLE_TRACE,_Fields.BROKER_ID,_Fields.OPTIONAL_SEGMENTS,_Fields.CID,_Fields.TABLE_SEGMENTS_INFO_LIST,_Fields.PING};
   public static final java.util.Map<_Fields, 
org.apache.thrift.meta_data.FieldMetaData> metaDataMap;
   static {
     java.util.Map<_Fields, org.apache.thrift.meta_data.FieldMetaData> tmpMap = 
new java.util.EnumMap<_Fields, 
org.apache.thrift.meta_data.FieldMetaData>(_Fields.class);
@@ -160,6 +166,8 @@ public class InstanceRequest implements 
org.apache.thrift.TBase<InstanceRequest,
     tmpMap.put(_Fields.TABLE_SEGMENTS_INFO_LIST, new 
org.apache.thrift.meta_data.FieldMetaData("tableSegmentsInfoList", 
org.apache.thrift.TFieldRequirementType.OPTIONAL, 
         new 
org.apache.thrift.meta_data.ListMetaData(org.apache.thrift.protocol.TType.LIST, 
             new 
org.apache.thrift.meta_data.StructMetaData(org.apache.thrift.protocol.TType.STRUCT,
 TableSegmentsInfo.class))));
+    tmpMap.put(_Fields.PING, new 
org.apache.thrift.meta_data.FieldMetaData("ping", 
org.apache.thrift.TFieldRequirementType.OPTIONAL, 
+        new 
org.apache.thrift.meta_data.FieldValueMetaData(org.apache.thrift.protocol.TType.BOOL)));
     metaDataMap = java.util.Collections.unmodifiableMap(tmpMap);
     
org.apache.thrift.meta_data.FieldMetaData.addStructMetaDataMap(InstanceRequest.class,
 metaDataMap);
   }
@@ -208,6 +216,7 @@ public class InstanceRequest implements 
org.apache.thrift.TBase<InstanceRequest,
       }
       this.tableSegmentsInfoList = __this__tableSegmentsInfoList;
     }
+    this.ping = other.ping;
   }
 
   @Override
@@ -227,6 +236,8 @@ public class InstanceRequest implements 
org.apache.thrift.TBase<InstanceRequest,
     this.optionalSegments = null;
     this.cid = null;
     this.tableSegmentsInfoList = null;
+    setPingIsSet(false);
+    this.ping = false;
   }
 
   public long getRequestId() {
@@ -465,6 +476,28 @@ public class InstanceRequest implements 
org.apache.thrift.TBase<InstanceRequest,
     }
   }
 
+  public boolean isPing() {
+    return this.ping;
+  }
+
+  public void setPing(boolean ping) {
+    this.ping = ping;
+    setPingIsSet(true);
+  }
+
+  public void unsetPing() {
+    __isset_bitfield = 
org.apache.thrift.EncodingUtils.clearBit(__isset_bitfield, __PING_ISSET_ID);
+  }
+
+  /** Returns true if field ping is set (has been assigned a value) and false 
otherwise */
+  public boolean isSetPing() {
+    return org.apache.thrift.EncodingUtils.testBit(__isset_bitfield, 
__PING_ISSET_ID);
+  }
+
+  public void setPingIsSet(boolean value) {
+    __isset_bitfield = 
org.apache.thrift.EncodingUtils.setBit(__isset_bitfield, __PING_ISSET_ID, 
value);
+  }
+
   @Override
   public void setFieldValue(_Fields field, 
@org.apache.thrift.annotation.Nullable java.lang.Object value) {
     switch (field) {
@@ -532,6 +565,14 @@ public class InstanceRequest implements 
org.apache.thrift.TBase<InstanceRequest,
       }
       break;
 
+    case PING:
+      if (value == null) {
+        unsetPing();
+      } else {
+        setPing((java.lang.Boolean)value);
+      }
+      break;
+
     }
   }
 
@@ -563,6 +604,9 @@ public class InstanceRequest implements 
org.apache.thrift.TBase<InstanceRequest,
     case TABLE_SEGMENTS_INFO_LIST:
       return getTableSegmentsInfoList();
 
+    case PING:
+      return isPing();
+
     }
     throw new java.lang.IllegalStateException();
   }
@@ -591,6 +635,8 @@ public class InstanceRequest implements 
org.apache.thrift.TBase<InstanceRequest,
       return isSetCid();
     case TABLE_SEGMENTS_INFO_LIST:
       return isSetTableSegmentsInfoList();
+    case PING:
+      return isSetPing();
     }
     throw new java.lang.IllegalStateException();
   }
@@ -680,6 +726,15 @@ public class InstanceRequest implements 
org.apache.thrift.TBase<InstanceRequest,
         return false;
     }
 
+    boolean this_present_ping = true && this.isSetPing();
+    boolean that_present_ping = true && that.isSetPing();
+    if (this_present_ping || that_present_ping) {
+      if (!(this_present_ping && that_present_ping))
+        return false;
+      if (this.ping != that.ping)
+        return false;
+    }
+
     return true;
   }
 
@@ -717,6 +772,10 @@ public class InstanceRequest implements 
org.apache.thrift.TBase<InstanceRequest,
     if (isSetTableSegmentsInfoList())
       hashCode = hashCode * 8191 + tableSegmentsInfoList.hashCode();
 
+    hashCode = hashCode * 8191 + ((isSetPing()) ? 131071 : 524287);
+    if (isSetPing())
+      hashCode = hashCode * 8191 + ((ping) ? 131071 : 524287);
+
     return hashCode;
   }
 
@@ -808,6 +867,16 @@ public class InstanceRequest implements 
org.apache.thrift.TBase<InstanceRequest,
         return lastComparison;
       }
     }
+    lastComparison = java.lang.Boolean.compare(isSetPing(), other.isSetPing());
+    if (lastComparison != 0) {
+      return lastComparison;
+    }
+    if (isSetPing()) {
+      lastComparison = org.apache.thrift.TBaseHelper.compareTo(this.ping, 
other.ping);
+      if (lastComparison != 0) {
+        return lastComparison;
+      }
+    }
     return 0;
   }
 
@@ -899,6 +968,12 @@ public class InstanceRequest implements 
org.apache.thrift.TBase<InstanceRequest,
       }
       first = false;
     }
+    if (isSetPing()) {
+      if (!first) sb.append(", ");
+      sb.append("ping:");
+      sb.append(this.ping);
+      first = false;
+    }
     sb.append(")");
     return sb.toString();
   }
@@ -1053,6 +1128,14 @@ public class InstanceRequest implements 
org.apache.thrift.TBase<InstanceRequest,
               org.apache.thrift.protocol.TProtocolUtil.skip(iprot, 
schemeField.type);
             }
             break;
+          case 9: // PING
+            if (schemeField.type == org.apache.thrift.protocol.TType.BOOL) {
+              struct.ping = iprot.readBool();
+              struct.setPingIsSet(true);
+            } else { 
+              org.apache.thrift.protocol.TProtocolUtil.skip(iprot, 
schemeField.type);
+            }
+            break;
           default:
             org.apache.thrift.protocol.TProtocolUtil.skip(iprot, 
schemeField.type);
         }
@@ -1136,6 +1219,11 @@ public class InstanceRequest implements 
org.apache.thrift.TBase<InstanceRequest,
           oprot.writeFieldEnd();
         }
       }
+      if (struct.isSetPing()) {
+        oprot.writeFieldBegin(PING_FIELD_DESC);
+        oprot.writeBool(struct.ping);
+        oprot.writeFieldEnd();
+      }
       oprot.writeFieldStop();
       oprot.writeStructEnd();
     }
@@ -1175,7 +1263,10 @@ public class InstanceRequest implements 
org.apache.thrift.TBase<InstanceRequest,
       if (struct.isSetTableSegmentsInfoList()) {
         optionals.set(5);
       }
-      oprot.writeBitSet(optionals, 6);
+      if (struct.isSetPing()) {
+        optionals.set(6);
+      }
+      oprot.writeBitSet(optionals, 7);
       if (struct.isSetSearchSegments()) {
         {
           oprot.writeI32(struct.searchSegments.size());
@@ -1212,6 +1303,9 @@ public class InstanceRequest implements 
org.apache.thrift.TBase<InstanceRequest,
           }
         }
       }
+      if (struct.isSetPing()) {
+        oprot.writeBool(struct.ping);
+      }
     }
 
     @Override
@@ -1222,7 +1316,7 @@ public class InstanceRequest implements 
org.apache.thrift.TBase<InstanceRequest,
       struct.query = new BrokerRequest();
       struct.query.read(iprot);
       struct.setQueryIsSet(true);
-      java.util.BitSet incoming = iprot.readBitSet(6);
+      java.util.BitSet incoming = iprot.readBitSet(7);
       if (incoming.get(0)) {
         {
           org.apache.thrift.protocol.TList _list31 = 
iprot.readListBegin(org.apache.thrift.protocol.TType.STRING);
@@ -1275,6 +1369,10 @@ public class InstanceRequest implements 
org.apache.thrift.TBase<InstanceRequest,
         }
         struct.setTableSegmentsInfoListIsSet(true);
       }
+      if (incoming.get(6)) {
+        struct.ping = iprot.readBool();
+        struct.setPingIsSet(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 927cee215f5..45ab8ea1212 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
@@ -18,9 +18,20 @@
  */
 package org.apache.pinot.common.failuredetector;
 
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.Consumer;
 import java.util.function.Function;
+import javax.annotation.Nullable;
+import org.apache.pinot.common.failuredetector.FailureDetector.ServerState;
 import org.apache.pinot.common.metrics.BrokerGauge;
 import org.apache.pinot.common.metrics.BrokerMetrics;
 import org.apache.pinot.common.metrics.MetricValueUtils;
@@ -28,7 +39,7 @@ import org.apache.pinot.spi.env.PinotConfiguration;
 import org.apache.pinot.spi.metrics.NoopPinotMetricsRegistry;
 import org.apache.pinot.spi.utils.CommonConstants.Broker;
 import org.apache.pinot.util.TestUtils;
-import org.testng.annotations.AfterClass;
+import org.testng.annotations.AfterMethod;
 import org.testng.annotations.BeforeMethod;
 import org.testng.annotations.Test;
 
@@ -40,6 +51,13 @@ public class ConnectionFailureDetectorTest {
   private static final String INSTANCE_ID = "Server_localhost_1234";
   private static final String HOST_NAME = "localhost";
 
+  private static final String OTHER_INSTANCE_ID = "Server_localhost_5678";
+
+  /// Long enough that the retry loop never runs during a test that is not 
about it.
+  private static final long RETRIES_OFF_MS = 600_000L;
+
+  private final List<FailureDetector> _startedFailureDetectors = new 
ArrayList<>();
+  private final List<String> _markedUnhealthy = new CopyOnWriteArrayList<>();
   private BrokerMetrics _brokerMetrics;
   private FailureDetector _failureDetector;
   private UnhealthyServerRetrier _unhealthyServerRetrier;
@@ -48,6 +66,7 @@ public class ConnectionFailureDetectorTest {
 
   @BeforeMethod
   public void setUp() {
+    _markedUnhealthy.clear();
     PinotConfiguration config = new PinotConfiguration();
     config.setProperty(Broker.FailureDetector.CONFIG_OF_TYPE, 
Broker.FailureDetector.Type.CONNECTION.name());
     
config.setProperty(Broker.FailureDetector.CONFIG_OF_RETRY_INITIAL_DELAY_MS, 
100);
@@ -177,6 +196,263 @@ public class ConnectionFailureDetectorTest {
     assertEquals(_unhealthyServerRetrier._retryUnhealthyServerCalled, 8);
   }
 
+  /// A server that leaves a query unanswered and then fails the follow-up 
check is marked unhealthy.
+  @Test
+  public void testServerFailingTheTimeoutCheckIsMarkedUnhealthy() {
+    FailureDetector failureDetector = 
startFailureDetectorWithChecker(instanceId -> ServerState.UNHEALTHY);
+    failureDetector.notifyServerNotResponded(INSTANCE_ID, HOST_NAME);
+
+    TestUtils.waitForCondition(aVoid -> 
failureDetector.getUnhealthyServers().contains(INSTANCE_ID), 5_000L,
+        "Server failing the check was not marked unhealthy");
+    assertEquals(_markedUnhealthy, List.of(INSTANCE_ID));
+  }
+
+  /// A server that leaves a query unanswered but passes the check -- busy, 
not dead -- is left alone.
+  @Test
+  public void testServerPassingTheTimeoutCheckIsLeftAlone() {
+    AtomicInteger numChecks = new AtomicInteger();
+    FailureDetector failureDetector = 
startFailureDetectorWithChecker(instanceId -> {
+      numChecks.incrementAndGet();
+      return ServerState.HEALTHY;
+    });
+    failureDetector.notifyServerNotResponded(INSTANCE_ID, HOST_NAME);
+
+    awaitCheckFinished(failureDetector, INSTANCE_ID, numChecks);
+    assertEquals(failureDetector.getUnhealthyServers(), Set.of());
+    assertEquals(_markedUnhealthy, List.of());
+  }
+
+  /// A check after a timeout runs the checkers only, never the retriers. A 
retrier may report a live server unhealthy
+  /// -- the multi-stage one does for an idle gRPC channel -- which must not 
take a server that answered out of routing.
+  @Test
+  public void testTimeoutCheckIgnoresRetriers() {
+    AtomicInteger numChecks = new AtomicInteger();
+    AtomicInteger numRetries = new AtomicInteger();
+    FailureDetector failureDetector = startFailureDetector(new 
BrokerMetrics(new NoopPinotMetricsRegistry()),
+        RETRIES_OFF_MS, instanceId -> {
+          numRetries.incrementAndGet();
+          return ServerState.UNHEALTHY;
+        }, instanceId -> {
+          numChecks.incrementAndGet();
+          return ServerState.HEALTHY;
+        });
+    failureDetector.notifyServerNotResponded(INSTANCE_ID, HOST_NAME);
+
+    awaitCheckFinished(failureDetector, INSTANCE_ID, numChecks);
+    assertEquals(failureDetector.getUnhealthyServers(), Set.of());
+    assertEquals(numRetries.get(), 0);
+  }
+
+  /// A check that throws is inconclusive, and an inconclusive check must err 
towards keeping the server.
+  @Test
+  public void testThrowingTimeoutCheckDoesNotMarkServerUnhealthy() {
+    AtomicInteger numChecks = new AtomicInteger();
+    FailureDetector failureDetector = 
startFailureDetectorWithChecker(instanceId -> {
+      numChecks.incrementAndGet();
+      throw new RuntimeException("Check failed to run");
+    });
+    failureDetector.notifyServerNotResponded(INSTANCE_ID, HOST_NAME);
+
+    awaitCheckFinished(failureDetector, INSTANCE_ID, numChecks);
+    assertEquals(failureDetector.getUnhealthyServers(), Set.of());
+  }
+
+  /// A burst of timeouts against one server -- every query in flight to it, 
say -- checks it once.
+  @Test
+  public void testBurstOfTimeoutsChecksServerOnce() {
+    CountDownLatch releaseCheck = new CountDownLatch(1);
+    AtomicInteger numChecks = new AtomicInteger();
+    FailureDetector failureDetector = 
startFailureDetectorWithChecker(instanceId -> {
+      numChecks.incrementAndGet();
+      awaitQuietly(releaseCheck);
+      return ServerState.HEALTHY;
+    });
+    for (int i = 0; i < 100; i++) {
+      failureDetector.notifyServerNotResponded(INSTANCE_ID, HOST_NAME);
+    }
+    TestUtils.waitForCondition(aVoid -> numChecks.get() == 1, 5_000L, "Check 
did not start");
+    for (int i = 0; i < 100; i++) {
+      failureDetector.notifyServerNotResponded(INSTANCE_ID, HOST_NAME);
+    }
+    releaseCheck.countDown();
+    assertEquals(numChecks.get(), 1);
+  }
+
+  /// A server already unhealthy is the retry loop's business; a timeout does 
not check it again.
+  @Test
+  public void testUnhealthyServerIsNotCheckedOnTimeout() {
+    List<String> checkedServers = new CopyOnWriteArrayList<>();
+    FailureDetector failureDetector = 
startFailureDetectorWithChecker(instanceId -> {
+      checkedServers.add(instanceId);
+      return ServerState.HEALTHY;
+    });
+    failureDetector.markServerUnhealthy(INSTANCE_ID, HOST_NAME);
+    failureDetector.notifyServerNotResponded(INSTANCE_ID, HOST_NAME);
+    // The skip happens on the caller's thread, so once another server's check 
has run there is nothing left pending.
+    failureDetector.notifyServerNotResponded(OTHER_INSTANCE_ID, HOST_NAME);
+
+    TestUtils.waitForCondition(aVoid -> 
checkedServers.contains(OTHER_INSTANCE_ID), 5_000L, "Check did not run");
+    assertEquals(checkedServers, List.of(OTHER_INSTANCE_ID));
+  }
+
+  /// With no checker registered -- pings disabled -- a timeout does nothing, 
while connection failures are still
+  /// detected.
+  @Test
+  public void testTimeoutDoesNothingWithoutAChecker()
+      throws InterruptedException {
+    AtomicInteger numRetries = new AtomicInteger();
+    FailureDetector failureDetector =
+        startFailureDetector(new BrokerMetrics(new 
NoopPinotMetricsRegistry()), RETRIES_OFF_MS, instanceId -> {
+          numRetries.incrementAndGet();
+          return ServerState.UNHEALTHY;
+        }, null);
+    failureDetector.notifyServerNotResponded(INSTANCE_ID, HOST_NAME);
+    // A check runs off this thread, within milliseconds; give one far longer 
than that before concluding there is none.
+    Thread.sleep(500L);
+    assertEquals(numRetries.get(), 0);
+    assertEquals(failureDetector.getUnhealthyServers(), Set.of());
+
+    failureDetector.markServerUnhealthy(INSTANCE_ID, HOST_NAME);
+    assertEquals(failureDetector.getUnhealthyServers(), Set.of(INSTANCE_ID));
+  }
+
+  /// Several servers going silent at once are all checked side by side, and 
all marked unhealthy. Each check waits
+  /// here until both have started: run one after the other, the first would 
give up waiting and pass.
+  @Test
+  public void testTimeoutChecksRunInParallel() {
+    CountDownLatch bothStarted = new CountDownLatch(2);
+    FailureDetector failureDetector = 
startFailureDetectorWithChecker(instanceId -> {
+      bothStarted.countDown();
+      return awaitQuietly(bothStarted) ? ServerState.UNHEALTHY : 
ServerState.HEALTHY;
+    });
+    failureDetector.notifyServerNotResponded(INSTANCE_ID, HOST_NAME);
+    failureDetector.notifyServerNotResponded(OTHER_INSTANCE_ID, HOST_NAME);
+
+    TestUtils.waitForCondition(aVoid -> 
failureDetector.getUnhealthyServers().size() == 2, 5_000L,
+        "Checks did not run in parallel");
+    assertEquals(failureDetector.getUnhealthyServers(), Set.of(INSTANCE_ID, 
OTHER_INSTANCE_ID));
+  }
+
+  /// Retries run side by side too, so one dead server waiting out a ping 
timeout cannot delay another's recovery.
+  @Test
+  public void testRetriesRunInParallel() {
+    CountDownLatch bothRetried = new CountDownLatch(2);
+    FailureDetector failureDetector = 
startFailureDetectorWithRetrier(instanceId -> {
+      bothRetried.countDown();
+      return awaitQuietly(bothRetried) ? ServerState.HEALTHY : 
ServerState.UNHEALTHY;
+    });
+    failureDetector.markServerUnhealthy(INSTANCE_ID, HOST_NAME);
+    failureDetector.markServerUnhealthy(OTHER_INSTANCE_ID, HOST_NAME);
+
+    TestUtils.waitForCondition(aVoid -> 
failureDetector.getUnhealthyServers().isEmpty(), 5_000L,
+        "Retries did not run in parallel");
+  }
+
+  /// A retrier that throws counts as a failed retry. The server is retried 
again later rather than stranded unhealthy
+  /// forever, which is what happened when the exception escaped the retry 
loop.
+  @Test
+  public void testThrowingRetryDoesNotStrandServer() {
+    AtomicInteger numRetries = new AtomicInteger();
+    FailureDetector failureDetector = 
startFailureDetectorWithRetrier(instanceId -> {
+      if (numRetries.incrementAndGet() == 1) {
+        throw new RuntimeException("Retry failed to run");
+      }
+      return ServerState.HEALTHY;
+    });
+    failureDetector.markServerUnhealthy(INSTANCE_ID, HOST_NAME);
+
+    TestUtils.waitForCondition(aVoid -> 
failureDetector.getUnhealthyServers().isEmpty(), 5_000L,
+        "Server was stranded unhealthy");
+    assertEquals(numRetries.get(), 2);
+  }
+
+  /// The gauge stays exact when servers change state concurrently, which 
parallel checks make routine. It used to be
+  /// adjusted by one inside each map update, where two concurrent updates 
read the same size. Repeated, since a single
+  /// round only sometimes hits the race.
+  @Test
+  public void testUnhealthyServerGaugeStaysExactUnderConcurrentChanges()
+      throws Exception {
+    BrokerMetrics brokerMetrics = new BrokerMetrics(new 
NoopPinotMetricsRegistry());
+    FailureDetector failureDetector = startFailureDetector(brokerMetrics, 
RETRIES_OFF_MS, null, null);
+    int numServers = 64;
+    ExecutorService executorService = Executors.newFixedThreadPool(16);
+    try {
+      List<Callable<Void>> markUnhealthy = new ArrayList<>();
+      List<Callable<Void>> markHealthy = new ArrayList<>();
+      for (int i = 0; i < numServers; i++) {
+        String instanceId = "Server_host_" + i;
+        markUnhealthy.add(() -> {
+          failureDetector.markServerUnhealthy(instanceId, HOST_NAME);
+          return null;
+        });
+        markHealthy.add(() -> {
+          failureDetector.markServerHealthy(instanceId, HOST_NAME);
+          return null;
+        });
+      }
+      for (int round = 0; round < 20; round++) {
+        executorService.invokeAll(markUnhealthy);
+        assertEquals(MetricValueUtils.getGlobalGaugeValue(brokerMetrics, 
BrokerGauge.UNHEALTHY_SERVERS), numServers);
+        executorService.invokeAll(markHealthy);
+        assertEquals(MetricValueUtils.getGlobalGaugeValue(brokerMetrics, 
BrokerGauge.UNHEALTHY_SERVERS), 0);
+      }
+    } finally {
+      executorService.shutdownNow();
+    }
+  }
+
+  /// Starts a detector that checks servers after a timeout with `checker`, 
and never retries during the test.
+  private FailureDetector startFailureDetectorWithChecker(Function<String, 
ServerState> checker) {
+    return startFailureDetector(new BrokerMetrics(new 
NoopPinotMetricsRegistry()), RETRIES_OFF_MS, null, checker);
+  }
+
+  /// Starts a detector that retries unhealthy servers with `retrier` after 
100 ms, and has no timeout check.
+  private FailureDetector startFailureDetectorWithRetrier(Function<String, 
ServerState> retrier) {
+    return startFailureDetector(new BrokerMetrics(new 
NoopPinotMetricsRegistry()), 100L, retrier, null);
+  }
+
+  /// Starts a detector with the given retrier and timeout checker, each 
optional, recording the servers it marks
+  /// unhealthy. Stopped after the test.
+  private FailureDetector startFailureDetector(BrokerMetrics brokerMetrics, 
long retryInitialDelayMs,
+      @Nullable Function<String, ServerState> retrier, @Nullable 
Function<String, ServerState> checker) {
+    PinotConfiguration config = new PinotConfiguration();
+    config.setProperty(Broker.FailureDetector.CONFIG_OF_TYPE, 
Broker.FailureDetector.Type.CONNECTION.name());
+    
config.setProperty(Broker.FailureDetector.CONFIG_OF_RETRY_INITIAL_DELAY_MS, 
retryInitialDelayMs);
+    config.setProperty(Broker.FailureDetector.CONFIG_OF_RETRY_DELAY_FACTOR, 1);
+    FailureDetector failureDetector = 
FailureDetectorFactory.getFailureDetector(config, brokerMetrics);
+    failureDetector.registerUnhealthyServerNotifier(_markedUnhealthy::add);
+    failureDetector.registerHealthyServerNotifier(instanceId -> {
+    });
+    if (retrier != null) {
+      failureDetector.registerUnhealthyServerRetrier(retrier);
+    }
+    if (checker != null) {
+      failureDetector.registerServerNotRespondedChecker(checker);
+    }
+    failureDetector.start();
+    _startedFailureDetectors.add(failureDetector);
+    return failureDetector;
+  }
+
+  /// Waits until the check of `instanceId` has finished, not merely started. 
A notification only starts a new check
+  /// once the previous one has finished, so a second check proves the first 
is done. Stops early if the server got
+  /// marked unhealthy, which ends its checks, and leaves that to the caller's 
assertions.
+  private static void awaitCheckFinished(FailureDetector failureDetector, 
String instanceId, AtomicInteger numChecks) {
+    TestUtils.waitForCondition(aVoid -> {
+      failureDetector.notifyServerNotResponded(instanceId, HOST_NAME);
+      return numChecks.get() >= 2 || 
failureDetector.getUnhealthyServers().contains(instanceId);
+    }, 5_000L, "Check did not finish");
+  }
+
+  private static boolean awaitQuietly(CountDownLatch latch) {
+    try {
+      return latch.await(10, TimeUnit.SECONDS);
+    } catch (InterruptedException e) {
+      Thread.currentThread().interrupt();
+      return false;
+    }
+  }
+
   private void verify(Set<String> expectedUnhealthyServers, int 
expectedNotifyUnhealthyServerCalled,
       int expectedNotifyHealthyServerCalled) {
     assertEquals(_failureDetector.getUnhealthyServers(), 
expectedUnhealthyServers);
@@ -186,9 +462,13 @@ public class ConnectionFailureDetectorTest {
     assertEquals(_healthyServerNotifier._notifyHealthyServerCalled, 
expectedNotifyHealthyServerCalled);
   }
 
-  @AfterClass
+  @AfterMethod
   public void tearDown() {
     _failureDetector.stop();
+    for (FailureDetector failureDetector : _startedFailureDetectors) {
+      failureDetector.stop();
+    }
+    _startedFailureDetectors.clear();
   }
 
   private static class HealthyServerNotifier implements Consumer<String> {
diff --git a/pinot-common/src/thrift/request.thrift 
b/pinot-common/src/thrift/request.thrift
index c2134677444..ca69b1f9387 100644
--- a/pinot-common/src/thrift/request.thrift
+++ b/pinot-common/src/thrift/request.thrift
@@ -60,4 +60,8 @@ struct InstanceRequest {
   6: optional list<string> optionalSegments;
   7: optional string cid;
   8: optional list<TableSegmentsInfo> tableSegmentsInfoList;
+  // Liveness check sent by the broker after a query times out. A server that 
understands it replies straight away
+  // without running anything; an older server ignores the field and answers 
it as an ordinary query, once the queries
+  // queued ahead of it have run.
+  9: optional bool ping;
 }
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/transport/InstanceRequestHandler.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/transport/InstanceRequestHandler.java
index 0c4e239c626..1e6a1177374 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/transport/InstanceRequestHandler.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/transport/InstanceRequestHandler.java
@@ -133,6 +133,10 @@ public class InstanceRequestHandler extends 
SimpleChannelInboundHandler<ByteBuf>
       
_serverMetrics.addMeteredGlobalValue(ServerMeter.REQUEST_DESERIALIZATION_EXCEPTIONS,
 1);
       return;
     }
+    if (instanceRequest.isPing()) {
+      sendPingResponse(ctx, instanceRequest.getRequestId());
+      return;
+    }
     ServerQueryRequest queryRequest;
     try {
       queryRequest = new ServerQueryRequest(instanceRequest, _serverMetrics, 
queryArrivalTimeMs);
@@ -160,6 +164,21 @@ public class InstanceRequestHandler extends 
SimpleChannelInboundHandler<ByteBuf>
     }
   }
 
+  /// Answers a broker's ping with an empty data table carrying its request 
id, which is all the broker waits for.
+  ///
+  /// The reply is written straight from this event loop and never goes 
through the query scheduler. The broker pings a
+  /// server after a query to it timed out, to ask whether the server is alive 
at all; a server that is merely busy must
+  /// still answer promptly, so the reply cannot wait behind queued queries.
+  private void sendPingResponse(ChannelHandlerContext ctx, long requestId) {
+    try {
+      DataTable dataTable = DataTableBuilderFactory.getEmptyDataTable();
+      dataTable.getMetadata().put(MetadataKey.REQUEST_ID.getName(), 
Long.toString(requestId));
+      ctx.writeAndFlush(Unpooled.wrappedBuffer(dataTable.toBytes()));
+    } catch (Exception e) {
+      LOGGER.warn("Failed to answer ping request: {}", requestId, e);
+    }
+  }
+
   /// Submit query for execution and register callback for execution results.
   /// If query cancellation is enabled, the query future is tracked as well.
   @VisibleForTesting
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 7d58a2a3413..d0c9d3f1c5b 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
@@ -18,9 +18,15 @@
  */
 package org.apache.pinot.core.transport;
 
+import com.google.common.annotations.VisibleForTesting;
+import io.netty.channel.Channel;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicLong;
 import javax.annotation.Nullable;
 import javax.annotation.concurrent.ThreadSafe;
 import org.apache.pinot.common.config.NettyConfig;
@@ -38,6 +44,9 @@ import org.apache.pinot.core.routing.TableRouteInfo;
 import 
org.apache.pinot.core.transport.server.routing.stats.ServerRoutingStatsManager;
 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.builder.TableNameBuilder;
+import org.apache.pinot.sql.parsers.CalciteSqlCompiler;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -50,6 +59,15 @@ import org.slf4j.LoggerFactory;
 public class QueryRouter {
   private static final Logger LOGGER = 
LoggerFactory.getLogger(QueryRouter.class);
 
+  /// The query a ping carries. A server that understands pings never runs it. 
An older server ignores the ping flag
+  /// and runs it like any other query, and naming a table no server hosts 
keeps that cheap: the server answers with a
+  /// table-missing error, and any answer at all is what a ping is after. It 
has to be a real query for that to work,
+  /// because a server that cannot parse a request drops it without replying. 
The older server still queues it behind
+  /// the queries it is running, though, which is why pings stay off until 
every server understands them.
+  private static final String PING_TABLE_NAME = "pinotBrokerPing";
+  private static final BrokerRequest PING_BROKER_REQUEST = 
CalciteSqlCompiler.compileToBrokerRequest(
+      "SELECT COUNT(*) FROM " + 
TableNameBuilder.OFFLINE.tableNameWithType(PING_TABLE_NAME));
+
   private final String _brokerId;
   private final ServerChannels _serverChannels;
   private final ServerChannels _serverChannelsTls;
@@ -57,6 +75,12 @@ public class QueryRouter {
 
   private final BrokerMetrics _brokerMetrics = BrokerMetrics.get();
   private final ConcurrentHashMap<Long, AsyncQueryResponse> 
_asyncQueryResponseMap = new ConcurrentHashMap<>();
+  /// 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();
+  /// Never initialized, which leaves stats collection disabled: pings must 
not skew adaptive server selection.
+  private final ServerRoutingStatsManager _noOpRoutingStatsManager =
+      new ServerRoutingStatsManager(new PinotConfiguration(), _brokerMetrics);
 
   /// Creates a query router with TLS config.
   ///
@@ -146,20 +170,17 @@ public class QueryRouter {
     asyncQueryResponse.markQueryFailed(serverRoutingInstance, e);
   }
 
+  /// Returns whether the broker has a query channel to the server, connected 
or not. There is one channel per table
+  /// type, so this checks both. A server never queried over this path -- e.g. 
one that only serves multi-stage queries
+  /// -- has none.
   public boolean hasChannel(ServerInstance serverInstance) {
-    if (_serverChannelsTls != null) {
-      return _serverChannelsTls.hasChannel(
-          serverInstance.toServerRoutingInstance(TableType.OFFLINE, 
ServerInstance.RoutingType.NETTY_TLS));
-    } else {
-      return _serverChannels.hasChannel(
-          serverInstance.toServerRoutingInstance(TableType.OFFLINE, 
ServerInstance.RoutingType.NETTY));
-    }
+    return !channelRoutingInstances(serverInstance).isEmpty();
   }
 
   /// Connects to the given server, returns `true` if the server is 
successfully connected.
   ///
-  /// Unchanged: this is the reachability probe the failure detector uses, 
opening the OFFLINE channel
-  /// with a TCP connect only. Startup pre-connect uses [#preConnect] instead.
+  /// 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.
   public boolean connect(ServerInstance serverInstance) {
     try {
       if (_serverChannelsTls != null) {
@@ -176,6 +197,116 @@ public class QueryRouter {
     }
   }
 
+  /// Pings the server over every channel queries to it use, and returns 
whether all of them answered within
+  /// `timeoutMs`. Returns `false` when there is no such channel, as there is 
nothing to ping over.
+  ///
+  /// A server that is alive answers straight away even when it is busy, 
because it replies from its network thread
+  /// instead of queueing behind queries (see `InstanceRequestHandler`). So a 
channel that stays silent means the server
+  /// cannot answer anything -- its node is gone, its network path is 
blackholed, or its JVM is frozen -- or that the
+  /// channel itself is half-open, left over from a server that went away. 
Either way queries sent over it would time
+  /// out, which is why every channel has to answer, not just one.
+  ///
+  /// Each channel gets a ping and a response of its own. With a shared one, a 
channel that fails at once would end the
+  /// wait for all of them, and a channel still due to answer would look 
silent.
+  ///
+  /// The connection a ping went out on is closed when that ping stays silent, 
so the next attempt connects afresh. It
+  /// is the one closed, rather than whichever connection the channel holds by 
then: a query may have reconnected in
+  /// the meantime, and its new connection has nothing to do with the silent 
one. Closing also ends whatever was still
+  /// in flight on the silent connection right away (see [DataTableHandler]), 
rather than leaving those queries to wait
+  /// out their own timeouts. Pings record no routing stats, so they never 
skew adaptive server selection.
+  public boolean ping(ServerInstance serverInstance, long timeoutMs) {
+    List<Long> requestIds = new ArrayList<>(2);
+    try {
+      List<ServerRoutingInstance> routingInstances = 
channelRoutingInstances(serverInstance);
+      if (routingInstances.isEmpty()) {
+        return false;
+      }
+      ServerChannels serverChannels = channels();
+      List<SentPing> sentPings = new ArrayList<>(routingInstances.size());
+      boolean answered = true;
+      for (ServerRoutingInstance routingInstance : routingInstances) {
+        long requestId = _pingRequestIdGenerator.decrementAndGet();
+        // The timeout starts at this ping's own send, so a slow send over one 
channel cannot cut short the wait for
+        // the next.
+        AsyncQueryResponse pingResponse =
+            new AsyncQueryResponse(this, requestId, Set.of(routingInstance), 
System.currentTimeMillis(), timeoutMs,
+                _noOpRoutingStatsManager, false);
+        requestIds.add(requestId);
+        _asyncQueryResponseMap.put(requestId, pingResponse);
+        try {
+          Channel channel = serverChannels.sendRequest(PING_TABLE_NAME, 
pingResponse, routingInstance,
+              newPingRequest(requestId, _brokerId), timeoutMs);
+          sentPings.add(new SentPing(pingResponse, channel));
+        } catch (Exception e) {
+          // No connection carried this ping, so there is none to close. Do 
not spend another connect timeout on the
+          // remaining channels: the ping has failed already, and the channels 
not reached stay as they are.
+          answered = false;
+          break;
+        }
+      }
+      for (SentPing sentPing : sentPings) {
+        if 
(sentPing.response().getFinalResponses().values().iterator().next().getDataTable()
 == null) {
+          answered = false;
+          sentPing.channel().close();
+        }
+      }
+      return answered;
+    } catch (InterruptedException e) {
+      Thread.currentThread().interrupt();
+      return false;
+    } catch (Exception e) {
+      // toServerRoutingInstance() rejects a server that has not advertised 
the port, which happens mid-rollout.
+      LOGGER.debug("Failed to ping server: {}", serverInstance, e);
+      return false;
+    } finally {
+      // getFinalResponses() deregisters each response it waits for; this 
covers the ones it never got to.
+      for (long requestId : requestIds) {
+        markQueryDone(requestId);
+      }
+    }
+  }
+
+  /// A ping that went out, and the connection it went out on.
+  private record SentPing(AsyncQueryResponse response, Channel channel) {
+  }
+
+  @VisibleForTesting
+  static InstanceRequest newPingRequest(long requestId, String brokerId) {
+    InstanceRequest pingRequest = new InstanceRequest(requestId, 
PING_BROKER_REQUEST);
+    pingRequest.setPing(true);
+    pingRequest.setSearchSegments(List.of());
+    pingRequest.setBrokerId(brokerId);
+    return pingRequest;
+  }
+
+  /// The routing instances of the server's query channels, connected or not: 
at most one per table type.
+  private List<ServerRoutingInstance> channelRoutingInstances(ServerInstance 
serverInstance) {
+    ServerChannels serverChannels = channels();
+    List<ServerRoutingInstance> routingInstances = new ArrayList<>(2);
+    for (TableType tableType : TableType.values()) {
+      ServerRoutingInstance routingInstance = routingInstance(serverInstance, 
tableType);
+      if (serverChannels.hasChannel(routingInstance)) {
+        routingInstances.add(routingInstance);
+      }
+    }
+    return routingInstances;
+  }
+
+  @VisibleForTesting
+  ServerChannels getServerChannels() {
+    return _serverChannels;
+  }
+
+  /// Queries go over TLS whenever the broker has TLS configured, so the 
server's channels are all of one kind.
+  private ServerChannels channels() {
+    return _serverChannelsTls != null ? _serverChannelsTls : _serverChannels;
+  }
+
+  private ServerRoutingInstance routingInstance(ServerInstance serverInstance, 
TableType tableType) {
+    return serverInstance.toServerRoutingInstance(tableType,
+        _serverChannelsTls != null ? ServerInstance.RoutingType.NETTY_TLS : 
ServerInstance.RoutingType.NETTY);
+  }
+
   /// Opens the channel used for the given table type ahead of query traffic, 
including the TLS
   /// handshake, bounded by `timeoutMs`. Returns `true` if it is connected.
   ///
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 d711c120337..6142662fe39 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
@@ -24,6 +24,7 @@ import io.netty.buffer.PooledByteBufAllocator;
 import io.netty.buffer.PooledByteBufAllocatorMetric;
 import io.netty.buffer.Unpooled;
 import io.netty.channel.Channel;
+import io.netty.channel.ChannelFuture;
 import io.netty.channel.ChannelInitializer;
 import io.netty.channel.ChannelOption;
 import io.netty.channel.EventLoopGroup;
@@ -139,14 +140,16 @@ public class ServerChannels {
     _brokerMetrics.setOrUpdateGlobalGauge(BrokerGauge.NETTY_POOLED_CHUNK_SIZE, 
metric::chunkSize);
   }
 
-  public void sendRequest(String rawTableName, AsyncQueryResponse 
asyncQueryResponse,
+  /// Sends the request to the server, connecting first if there is no open 
connection, and returns the connection the
+  /// request was written to.
+  public Channel sendRequest(String rawTableName, AsyncQueryResponse 
asyncQueryResponse,
       ServerRoutingInstance serverRoutingInstance, InstanceRequest 
instanceRequest, long timeoutMs)
       throws Exception {
     byte[] requestBytes = 
THREAD_LOCAL_T_SERIALIZER.get().serialize(instanceRequest);
     ServerChannel serverChannel = 
_serverToChannelMap.computeIfAbsent(serverRoutingInstance, ServerChannel::new);
     // This server is now query-carrying, whoever opened the channel. See 
[#hasChannel].
     serverChannel._openedByQuery = true;
-    serverChannel.sendRequest(rawTableName, asyncQueryResponse, 
serverRoutingInstance, requestBytes, timeoutMs);
+    return serverChannel.sendRequest(rawTableName, asyncQueryResponse, 
serverRoutingInstance, requestBytes, timeoutMs);
   }
 
   /// Whether this broker has sent, or tried to send, a single-stage query to 
the given server.
@@ -249,13 +252,15 @@ public class ServerChannels {
       }
     }
 
-    void sendRequest(String rawTableName, AsyncQueryResponse 
asyncQueryResponse,
+    Channel sendRequest(String rawTableName, AsyncQueryResponse 
asyncQueryResponse,
         ServerRoutingInstance serverRoutingInstance, byte[] requestBytes, long 
timeoutMs)
         throws InterruptedException, TimeoutException {
       if (_channelLock.tryLock(timeoutMs, TimeUnit.MILLISECONDS)) {
         try {
-          connectWithoutLocking();
+          connectWithoutLocking(timeoutMs);
+          Channel channel = _channel;
           sendRequestWithoutLocking(rawTableName, asyncQueryResponse, 
serverRoutingInstance, requestBytes);
+          return channel;
         } finally {
           _channelLock.unlock();
         }
@@ -264,20 +269,29 @@ public class ServerChannels {
       }
     }
 
-    /// Lazy query path: opens the TCP connection only. Any TLS handshake is 
left to proceed
-    /// asynchronously so the channel lock is released as soon as the socket 
is up, keeping the first
-    /// query's critical section short. Startup pre-connect uses 
[#preConnectWithoutLocking(long)]
-    /// instead, which additionally pays the handshake.
-    void connectWithoutLocking()
-        throws InterruptedException {
+    /// Lazy query path: opens the TCP connection only, waiting at most 
`timeoutMs`. Any TLS handshake is left to
+    /// proceed asynchronously so the channel lock is released as soon as the 
socket is up, keeping the first query's
+    /// critical section short. Startup pre-connect uses 
[#preConnectWithoutLocking(long)] instead, which additionally
+    /// pays the handshake.
+    ///
+    /// 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.
+    void connectWithoutLocking(long timeoutMs)
+        throws InterruptedException, TimeoutException {
       if (_channel == null || !_channel.isActive()) {
         long startTime = System.currentTimeMillis();
-        _channel = _bootstrap.connect().sync().channel();
+        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);
       }
     }
 
-    /// Like [#connectWithoutLocking()] but additionally waits out the TLS 
handshake, and bounds both
+    /// Like [#connectWithoutLocking(long)] but additionally waits out the TLS 
handshake, and bounds both
     /// waits by `timeoutMs`.
     ///
     /// Used only by startup pre-connect ([#preConnect(long)]), never by the 
lazy query path or by the
@@ -354,7 +368,9 @@ public class ServerChannels {
     void sendRequestWithoutLocking(String rawTableName, AsyncQueryResponse 
asyncQueryResponse,
         ServerRoutingInstance serverRoutingInstance, byte[] requestBytes) {
       long startTimeMs = System.currentTimeMillis();
-      
_channel.writeAndFlush(Unpooled.wrappedBuffer(requestBytes)).addListener(f -> {
+      // The listener runs later, by when a reconnect may have replaced 
_channel: on failure it closes this connection.
+      Channel channel = _channel;
+      
channel.writeAndFlush(Unpooled.wrappedBuffer(requestBytes)).addListener(f -> {
         if (f.isSuccess()) {
           int requestSentLatencyMs = (int) (System.currentTimeMillis() - 
startTimeMs);
           _brokerMetrics.addTimedTableValue(rawTableName, 
BrokerTimer.NETTY_CONNECTION_SEND_REQUEST_LATENCY,
@@ -367,7 +383,7 @@ public class ServerChannels {
               new RuntimeException("Failed to send request to server: " + 
serverRoutingInstance, f.cause()))) {
             
_brokerMetrics.addMeteredGlobalValue(BrokerMeter.SERVER_MARKED_DOWN_SKIPPED, 1);
           }
-          _channel.close();
+          channel.close();
         }
       });
       
_brokerMetrics.addMeteredGlobalValue(BrokerMeter.NETTY_CONNECTION_REQUESTS_SENT,
 1);
@@ -383,7 +399,8 @@ public class ServerChannels {
         throws InterruptedException, TimeoutException {
       if (_channelLock.tryLock(TRY_CONNECT_CHANNEL_LOCK_TIMEOUT_MS, 
TimeUnit.MILLISECONDS)) {
         try {
-          connectWithoutLocking();
+          // The connect gets as long as the lock wait.
+          connectWithoutLocking(TRY_CONNECT_CHANNEL_LOCK_TIMEOUT_MS);
         } finally {
           _channelLock.unlock();
         }
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/transport/InstanceRequestHandlerTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/transport/InstanceRequestHandlerTest.java
index b83f19e6e80..ecf17e252f2 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/transport/InstanceRequestHandlerTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/transport/InstanceRequestHandlerTest.java
@@ -20,10 +20,12 @@ package org.apache.pinot.core.transport;
 
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListenableFutureTask;
+import com.google.common.util.concurrent.SettableFuture;
 import io.netty.buffer.ByteBuf;
 import io.netty.buffer.Unpooled;
 import io.netty.channel.ChannelFuture;
 import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.embedded.EmbeddedChannel;
 import io.netty.util.concurrent.Future;
 import io.netty.util.concurrent.GenericFutureListener;
 import java.util.HashSet;
@@ -59,6 +61,7 @@ import org.testng.annotations.Test;
 
 import static org.mockito.ArgumentMatchers.any;
 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;
@@ -75,6 +78,59 @@ public class InstanceRequestHandlerTest {
     ServerMetrics.register(new ServerMetrics(registry));
   }
 
+  /// A ping is answered straight from the network thread: the scheduler is 
never touched, so a server whose queries
+  /// are queued, stuck or slow still answers it at once.
+  @Test
+  public void testPingIsAnsweredWithoutTouchingTheScheduler()
+      throws Exception {
+    QueryScheduler queryScheduler = mock(QueryScheduler.class);
+    EmbeddedChannel channel = new EmbeddedChannel(
+        new InstanceRequestHandler("server01", new PinotConfiguration(), 
queryScheduler, mock(AccessControl.class),
+            ThreadAccountantUtils.getNoOpAccountant()));
+
+    long requestId = -42L;
+    
channel.writeInbound(Unpooled.wrappedBuffer(serialize(QueryRouter.newPingRequest(requestId,
 "broker01"))));
+
+    ByteBuf reply = channel.readOutbound();
+    byte[] replyBytes = new byte[reply.readableBytes()];
+    reply.readBytes(replyBytes);
+    reply.release();
+    DataTable dataTable = DataTableFactory.getDataTable(replyBytes);
+    
assertEquals(dataTable.getMetadata().get(DataTable.MetadataKey.REQUEST_ID.getName()),
 Long.toString(requestId),
+        "The broker matches the reply to its ping by request id");
+    verify(queryScheduler, never()).submit(any());
+    channel.finishAndReleaseAll();
+  }
+
+  /// A server that predates pings ignores the unknown field and sees an 
ordinary query, which it answers once the
+  /// queries queued ahead of it have run. This is the step where such a 
server would otherwise drop the request
+  /// without replying: if it cannot parse the request into a query, it sends 
nothing back. (That queueing is why pings
+  /// are off by default: a busy old server can miss the ping timeout.)
+  @Test
+  public void testServerThatIgnoresThePingFlagSeesAnAnswerableQuery()
+      throws Exception {
+    InstanceRequest asSeenByOldServer = QueryRouter.newPingRequest(-42L, 
"broker01");
+    asSeenByOldServer.unsetPing();
+
+    ServerQueryRequest queryRequest =
+        new ServerQueryRequest(asSeenByOldServer, ServerMetrics.get(), 
System.currentTimeMillis());
+    assertEquals(queryRequest.getRequestId(), -42L);
+
+    QueryScheduler queryScheduler = mock(QueryScheduler.class);
+    when(queryScheduler.submit(any())).thenReturn(SettableFuture.create());
+    EmbeddedChannel channel = new EmbeddedChannel(
+        new InstanceRequestHandler("server01", new PinotConfiguration(), 
queryScheduler, mock(AccessControl.class),
+            ThreadAccountantUtils.getNoOpAccountant()));
+    channel.writeInbound(Unpooled.wrappedBuffer(serialize(asSeenByOldServer)));
+    verify(queryScheduler).submit(any());
+    channel.finishAndReleaseAll();
+  }
+
+  private static byte[] serialize(InstanceRequest instanceRequest)
+      throws Exception {
+    return new TSerializer(new 
TCompactProtocol.Factory()).serialize(instanceRequest);
+  }
+
   @Test
   public void testCancelQuery()
       throws InterruptedException {
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 86a12592192..379326ca7fa 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
@@ -19,8 +19,23 @@
 package org.apache.pinot.core.transport;
 
 import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.SettableFuture;
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.Unpooled;
+import io.netty.channel.ChannelHandler;
 import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelOutboundHandlerAdapter;
+import io.netty.channel.ChannelPromise;
+import io.netty.channel.SimpleChannelInboundHandler;
+import io.netty.channel.embedded.EmbeddedChannel;
+import io.netty.util.ReferenceCountUtil;
+import java.io.IOException;
+import java.io.InputStream;
 import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.SocketTimeoutException;
+import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -31,6 +46,7 @@ import org.apache.pinot.common.metrics.BrokerMeter;
 import org.apache.pinot.common.metrics.BrokerMetrics;
 import org.apache.pinot.common.metrics.ServerMetrics;
 import org.apache.pinot.common.request.BrokerRequest;
+import org.apache.pinot.common.request.InstanceRequest;
 import org.apache.pinot.common.utils.DataSchema;
 import org.apache.pinot.core.common.datatable.DataTableBuilder;
 import org.apache.pinot.core.common.datatable.DataTableBuilderFactory;
@@ -47,6 +63,8 @@ import org.apache.pinot.spi.metrics.PinotMetricsRegistry;
 import org.apache.pinot.spi.utils.CommonConstants;
 import org.apache.pinot.sql.parsers.CalciteSqlCompiler;
 import org.apache.pinot.util.TestUtils;
+import org.apache.thrift.TDeserializer;
+import org.apache.thrift.protocol.TCompactProtocol;
 import org.testng.annotations.AfterMethod;
 import org.testng.annotations.BeforeClass;
 import org.testng.annotations.Test;
@@ -236,6 +254,12 @@ public class QueryRoutingTest {
     assertEquals(serverResponse.getDeserializationTimeMs(), 0);
     // Query should time out
     assertTrue(System.currentTimeMillis() - startTimeMs >= 1000);
+    // A request left unanswered until the deadline produces a timeout and 
nothing else: the send succeeded and the
+    // channel was never torn down, so no failed server is reported. This is 
why the broker's failure detector cannot
+    // rely on getFailedServer() alone -- see 
FailureDetector#notifyServerNotResponded.
+    assertEquals(asyncQueryResponse.getStatus(), 
QueryResponse.Status.TIMED_OUT);
+    assertNull(asyncQueryResponse.getException());
+    assertNull(asyncQueryResponse.getFailedServer());
     _requestCount += 2;
     waitForStatsUpdate(_requestCount);
     
assertEquals(_serverRoutingStatsManager.fetchNumInFlightRequestsForServer(serverId).intValue(),
 0);
@@ -746,6 +770,252 @@ public class QueryRoutingTest {
         PinotMetricUtils.makePinotMetricName(BrokerMetrics.class, fullName), 
meter.getUnit(), TimeUnit.SECONDS).count();
   }
 
+  /// A server that answers queries answers a ping over the channel they use. 
Before any query there is no channel to
+  /// ping over, and the ping must not open one either: that would make a 
server that only serves multi-stage queries
+  /// look queried over this path.
+  @Test
+  public void testPingIsAnsweredByLiveServer()
+      throws Exception {
+    long requestId = 123;
+    _queryServer = getQueryServer(0, dataTableBytesWithRequestId(requestId));
+    initializeTestFixtures(startAndGetPort(_queryServer));
+    QueryRouter queryRouter = newIsolatedQueryRouter();
+    try {
+      assertFalse(queryRouter.ping(_serverInstance, 5_000L));
+      assertFalse(queryRouter.hasChannel(_serverInstance));
+
+      assertNotNull(queryRouter.submitQuery(requestId, "testTable", 
BROKER_REQUEST, _routingTable, null, null, 5_000L)
+          
.getFinalResponses().get(_offlineServerRoutingInstance).getDataTable());
+      assertTrue(queryRouter.ping(_serverInstance, 5_000L));
+    } finally {
+      queryRouter.shutDown();
+    }
+  }
+
+  /// The property that keeps busy servers in routing: the ping is answered 
from the server's network thread, so it
+  /// comes back at once even while a query to that server is stuck.
+  @Test
+  public void testPingIsAnsweredPromptlyWhileTheQueryEngineIsStuck() {
+    _queryServer = QueryServerTestUtils.newQueryServer(invocation -> 
SettableFuture.<byte[]>create());
+    initializeTestFixtures(startAndGetPort(_queryServer));
+    QueryRouter queryRouter = newIsolatedQueryRouter();
+    try {
+      AsyncQueryResponse stuckQuery =
+          queryRouter.submitQuery(123, "testTable", BROKER_REQUEST, 
_routingTable, null, null, 60_000L);
+
+      long startTimeMs = System.currentTimeMillis();
+      assertTrue(queryRouter.ping(_serverInstance, 5_000L));
+      assertTrue(System.currentTimeMillis() - startTimeMs < 2_000L,
+          "A server whose query engine is stuck must still answer a ping 
promptly");
+      assertEquals(stuckQuery.getStatus(), QueryResponse.Status.IN_PROGRESS);
+    } finally {
+      queryRouter.shutDown();
+    }
+  }
+
+  /// A frozen JVM or a vanished node: the kernel (or nothing at all) 
completes the TCP handshake, but nothing ever
+  /// reads the request. Modeled by a listening socket that is never accepted 
from. The unanswered ping must also close
+  /// the channel, or every later attempt would write into the same possibly 
half-open connection.
+  @Test
+  public void testPingGoesUnansweredByFrozenServer()
+      throws Exception {
+    long timeoutMs = 1_000L;
+    QueryRouter queryRouter = newIsolatedQueryRouter();
+    try (ServerSocket frozenServer = new ServerSocket(0)) {
+      initializeTestFixtures(frozenServer.getLocalPort());
+      // Opens the channel, and is never answered.
+      AsyncQueryResponse query =
+          queryRouter.submitQuery(123, "testTable", BROKER_REQUEST, 
_routingTable, null, null, 60_000L);
+
+      long startTimeMs = System.currentTimeMillis();
+      assertFalse(queryRouter.ping(_serverInstance, timeoutMs));
+      long elapsedMs = System.currentTimeMillis() - startTimeMs;
+      assertTrue(elapsedMs >= timeoutMs && elapsedMs < timeoutMs + 2_000L,
+          "A ping to a frozen server must fail after the ping timeout, not 
sooner or much later; took " + elapsedMs);
+      // Closing the channel also ends the query waiting on it, instead of 
leaving it to its own timeout.
+      TestUtils.waitForCondition(aVoid -> query.getStatus() != 
QueryResponse.Status.IN_PROGRESS, 5_000L,
+          "The query on the closed channel did not end");
+
+      // Only now pick up the connection: past the query and the ping, the 
broker must have closed its end.
+      frozenServer.setSoTimeout(5_000);
+      try (Socket connection = frozenServer.accept()) {
+        connection.setSoTimeout(5_000);
+        InputStream inputStream = connection.getInputStream();
+        while (inputStream.read() != -1) {
+          // Skip the requests.
+        }
+      } catch (SocketTimeoutException e) {
+        fail("The channel to a server that left a ping unanswered must be 
closed");
+      }
+    } finally {
+      queryRouter.shutDown();
+    }
+  }
+
+  /// Every channel queries use has to answer. A server whose OFFLINE channel 
answers while its REALTIME channel stays
+  /// silent -- left half-open, say, when the server came back under a new 
address -- fails the ping, as REALTIME
+  /// queries would time out. Only the silent channel is closed: the one that 
answered still carries queries.
+  @Test
+  public void testPingFailsWhenAnyChannelIsSilent()
+      throws Exception {
+    long requestId = 123;
+    _queryServer = getQueryServer(0, dataTableBytesWithRequestId(requestId));
+    initializeTestFixtures(startAndGetPort(_queryServer));
+    QueryRouter queryRouter = newIsolatedQueryRouter();
+    EmbeddedChannel silentChannel = new EmbeddedChannel();
+    try {
+      assertNotNull(queryRouter.submitQuery(requestId, "testTable", 
BROKER_REQUEST, _routingTable, null, null, 5_000L)
+          
.getFinalResponses().get(_offlineServerRoutingInstance).getDataTable());
+      ServerChannels serverChannels = queryRouter.getServerChannels();
+      ServerChannels.ServerChannel realtimeChannel =
+          
serverChannels.getOrCreateServerChannel(_realtimeServerRoutingInstance);
+      realtimeChannel._openedByQuery = true;
+      realtimeChannel.setChannel(silentChannel);
+
+      assertFalse(queryRouter.ping(_serverInstance, 1_000L));
+      assertFalse(silentChannel.isOpen(), "The silent channel must be closed");
+      // A close runs on the channel's event loop, so give a wrong one time to 
land.
+      
assertFalse(serverChannels.getOrCreateServerChannel(_offlineServerRoutingInstance)._channel.closeFuture()
+          .await(500, TimeUnit.MILLISECONDS), "The channel that answered must 
stay open");
+    } finally {
+      silentChannel.finishAndReleaseAll();
+      queryRouter.shutDown();
+    }
+  }
+
+  /// Each table type has its own channel to a server. A server queried only 
for REALTIME tables -- as in the incident
+  /// this guards -- has no OFFLINE channel, and must still be found and 
pinged over the channel queries use.
+  @Test
+  public void testRealtimeOnlyServerIsFoundAndPinged()
+      throws Exception {
+    long requestId = 123;
+    DataTable dataTable = DataTableBuilderFactory.getEmptyDataTable();
+    dataTable.getMetadata().put(MetadataKey.REQUEST_ID.getName(), 
Long.toString(requestId));
+    _queryServer = getQueryServer(0, dataTable.toBytes());
+    initializeTestFixtures(startAndGetPort(_queryServer));
+    assertFalse(_queryRouter.hasChannel(_serverInstance));
+
+    _queryRouter.submitQuery(requestId, "testTable", null, null, 
BROKER_REQUEST, _routingTable, 5_000L)
+        .getFinalResponses();
+    _requestCount += 2;
+    waitForStatsUpdate(_requestCount);
+
+    assertTrue(_queryRouter.hasChannel(_serverInstance),
+        "A REALTIME channel must count, or a REALTIME-only server is never 
checked");
+    assertTrue(_queryRouter.ping(_serverInstance, 5_000L));
+  }
+
+  /// A channel that fails at once must not end the wait for another one that 
is still due to answer, and get that one
+  /// closed as if it were silent -- along with the queries in flight on it.
+  @Test
+  public void testChannelFailingAtOnceDoesNotCloseAnotherStillDueToAnswer()
+      throws Exception {
+    _queryServer = new QueryServer(0, null, new SlowPingHandler(300L));
+    initializeTestFixtures(startAndGetPort(_queryServer));
+    QueryRouter queryRouter = newIsolatedQueryRouter();
+    // Fails every write at once, as a connection that was just reset does.
+    EmbeddedChannel failingChannel = new EmbeddedChannel(new 
ChannelOutboundHandlerAdapter() {
+      @Override
+      public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise 
promise) {
+        ReferenceCountUtil.release(msg);
+        promise.setFailure(new IOException("Connection reset"));
+      }
+    });
+    try {
+      // Opens the OFFLINE channel, and stays in flight: the server never 
answers queries.
+      AsyncQueryResponse query =
+          queryRouter.submitQuery(123, "testTable", BROKER_REQUEST, 
_routingTable, null, null, 60_000L);
+      ServerChannels.ServerChannel realtimeChannel =
+          
queryRouter.getServerChannels().getOrCreateServerChannel(_realtimeServerRoutingInstance);
+      realtimeChannel._openedByQuery = true;
+      realtimeChannel.setChannel(failingChannel);
+
+      assertFalse(queryRouter.ping(_serverInstance, 5_000L));
+      // A close runs on the channel's event loop, so give a wrong one time to 
land.
+      
assertFalse(queryRouter.getServerChannels().getOrCreateServerChannel(_offlineServerRoutingInstance)._channel
+          .closeFuture().await(500, TimeUnit.MILLISECONDS), "The channel that 
answered late must stay open");
+      assertEquals(query.getStatus(), QueryResponse.Status.IN_PROGRESS);
+    } finally {
+      failingChannel.finishAndReleaseAll();
+      queryRouter.shutDown();
+    }
+  }
+
+  /// A ping that stays silent closes the connection it went out on, not 
whichever connection its channel holds by
+  /// then. Here the ping's connection drops while the ping waits, and a query 
reconnects and sends over a fresh
+  /// connection before the ping is done: the fresh connection, and the query 
on it, must survive.
+  @Test
+  public void testFailedPingClosesOnlyItsOwnConnection()
+      throws Exception {
+    // Nothing listens on this port: every connection below is planted.
+    initializeTestFixtures(12345);
+    QueryRouter queryRouter = newIsolatedQueryRouter();
+    ServerChannels.ServerChannel offlineChannel =
+        
queryRouter.getServerChannels().getOrCreateServerChannel(_offlineServerRoutingInstance);
+    offlineChannel._openedByQuery = true;
+    EmbeddedChannel freshConnection = new EmbeddedChannel(
+        new DataTableHandler(queryRouter, 
ThreadAccountantUtils.getNoOpAccountant(), _offlineServerRoutingInstance));
+    List<AsyncQueryResponse> queriesOnFreshConnection = new ArrayList<>();
+    EmbeddedChannel pingConnection = new EmbeddedChannel(new 
ChannelOutboundHandlerAdapter() {
+      @Override
+      public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise 
promise) {
+        ReferenceCountUtil.release(msg);
+        promise.setSuccess();
+        // The connection drops, and its inactive callback runs to the end, 
failing the ping that waits on it. (Run here
+        // directly: Netty would defer the callback of a channel closed in the 
middle of its own write.)
+        ctx.channel().close();
+        queryRouter.markServerUnavailable(_offlineServerRoutingInstance, new 
IOException("Connection dropped"));
+        // Before the ping gets to its cleanup, a query reconnects and sends 
over a fresh connection. This runs on the
+        // thread that holds the channel lock for the ping, so the query gets 
the lock too.
+        offlineChannel.setChannel(freshConnection);
+        queriesOnFreshConnection.add(
+            queryRouter.submitQuery(123, "testTable", BROKER_REQUEST, 
_routingTable, null, null, 60_000L));
+      }
+    });
+    offlineChannel.setChannel(pingConnection);
+    try {
+      assertFalse(queryRouter.ping(_serverInstance, 5_000L));
+      assertTrue(freshConnection.isOpen(), "The ping must not close the 
connection that replaced its own");
+      assertEquals(queriesOnFreshConnection.size(), 1);
+      assertEquals(queriesOnFreshConnection.get(0).getStatus(), 
QueryResponse.Status.IN_PROGRESS);
+    } finally {
+      pingConnection.finishAndReleaseAll();
+      freshConnection.finishAndReleaseAll();
+      queryRouter.shutDown();
+    }
+  }
+
+  /// Answers pings after a delay, and queries never.
+  @ChannelHandler.Sharable
+  private static class SlowPingHandler extends 
SimpleChannelInboundHandler<ByteBuf> {
+    private final long _delayMs;
+
+    SlowPingHandler(long delayMs) {
+      _delayMs = delayMs;
+    }
+
+    @Override
+    protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg)
+        throws Exception {
+      byte[] requestBytes = new byte[msg.readableBytes()];
+      msg.readBytes(requestBytes);
+      InstanceRequest instanceRequest = new InstanceRequest();
+      new TDeserializer(new 
TCompactProtocol.Factory()).deserialize(instanceRequest, requestBytes);
+      if (instanceRequest.isPing()) {
+        byte[] responseBytes = 
dataTableBytesWithRequestId(instanceRequest.getRequestId());
+        ctx.executor().schedule(() -> 
ctx.writeAndFlush(Unpooled.wrappedBuffer(responseBytes)), _delayMs,
+            TimeUnit.MILLISECONDS);
+      }
+    }
+  }
+
+  private static byte[] dataTableBytesWithRequestId(long requestId)
+      throws IOException {
+    DataTable dataTable = DataTableBuilderFactory.getEmptyDataTable();
+    dataTable.getMetadata().put(MetadataKey.REQUEST_ID.getName(), 
Long.toString(requestId));
+    return dataTable.toBytes();
+  }
+
   private void waitForStatsUpdate(long taskCount) {
     TestUtils.waitForCondition(aVoid -> {
       return (_serverRoutingStatsManager.getCompletedTaskCount() == taskCount);
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/transport/QueryServerTestUtils.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/transport/QueryServerTestUtils.java
new file mode 100644
index 00000000000..53cb513e8b6
--- /dev/null
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/transport/QueryServerTestUtils.java
@@ -0,0 +1,69 @@
+/**
+ * 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.core.transport;
+
+import java.net.InetSocketAddress;
+import org.apache.pinot.core.query.scheduler.QueryScheduler;
+import org.apache.pinot.server.access.AccessControl;
+import org.apache.pinot.spi.accounting.ThreadAccountantUtils;
+import org.apache.pinot.spi.env.PinotConfiguration;
+import org.mockito.stubbing.Answer;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+
+/// Helpers for standing up a real Netty [QueryServer] in a test.
+///
+/// Lives in `org.apache.pinot.core.transport` because 
[QueryServer#getChannel] and the `host`/`port`
+/// [ServerInstance] constructor are package-private; pinot-broker consumes 
this through the pinot-core test jar.
+public class QueryServerTestUtils {
+  private QueryServerTestUtils() {
+  }
+
+  /// Builds an unstarted query server whose scheduler answers every 
submission with `submitAnswer`.
+  public static QueryServer newQueryServer(Answer<?> submitAnswer) {
+    return newQueryServer(0, submitAnswer);
+  }
+
+  /// Same as [#newQueryServer(Answer)], on a given port -- for a test that 
brings a server back where it was.
+  public static QueryServer newQueryServer(int port, Answer<?> submitAnswer) {
+    QueryScheduler queryScheduler = mock(QueryScheduler.class);
+    when(queryScheduler.submit(any())).thenAnswer(submitAnswer);
+    InstanceRequestHandler handler =
+        new InstanceRequestHandler("testServer", new PinotConfiguration(), 
queryScheduler, mock(AccessControl.class),
+            ThreadAccountantUtils.getNoOpAccountant());
+    return new QueryServer(port, null, handler);
+  }
+
+  /// Starts the server and returns the port it bound to.
+  ///
+  /// Binds on port 0 and reads the port back, rather than picking a free port 
up front, which would leave a window
+  /// for another process on the machine to take it. [QueryServer#start] 
blocks on `bind().sync()`, so the server is
+  /// accepting connections when this returns.
+  public static int startAndGetPort(QueryServer server) {
+    server.start();
+    return ((InetSocketAddress) server.getChannel().localAddress()).getPort();
+  }
+
+  public static ServerInstance serverInstance(String hostname, int port) {
+    return new ServerInstance(hostname, port);
+  }
+}
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 7fec4b33fed..bbbb0b7d971 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
@@ -25,6 +25,7 @@ import io.netty.channel.ChannelFuture;
 import io.netty.channel.ChannelOption;
 import io.netty.util.concurrent.GenericFutureListener;
 import java.net.InetSocketAddress;
+import java.util.concurrent.TimeoutException;
 import org.apache.pinot.common.config.NettyConfig;
 import org.apache.pinot.common.metrics.BrokerMetrics;
 import org.apache.pinot.common.request.BrokerRequest;
@@ -49,6 +50,7 @@ import static org.testng.Assert.assertNotNull;
 import static org.testng.Assert.assertSame;
 import static org.testng.Assert.assertThrows;
 import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.fail;
 
 
 public class ServerChannelsTest {
@@ -82,7 +84,6 @@ public class ServerChannelsTest {
           new ServerRoutingInstance("localhost", 
dummyServer.getAddress().getPort(), TableType.REALTIME);
       ServerChannels serverChannels =
           new ServerChannels(queryRouter, nettyConfig, null, 
ThreadAccountantUtils.getNoOpAccountant());
-      serverChannels.connect(serverRoutingInstance);
 
       final long requestId = System.currentTimeMillis();
 
@@ -98,6 +99,37 @@ public class ServerChannelsTest {
     }
   }
 
+  /// Connecting is bounded by the caller's timeout. Without that bound a 
connect to a host that drops packets -- the
+  /// host the failure detector pings -- would block for Netty's 30 s default. 
192.0.2.1 is reserved for
+  /// documentation (RFC 5737) and routes nowhere: depending on the network 
the connect either hangs, which the timeout
+  /// must cut short, or fails at once.
+  @Test
+  public void testConnectIsBoundedByTheCallerTimeout()
+      throws Exception {
+    ServerChannels serverChannels =
+        new ServerChannels(mock(QueryRouter.class), null, null, 
ThreadAccountantUtils.getNoOpAccountant());
+    try {
+      InstanceRequest instanceRequest = new InstanceRequest();
+      instanceRequest.setRequestId(1L);
+      instanceRequest.setQuery(new BrokerRequest());
+      long timeoutMs = 500L;
+      long startTimeMs = System.currentTimeMillis();
+      try {
+        serverChannels.sendRequest("dummy_table_name", 
mock(AsyncQueryResponse.class),
+            new ServerRoutingInstance("192.0.2.1", 8098, TableType.OFFLINE), 
instanceRequest, timeoutMs);
+        fail("Connecting to an unroutable address must fail");
+      } catch (Exception e) {
+        // A network that rejects the address fails the connect at once. One 
that drops the packets must hit the bound.
+        if (System.currentTimeMillis() - startTimeMs >= timeoutMs) {
+          assertTrue(e instanceof TimeoutException, "A hanging connect must 
end in the caller's timeout, got: " + e);
+        }
+      }
+      assertTrue(System.currentTimeMillis() - startTimeMs < 5_000L, "The 
connect must be bounded by the timeout");
+    } finally {
+      serverChannels.shutDown();
+    }
+  }
+
   @Test
   public void testChannelsShareBufferAllocator() {
     ServerChannels serverChannels =
@@ -149,6 +181,10 @@ public class ServerChannelsTest {
     // Simulate write failure
     when(mockFuture.isSuccess()).thenReturn(false);
     when(mockFuture.cause()).thenReturn(new OutOfMemoryError("Direct buffer 
memory"));
+    // By the time the listener runs, a reconnect may have replaced the 
channel. Only the connection written to is
+    // closed.
+    Channel replacementChannel = mock(Channel.class);
+    serverChannel.setChannel(replacementChannel);
 
     try {
       listenerCaptor.getValue().operationComplete(mockFuture);
@@ -157,6 +193,7 @@ public class ServerChannelsTest {
     }
 
     verify(mockChannel).close();
+    verify(replacementChannel, never()).close();
 
     
verify(asyncQueryResponse).markServerUnavailable(any(ServerRoutingInstance.class),
 any(Exception.class));
     verify(asyncQueryResponse, 
never()).markRequestSent(any(ServerRoutingInstance.class), any(Integer.class));
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 363490d95b6..e69bc0bd393 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
@@ -1352,7 +1352,8 @@ public class CommonConstants {
         // Do not detect any failure
         NO_OP,
 
-        // Detect connection failures
+        // Detect connection failures, and optionally servers that leave a 
query unanswered and then fail a ping (see
+        // CONFIG_OF_ENABLE_PING_ON_TIMEOUT)
         CONNECTION,
 
         // Use the custom failure detector of the configured class name
@@ -1371,6 +1372,23 @@ public class CommonConstants {
       public static final double DEFAULT_RETRY_DELAY_FACTOR = 2.0;
       public static final String CONFIG_OF_MAX_RETRIES = 
"pinot.broker.failure.detector.max.retries";
       public static final int DEFAULT_MAX_RETRIES = 10;
+
+      /// When a single-stage query times out on a server, ping the server and 
mark it unhealthy if the ping goes
+      /// unanswered. Unhealthy servers are then retried with a ping too, 
instead of a TCP connect. This is how a server
+      /// whose node is gone or whose JVM is frozen is detected, since such a 
server keeps its connection open and
+      /// raises no connection failure. A server that is merely busy still 
answers the ping, so it is left alone.
+      ///
+      /// Enable only once every server runs a version that answers pings. An 
older server runs a ping like a query,
+      /// behind the queries already queued on it, so a busy one can miss the 
ping timeout and be taken out of routing.
+      public static final String CONFIG_OF_ENABLE_PING_ON_TIMEOUT =
+          "pinot.broker.failure.detector.enable.ping.on.timeout";
+      public static final boolean DEFAULT_ENABLE_PING_ON_TIMEOUT = false;
+
+      /// With pings enabled, how long to wait for a server to answer one 
before treating it as unhealthy, both when
+      /// checking a server after a query timeout and when retrying an 
unhealthy one. Keep it above the longest
+      /// 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;
     }
 
     // Configs related to AdaptiveServerSelection.


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

Reply via email to