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 26f47121863 Broker startup pre-connect: remove the straggler-grace 
window (#19519)
26f47121863 is described below

commit 26f471218633374f1eea3f6b5c6d81461b9b832b
Author: Jinesh Parakh <[email protected]>
AuthorDate: Thu Sep 10 21:41:12 2026 +0530

    Broker startup pre-connect: remove the straggler-grace window (#19519)
---
 .../broker/requesthandler/ServerPreConnector.java  |  72 ++++++--------
 .../requesthandler/ServerPreConnectorTest.java     | 106 +++++++++++++++------
 2 files changed, 107 insertions(+), 71 deletions(-)

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
index c90b65e9f0b..3e01cdaa2fa 100644
--- 
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
@@ -52,11 +52,13 @@ import org.slf4j.LoggerFactory;
 /// 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.
+/// the remaining budget, and the caller's `deadlineMs` as the single release 
bound. There is no early
+/// release: [#preConnect] waits for each channel to resolve (connect or fail) 
up to the deadline, then
+/// returns. That keeps readiness honest -- the broker is released once its 
channels are actually warm, or
+/// the budget is spent -- rather than guessing from a quiet period that the 
rest are stuck, which cannot
+/// tell a slow-but-healthy connect from a dead one and so can release with 
healthy channels still cold. A
+/// failed or unreachable connect is logged and skipped and never stops the 
others from being waited for;
+/// the deadline is the only thing that ends the wait early. 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.
@@ -70,19 +72,11 @@ public class ServerPreConnector {
   /// 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
+  /// behind the workers, so the deadline 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
@@ -110,10 +104,10 @@ public class ServerPreConnector {
   }
 
   /// 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.
+  /// [System#currentTimeMillis] value). Returns the number of channels 
connected by the deadline. A channel
+  /// still connecting when the deadline passes keeps warming on its daemon 
thread and is published for the
+  /// first query to reuse; it is simply not counted. Never throws: a channel 
that fails or times out is
+  /// logged and skipped, and never stops the others from being waited for.
   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
@@ -132,7 +126,6 @@ public class ServerPreConnector {
     // 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(),
@@ -143,24 +136,16 @@ public class ServerPreConnector {
         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);
+          // Wait up to the whole remaining budget for the next channel. A 
completed channel returns
+          // immediately, so a fully healthy cluster is released as soon as 
its last channel is up -- not at
+          // the deadline. The only thing this actually blocks on is a channel 
that never completes (an
+          // unreachable server that black-holes to its own connect timeout); 
the deadline is the single
+          // bound on that, and one such server costs at most the budget, 
never the others' warm-up.
+          Future<Boolean> future = completionService.poll(remainingMs, 
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);
+            LOGGER.info("Pre-connect budget elapsed with {}/{} channel(s) 
still outstanding; releasing startup "
+                + "and leaving them to the lazy connect path", channelCount - 
i, channelCount);
             break;
           }
           if (Boolean.TRUE.equals(future.get())) {
@@ -171,23 +156,22 @@ public class ServerPreConnector {
           Thread.currentThread().interrupt();
           break;
         } catch (ExecutionException e) {
-          // A server that is unreachable or itself restarting must not block 
startup.
+          // A server that is unreachable or itself restarting must not stop 
us waiting for the others.
           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.
+      // shutdown(), not shutdownNow(): a channel still connecting past the 
deadline is 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 still
+      // warms in the background and is 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");
+      LOGGER.warn("Broker pre-connected {}/{} channel(s) in {} ms; the rest 
fall back to the lazy connect path",
+          connected, channelCount, elapsedMs);
     } else {
       LOGGER.info("Broker pre-connected {}/{} channel(s) in {} ms", connected, 
channelCount, elapsedMs);
     }
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
index 00db6cad8fc..8c2bd3dbe47 100644
--- 
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
@@ -169,14 +169,14 @@ public class ServerPreConnectorTest {
     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.
+  /// An unreachable server that black-holes (never completes) holds 
pre-connect only until the deadline,
+  /// and the healthy channels are still counted. There is no early release: 
the deadline is the single
+  /// bound, so a broker with one dead server pays at most the budget on 
startup, and that cost is tuned
+  /// through the budget rather than a heuristic.
   @Test
-  public void oneStuckChannelDoesNotHoldStartupForTheWholeBudget() {
+  public void 
blackHoledChannelIsBoundedByTheDeadlineAndHealthyChannelsAreCounted() {
     List<ServerInstance> servers = mockServers(4);   // 4 x 2 table types = 8 
channels
-    long budgetMs = 30_000L;
+    long budgetMs = 800L;
     AtomicInteger n = new AtomicInteger();
     long startMs = System.currentTimeMillis();
 
@@ -184,7 +184,7 @@ public class ServerPreConnectorTest {
         (server, tableType, timeoutMs) -> {
           if (n.getAndIncrement() == 0) {
             try {
-              Thread.sleep(timeoutMs);
+              Thread.sleep(5_000L);   // black-hole well past the budget; 
never connects in time
             } catch (InterruptedException e) {
               Thread.currentThread().interrupt();
             }
@@ -195,18 +195,18 @@ public class ServerPreConnectorTest {
     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");
+    // Bounded by the deadline: the one black-holed channel holds only until 
the budget, never longer.
+    assertTrue(elapsedMs < 3 * budgetMs,
+        "one black-holed channel held startup for " + elapsedMs + " ms of an " 
+ 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.
+  /// Slow-but-healthy channels are all counted: with the deadline as the only 
bound, a cluster whose every
+  /// channel is slow (but faster than the budget) still connects every one 
and is released once they are
+  /// all up -- not at the deadline, and never with any of them abandoned.
   @Test
-  public void slowFirstChannelIsStillCountedAndNotAbandonedByGraceWindow() {
+  public void slowHealthyChannelsAreAllCounted() {
     List<ServerInstance> servers = mockServers(3);      // 3 x 2 = 6 channels
-    long slowMs = ServerPreConnector.STRAGGLER_GRACE_MS + 500L;   // slower 
than grace, faster than budget
+    long slowMs = 2_500L;                               // slow, but faster 
than the budget
     long budgetMs = 30_000L;
     long startMs = System.currentTimeMillis();
 
@@ -222,27 +222,26 @@ public class ServerPreConnectorTest {
         }).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");
+    assertEquals(connected, 6, "every channel must be counted even though all 
are slow");
+    assertTrue(elapsedMs >= slowMs, "the slow channels must be waited for, not 
abandoned");
+    assertTrue(elapsedMs < budgetMs, "must be released once the channels are 
back, not held to the deadline");
   }
 
-  /// 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.
+  /// The core invariant: a failed connect must not stop us waiting for the 
others. One connect fails
+  /// instantly and completes first; the five healthy-but-slower channels must 
still all be waited for and
+  /// counted. (Under the old grace window, keying the release on the first 
*completion* undercounted this
+  /// to 0; waiting to the deadline makes it unconditional.)
   @Test
-  public void healthyButSlowChannelsNotAbandonedAfterFastFailure() {
+  public void aFastFailureDoesNotStopWaitingForTheOtherChannels() {
     List<ServerInstance> servers = mockServers(3);      // 3 x 2 = 6 channels
-    long slowMs = ServerPreConnector.STRAGGLER_GRACE_MS + 500L;   // slower 
than grace, faster than budget
+    long slowMs = 2_500L;                               // slow, but faster 
than the 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
+            return false;   // one instant failure, completes first, must not 
end the wait
           }
           try {
             Thread.sleep(slowMs);
@@ -254,7 +253,7 @@ public class ServerPreConnectorTest {
         }).preConnect(System.currentTimeMillis() + budgetMs);
 
     assertEquals(connected, 5,
-        "the five healthy channels must be counted; a fast failure must not 
trigger the grace window");
+        "the five healthy channels must be counted; a fast failure must not 
stop us waiting for them");
   }
 
   /// More channels than worker threads: the surplus queues behind the pool 
and still all connect. Exercises
@@ -276,6 +275,59 @@ public class ServerPreConnectorTest {
     assertEquals(calls.get(), count * 2, "every channel must be attempted");
   }
 
+  /// More channels than worker threads, every one HEALTHY but slow. The 
surplus completes in waves one
+  /// connect-latency apart; because pre-connect waits to the deadline rather 
than releasing on a quiet
+  /// window, every wave is waited for and all connect. (The grace window this 
replaces under-counted this
+  /// to ~one wave when the inter-wave gap exceeded the window.)
+  @Test
+  public void manyHealthyChannelsInWavesAllConnect() {
+    int count = ServerPreConnector.MAX_CONNECT_THREADS * 3;   // 48 channels, 
pool caps at 16 -> 3 waves
+    List<ServerInstance> servers = mockServers(count);
+    long slowMs = 3_000L;
+
+    int connected = new ServerPreConnector(() -> targets(servers, 
TableType.OFFLINE),
+        (server, tableType, timeoutMs) -> {
+          try {
+            Thread.sleep(slowMs);
+          } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            return false;
+          }
+          return true;
+        }).preConnect(System.currentTimeMillis() + 60_000L);
+
+    assertEquals(connected, count, "all healthy channels must connect even 
when they complete in waves");
+  }
+
+  /// Mixed connect latencies -- one fast server, the rest slower -- now 
connect **all** channels. The old
+  /// grace window sized itself off the first (fastest) connect and released 
before the slower healthy
+  /// channels returned, counting only 1; waiting to the deadline waits for 
every one. Regression test for
+  /// that mixed-latency under-count.
+  @Test
+  public void mixedLatencyAllChannelsConnect() {
+    List<ServerInstance> servers = mockServers(8);   // 8 channels, all start 
at once on the 16-worker pool
+    long slowMs = 3_000L;
+    long budgetMs = 30_000L;
+    AtomicInteger n = new AtomicInteger();
+
+    int connected = new ServerPreConnector(() -> targets(servers, 
TableType.OFFLINE),
+        (server, tableType, timeoutMs) -> {
+          if (n.getAndIncrement() == 0) {
+            return true;   // one fast connect; must not curtail waiting for 
the slower healthy ones
+          }
+          try {
+            Thread.sleep(slowMs);
+          } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            return false;
+          }
+          return true;
+        }).preConnect(System.currentTimeMillis() + budgetMs);
+
+    assertEquals(connected, 8,
+        "with the deadline as the only bound, a fast connect no longer 
abandons the slower healthy channels");
+  }
+
   /// 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.


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

Reply via email to