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

gortiz 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 6aaa0405566 Add broker startup pre-connect for broker-to-server 
channels (SSE) (#19407)
6aaa0405566 is described below

commit 6aaa0405566c576a51b64cb4a031033bb2409eba
Author: Jinesh Parakh <[email protected]>
AuthorDate: Mon Sep 7 20:34:44 2026 +0530

    Add broker startup pre-connect for broker-to-server channels (SSE) (#19407)
---
 .../broker/broker/helix/BaseBrokerStarter.java     | 126 ++++++-
 .../requesthandler/BrokerRequestHandler.java       |  10 +
 .../BrokerRequestHandlerDelegate.java              |   7 +
 .../broker/requesthandler/ServerPreConnector.java  | 196 ++++++++++
 .../SingleConnectionBrokerRequestHandler.java      |  46 +++
 .../routing/manager/BaseBrokerRoutingManager.java  |   5 +
 .../manager/MultiClusterRoutingManager.java        |  12 +
 .../requesthandler/ServerPreConnectorTest.java     | 406 +++++++++++++++++++++
 .../manager/MultiClusterRoutingManagerTest.java    |  14 +
 .../apache/pinot/common/metrics/BrokerTimer.java   |   9 +
 .../apache/pinot/core/routing/RoutingManager.java  |  11 +
 .../apache/pinot/core/transport/QueryRouter.java   |  26 ++
 .../pinot/core/transport/ServerChannels.java       | 136 ++++++-
 .../pinot/core/transport/ServerChannelsTest.java   |  95 +++++
 .../BrokerServerPreConnectIntegrationTest.java     | 142 +++++++
 .../integration/tests/TlsIntegrationTest.java      |  29 ++
 .../apache/pinot/spi/utils/CommonConstants.java    |  15 +
 17 files changed, 1271 insertions(+), 14 deletions(-)

diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/broker/helix/BaseBrokerStarter.java
 
b/pinot-broker/src/main/java/org/apache/pinot/broker/broker/helix/BaseBrokerStarter.java
index 5ed947953e3..e02549da2e9 100644
--- 
a/pinot-broker/src/main/java/org/apache/pinot/broker/broker/helix/BaseBrokerStarter.java
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/broker/helix/BaseBrokerStarter.java
@@ -142,6 +142,10 @@ public abstract class BaseBrokerStarter implements 
ServiceStartable {
   /// desynchronize brokers on shared storage.
   private static final int RESPONSE_STORE_CLEANUP_INITIAL_DELAY_JITTER_DIVISOR 
= 4;
 
+  /// How often the pre-connect thread re-checks whether Helix has converged. 
Short enough not to add
+  /// meaningful delay to a fast startup, long enough not to hammer the Helix 
data accessor.
+  private static final long HELIX_CONVERGENCE_POLL_INTERVAL_MS = 200L;
+
   protected PinotConfiguration _brokerConf;
   protected List<ListenerConfig> _listenerConfigs;
   protected String _clusterName;
@@ -189,6 +193,17 @@ public abstract class BaseBrokerStarter implements 
ServiceStartable {
   protected BrokerGrpcServer _brokerGrpcServer;
   protected FailureDetector _failureDetector;
   protected ThreadAccountant _threadAccountant;
+  /// The Helix-convergence half of the service-status composite, held so 
startup pre-connect can wait
+  /// on exactly that signal -- convergence is the point at which routing, and 
the servers it
+  /// references, first exist.
+  @Nullable
+  private volatile ServiceStatus.ServiceStatusCallback 
_helixConvergenceCallback;
+  /// The background pre-connect thread, tracked so shutdown can interrupt it.
+  @Nullable
+  private volatile Thread _preConnectThread;
+  /// Whether startup pre-connect is enabled, and its budget. Read once in 
`start()`.
+  private boolean _preConnectEnabled;
+  private long _preConnectTimeoutMs;
 
   @Override
   public void init(PinotConfiguration brokerConf)
@@ -669,10 +684,25 @@ public abstract class BaseBrokerStarter implements 
ServiceStartable {
         
_brokerConf.getProperty(CommonConstants.Groovy.GROOVY_QUERY_STATIC_ANALYZER_CONFIG,
         
_brokerConf.getProperty(CommonConstants.Groovy.GROOVY_ALL_STATIC_ANALYZER_CONFIG)));
 
-    // Register the service status handler
+    // Only the Netty single-stage transport has broker-to-server channels to 
open; the gRPC single-stage
+    // handler, the multi-stage engine and the time-series path all use 
different transports and are
+    // unaffected by this flag.
+    _preConnectEnabled = 
_brokerConf.getProperty(Broker.CONFIG_OF_BROKER_STARTUP_PRECONNECT_ENABLED,
+        Broker.DEFAULT_BROKER_STARTUP_PRECONNECT_ENABLED);
+    _preConnectTimeoutMs = 
_brokerConf.getProperty(Broker.CONFIG_OF_BROKER_STARTUP_PRECONNECT_TIMEOUT_MS,
+        Broker.DEFAULT_BROKER_STARTUP_PRECONNECT_TIMEOUT_MS);
     registerServiceStatusHandler();
-
-    _isStarting = false;
+    if (_preConnectEnabled) {
+      // Startup is not finished until the broker-to-server channels are open, 
so `_isStarting` stays set
+      // and the existing lifecycle callback keeps reporting STARTING -- no 
query is routed here before the
+      // connect and TLS handshake have been paid. "Still pre-connecting" is 
not a new kind of statement,
+      // it is the same one, so it reuses the same flag rather than a second 
parallel gate. The flag was
+      // set before the status handler was registered, so there is 
structurally no window in which
+      // readiness is granted un-gated. The pre-connect thread clears it when 
it finishes.
+      startPreConnect();
+    } else {
+      _isStarting = false;
+    }
     _brokerMetrics.addTimedValue(BrokerTimer.STARTUP_SUCCESS_DURATION_MS,
         System.currentTimeMillis() - startTimeMs, TimeUnit.MILLISECONDS);
 
@@ -852,13 +882,90 @@ public abstract class BaseBrokerStarter implements 
ServiceStartable {
             Broker.DEFAULT_BROKER_MIN_RESOURCE_PERCENT_FOR_START);
 
     LOGGER.info("Registering service status handler");
-    ServiceStatus.setServiceStatusCallback(_instanceId, new 
ServiceStatus.MultipleCallbackServiceStatusCallback(
-        List.of(
+    // The two Helix callbacks are grouped into their own composite so startup 
pre-connect can wait on
+    // exactly the "Helix has converged" signal. CurrentState only reports 
ONLINE once the
+    // OFFLINE->ONLINE transition has returned, and that transition is what 
builds routing -- so
+    // convergence is the precondition for routing entries, and the servers 
they reference, existing.
+    // Behaviour is unchanged: MultipleCallbackServiceStatusCallback surfaces 
the first non-GOOD
+    // callback, so nesting the two Helix callbacks reports the same status as 
listing them flat.
+    _helixConvergenceCallback = new 
ServiceStatus.MultipleCallbackServiceStatusCallback(List.of(
+        new 
ServiceStatus.IdealStateAndCurrentStateMatchServiceStatusCallback(_participantHelixManager,
+            _clusterName, _instanceId, resourcesToMonitor, 
minResourcePercentForStartup),
+        new 
ServiceStatus.IdealStateAndExternalViewMatchServiceStatusCallback(_participantHelixManager,
+            _clusterName, _instanceId, resourcesToMonitor, 
minResourcePercentForStartup)));
+
+    ServiceStatus.setServiceStatusCallback(_instanceId,
+        new ServiceStatus.MultipleCallbackServiceStatusCallback(List.of(
             new ServiceStatus.LifecycleServiceStatusCallback(this::isStarting, 
this::isShuttingDown),
-            new 
ServiceStatus.IdealStateAndCurrentStateMatchServiceStatusCallback(_participantHelixManager,
-                _clusterName, _instanceId, resourcesToMonitor, 
minResourcePercentForStartup),
-            new 
ServiceStatus.IdealStateAndExternalViewMatchServiceStatusCallback(_participantHelixManager,
-                _clusterName, _instanceId, resourcesToMonitor, 
minResourcePercentForStartup))));
+            _helixConvergenceCallback)));
+  }
+
+  /// Runs startup server pre-connect on a background thread and ends startup 
([#_isStarting]) when it
+  /// finishes. Asynchronous so `start()` still returns promptly -- readiness 
is withheld through
+  /// `ServiceStatus`, not by blocking startup. The flag is cleared in a 
`finally` so startup ends even if
+  /// pre-connect throws or is interrupted: readiness withheld indefinitely 
would stall a rolling restart,
+  /// a worse failure than serving a broker whose channels are not yet warm.
+  ///
+  /// Only called when pre-connect is enabled; otherwise `start()` ends 
startup itself and readiness
+  /// behaves exactly as before.
+  private void startPreConnect() {
+    _preConnectThread = new Thread(() -> {
+      // Set once Helix converges. Both the budget and the duration metric are 
measured from here, not
+      // from thread start, so the deliberately unbounded convergence wait is 
charged against neither: the
+      // Helix callbacks withhold readiness until convergence anyway, so it 
costs nothing.
+      long preConnectStartMs = 0L;
+      try {
+        long threadStartMs = System.currentTimeMillis();
+        awaitHelixConvergence();
+        preConnectStartMs = System.currentTimeMillis();
+        LOGGER.info("Helix converged after {} ms; pre-connecting server 
channels",
+            preConnectStartMs - threadStartMs);
+        int connected = 
_brokerRequestHandler.preConnectServers(preConnectStartMs + 
_preConnectTimeoutMs);
+        LOGGER.info("Startup server pre-connect opened {} channel(s); ending 
startup", connected);
+      } catch (InterruptedException e) {
+        // Normal on shutdown; stopPreConnect() interrupts us.
+        Thread.currentThread().interrupt();
+        LOGGER.info("Startup server pre-connect interrupted before completion; 
ending startup");
+      } catch (Throwable t) {
+        LOGGER.warn("Startup server pre-connect threw; ending startup anyway", 
t);
+      } finally {
+        _isStarting = false;
+        // Record the duration only if convergence was reached, so the metric 
measures the pre-connect work
+        // itself and never the (unbounded) convergence wait -- e.g. when 
shutdown interrupts the wait.
+        if (preConnectStartMs > 0L) {
+          
_brokerMetrics.addTimedValue(BrokerTimer.STARTUP_PRECONNECT_DURATION_MS,
+              System.currentTimeMillis() - preConnectStartMs, 
TimeUnit.MILLISECONDS);
+        }
+      }
+    }, "broker-startup-preconnect");
+    _preConnectThread.setDaemon(true);
+    _preConnectThread.start();
+  }
+
+  /// Blocks until the Helix-convergence callbacks report GOOD -- the point at 
which routing entries and
+  /// the servers they reference exist. Deliberately **unbounded** and 
interruptible: a broker that never
+  /// converges is never Ready regardless of pre-connect, and shutdown 
interrupts this thread. Monitors
+  /// `brokerResource` only (partitions in {OFFLINE, ONLINE, DROPPED}); 
segment states live in the table
+  /// resources that servers monitor and cannot hold this up.
+  private void awaitHelixConvergence()
+      throws InterruptedException {
+    ServiceStatus.ServiceStatusCallback callback = _helixConvergenceCallback;
+    if (callback == null) {
+      return;
+    }
+    while (callback.getServiceStatus() != ServiceStatus.Status.GOOD) {
+      Thread.sleep(HELIX_CONVERGENCE_POLL_INTERVAL_MS);
+    }
+  }
+
+  /// Interrupts an in-flight pre-connect so shutdown never waits on it. Best 
effort: the thread is a
+  /// daemon and records its metric in a `finally` regardless.
+  private void stopPreConnect() {
+    Thread thread = _preConnectThread;
+    if (thread != null && thread.isAlive()) {
+      LOGGER.info("Interrupting in-flight startup server pre-connect for 
shutdown");
+      thread.interrupt();
+    }
   }
 
   private String getDefaultBrokerId() {
@@ -893,6 +1000,7 @@ public abstract class BaseBrokerStarter implements 
ServiceStartable {
   public void stop() {
     LOGGER.info("Shutting down Pinot broker");
     _isShuttingDown = true;
+    stopPreConnect();
 
     LOGGER.info("Disconnecting participant Helix manager");
     _participantHelixManager.disconnect();
diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BrokerRequestHandler.java
 
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BrokerRequestHandler.java
index dc4a5624f42..c9dcc497277 100644
--- 
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BrokerRequestHandler.java
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BrokerRequestHandler.java
@@ -47,6 +47,16 @@ public interface BrokerRequestHandler {
 
   void shutDown();
 
+  /// Opens broker-to-server channels ahead of traffic so the first real query 
does not pay the blocking
+  /// connect -- and, when broker-to-server TLS is on, the handshake -- on its 
critical path. Called once
+  /// at startup after Helix has converged, when 
`pinot.broker.startup.preconnect.enabled` is set.
+  ///
+  /// Only the single-connection SSE handler opens Netty channels, so the 
default is a no-op. Returns the
+  /// number of channels connected before `deadlineMs` (an absolute 
[System#currentTimeMillis] value).
+  default int preConnectServers(long deadlineMs) {
+    return 0;
+  }
+
   BrokerResponse handleRequest(JsonNode request, @Nullable SqlNodeAndOptions 
sqlNodeAndOptions,
       @Nullable RequesterIdentity requesterIdentity, RequestContext 
requestContext, @Nullable HttpHeaders httpHeaders)
       throws Exception;
diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BrokerRequestHandlerDelegate.java
 
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BrokerRequestHandlerDelegate.java
index 1835030436e..bca292de883 100644
--- 
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BrokerRequestHandlerDelegate.java
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BrokerRequestHandlerDelegate.java
@@ -76,6 +76,13 @@ public class BrokerRequestHandlerDelegate implements 
BrokerRequestHandler {
     }
   }
 
+  @Override
+  public int preConnectServers(long deadlineMs) {
+    // Only the single-stage handler owns the broker-to-server Netty channels; 
the multi-stage (gRPC)
+    // and time-series paths have nothing to pre-connect here.
+    return _singleStageBrokerRequestHandler.preConnectServers(deadlineMs);
+  }
+
   @Override
   public void shutDown() {
     _singleStageBrokerRequestHandler.shutDown();
diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/ServerPreConnector.java
 
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/ServerPreConnector.java
new file mode 100644
index 00000000000..c90b65e9f0b
--- /dev/null
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/ServerPreConnector.java
@@ -0,0 +1,196 @@
+/**
+ * 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.annotations.VisibleForTesting;
+import com.google.common.util.concurrent.ThreadFactoryBuilder;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.concurrent.CompletionService;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorCompletionService;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+import javax.annotation.concurrent.ThreadSafe;
+import org.apache.pinot.core.transport.ServerInstance;
+import org.apache.pinot.spi.config.table.TableType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+
+/// Opens broker-to-server Netty channels ahead of query traffic, so the first 
real query does not pay
+/// the blocking `connect()` -- and, when broker-to-server TLS is on, the 
handshake -- on its critical
+/// path.
+///
+/// A channel's identity includes the table type, so OFFLINE and REALTIME are 
**separate** channels
+/// (separate sockets, separate handshakes) to the same physical server. The 
caller supplies the exact
+/// (server, table type) pairs to open, derived from what this broker actually 
routes -- so an offline-only
+/// cluster opens no REALTIME channels, and a broker serving one tenant does 
not connect to another
+/// tenant's servers. A table that lands on a server later is left to the lazy 
connect path (one query pays
+/// the connect) rather than pre-warmed here on the chance it appears: the 
would-be second channel shares
+/// nothing with the first, so pre-warming it amortizes nothing. Connecting an 
already-active channel is a
+/// no-op, so this is safe to call more than once.
+///
+/// Bounded so it can never stall startup: a capped thread pool, a per-channel 
connect bound derived from
+/// the remaining budget, a per-channel wait clamped to the caller's deadline, 
and a straggler grace window
+/// ([#STRAGGLER_GRACE_MS]) that, once at least one channel is up, releases 
the caller when the rest stop
+/// arriving rather than waiting out the whole budget on one stuck server. A 
server that is unreachable or
+/// itself restarting is logged and skipped -- the existing lazy-connect path 
still serves it. This class
+/// is stateless and thread-safe.
+///
+/// It takes its dependencies as functions rather than concrete 
`RoutingManager`/`QueryRouter` types so
+/// the parallelism, budget and failure handling can be unit-tested without a 
live broker.
+@ThreadSafe
+public class ServerPreConnector {
+  private static final Logger LOGGER = 
LoggerFactory.getLogger(ServerPreConnector.class);
+
+  /// Cap on the connect thread pool: a large tenant must not spawn a thread 
per server. Safe to exceed
+  /// the core count even on a 2- or 4-vCPU broker: each task is blocking 
connect + TLS handshake (mostly
+  /// network wait, with the actual I/O on Netty's event loop), and this runs 
during startup before any
+  /// query load, so the threads are almost entirely parked rather than 
contending for CPU.
+  ///
+  /// It is a throughput cap, not a safety bound: with more channels than 
threads the surplus queues
+  /// behind the workers, so the budget alone must not be what stops a stuck 
connect. That is why
+  /// [ChannelConnector] takes a per-channel timeout.
+  @VisibleForTesting
+  static final int MAX_CONNECT_THREADS = 16;
+
+  /// How long to keep waiting, once at least one channel is up, for the next 
one before concluding the
+  /// rest are stuck. A quiet window this long means what is left is stuck 
rather than merely slow, so the
+  /// caller is released and the stragglers finish -- or time out -- on their 
own daemon threads. Until the
+  /// first *successful* connect the whole budget is available: with nothing 
up yet there is no way to tell
+  /// "every server is slow" from "a few are stuck", and a fast failure must 
not start the clock.
+  @VisibleForTesting
+  static final long STRAGGLER_GRACE_MS = 2_000L;
+
+  /// Opens one broker-to-server channel. Implementations must bound their own 
wait by `timeoutMs` and
+  /// must not throw; the return value reports whether the channel is 
connected.
+  @FunctionalInterface
+  public interface ChannelConnector {
+    boolean connect(ServerInstance serverInstance, TableType tableType, long 
timeoutMs);
+  }
+
+  /// A (server, table type) channel to open. The table type is part of the 
channel identity: OFFLINE and
+  /// REALTIME are separate channels (separate sockets) to the same physical 
server.
+  public record ChannelTarget(ServerInstance serverInstance, TableType 
tableType) {
+  }
+
+  private final Supplier<Collection<ChannelTarget>> _targetsSupplier;
+  private final ChannelConnector _connector;
+
+  /// @param targetsSupplier supplies the (server, table type) channels to 
open, evaluated once per
+  ///     [#preConnect] call after the caller has ensured routing is built. 
Derive these from routing so
+  ///     only channels this broker actually uses are opened. Must return a 
non-null collection and must
+  ///     not throw (it is evaluated before the failure-handling loop); the 
production supplier reads
+  ///     routing, which cannot do either.
+  /// @param connector opens the channel for one (server, table type) within a 
timeout
+  public ServerPreConnector(Supplier<Collection<ChannelTarget>> 
targetsSupplier, ChannelConnector connector) {
+    _targetsSupplier = targetsSupplier;
+    _connector = connector;
+  }
+
+  /// Opens the supplied (server, table type) channels in parallel, bounded by 
`deadlineMs` (an absolute
+  /// [System#currentTimeMillis] value). Returns the number of channels 
connected **before the caller was
+  /// released** -- so a straggler that connects after the grace window (see 
[#STRAGGLER_GRACE_MS]) is not
+  /// counted, even though its channel is still published for the first query 
to reuse. Never throws: a
+  /// channel that fails or times out is logged and skipped.
+  public int preConnect(long deadlineMs) {
+    // Snapshot the target view once. The supplier may derive from a live 
routing view that another thread
+    // updates during startup; snapshotting keeps the channel count consistent 
with the tasks actually
+    // submitted below, so we never poll for phantom channels or under-count 
real ones.
+    List<ChannelTarget> targets = new ArrayList<>(_targetsSupplier.get());
+    if (targets.isEmpty() || System.currentTimeMillis() >= deadlineMs) {
+      return 0;
+    }
+    long startMs = System.currentTimeMillis();
+    int channelCount = targets.size();
+    ExecutorService executor = 
Executors.newFixedThreadPool(Math.min(channelCount, MAX_CONNECT_THREADS),
+        new 
ThreadFactoryBuilder().setNameFormat("broker-preconnect-%d").setDaemon(true).build());
+    // A completion service hands channels back in the order they finish, not 
the order submitted, so a
+    // slow or unreachable server never delays the counting of faster ones 
that finished behind it. This
+    // removes head-of-line blocking from the *counting* only: with more 
channels than workers the surplus
+    // still queues for a worker, which is what the per-channel timeout bounds.
+    CompletionService<Boolean> completionService = new 
ExecutorCompletionService<>(executor);
+    int connected = 0;
+    boolean releasedEarly = false;
+    try {
+      for (ChannelTarget target : targets) {
+        completionService.submit(() -> 
_connector.connect(target.serverInstance(), target.tableType(),
+            Math.max(0L, deadlineMs - System.currentTimeMillis())));
+      }
+      for (int i = 0; i < channelCount; i++) {
+        long remainingMs = deadlineMs - System.currentTimeMillis();
+        if (remainingMs <= 0) {
+          break;
+        }
+        // Keep the whole budget available until the first *successful* 
connect: a quiet window only means
+        // "the rest are stuck" once at least one channel has actually come 
up. Keying the exemption on the
+        // first completion instead would let a single fast event -- an 
instantly refused connect, or one
+        // nearby server -- start the grace clock before the 
healthy-but-slower channels return, abandoning
+        // them. Once one channel is up, a quiet grace window is the signal 
that what is left is stuck rather
+        // than slow, and the caller is released -- one unreachable server 
otherwise holds the gate for the
+        // entire budget. (A cluster where no server ever connects still exits 
promptly when connects fail
+        // fast, and waits the budget only when every connect black-holes, 
which is the correct thing to do
+        // for a broker that can reach nothing.)
+        long waitMs = connected == 0 ? remainingMs : Math.min(remainingMs, 
STRAGGLER_GRACE_MS);
+        try {
+          Future<Boolean> future = completionService.poll(waitMs, 
TimeUnit.MILLISECONDS);
+          if (future == null) {
+            // The grace cap was binding (we could have waited longer but 
chose not to) only when waitMs was
+            // clamped below the remaining budget; otherwise this is plain 
budget exhaustion.
+            releasedEarly = waitMs < remainingMs;
+            LOGGER.info("No pre-connect channel completed in {} ms with {}/{} 
still outstanding; releasing startup "
+                + "and leaving them to the lazy connect path", waitMs, 
channelCount - i, channelCount);
+            break;
+          }
+          if (Boolean.TRUE.equals(future.get())) {
+            connected++;
+          }
+        } catch (InterruptedException e) {
+          // Shutdown: stopPreConnect() interrupts us. Restore the flag and 
stop promptly.
+          Thread.currentThread().interrupt();
+          break;
+        } catch (ExecutionException e) {
+          // A server that is unreachable or itself restarting must not block 
startup.
+          LOGGER.debug("Pre-connect did not complete for one channel", e);
+        }
+      }
+    } finally {
+      // shutdown(), not shutdownNow(): a channel we stopped waiting on is 
still connecting on a daemon
+      // thread. Interrupting a worker parked in connect().sync() abandons a 
ChannelFuture that can still
+      // complete, leaking a socket nobody references or closes. Letting the 
workers run means a late
+      // channel is still published for the first query to reuse, and each 
task is already bounded by its
+      // own deadline-derived timeout, so none outlives the budget.
+      executor.shutdown();
+    }
+    long elapsedMs = System.currentTimeMillis() - startMs;
+    if (connected < channelCount) {
+      LOGGER.warn("Broker pre-connected {}/{} channel(s) in {} ms ({}); the 
rest fall back to the lazy connect "
+          + "path", connected, channelCount, elapsedMs, releasedEarly ? 
"released early on a straggler grace window"
+          : "budget elapsed");
+    } else {
+      LOGGER.info("Broker pre-connected {}/{} channel(s) in {} ms", connected, 
channelCount, elapsedMs);
+    }
+    return connected;
+  }
+}
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 b9d3ad23e19..4fd4ef47bac 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
@@ -21,9 +21,12 @@ package org.apache.pinot.broker.requesthandler;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.Maps;
 import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
 import java.util.IdentityHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import javax.annotation.Nullable;
 import javax.annotation.concurrent.ThreadSafe;
@@ -53,6 +56,7 @@ import org.apache.pinot.core.transport.ServerRoutingInstance;
 import 
org.apache.pinot.core.transport.server.routing.stats.ServerRoutingStatsManager;
 import org.apache.pinot.materializedview.handler.MaterializedViewHandler;
 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.exception.QueryErrorCode;
 import org.apache.pinot.spi.exception.QueryException;
@@ -113,6 +117,48 @@ public class SingleConnectionBrokerRequestHandler extends 
BaseSingleStageBrokerR
     _brokerReduceService.shutDown();
   }
 
+  /// Opens the broker-to-server channels this broker actually routes to, 
taking the blocking connect --
+  /// and, when broker-to-server TLS is on, the handshake -- off the first 
real query's critical path. The
+  /// channels opened are derived from routing (see 
[#routableChannelTargets]), so an offline-only cluster
+  /// opens no REALTIME channels and a broker serving one tenant does not 
connect to another tenant's
+  /// servers. The caller guarantees Helix has converged, so routing reflects 
the tables and servers this
+  /// broker serves. Never throws; returns the number of channels connected 
before `deadlineMs`.
+  @Override
+  public int preConnectServers(long deadlineMs) {
+    return new ServerPreConnector(() -> 
routableChannelTargets(_routingManager),
+        _queryRouter::preConnect).preConnect(deadlineMs);
+  }
+
+  /// Derives the (server, table type) channels this broker actually routes 
to, so pre-connect opens
+  /// exactly those. Iterates the broker's routable tables (each name carries 
its type) and resolves each
+  /// table's serving instances to `ServerInstance`s, deduping across tables. 
This deliberately does **not**
+  /// use `getRoutableServerInstanceMap()` as the server set: that is every 
enabled server in the whole
+  /// cluster, not this broker's, and crossing it with both table types would 
open a duplicate socket per
+  /// server (OFFLINE and REALTIME are separate channels) plus channels to 
servers this broker never
+  /// queries. Static and package-private for unit testing against a mocked 
[RoutingManager].
+  @VisibleForTesting
+  static Collection<ServerPreConnector.ChannelTarget> 
routableChannelTargets(RoutingManager routingManager) {
+    Map<String, ServerInstance> serverInstanceMap = 
routingManager.getRoutableServerInstanceMap();
+    Set<ServerPreConnector.ChannelTarget> targets = new HashSet<>();
+    for (String tableNameWithType : routingManager.getRoutableTables()) {
+      TableType tableType = 
TableNameBuilder.getTableTypeFromTableName(tableNameWithType);
+      if (tableType == null) {
+        continue;
+      }
+      Set<String> servingInstances = 
routingManager.getServingInstances(tableNameWithType);
+      if (servingInstances == null) {
+        continue;
+      }
+      for (String instanceId : servingInstances) {
+        ServerInstance serverInstance = serverInstanceMap.get(instanceId);
+        if (serverInstance != null) {
+          targets.add(new ServerPreConnector.ChannelTarget(serverInstance, 
tableType));
+        }
+      }
+    }
+    return targets;
+  }
+
   @Override
   protected BrokerResponseNative processBrokerRequest(long requestId, 
BrokerRequest originalBrokerRequest,
       BrokerRequest serverBrokerRequest, TableRouteInfo route, long timeoutMs,
diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/manager/BaseBrokerRoutingManager.java
 
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/manager/BaseBrokerRoutingManager.java
index dfde3e9c950..db3f106b2e8 100644
--- 
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/manager/BaseBrokerRoutingManager.java
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/manager/BaseBrokerRoutingManager.java
@@ -1269,6 +1269,11 @@ public abstract class BaseBrokerRoutingManager 
implements RoutingManager, Cluste
     return _routableServerInstanceMap;
   }
 
+  @Override
+  public Set<String> getRoutableTables() {
+    return Set.copyOf(_routingEntryMap.keySet());
+  }
+
   /// Must be called under `_globalLock.writeLock()` when rebuilding 
`_routableServerInstanceMap` from a freshly
   /// computed set of routable server IDs.
   @GuardedBy("_globalLock.writeLock()")
diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/manager/MultiClusterRoutingManager.java
 
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/manager/MultiClusterRoutingManager.java
index 9f04a4ca3d4..538b85dd416 100644
--- 
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/manager/MultiClusterRoutingManager.java
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/manager/MultiClusterRoutingManager.java
@@ -167,6 +167,18 @@ public class MultiClusterRoutingManager implements 
RoutingManager {
     return combined;
   }
 
+  @Override
+  public Set<String> getRoutableTables() {
+    // Union across clusters so startup pre-connect derives channels for every 
cluster this broker routes
+    // to; without this override the RoutingManager default returns empty and 
pre-connect would silently do
+    // nothing on a multi-cluster broker.
+    Set<String> combined = new 
HashSet<>(_localClusterRoutingManager.getRoutableTables());
+    for (BaseBrokerRoutingManager remoteCluster : 
_remoteClusterRoutingManagers) {
+      combined.addAll(remoteCluster.getRoutableTables());
+    }
+    return combined;
+  }
+
   @Override
   public TablePartitionInfo getTablePartitionInfo(String tableNameWithType) {
     return findFirst(mgr -> mgr.getTablePartitionInfo(tableNameWithType), 
tableNameWithType);
diff --git 
a/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/ServerPreConnectorTest.java
 
b/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/ServerPreConnectorTest.java
new file mode 100644
index 00000000000..00db6cad8fc
--- /dev/null
+++ 
b/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/ServerPreConnectorTest.java
@@ -0,0 +1,406 @@
+/**
+ * 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 java.util.ArrayList;
+import java.util.EnumSet;
+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.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import org.apache.pinot.broker.requesthandler.ServerPreConnector.ChannelTarget;
+import org.apache.pinot.core.routing.RoutingManager;
+import org.apache.pinot.core.transport.ServerInstance;
+import org.apache.pinot.spi.config.table.TableType;
+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;
+
+
+public class ServerPreConnectorTest {
+  private static final long ONE_MINUTE_MS = 60_000L;
+
+  private static List<ServerInstance> mockServers(int count) {
+    List<ServerInstance> servers = new ArrayList<>(count);
+    for (int i = 0; i < count; i++) {
+      servers.add(mock(ServerInstance.class));
+    }
+    return servers;
+  }
+
+  /// The (server, table type) channels the caller would derive from routing: 
here just the cross product
+  /// of the given servers and types, so the connector-behaviour tests can 
exercise a known channel count.
+  private static List<ChannelTarget> targets(List<ServerInstance> servers, 
TableType... types) {
+    List<ChannelTarget> targets = new ArrayList<>(servers.size() * 
types.length);
+    for (ServerInstance server : servers) {
+      for (TableType type : types) {
+        targets.add(new ChannelTarget(server, type));
+      }
+    }
+    return targets;
+  }
+
+  private static long farDeadline() {
+    return System.currentTimeMillis() + ONE_MINUTE_MS;
+  }
+
+  @Test
+  public void connectsEverySuppliedTarget() {
+    List<ServerInstance> servers = mockServers(3);
+    Set<TableType> tableTypesSeen = ConcurrentHashMap.newKeySet();
+    Set<Integer> serversSeen = ConcurrentHashMap.newKeySet();
+    AtomicInteger calls = new AtomicInteger();
+
+    int connected = new ServerPreConnector(() -> targets(servers, 
TableType.OFFLINE, TableType.REALTIME),
+        (server, tableType, timeoutMs) -> {
+          calls.incrementAndGet();
+          tableTypesSeen.add(tableType);
+          serversSeen.add(System.identityHashCode(server));
+          return true;
+        }).preConnect(farDeadline());
+
+    // 3 servers x 2 table types.
+    assertEquals(connected, 6);
+    assertEquals(calls.get(), 6);
+    assertEquals(serversSeen.size(), 3);
+    assertEquals(tableTypesSeen, EnumSet.of(TableType.OFFLINE, 
TableType.REALTIME));
+  }
+
+  @Test
+  public void emptyTargetsReturnsZeroWithoutConnecting() {
+    AtomicInteger calls = new AtomicInteger();
+    int connected = new ServerPreConnector(List::of, (server, tableType, 
timeoutMs) -> {
+      calls.incrementAndGet();
+      return true;
+    }).preConnect(farDeadline());
+
+    assertEquals(connected, 0);
+    assertEquals(calls.get(), 0);
+  }
+
+  @Test
+  public void deadlineAlreadyPassedReturnsZeroWithoutConnecting() {
+    List<ServerInstance> servers = mockServers(2);
+    AtomicInteger calls = new AtomicInteger();
+    int connected = new ServerPreConnector(() -> targets(servers, 
TableType.OFFLINE, TableType.REALTIME),
+        (server, tableType, timeoutMs) -> {
+          calls.incrementAndGet();
+          return true;
+        }).preConnect(System.currentTimeMillis() - 1);
+
+    assertEquals(connected, 0);
+    assertEquals(calls.get(), 0);
+  }
+
+  @Test
+  public void countsOnlySuccessfulConnects() {
+    List<ServerInstance> servers = mockServers(4);
+    // OFFLINE succeeds, REALTIME fails: exactly one successful channel per 
server.
+    int connected = new ServerPreConnector(() -> targets(servers, 
TableType.OFFLINE, TableType.REALTIME),
+        (server, tableType, timeoutMs) -> tableType == 
TableType.OFFLINE).preConnect(farDeadline());
+
+    assertEquals(connected, 4);
+  }
+
+  @Test
+  public void connectFailureIsSwallowedAndOthersStillConnect() {
+    List<ServerInstance> servers = mockServers(5);
+    AtomicInteger attempts = new AtomicInteger();
+    // Every REALTIME attempt throws; the method must not propagate it and 
must still connect OFFLINE.
+    int connected = new ServerPreConnector(() -> targets(servers, 
TableType.OFFLINE, TableType.REALTIME),
+        (server, tableType, timeoutMs) -> {
+          attempts.incrementAndGet();
+          if (tableType == TableType.REALTIME) {
+            throw new RuntimeException("connect blew up");
+          }
+          return true;
+        }).preConnect(farDeadline());
+
+    assertEquals(connected, 5);        // only the 5 OFFLINE channels
+    assertEquals(attempts.get(), 10);  // all 10 were still attempted
+  }
+
+  @Test
+  public void respectsBudgetAndDoesNotWaitForSlowConnects() {
+    List<ServerInstance> servers = mockServers(4);
+    // Every connect is far slower than the budget; preConnect must return 
near the budget, not wait for
+    // the connects, and must not throw. Nothing completes, so the first poll 
(which gets the whole budget)
+    // returns empty and releases.
+    long budgetMs = 400L;
+    long startMs = System.currentTimeMillis();
+    int connected = new ServerPreConnector(() -> targets(servers, 
TableType.OFFLINE, TableType.REALTIME),
+        (server, tableType, timeoutMs) -> {
+          try {
+            Thread.sleep(5_000L);
+          } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            return false;
+          }
+          return true;
+        }).preConnect(System.currentTimeMillis() + budgetMs);
+    long elapsedMs = System.currentTimeMillis() - startMs;
+
+    assertEquals(connected, 0);
+    // Comfortably below the 5s connect: proves the budget bounded the wait 
rather than blocking on
+    // the slow connects.
+    assertTrue(elapsedMs < 3_000L, "preConnect took " + elapsedMs + " ms, 
expected it to honor the budget");
+  }
+
+  /// One unreachable server must not hold startup for the whole budget: once 
the healthy channels are back
+  /// and nothing more arrives for the grace window, preConnect returns and 
leaves the straggler to finish
+  /// (or time out) on its own daemon thread. Without the grace window the 
final poll would block for the
+  /// rest of the budget.
+  @Test
+  public void oneStuckChannelDoesNotHoldStartupForTheWholeBudget() {
+    List<ServerInstance> servers = mockServers(4);   // 4 x 2 table types = 8 
channels
+    long budgetMs = 30_000L;
+    AtomicInteger n = new AtomicInteger();
+    long startMs = System.currentTimeMillis();
+
+    int connected = new ServerPreConnector(() -> targets(servers, 
TableType.OFFLINE, TableType.REALTIME),
+        (server, tableType, timeoutMs) -> {
+          if (n.getAndIncrement() == 0) {
+            try {
+              Thread.sleep(timeoutMs);
+            } catch (InterruptedException e) {
+              Thread.currentThread().interrupt();
+            }
+            return false;
+          }
+          return true;
+        }).preConnect(startMs + budgetMs);
+    long elapsedMs = System.currentTimeMillis() - startMs;
+
+    assertEquals(connected, 7, "the seven healthy channels must still be 
counted");
+    assertTrue(elapsedMs < 5 * ServerPreConnector.STRAGGLER_GRACE_MS,
+        "one stuck channel held startup for " + elapsedMs + " ms of a " + 
budgetMs + " ms budget");
+  }
+
+  /// The whole budget is available until the first successful connect: a 
cluster whose channels are all
+  /// slower than the grace window (but faster than the budget) must still 
connect every one, not bail at
+  /// the grace window having connected nothing. If the exemption were 
missing, the first poll would time
+  /// out at the grace window before any channel returned, and connected would 
be 0.
+  @Test
+  public void slowFirstChannelIsStillCountedAndNotAbandonedByGraceWindow() {
+    List<ServerInstance> servers = mockServers(3);      // 3 x 2 = 6 channels
+    long slowMs = ServerPreConnector.STRAGGLER_GRACE_MS + 500L;   // slower 
than grace, faster than budget
+    long budgetMs = 30_000L;
+    long startMs = System.currentTimeMillis();
+
+    int connected = new ServerPreConnector(() -> targets(servers, 
TableType.OFFLINE, TableType.REALTIME),
+        (server, tableType, timeoutMs) -> {
+          try {
+            Thread.sleep(slowMs);
+          } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            return false;
+          }
+          return true;
+        }).preConnect(startMs + budgetMs);
+    long elapsedMs = System.currentTimeMillis() - startMs;
+
+    assertEquals(connected, 6, "every channel must be counted even though all 
are slower than the grace window");
+    assertTrue(elapsedMs >= slowMs, "the first channel must be waited for past 
the grace window, not abandoned");
+    assertTrue(elapsedMs < budgetMs, "must not wait the whole budget once the 
channels are back");
+  }
+
+  /// A fast failure (an instantly refused connect) that completes before the 
healthy channels must NOT
+  /// consume the whole-budget exemption and cause the grace window to abandon 
the healthy-but-slower
+  /// channels. Since the exemption keys on the first *successful* connect, 
the fast failure does not start
+  /// the grace clock, and the five healthy channels are all waited for and 
counted. Regression test for a
+  /// grace-window bug where keying on the first *completion* undercounted to 
0 here.
+  @Test
+  public void healthyButSlowChannelsNotAbandonedAfterFastFailure() {
+    List<ServerInstance> servers = mockServers(3);      // 3 x 2 = 6 channels
+    long slowMs = ServerPreConnector.STRAGGLER_GRACE_MS + 500L;   // slower 
than grace, faster than budget
+    long budgetMs = 30_000L;
+    AtomicInteger n = new AtomicInteger();
+
+    int connected = new ServerPreConnector(() -> targets(servers, 
TableType.OFFLINE, TableType.REALTIME),
+        (server, tableType, timeoutMs) -> {
+          if (n.getAndIncrement() == 0) {
+            return false;   // one instant failure, completes first, must not 
start the grace clock
+          }
+          try {
+            Thread.sleep(slowMs);
+          } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            return false;
+          }
+          return true;
+        }).preConnect(System.currentTimeMillis() + budgetMs);
+
+    assertEquals(connected, 5,
+        "the five healthy channels must be counted; a fast failure must not 
trigger the grace window");
+  }
+
+  /// More channels than worker threads: the surplus queues behind the pool 
and still all connect. Exercises
+  /// the `min(channelCount, MAX_CONNECT_THREADS)` pool sizing and the queue 
draining that the completion
+  /// loop depends on.
+  @Test
+  public void moreTargetsThanThreadsAllConnect() {
+    int count = ServerPreConnector.MAX_CONNECT_THREADS * 3;   // 48 servers -> 
96 channels, pool caps at 16
+    List<ServerInstance> servers = mockServers(count);
+    AtomicInteger calls = new AtomicInteger();
+
+    int connected = new ServerPreConnector(() -> targets(servers, 
TableType.OFFLINE, TableType.REALTIME),
+        (server, tableType, timeoutMs) -> {
+          calls.incrementAndGet();
+          return true;
+        }).preConnect(farDeadline());
+
+    assertEquals(connected, count * 2, "every queued channel must eventually 
connect");
+    assertEquals(calls.get(), count * 2, "every channel must be attempted");
+  }
+
+  /// The thread pool is a throughput cap, not a safety bound, so each connect 
has to carry its own
+  /// deadline-derived timeout. Without it a channel queued behind a stuck 
worker could outlive the
+  /// budget entirely.
+  @Test
+  public void passesRemainingBudgetToEachConnect() {
+    List<ServerInstance> servers = mockServers(2);
+    long budgetMs = 5_000L;
+    AtomicLong maxTimeoutSeen = new AtomicLong(Long.MIN_VALUE);
+    AtomicLong minTimeoutSeen = new AtomicLong(Long.MAX_VALUE);
+
+    int connected = new ServerPreConnector(() -> targets(servers, 
TableType.OFFLINE, TableType.REALTIME),
+        (server, tableType, timeoutMs) -> {
+          maxTimeoutSeen.accumulateAndGet(timeoutMs, Math::max);
+          minTimeoutSeen.accumulateAndGet(timeoutMs, Math::min);
+          return true;
+        }).preConnect(System.currentTimeMillis() + budgetMs);
+
+    assertEquals(connected, 4);
+    assertTrue(maxTimeoutSeen.get() <= budgetMs,
+        "connect timeout " + maxTimeoutSeen.get() + " ms must never exceed the 
budget " + budgetMs + " ms");
+    assertTrue(minTimeoutSeen.get() >= 0, "connect timeout must never be 
negative");
+  }
+
+  /// A connector may legitimately be handed a zero timeout when the budget 
runs out mid-flight. It must
+  /// be treated as "no budget", never as "wait forever" -- Netty reads
+  /// `ChannelOption.CONNECT_TIMEOUT_MILLIS <= 0` as *no* connect timeout, so 
a zero leaking through to
+  /// the bootstrap would park a worker indefinitely, which is the opposite of 
what the bound is for.
+  @Test
+  public void connectTimeoutIsNeverNegative() {
+    List<ServerInstance> servers = mockServers(8);
+    AtomicLong minTimeoutSeen = new AtomicLong(Long.MAX_VALUE);
+
+    new ServerPreConnector(() -> targets(servers, TableType.OFFLINE, 
TableType.REALTIME),
+        (server, tableType, timeoutMs) -> {
+          minTimeoutSeen.accumulateAndGet(timeoutMs, Math::min);
+          try {
+            Thread.sleep(20L);
+          } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+          }
+          return true;
+        }).preConnect(System.currentTimeMillis() + 50L);
+
+    assertTrue(minTimeoutSeen.get() >= 0,
+        "connect timeout " + minTimeoutSeen.get() + " ms must never be 
negative");
+  }
+
+  // ---- routing-derived target selection 
(SingleConnectionBrokerRequestHandler#routableChannelTargets) ----
+
+  private static RoutingManager routing(Map<String, ServerInstance> 
serverInstanceMap,
+      Map<String, Set<String>> tableToServingInstances) {
+    RoutingManager routingManager = mock(RoutingManager.class);
+    
when(routingManager.getRoutableServerInstanceMap()).thenReturn(serverInstanceMap);
+    
when(routingManager.getRoutableTables()).thenReturn(tableToServingInstances.keySet());
+    tableToServingInstances.forEach(
+        (table, servingInstances) -> 
when(routingManager.getServingInstances(table)).thenReturn(servingInstances));
+    return routingManager;
+  }
+
+  private static Map<String, ServerInstance> serverInstances(String... ids) {
+    Map<String, ServerInstance> map = new HashMap<>();
+    for (String id : ids) {
+      map.put(id, mock(ServerInstance.class));
+    }
+    return map;
+  }
+
+  /// Only the (server, table type) pairs routing actually uses are targeted: 
a server gets a channel for
+  /// each type that routes to it (s2 hybrid -> both), never the cross 
product, and a server no table routes
+  /// to (s4) gets nothing -- even though it is in the cluster-wide 
`getRoutableServerInstanceMap()`.
+  @Test
+  public void routableTargetsAreDerivedFromRoutingNotCrossProduct() {
+    Map<String, ServerInstance> serverMap = serverInstances("s1", "s2", "s3", 
"s4");
+    RoutingManager routingManager = routing(serverMap, Map.of(
+        "a_OFFLINE", Set.of("s1", "s2"),
+        "b_REALTIME", Set.of("s2", "s3")));
+
+    Set<ChannelTarget> targets =
+        new 
HashSet<>(SingleConnectionBrokerRequestHandler.routableChannelTargets(routingManager));
+
+    assertEquals(targets, Set.of(
+        new ChannelTarget(serverMap.get("s1"), TableType.OFFLINE),
+        new ChannelTarget(serverMap.get("s2"), TableType.OFFLINE),
+        new ChannelTarget(serverMap.get("s2"), TableType.REALTIME),
+        new ChannelTarget(serverMap.get("s3"), TableType.REALTIME)));
+  }
+
+  /// An offline-only cluster opens no REALTIME channels (the 
wasted-duplicate-socket case).
+  @Test
+  public void offlineOnlyClusterTargetsNoRealtimeChannels() {
+    Map<String, ServerInstance> serverMap = serverInstances("s1", "s2");
+    RoutingManager routingManager = routing(serverMap, Map.of(
+        "a_OFFLINE", Set.of("s1", "s2"),
+        "b_OFFLINE", Set.of("s1")));
+
+    Set<ChannelTarget> targets =
+        new 
HashSet<>(SingleConnectionBrokerRequestHandler.routableChannelTargets(routingManager));
+
+    // Two tables on s1 dedupe to a single (s1, OFFLINE); no REALTIME anywhere.
+    assertEquals(targets, Set.of(
+        new ChannelTarget(serverMap.get("s1"), TableType.OFFLINE),
+        new ChannelTarget(serverMap.get("s2"), TableType.OFFLINE)));
+  }
+
+  /// No routable tables -> no targets (a broker that converged with an empty 
routing table, e.g. a tenant
+  /// with no tables yet). preConnect then connects nothing.
+  @Test
+  public void routableTargetsEmptyWhenNoRoutableTables() {
+    RoutingManager routingManager = routing(serverInstances("s1", "s2"), 
Map.of());
+    
assertTrue(SingleConnectionBrokerRequestHandler.routableChannelTargets(routingManager).isEmpty());
+  }
+
+  /// A serving instance not present in the routable-server map (e.g. just 
disabled) is skipped rather than
+  /// producing a null-server target, and a table with no routing is ignored.
+  @Test
+  public void routableTargetsSkipUnknownServersAndUnroutedTables() {
+    Map<String, ServerInstance> serverMap = serverInstances("s1");
+    Map<String, Set<String>> tables = new HashMap<>();
+    tables.put("a_OFFLINE", Set.of("s1", "gone"));   // "gone" is not in the 
routable-server map
+    tables.put("b_OFFLINE", null);                   // no routing yet
+    RoutingManager routingManager = routing(serverMap, tables);
+
+    Set<ChannelTarget> targets =
+        new 
HashSet<>(SingleConnectionBrokerRequestHandler.routableChannelTargets(routingManager));
+
+    assertEquals(targets, Set.of(new ChannelTarget(serverMap.get("s1"), 
TableType.OFFLINE)));
+  }
+}
diff --git 
a/pinot-broker/src/test/java/org/apache/pinot/broker/routing/manager/MultiClusterRoutingManagerTest.java
 
b/pinot-broker/src/test/java/org/apache/pinot/broker/routing/manager/MultiClusterRoutingManagerTest.java
index 0ea105a56e1..98131d62716 100644
--- 
a/pinot-broker/src/test/java/org/apache/pinot/broker/routing/manager/MultiClusterRoutingManagerTest.java
+++ 
b/pinot-broker/src/test/java/org/apache/pinot/broker/routing/manager/MultiClusterRoutingManagerTest.java
@@ -285,6 +285,20 @@ public class MultiClusterRoutingManagerTest {
     assertTrue(result.containsKey("server2"));
   }
 
+  /// Routable tables union across local and remote clusters. Without this 
override the RoutingManager
+  /// default returns empty and startup pre-connect would derive no channels 
on a multi-cluster broker.
+  @Test
+  public void testGetRoutableTablesCombinesAll() {
+    
when(_localClusterRoutingManager.getRoutableTables()).thenReturn(Set.of("a_OFFLINE",
 "shared_OFFLINE"));
+    
when(_remoteClusterRoutingManager1.getRoutableTables()).thenReturn(Set.of("b_REALTIME",
 "shared_OFFLINE"));
+    
when(_remoteClusterRoutingManager2.getRoutableTables()).thenReturn(Set.of());
+
+    Set<String> result = _multiClusterRoutingManager.getRoutableTables();
+
+    // Union with the duplicate ("shared_OFFLINE") collapsed.
+    assertEquals(result, Set.of("a_OFFLINE", "b_REALTIME", "shared_OFFLINE"));
+  }
+
   // Helper methods
 
   private BrokerRequest createMockBrokerRequest(String tableName) {
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerTimer.java 
b/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerTimer.java
index b7b8cc338c6..b049e51f66c 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerTimer.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerTimer.java
@@ -54,6 +54,15 @@ public enum BrokerTimer implements AbstractMetrics.Timer {
   REALTIME_TOTAL_CPU_TIME_NS(false),
   // How long it took the server to start.
   STARTUP_SUCCESS_DURATION_MS(true),
+  // How long the startup server pre-connect (open broker->server channels, 
including TLS handshake)
+  // took, from Helix convergence to the last channel connecting or the budget 
expiring.
+  STARTUP_PRECONNECT_DURATION_MS(true),
+  // Time to establish a single broker->server Netty channel (TCP connect, 
plus the TLS handshake on the
+  // startup pre-connect path). Complements the 
NETTY_CONNECTION_CONNECT_TIME_MS gauge, which only keeps
+  // the last value, by keeping a burst of connections on a cold broker 
analyzable across the whole
+  // distribution. NOTE: must not be named after that gauge -- both derive 
their metric name from the
+  // enum constant, and a gauge and a timer sharing one name collide in the 
metrics registry.
+  NETTY_CONNECTION_CONNECT_LATENCY_MS(true),
   // GRPC query execution time
   GRPC_QUERY_EXECUTION_MS(true),
   // Audit logging timers
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/routing/RoutingManager.java 
b/pinot-core/src/main/java/org/apache/pinot/core/routing/RoutingManager.java
index 53198ede031..fbd6fa27f37 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/routing/RoutingManager.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/routing/RoutingManager.java
@@ -127,6 +127,17 @@ public interface RoutingManager {
   /// @return true if the route table exists.
   boolean routingExists(String tableNameWithType);
 
+  /// Returns the tables this instance currently has routing for.
+  ///
+  /// Used by broker startup pre-connect to derive the (server, table type) 
channels this broker actually
+  /// routes to, so it opens exactly those rather than the cross product of 
every enabled server with every
+  /// table type. The default returns an empty set for implementations that do 
not track routing per table.
+  ///
+  /// @return table names with type; empty when unknown.
+  default Set<String> getRoutableTables() {
+    return Set.of();
+  }
+
   /// Acquire the time boundary info. Useful for hybrid logical table queries 
that needs to split between
   /// realtime and offline.
   /// @param offlineTableName offline table name
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 e885a14788a..7d58a2a3413 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
@@ -157,6 +157,9 @@ public class QueryRouter {
   }
 
   /// 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.
   public boolean connect(ServerInstance serverInstance) {
     try {
       if (_serverChannelsTls != null) {
@@ -173,6 +176,29 @@ public class QueryRouter {
     }
   }
 
+  /// 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.
+  ///
+  /// [ServerRoutingInstance] includes the table type in its 
`equals`/`hashCode`, so OFFLINE and REALTIME
+  /// map to **separate** channels for the same physical server; the caller 
decides which of them this
+  /// broker actually routes to. Whatever is left out is still established 
lazily by the first query that
+  /// needs it.
+  public boolean preConnect(ServerInstance serverInstance, TableType 
tableType, long timeoutMs) {
+    try {
+      if (_serverChannelsTls != null) {
+        _serverChannelsTls.preConnect(
+            serverInstance.toServerRoutingInstance(tableType, 
ServerInstance.RoutingType.NETTY_TLS), timeoutMs);
+      } else {
+        _serverChannels.preConnect(
+            serverInstance.toServerRoutingInstance(tableType, 
ServerInstance.RoutingType.NETTY), timeoutMs);
+      }
+      return true;
+    } catch (Exception e) {
+      LOGGER.debug("Failed to pre-connect to server: {} for table type: {}", 
serverInstance, tableType, e);
+      return false;
+    }
+  }
+
   public void shutDown() {
     _serverChannels.shutDown();
   }
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 2bf34540cf3..d711c120337 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
@@ -36,6 +36,8 @@ import io.netty.channel.kqueue.KQueueSocketChannel;
 import io.netty.channel.nio.NioEventLoopGroup;
 import io.netty.channel.socket.SocketChannel;
 import io.netty.channel.socket.nio.NioSocketChannel;
+import io.netty.handler.ssl.SslHandler;
+import io.netty.util.concurrent.Future;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
@@ -141,12 +143,25 @@ public class ServerChannels {
       ServerRoutingInstance serverRoutingInstance, InstanceRequest 
instanceRequest, long timeoutMs)
       throws Exception {
     byte[] requestBytes = 
THREAD_LOCAL_T_SERIALIZER.get().serialize(instanceRequest);
-    _serverToChannelMap.computeIfAbsent(serverRoutingInstance, 
ServerChannel::new)
-        .sendRequest(rawTableName, asyncQueryResponse, serverRoutingInstance, 
requestBytes, timeoutMs);
+    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);
   }
 
+  /// Whether this broker has sent, or tried to send, a single-stage query to 
the given server.
+  ///
+  /// Deliberately **not** "is there an entry in the channel map". Startup 
pre-connect opens channels
+  /// before any query, so a plain `containsKey` would flip true for every 
reachable server merely because
+  /// pre-connect ran. 
`SingleConnectionBrokerRequestHandler#retryUnhealthyServer` uses this to decide
+  /// whether the single-stage transport has any opinion on a server's health 
at all -- it reports
+  /// `UNKNOWN` when it does not -- so on a cluster serving only multi-stage 
queries the answer has to stay
+  /// `false`, exactly as it was before pre-connect existed. Otherwise a 
server that multi-stage can reach
+  /// over gRPC but single-stage cannot reach over Netty would be voted 
`UNHEALTHY` and dropped from
+  /// routing by a transport that never carries its queries.
   public boolean hasChannel(ServerRoutingInstance serverRoutingInstance) {
-    return _serverToChannelMap.containsKey(serverRoutingInstance);
+    ServerChannel serverChannel = 
_serverToChannelMap.get(serverRoutingInstance);
+    return serverChannel != null && serverChannel._openedByQuery;
   }
 
   public void connect(ServerRoutingInstance serverRoutingInstance)
@@ -154,6 +169,16 @@ public class ServerChannels {
     _serverToChannelMap.computeIfAbsent(serverRoutingInstance, 
ServerChannel::new).connect();
   }
 
+  /// Opens a channel ahead of query traffic, awaiting the TLS handshake so 
that neither the connect nor
+  /// the handshake lands on the first query's critical path. Both waits are 
bounded by `timeoutMs`.
+  ///
+  /// The channel is entered into the same map the query path uses, so the 
first query reuses it rather
+  /// than connecting again -- but it is not marked query-carrying, so 
[#hasChannel] is unaffected.
+  public void preConnect(ServerRoutingInstance serverRoutingInstance, long 
timeoutMs)
+      throws InterruptedException, TimeoutException {
+    _serverToChannelMap.computeIfAbsent(serverRoutingInstance, 
ServerChannel::new).preConnect(timeoutMs);
+  }
+
   public void shutDown() {
     // Shut down immediately
     _eventLoopGroup.shutdownGracefully(0, 0, TimeUnit.SECONDS);
@@ -171,6 +196,11 @@ public class ServerChannels {
     // lock to protect channel as requests must be written into channel 
sequentially
     final ReentrantLock _channelLock = new ReentrantLock();
     Channel _channel;
+    // Set once a query has been sent, or attempted, through this channel; 
startup pre-connect leaves it
+    // false. Read by hasChannel(), which the failure detector uses to decide 
whether the single-stage
+    // transport has any opinion on this server's health. Volatile: written on 
a query thread, read on the
+    // failure-detector retry thread.
+    volatile boolean _openedByQuery;
 
     ServerChannel(ServerRoutingInstance serverRoutingInstance) {
       _serverRoutingInstance = serverRoutingInstance;
@@ -234,13 +264,90 @@ 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 {
       if (_channel == null || !_channel.isActive()) {
         long startTime = System.currentTimeMillis();
         _channel = _bootstrap.connect().sync().channel();
-        
_brokerMetrics.setValueOfGlobalGauge(BrokerGauge.NETTY_CONNECTION_CONNECT_TIME_MS,
-            System.currentTimeMillis() - startTime);
+        recordConnectTime(System.currentTimeMillis() - startTime);
+      }
+    }
+
+    /// Like [#connectWithoutLocking()] 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
+    /// failure detector's reconnect probe ([#connect()]) -- both of those 
keep the shorter critical
+    /// section they had before this feature existed.
+    ///
+    /// `_channel` is assigned only after the handshake succeeds. Netty fails 
the handshake promise
+    /// *before* it closes the channel (`SslHandler#setHandshakeFailure` calls 
`Promise#tryFailure` and
+    /// only then `SslUtils#handleHandshakeFailure` -> `ctx.close()`, which is 
itself asynchronous), so a
+    /// channel assigned up front would briefly still report `isActive()`, and 
a query written into it
+    /// would fail.
+    void preConnectWithoutLocking(long timeoutMs)
+        throws InterruptedException, TimeoutException {
+      if (_channel != null && _channel.isActive()) {
+        return;
+      }
+      if (timeoutMs <= 0) {
+        // No budget left. Must return before touching CONNECT_TIMEOUT_MILLIS: 
Netty only schedules its
+        // connect-timeout task when the value is > 0, so passing 0 would mean 
"wait forever".
+        throw new TimeoutException("No pre-connect budget left for server: " + 
_serverRoutingInstance);
+      }
+      long startTime = System.currentTimeMillis();
+      // The shared bootstrap leaves ChannelOption.CONNECT_TIMEOUT_MILLIS at 
Netty's 30s default, which is
+      // the whole pre-connect budget -- one server whose SYN is dropped 
rather than refused would consume
+      // it alone and occupy a worker for the entire startup. Clone the 
bootstrap so the tighter bound
+      // applies to pre-connect only and the query path is untouched.
+      Channel channel = _bootstrap.clone()
+          .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, (int) 
Math.min(timeoutMs, Integer.MAX_VALUE))
+          .connect().sync().channel();
+      try {
+        awaitTlsHandshake(channel, Math.max(0L, timeoutMs - 
(System.currentTimeMillis() - startTime)));
+      } catch (Throwable t) {
+        channel.close();
+        throw t;
+      }
+      _channel = channel;
+      recordConnectTime(System.currentTimeMillis() - startTime);
+    }
+
+    private void recordConnectTime(long connectTimeMs) {
+      
_brokerMetrics.setValueOfGlobalGauge(BrokerGauge.NETTY_CONNECTION_CONNECT_TIME_MS,
 connectTimeMs);
+      
_brokerMetrics.addTimedValue(BrokerTimer.NETTY_CONNECTION_CONNECT_LATENCY_MS, 
connectTimeMs,
+          TimeUnit.MILLISECONDS);
+    }
+
+    /// Blocks until the TLS handshake on a freshly-connected channel 
completes, for at most `timeoutMs`.
+    ///
+    /// `bootstrap.connect().sync()` returns once the TCP connection is up; 
the client-mode [SslHandler]
+    /// then drives the handshake asynchronously on the event loop. Awaiting 
its future here pays the
+    /// handshake -- two round trips plus certificate validation -- on the 
connecting thread rather than
+    /// on the first query that writes to the channel. On a plaintext channel 
there is no [SslHandler] in
+    /// the pipeline and this is a no-op. The calling thread is never an 
event-loop thread, so this cannot
+    /// deadlock.
+    ///
+    /// The wait is bounded by the caller's remaining budget rather than by 
[SslHandler]'s own
+    /// `handshakeTimeoutMillis`, which defaults to 10s and is not set on this 
pipeline -- otherwise a
+    /// single hung TLS peer could outlive the pre-connect budget.
+    private void awaitTlsHandshake(Channel channel, long timeoutMs)
+        throws InterruptedException, TimeoutException {
+      SslHandler sslHandler = channel.pipeline().get(SslHandler.class);
+      if (sslHandler == null) {
+        return;
+      }
+      Future<Channel> handshakeFuture = sslHandler.handshakeFuture();
+      if (!handshakeFuture.await(timeoutMs, TimeUnit.MILLISECONDS)) {
+        throw new TimeoutException("Timed out waiting for the TLS handshake to 
server: " + _serverRoutingInstance);
+      }
+      if (!handshakeFuture.isSuccess()) {
+        throw new RuntimeException("Failed the TLS handshake to server: " + 
_serverRoutingInstance,
+            handshakeFuture.cause());
       }
     }
 
@@ -267,6 +374,11 @@ public class ServerChannels {
       
_brokerMetrics.addMeteredGlobalValue(BrokerMeter.NETTY_CONNECTION_BYTES_SENT, 
requestBytes.length);
     }
 
+    /// Opens the TCP connection, as the failure detector's reconnect probe
+    /// (`SingleConnectionBrokerRequestHandler#retryUnhealthyServer`) has 
always done. Deliberately does
+    /// **not** await the TLS handshake: this runs at steady state under live 
traffic, and holding
+    /// `_channelLock` across a handshake would make concurrent queries to a 
recovering server queue
+    /// behind it -- the very serialization startup pre-connect exists to 
remove.
     void connect()
         throws InterruptedException, TimeoutException {
       if (_channelLock.tryLock(TRY_CONNECT_CHANNEL_LOCK_TIMEOUT_MS, 
TimeUnit.MILLISECONDS)) {
@@ -279,5 +391,19 @@ public class ServerChannels {
         throw new TimeoutException(CHANNEL_LOCK_TIMEOUT_MSG);
       }
     }
+
+    /// Startup pre-connect: opens the connection and pays the TLS handshake, 
bounded by `timeoutMs`.
+    void preConnect(long timeoutMs)
+        throws InterruptedException, TimeoutException {
+      if (_channelLock.tryLock(TRY_CONNECT_CHANNEL_LOCK_TIMEOUT_MS, 
TimeUnit.MILLISECONDS)) {
+        try {
+          preConnectWithoutLocking(timeoutMs);
+        } finally {
+          _channelLock.unlock();
+        }
+      } else {
+        throw new TimeoutException(CHANNEL_LOCK_TIMEOUT_MSG);
+      }
+    }
   }
 }
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 e2b7c89db5f..7fec4b33fed 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
@@ -44,8 +44,11 @@ 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.assertFalse;
 import static org.testng.Assert.assertNotNull;
 import static org.testng.Assert.assertSame;
+import static org.testng.Assert.assertThrows;
+import static org.testng.Assert.assertTrue;
 
 
 public class ServerChannelsTest {
@@ -197,4 +200,96 @@ public class ServerChannelsTest {
 
     serverChannels.shutDown();
   }
+
+  /// The load-bearing guarantee for clusters that do not use the single-stage 
engine: pre-connect must
+  /// not make `hasChannel()` true. `retryUnhealthyServer` reads it to decide 
whether the single-stage
+  /// transport has any opinion on a server's health, and answers `UNKNOWN` 
when it does not. If opening a
+  /// channel ahead of traffic flipped it, an MSE-only cluster would start 
voting servers `UNHEALTHY` on
+  /// Netty reachability -- and that vote short-circuits the retrier loop 
before gRPC is ever consulted.
+  @Test
+  public void testSuccessfulPreConnectLeavesHasChannelFalse()
+      throws Exception {
+    HttpServer dummyServer = HttpServer.create();
+    dummyServer.bind(new InetSocketAddress("localhost", 0), 0);
+    dummyServer.start();
+    ServerChannels serverChannels =
+        new ServerChannels(mock(QueryRouter.class), null, null, 
ThreadAccountantUtils.getNoOpAccountant());
+    try {
+      ServerRoutingInstance instance =
+          new ServerRoutingInstance("localhost", 
dummyServer.getAddress().getPort(), TableType.OFFLINE);
+      serverChannels.preConnect(instance, 5_000L);
+      assertFalse(serverChannels.hasChannel(instance),
+          "Pre-connect must not make the server look query-carrying to the 
failure detector");
+    } finally {
+      serverChannels.shutDown();
+      dummyServer.stop(0);
+    }
+  }
+
+  /// ... and a failed pre-connect must not either, so an unreachable server 
is not voted on.
+  @Test
+  public void testFailedPreConnectLeavesHasChannelFalse() {
+    ServerChannels serverChannels =
+        new ServerChannels(mock(QueryRouter.class), null, null, 
ThreadAccountantUtils.getNoOpAccountant());
+    // Port 1 on localhost: nothing listens, so the connect is refused rather 
than timing out.
+    ServerRoutingInstance unreachable = new ServerRoutingInstance("localhost", 
1, TableType.OFFLINE);
+    try {
+      assertThrows(Exception.class, () -> 
serverChannels.preConnect(unreachable, 5_000L));
+      assertFalse(serverChannels.hasChannel(unreachable));
+    } finally {
+      serverChannels.shutDown();
+    }
+  }
+
+  /// Sending a query is what makes a server query-carrying -- unchanged from 
before pre-connect existed,
+  /// including when the connect itself fails.
+  @Test
+  public void testSendRequestMakesHasChannelTrue() {
+    ServerChannels serverChannels =
+        new ServerChannels(mock(QueryRouter.class), null, null, 
ThreadAccountantUtils.getNoOpAccountant());
+    ServerRoutingInstance unreachable = new ServerRoutingInstance("localhost", 
1, TableType.OFFLINE);
+    try {
+      InstanceRequest instanceRequest = new InstanceRequest();
+      instanceRequest.setRequestId(1L);
+      instanceRequest.setQuery(new BrokerRequest());
+      assertFalse(serverChannels.hasChannel(unreachable));
+      assertThrows(Exception.class, () -> serverChannels.sendRequest("t", 
mock(AsyncQueryResponse.class), unreachable,
+          instanceRequest, 5_000L));
+      assertTrue(serverChannels.hasChannel(unreachable));
+    } finally {
+      serverChannels.shutDown();
+    }
+  }
+
+  /// The point of pre-connect: the first query reuses the warm channel rather 
than opening a second one,
+  /// and only then does the server become query-carrying.
+  @Test
+  public void testFirstQueryReusesThePreConnectedChannel()
+      throws Exception {
+    HttpServer dummyServer = HttpServer.create();
+    dummyServer.bind(new InetSocketAddress("localhost", 0), 0);
+    dummyServer.start();
+    ServerChannels serverChannels =
+        new ServerChannels(mock(QueryRouter.class), null, null, 
ThreadAccountantUtils.getNoOpAccountant());
+    try {
+      ServerRoutingInstance instance =
+          new ServerRoutingInstance("localhost", 
dummyServer.getAddress().getPort(), TableType.OFFLINE);
+      serverChannels.preConnect(instance, 5_000L);
+      ServerChannels.ServerChannel preConnected = 
serverChannels.getOrCreateServerChannel(instance);
+      Channel warmChannel = preConnected._channel;
+      assertNotNull(warmChannel);
+
+      InstanceRequest instanceRequest = new InstanceRequest();
+      instanceRequest.setRequestId(1L);
+      instanceRequest.setQuery(new BrokerRequest());
+      serverChannels.sendRequest("t", mock(AsyncQueryResponse.class), 
instance, instanceRequest, 5_000L);
+
+      assertSame(serverChannels.getOrCreateServerChannel(instance), 
preConnected);
+      assertSame(preConnected._channel, warmChannel, "The query must reuse the 
pre-connected channel");
+      assertTrue(serverChannels.hasChannel(instance));
+    } finally {
+      serverChannels.shutDown();
+      dummyServer.stop(0);
+    }
+  }
 }
diff --git 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/BrokerServerPreConnectIntegrationTest.java
 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/BrokerServerPreConnectIntegrationTest.java
new file mode 100644
index 00000000000..54ac65a0147
--- /dev/null
+++ 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/BrokerServerPreConnectIntegrationTest.java
@@ -0,0 +1,142 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.integration.tests;
+
+import java.io.File;
+import java.util.List;
+import org.apache.commons.io.FileUtils;
+import org.apache.pinot.broker.requesthandler.ServerPreConnector;
+import org.apache.pinot.common.utils.ServiceStatus;
+import org.apache.pinot.core.transport.ServerInstance;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.env.PinotConfiguration;
+import org.apache.pinot.spi.utils.CommonConstants;
+import org.apache.pinot.util.TestUtils;
+import org.testng.Assert;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+import static org.mockito.Mockito.mock;
+
+
+/// Integration test for the broker startup server pre-connect feature
+/// (`pinot.broker.startup.preconnect.enabled`). Brings up a real ZK + 
controller + server + broker with
+/// an offline table, then verifies end-to-end that:
+///
+///  1. The production pre-connect path (`RoutingManager` -> `QueryRouter` -> 
`ServerChannels` -> a live
+///     server) opens exactly the channels routing derives -- for this 
offline-only table, one OFFLINE
+///     channel per serving server and no REALTIME channels.
+///  2. The readiness gate genuinely holds and then releases -- exercised 
directly against
+///     [ServerPreConnector] with a slow connector, because the broker's own 
gate is fast enough that
+///     asserting on its terminal status could not distinguish a working gate 
from one stuck open.
+///
+/// The server is started **before** the broker so that routing, and therefore 
the set of channels to
+/// pre-connect, is non-empty by the time the broker's pre-connect thread 
runs. With the broker first
+/// there is nothing to connect and the feature would appear to pass while 
doing nothing.
+public class BrokerServerPreConnectIntegrationTest extends 
BaseClusterIntegrationTest {
+  private static final long PRECONNECT_TIMEOUT_MS = 30_000L;
+
+  @Override
+  protected void overrideBrokerConf(PinotConfiguration brokerConf) {
+    
brokerConf.setProperty(CommonConstants.Broker.CONFIG_OF_BROKER_STARTUP_PRECONNECT_ENABLED,
 true);
+    
brokerConf.setProperty(CommonConstants.Broker.CONFIG_OF_BROKER_STARTUP_PRECONNECT_TIMEOUT_MS,
+        PRECONNECT_TIMEOUT_MS);
+  }
+
+  @BeforeClass
+  public void setUp()
+      throws Exception {
+    TestUtils.ensureDirectoriesExistAndEmpty(_tempDir, _segmentDir, _tarDir);
+    startZk();
+    startController();
+    startServer();
+    startBroker();
+
+    Schema schema = createSchema();
+    addSchema(schema);
+    TableConfig tableConfig = createOfflineTableConfig();
+    addTableConfig(tableConfig);
+
+    // Build and upload segments so the broker has a live server to route to 
-- and therefore a real
+    // channel to pre-connect against.
+    List<File> avroFiles = unpackAvroData(_tempDir);
+    ClusterIntegrationTestUtils.buildSegmentsFromAvro(avroFiles, tableConfig, 
schema, 0, _segmentDir, _tarDir);
+    uploadSegments(getTableName(), _tarDir);
+
+    waitForAllDocsLoaded(600_000L);
+  }
+
+  @AfterClass
+  public void tearDown()
+      throws Exception {
+    dropOfflineTable(getTableName());
+    stopBroker();
+    stopServer();
+    stopController();
+    stopZk();
+    FileUtils.deleteDirectory(_tempDir);
+  }
+
+  @Test
+  public void preConnectEnabledBrokerReachesGoodServiceStatus() {
+    String instanceId = _brokerStarters.get(0).getInstanceId();
+    TestUtils.waitForCondition(aVoid -> 
ServiceStatus.getServiceStatus(instanceId) == ServiceStatus.Status.GOOD,
+        PRECONNECT_TIMEOUT_MS, "Broker with pre-connect enabled never reported 
GOOD service status");
+  }
+
+  @Test
+  public void preConnectOpensChannelsToEveryLiveServer() {
+    // Routing-derived targets: an offline-only table yields one OFFLINE 
channel per serving server and no
+    // REALTIME channels. Connecting an already-open channel is a no-op that 
still counts as connected, so
+    // the expected count holds regardless of any earlier lazy connects from 
setUp's queries.
+    int expectedChannels = _serverStarters.size();
+    int connected = _brokerStarters.get(0).getBrokerRequestHandler()
+        .preConnectServers(System.currentTimeMillis() + PRECONNECT_TIMEOUT_MS);
+    Assert.assertEquals(connected, expectedChannels,
+        "Pre-connect should open one OFFLINE channel per serving server and no 
REALTIME channels");
+  }
+
+  /// The readiness gate is the highest-risk part of the feature, and the 
broker's own pre-connect
+  /// finishes in milliseconds here, so a terminal-status assertion cannot 
tell a working gate from one
+  /// that never engaged. Drive [ServerPreConnector] directly with a connector 
slower than the budget and
+  /// assert both halves of the contract: the wait is bounded, and it ends.
+  @Test
+  public void preConnectBoundsTheGateWhenServersAreSlow() {
+    List<ServerPreConnector.ChannelTarget> targets =
+        List.of(new 
ServerPreConnector.ChannelTarget(mock(ServerInstance.class), 
TableType.OFFLINE));
+    long budgetMs = 500L;
+    long startMs = System.currentTimeMillis();
+    int connected = new ServerPreConnector(() -> targets, (server, tableType, 
timeoutMs) -> {
+      try {
+        Thread.sleep(30_000L);
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+      }
+      return false;
+    }).preConnect(startMs + budgetMs);
+    long elapsedMs = System.currentTimeMillis() - startMs;
+
+    Assert.assertEquals(connected, 0);
+    Assert.assertTrue(elapsedMs < 10_000L,
+        "Pre-connect held the readiness gate for " + elapsedMs + " ms, well 
past its " + budgetMs + " ms budget");
+  }
+}
diff --git 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/TlsIntegrationTest.java
 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/TlsIntegrationTest.java
index 7da5dce82e2..9c8db73105e 100644
--- 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/TlsIntegrationTest.java
+++ 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/TlsIntegrationTest.java
@@ -589,6 +589,35 @@ public class TlsIntegrationTest extends 
BaseClusterIntegrationTest {
     }
   }
 
+  /// Startup pre-connect over a **real** broker-to-server TLS channel. This 
cluster runs the server with
+  /// `netty.enabled=false` and `nettytls.enabled=true`, so every single-stage 
channel the broker opens
+  /// carries an `SslHandler` -- which is the case pre-connect exists for, and 
the one no plaintext test
+  /// can reach.
+  ///
+  /// The assertion is the channel count rather than a log line because 
`preConnectServers` awaits the
+  /// handshake and reports a channel as connected only once it has completed: 
a handshake that failed,
+  /// timed out, or was never awaited would show up here as a short count. 
Pre-connect swallows its own
+  /// failures by design, so without this the TLS path could break silently 
and every other assertion in
+  /// this class would still pass.
+  ///
+  /// Connecting an already-open channel is a no-op that still counts, so the 
expected count holds
+  /// regardless of channels the preceding tests' queries already opened 
lazily.
+  ///
+  /// Pre-connect opens only the (server, table type) pairs routing derives. 
This cluster's offline table
+  /// has no segments uploaded -- only the realtime table is fed, via Kafka -- 
so only the REALTIME channel
+  /// is routed: one per serving server, and no OFFLINE channel. The old cross 
product would have opened an
+  /// OFFLINE channel here too, to a server holding no offline segment: 
exactly the wasted TLS handshake and
+  /// idle socket this change removes.
+  @Test
+  public void testPreConnectOpensTlsChannelsToEveryServer() {
+    int expectedChannels = _serverStarters.size();
+    int connected = _brokerStarters.get(0).getBrokerRequestHandler()
+        .preConnectServers(System.currentTimeMillis() + 30_000L);
+    Assert.assertEquals(connected, expectedChannels,
+        "Pre-connect should complete the TLS handshake for the realtime 
channel each server serves, and open "
+            + "no offline channel");
+  }
+
   @Test
   public void testLogicalTableTlsRouting()
       throws Exception {
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 50cb94b4a15..02c91c1afe1 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
@@ -434,6 +434,21 @@ public class CommonConstants {
     public static final String CONFIG_OF_BROKER_MIN_RESOURCE_PERCENT_FOR_START 
=
         "pinot.broker.startup.minResourcePercent";
     public static final double DEFAULT_BROKER_MIN_RESOURCE_PERCENT_FOR_START = 
100.0;
+
+    // When enabled, once Helix converges at startup the broker opens a Netty 
channel to every (server,
+    // table type) it routes to -- including the TLS handshake when 
broker->server TLS is on -- so the
+    // first real query does not pay the blocking connect on its critical 
path. Runs on a background
+    // thread and is bounded by 
CONFIG_OF_BROKER_STARTUP_PRECONNECT_TIMEOUT_MS; channels that do not make
+    // it fall back to the lazy path. On by default; set to false to restore 
the pure lazy-connect path,
+    // whose behaviour is then unchanged.
+    public static final String CONFIG_OF_BROKER_STARTUP_PRECONNECT_ENABLED =
+        "pinot.broker.startup.preconnect.enabled";
+    public static final boolean DEFAULT_BROKER_STARTUP_PRECONNECT_ENABLED = 
true;
+    // Upper bound on the whole pre-connect step so a slow or unreachable 
server cannot delay it
+    // indefinitely; channels not connected within the budget fall back to the 
lazy path.
+    public static final String CONFIG_OF_BROKER_STARTUP_PRECONNECT_TIMEOUT_MS =
+        "pinot.broker.startup.preconnect.timeoutMs";
+    public static final long DEFAULT_BROKER_STARTUP_PRECONNECT_TIMEOUT_MS = 
30_000L;
     public static final String CONFIG_OF_ENABLE_QUERY_LIMIT_OVERRIDE = 
"pinot.broker.enable.query.limit.override";
 
     // Config for number of threads to use for Broker reduce-phase.


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

Reply via email to