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 91b9f06d478 [multistage] Add gRPC keep-alive to the MSE mailbox 
channels (#19383)
91b9f06d478 is described below

commit 91b9f06d4781696d3b65e59dc42d9017e679c859
Author: Gonzalo Ortiz Jaureguizar <[email protected]>
AuthorDate: Mon Aug 31 17:38:15 2026 +0200

    [multistage] Add gRPC keep-alive to the MSE mailbox channels (#19383)
---
 .../pinot/query/grpc/GrpcKeepAliveConfig.java      | 103 ++++++++
 .../apache/pinot/query/mailbox/MailboxService.java |  15 +-
 .../query/mailbox/channel/ChannelManager.java      |  54 +++-
 .../query/mailbox/channel/GrpcMailboxServer.java   |  32 ++-
 .../query/service/dispatch/DispatchClient.java     |  52 +---
 .../query/service/dispatch/QueryDispatcher.java    |   9 +-
 .../pinot/query/grpc/GrpcKeepAliveConfigTest.java  | 119 +++++++++
 .../mailbox/MailboxServiceKeepAliveWiringTest.java |  82 ++++++
 .../query/mailbox/channel/ChannelManagerTest.java  |  48 +++-
 .../channel/MailboxChannelKeepAliveTest.java       | 291 +++++++++++++++++++++
 .../MailboxServerPermitKeepAliveBehaviorTest.java  | 146 +++++++++++
 .../channel/MailboxServerPermitKeepAliveTest.java  |  77 ++++++
 .../apache/pinot/spi/utils/CommonConstants.java    |  62 +++++
 13 files changed, 1026 insertions(+), 64 deletions(-)

diff --git 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/grpc/GrpcKeepAliveConfig.java
 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/grpc/GrpcKeepAliveConfig.java
new file mode 100644
index 00000000000..880d9bbff01
--- /dev/null
+++ 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/grpc/GrpcKeepAliveConfig.java
@@ -0,0 +1,103 @@
+/**
+ * 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.query.grpc;
+
+import com.google.common.base.Preconditions;
+import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
+import java.util.concurrent.TimeUnit;
+import org.apache.pinot.spi.env.PinotConfiguration;
+import org.apache.pinot.spi.utils.CommonConstants;
+
+
+/// Immutable gRPC client keep-alive settings for the multi-stage engine's 
internal channels.
+///
+/// Keep-alive makes a *silently* unreachable peer observable. A peer whose 
process is gone sends a `RST`, and gRPC
+/// fails the channel immediately; a peer whose kernel is hung, or that sits 
behind a one-way partition, sends
+/// nothing at all. Without a transport-level ping such a channel stays in 
`READY` indefinitely, and every RPC
+/// issued on it parks until its own deadline. Since channels are cached per 
peer, that outlives the query that
+/// first hit it: the cached channel keeps pointing at a dead peer, and 
because gRPC only re-resolves DNS when a
+/// transport is dropped, even a peer that has since come back at a new 
address is never reached again.
+///
+/// Applies to the MSE's own channels only. The keep-alive settings for the 
user-facing gRPC query service live in
+/// [org.apache.pinot.common.config.GrpcConfig].
+///
+/// @param timeMs interval between keep-alive pings, in milliseconds; 
keep-alive is disabled when not positive.
+///               gRPC clamps this up to its own 10s floor, so a smaller value 
has no effect
+/// @param timeoutMs how long a ping may go unanswered before the transport is 
declared dead. Must be positive when
+///                  keep-alive is enabled
+/// @param withoutCalls whether to ping while the connection carries no active 
RPC. Requires the peer to permit it
+///                     (`permitKeepAliveWithoutCalls`), otherwise the peer 
answers with `GOAWAY(ENHANCE_YOUR_CALM)`
+public record GrpcKeepAliveConfig(int timeMs, int timeoutMs, boolean 
withoutCalls) {
+  /// No keep-alive pings. gRPC's own default, and what every MSE channel did 
before keep-alive was configurable.
+  public static final GrpcKeepAliveConfig DISABLED = new 
GrpcKeepAliveConfig(-1, 30_000, false);
+
+  /// Reads the policy for the multi-stage engine's **mailbox** channels from 
`config`.
+  ///
+  /// The single place these three keys and their defaults are interpreted, so 
that anything opening a
+  /// mailbox channel — including an alternative execution engine that 
maintains its own channels —
+  /// resolves exactly what 
[org.apache.pinot.query.mailbox.channel.ChannelManager] would, and cannot
+  /// drift from it if a default changes.
+  ///
+  /// The broker dispatch channel has its own keys and reads them at its own 
call site
+  /// 
([org.apache.pinot.broker.requesthandler.MultiStageBrokerRequestHandler]), 
because it also has to
+  /// hand them to a [org.apache.pinot.query.service.dispatch.QueryDispatcher] 
overload that predates
+  /// this type.
+  public static GrpcKeepAliveConfig forMailboxChannels(PinotConfiguration 
config) {
+    return new GrpcKeepAliveConfig(
+        
config.getProperty(CommonConstants.MultiStageQueryRunner.KEY_OF_CHANNEL_KEEP_ALIVE_TIME_MS,
+            
CommonConstants.MultiStageQueryRunner.DEFAULT_CHANNEL_KEEP_ALIVE_TIME_MS),
+        
config.getProperty(CommonConstants.MultiStageQueryRunner.KEY_OF_CHANNEL_KEEP_ALIVE_TIMEOUT_MS,
+            
CommonConstants.MultiStageQueryRunner.DEFAULT_CHANNEL_KEEP_ALIVE_TIMEOUT_MS),
+        
config.getProperty(CommonConstants.MultiStageQueryRunner.KEY_OF_CHANNEL_KEEP_ALIVE_WITHOUT_CALLS,
+            
CommonConstants.MultiStageQueryRunner.DEFAULT_CHANNEL_KEEP_ALIVE_WITHOUT_CALLS));
+  }
+
+  public GrpcKeepAliveConfig {
+    if (timeMs > 0) {
+      Preconditions.checkArgument(timeoutMs > 0,
+          "keepAliveTimeoutMs must be positive when keep-alive is enabled, 
got: %s", timeoutMs);
+    }
+  }
+
+  /// Applies these settings to `builder`, leaving it untouched when 
keep-alive is disabled.
+  ///
+  /// Centralizing the enabled check here is what keeps a caller from 
configuring a `keepAliveTime` of `-1`, which
+  /// gRPC rejects, instead of leaving the ping off.
+  public NettyChannelBuilder configure(NettyChannelBuilder builder) {
+    if (isEnabled()) {
+      builder.keepAliveTime(timeMs, TimeUnit.MILLISECONDS)
+          .keepAliveTimeout(timeoutMs, TimeUnit.MILLISECONDS)
+          .keepAliveWithoutCalls(withoutCalls);
+    }
+    return builder;
+  }
+
+  public boolean isEnabled() {
+    return timeMs > 0;
+  }
+
+  /// Renders as the mailbox and dispatch startup logs show it. Kept instead 
of the generated `toString` so a
+  /// disabled policy reads as such rather than as a `-1` interval.
+  @Override
+  public String toString() {
+    return isEnabled()
+        ? "keepAlive[timeMs=" + timeMs + ", timeoutMs=" + timeoutMs + ", 
withoutCalls=" + withoutCalls + "]"
+        : "keepAlive[disabled]";
+  }
+}
diff --git 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/mailbox/MailboxService.java
 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/mailbox/MailboxService.java
index 87403ee3a6d..0a130b6692f 100644
--- 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/mailbox/MailboxService.java
+++ 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/mailbox/MailboxService.java
@@ -41,6 +41,7 @@ import 
org.apache.pinot.core.instance.context.ControllerContext;
 import org.apache.pinot.core.instance.context.ServerContext;
 import org.apache.pinot.core.transport.grpc.GrpcQueryServer;
 import org.apache.pinot.query.access.QueryAccessControlFactory;
+import org.apache.pinot.query.grpc.GrpcKeepAliveConfig;
 import org.apache.pinot.query.mailbox.channel.ChannelManager;
 import org.apache.pinot.query.mailbox.channel.GrpcMailboxServer;
 import org.apache.pinot.query.runtime.operator.MailboxSendOperator;
@@ -131,11 +132,13 @@ public class MailboxService {
     int writeBufferLowWaterMarkBytes = config.getProperty(
         
CommonConstants.MultiStageQueryRunner.KEY_OF_GRPC_WRITE_BUFFER_LOW_WATER_MARK_BYTES,
         
CommonConstants.MultiStageQueryRunner.DEFAULT_GRPC_WRITE_BUFFER_LOW_WATER_MARK_BYTES);
+    GrpcKeepAliveConfig keepAliveConfig = 
GrpcKeepAliveConfig.forMailboxChannels(config);
     _channelManager = new ChannelManager(_clientSslContext, 
_maxInboundMessageSize, getIdleTimeout(config),
-        writeBufferHighWaterMarkBytes, writeBufferLowWaterMarkBytes);
+        writeBufferHighWaterMarkBytes, writeBufferLowWaterMarkBytes, 
keepAliveConfig);
     _accessControlFactory = accessControlFactory;
     registerMailboxClientGauges();
-    LOGGER.info("Initialized MailboxService with hostname: {}, port: {}", 
hostname, port);
+    LOGGER.info("Initialized MailboxService with hostname: {}, port: {}, 
channel {}", hostname, port,
+        keepAliveConfig);
   }
 
   /// Registers gauges exposing the memory used by the gRPC client allocator
@@ -240,6 +243,14 @@ public class MailboxService {
   /// [ServerGauge#MAILBOX_CLIENT_USED_DIRECT_MEMORY] — bytes pinned by the
   /// shared gRPC client allocator backing every [GrpcSendingMailbox] created
   /// from this service.
+  /// The channel manager these mailboxes send through. Exposed so a test can 
assert that the policy
+  /// resolved from config actually reaches the transport, which no assertion 
on the parsed values can
+  /// show.
+  @VisibleForTesting
+  ChannelManager getChannelManager() {
+    return _channelManager;
+  }
+
   @VisibleForTesting
   public long getMailboxClientUsedDirectMemoryBytes() {
     return _channelManager.usedDirectMemoryBytes();
diff --git 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/mailbox/channel/ChannelManager.java
 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/mailbox/channel/ChannelManager.java
index bc0b0933142..b9b06816935 100644
--- 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/mailbox/channel/ChannelManager.java
+++ 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/mailbox/channel/ChannelManager.java
@@ -31,6 +31,7 @@ import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeUnit;
 import javax.annotation.Nullable;
 import org.apache.commons.lang3.tuple.Pair;
+import org.apache.pinot.query.grpc.GrpcKeepAliveConfig;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -50,6 +51,12 @@ public class ChannelManager {
   /// (including TLS negotiation) before sending any message, which increases 
the latency of the first query sent after
   /// a period of inactivity. In order to achieve that, we set the idle 
timeout to a very large value by default.
   private final Duration _idleTimeout;
+  /// Transport-level liveness policy for every channel this manager hands out.
+  ///
+  /// The idle timeout above cannot substitute for it. A channel that is being 
used is never idle, so on a cluster
+  /// that keeps serving queries the idle timeout never fires — and it is 
disabled by default anyway. Keep-alive is
+  /// the only mechanism here that a peer which stopped answering without 
closing its socket cannot outlive.
+  private final GrpcKeepAliveConfig _keepAliveConfig;
   private final int _maxInboundMessageSize;
   /// Buffer allocator configured to prefer direct (off-heap) buffers for 
better performance.
   /// Using a single allocator instance across all channels allows for better 
memory pooling and reduces fragmentation.
@@ -71,11 +78,13 @@ public class ChannelManager {
   ///                                     unwritable; it becomes writable 
again only when the queue drains below this
   ///                                     low watermark. Must satisfy `0 < low 
≤ high`; validated eagerly here so
   ///                                     misconfiguration surfaces at startup 
rather than on the first query.
+  /// @param keepAliveConfig gRPC keep-alive policy applied to every channel; 
see [GrpcKeepAliveConfig]
   public ChannelManager(@Nullable SslContext clientSslContext, int 
maxInboundMessageSize, Duration idleTimeout,
-      int writeBufferHighWaterMarkBytes, int writeBufferLowWaterMarkBytes) {
+      int writeBufferHighWaterMarkBytes, int writeBufferLowWaterMarkBytes, 
GrpcKeepAliveConfig keepAliveConfig) {
     _clientSslContext = clientSslContext;
     _maxInboundMessageSize = maxInboundMessageSize;
     _idleTimeout = idleTimeout;
+    _keepAliveConfig = keepAliveConfig;
     Preconditions.checkArgument(writeBufferLowWaterMarkBytes > 0,
         "writeBufferLowWaterMarkBytes must be positive, got: %s", 
writeBufferLowWaterMarkBytes);
     // The `low <= high` (and `low >= 0`) invariant is also checked by Netty's 
WriteBufferWaterMark constructor; by
@@ -96,7 +105,7 @@ public class ChannelManager {
                 .withOption(ChannelOption.ALLOCATOR, _bufAllocator)
                 .withOption(ChannelOption.WRITE_BUFFER_WATER_MARK, 
_writeBufferWaterMark)
                 .sslContext(_clientSslContext);
-            return decorate(channelBuilder).build();
+            return watchState(decorate(channelBuilder).build(), k.getLeft(), 
k.getRight());
           }
       );
     } else {
@@ -108,11 +117,39 @@ public class ChannelManager {
                 .withOption(ChannelOption.ALLOCATOR, _bufAllocator)
                 .withOption(ChannelOption.WRITE_BUFFER_WATER_MARK, 
_writeBufferWaterMark)
                 .usePlaintext();
-            return decorate(channelBuilder).build();
+            return watchState(decorate(channelBuilder).build(), k.getLeft(), 
k.getRight());
           });
     }
   }
 
+  /// Logs at WARN when `channel` leaves `READY`, and returns it.
+  ///
+  /// Keep-alive makes a silent peer *detectable*; this is what makes it 
**legible**. Without it the only
+  /// record of the failure is the absence of one: the startup lines say what 
was configured, and a
+  /// channel that later dropped looks exactly like a channel that never had a 
problem. The transition is
+  /// the moment worth reading in a log, because it is when a peer stopped 
answering — every mailbox send
+  /// queued behind it has already been failing for one keep-alive interval by 
then.
+  ///
+  /// Re-arms itself, which is how one callback follows a channel for its 
whole life; gRPC's
+  /// `notifyWhenStateChanged` is single-shot. Stops on shutdown, so a 
terminated channel cannot keep
+  /// re-registering.
+  private ManagedChannel watchState(ManagedChannel channel, String hostname, 
int port) {
+    ConnectivityState state = channel.getState(false);
+    channel.notifyWhenStateChanged(state, () -> {
+      if (channel.isShutdown()) {
+        return;
+      }
+      ConnectivityState next = channel.getState(false);
+      if (state == ConnectivityState.READY && next != ConnectivityState.READY) 
{
+        LOGGER.warn("Mailbox channel to {}:{} left READY for {}; sends to that 
peer will fail until it "
+            + "reconnects. If keep-alive reported it, the peer stopped 
answering about one keep-alive "
+            + "interval ago.", hostname, port, next);
+      }
+      watchState(channel, hostname, port);
+    });
+    return channel;
+  }
+
   /// Resets the connection backoff for the channel to the given server if the 
channel is in
   /// TRANSIENT_FAILURE state. Returns true if a reset was performed, false 
otherwise.
   ///
@@ -128,7 +165,16 @@ public class ChannelManager {
   }
 
   private NettyChannelBuilder decorate(NettyChannelBuilder builder) {
-    return builder.idleTimeout(_idleTimeout.getSeconds(), TimeUnit.SECONDS);
+    return 
_keepAliveConfig.configure(builder.idleTimeout(_idleTimeout.getSeconds(), 
TimeUnit.SECONDS));
+  }
+
+  /// The keep-alive policy applied to every channel this manager hands out.
+  ///
+  /// Public because the assertion that matters spans packages: a test of 
[MailboxService] has to show
+  /// that the policy resolved from config reached the transport, and no 
assertion on parsed values can
+  /// show that. Returns an immutable record, so exposing it grants no control 
over the manager.
+  public GrpcKeepAliveConfig getKeepAliveConfig() {
+    return _keepAliveConfig;
   }
 
   /// Bytes of direct (off-heap) memory currently pinned by the shared gRPC
diff --git 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/mailbox/channel/GrpcMailboxServer.java
 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/mailbox/channel/GrpcMailboxServer.java
index 285e7804d0d..7f947411f48 100644
--- 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/mailbox/channel/GrpcMailboxServer.java
+++ 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/mailbox/channel/GrpcMailboxServer.java
@@ -18,6 +18,7 @@
  */
 package org.apache.pinot.query.mailbox.channel;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
 import io.grpc.Server;
 import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
@@ -63,6 +64,8 @@ public class GrpcMailboxServer extends 
PinotMailboxGrpc.PinotMailboxImplBase {
   private final int _flowControlWindowBytes;
   private final int _inboundMessageCredit;
   private final boolean _manualInboundFlowControlEnabled;
+  private final int _permitKeepAliveTimeMs;
+  private final boolean _permitKeepAliveWithoutCalls;
 
   /// Constructs a gRPC-based mailbox server.
   ///
@@ -160,11 +163,25 @@ public class GrpcMailboxServer extends 
PinotMailboxGrpc.PinotMailboxImplBase {
         "%s (%s) must be >= %s (%s)",
         
CommonConstants.MultiStageQueryRunner.KEY_OF_GRPC_FLOW_CONTROL_WINDOW_BYTES, 
_flowControlWindowBytes,
         
CommonConstants.MultiStageQueryRunner.KEY_OF_MAX_INBOUND_QUERY_DATA_BLOCK_SIZE_BYTES,
 maxInboundMessageSize);
+    // Keep-alive enforcement. A peer configured with a keep-alive time below 
permitKeepAliveTime has its pings
+    // counted as "bad" and, past the server's strike threshold, gets 
GOAWAY(ENHANCE_YOUR_CALM) — which drops the
+    // mailbox channel mid-query. Defaults match Netty's own so that a peer 
left at the default keep-alive time is
+    // never punished; both sides have to be tuned down together.
+    _permitKeepAliveTimeMs = config.getProperty(
+        
CommonConstants.MultiStageQueryRunner.KEY_OF_MAILBOX_SERVER_PERMIT_KEEP_ALIVE_TIME_MS,
+        
CommonConstants.MultiStageQueryRunner.DEFAULT_OF_MAILBOX_SERVER_PERMIT_KEEP_ALIVE_TIME_MS);
+    _permitKeepAliveWithoutCalls = config.getProperty(
+        
CommonConstants.MultiStageQueryRunner.KEY_OF_MAILBOX_SERVER_PERMIT_KEEP_ALIVE_WITHOUT_CALLS,
+        
CommonConstants.MultiStageQueryRunner.DEFAULT_OF_MAILBOX_SERVER_PERMIT_KEEP_ALIVE_WITHOUT_CALLS);
+    if (_permitKeepAliveTimeMs > 0) {
+      builder.permitKeepAliveTime(_permitKeepAliveTimeMs, 
TimeUnit.MILLISECONDS);
+    }
     builder
         .addService(this)
         .withOption(ChannelOption.ALLOCATOR, bufAllocator)
         .withChildOption(ChannelOption.ALLOCATOR, bufAllocator)
         .maxInboundMessageSize(maxInboundMessageSize)
+        .permitKeepAliveWithoutCalls(_permitKeepAliveWithoutCalls)
         .flowControlWindow(_flowControlWindowBytes);
 
     // Add SSL context only if TLS is configured
@@ -177,10 +194,21 @@ public class GrpcMailboxServer extends 
PinotMailboxGrpc.PinotMailboxImplBase {
     _server = builder.build();
   }
 
+  @VisibleForTesting
+  int getPermitKeepAliveTimeMs() {
+    return _permitKeepAliveTimeMs;
+  }
+
+  @VisibleForTesting
+  boolean isPermitKeepAliveWithoutCalls() {
+    return _permitKeepAliveWithoutCalls;
+  }
+
   public void start() {
     LOGGER.info("Starting GrpcMailboxServer with flowControlWindow={} bytes, 
inboundMessageCredit={}, "
-            + "manualInboundFlowControlEnabled={}",
-        _flowControlWindowBytes, _inboundMessageCredit, 
_manualInboundFlowControlEnabled);
+            + "manualInboundFlowControlEnabled={}, permitKeepAliveTimeMs={}, 
permitKeepAliveWithoutCalls={}",
+        _flowControlWindowBytes, _inboundMessageCredit, 
_manualInboundFlowControlEnabled, _permitKeepAliveTimeMs,
+        _permitKeepAliveWithoutCalls);
     try {
       _server.start();
     } catch (IOException e) {
diff --git 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/service/dispatch/DispatchClient.java
 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/service/dispatch/DispatchClient.java
index a0332f97911..489ab1a3595 100644
--- 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/service/dispatch/DispatchClient.java
+++ 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/service/dispatch/DispatchClient.java
@@ -18,7 +18,6 @@
  */
 package org.apache.pinot.query.service.dispatch;
 
-import com.google.common.base.Preconditions;
 import io.grpc.Deadline;
 import io.grpc.ManagedChannel;
 import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
@@ -27,7 +26,6 @@ import io.grpc.netty.shaded.io.netty.channel.ChannelOption;
 import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext;
 import io.grpc.stub.StreamObserver;
 import java.util.List;
-import java.util.concurrent.TimeUnit;
 import java.util.function.BiConsumer;
 import java.util.function.Consumer;
 import javax.annotation.Nullable;
@@ -35,6 +33,7 @@ import org.apache.pinot.common.config.TlsConfig;
 import org.apache.pinot.common.proto.PinotQueryWorkerGrpc;
 import org.apache.pinot.common.proto.Worker;
 import org.apache.pinot.common.utils.grpc.ServerGrpcQueryClient;
+import org.apache.pinot.query.grpc.GrpcKeepAliveConfig;
 import org.apache.pinot.query.routing.QueryServerInstance;
 import 
org.apache.pinot.query.service.dispatch.streaming.StreamingDispatchObserver;
 import org.apache.pinot.query.service.dispatch.streaming.StreamingQuerySession;
@@ -59,15 +58,15 @@ class DispatchClient {
   private final PinotQueryWorkerGrpc.PinotQueryWorkerStub _dispatchStub;
 
   public DispatchClient(String host, int port, @Nullable TlsConfig tlsConfig) {
-    this(host, port, tlsConfig, null, KeepAliveConfig.DISABLED);
+    this(host, port, tlsConfig, null, GrpcKeepAliveConfig.DISABLED);
   }
 
   public DispatchClient(String host, int port, @Nullable TlsConfig tlsConfig, 
@Nullable SslContext sslContext) {
-    this(host, port, tlsConfig, sslContext, KeepAliveConfig.DISABLED);
+    this(host, port, tlsConfig, sslContext, GrpcKeepAliveConfig.DISABLED);
   }
 
   DispatchClient(String host, int port, @Nullable TlsConfig tlsConfig, 
@Nullable SslContext sslContext,
-      KeepAliveConfig keepAliveConfig) {
+      GrpcKeepAliveConfig keepAliveConfig) {
     // Always use NettyChannelBuilder to allow setting Netty-specific channel 
options like the buffer allocator.
     // This ensures we can explicitly configure direct (off-heap) buffers for 
better performance.
     NettyChannelBuilder channelBuilder = NettyChannelBuilder.forAddress(host, 
port)
@@ -81,51 +80,10 @@ class DispatchClient {
     }
     // Enable gRPC keep-alive when configured so that a silently unreachable 
peer transitions the channel out of READY,
     // which lets the broker's FailureDetector exclude it from routing.
-    if (keepAliveConfig.isEnabled()) {
-      channelBuilder.keepAliveTime(keepAliveConfig.getTimeMs(), 
TimeUnit.MILLISECONDS)
-          .keepAliveTimeout(keepAliveConfig.getTimeoutMs(), 
TimeUnit.MILLISECONDS)
-          .keepAliveWithoutCalls(keepAliveConfig.isWithoutCalls());
-    }
-    _channel = channelBuilder.build();
+    _channel = keepAliveConfig.configure(channelBuilder).build();
     _dispatchStub = PinotQueryWorkerGrpc.newStub(_channel);
   }
 
-  /// Immutable gRPC keep-alive configuration for broker dispatch channels. 
Keep-alive is disabled when `timeMs` is not
-  /// positive.
-  static final class KeepAliveConfig {
-    static final KeepAliveConfig DISABLED = new KeepAliveConfig(-1, 30_000, 
false);
-
-    private final int _timeMs;
-    private final int _timeoutMs;
-    private final boolean _withoutCalls;
-
-    KeepAliveConfig(int timeMs, int timeoutMs, boolean withoutCalls) {
-      if (timeMs > 0) {
-        Preconditions.checkArgument(timeoutMs > 0,
-            "keepAliveTimeoutMs must be positive when keep-alive is enabled, 
got: %s", timeoutMs);
-      }
-      _timeMs = timeMs;
-      _timeoutMs = timeoutMs;
-      _withoutCalls = withoutCalls;
-    }
-
-    boolean isEnabled() {
-      return _timeMs > 0;
-    }
-
-    int getTimeMs() {
-      return _timeMs;
-    }
-
-    int getTimeoutMs() {
-      return _timeoutMs;
-    }
-
-    boolean isWithoutCalls() {
-      return _withoutCalls;
-    }
-  }
-
   public ManagedChannel getChannel() {
     return _channel;
   }
diff --git 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/service/dispatch/QueryDispatcher.java
 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/service/dispatch/QueryDispatcher.java
index 80d9f1134aa..834d3b008b8 100644
--- 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/service/dispatch/QueryDispatcher.java
+++ 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/service/dispatch/QueryDispatcher.java
@@ -64,6 +64,7 @@ import org.apache.pinot.core.transport.ServerInstance;
 import 
org.apache.pinot.core.transport.server.routing.stats.ServerRoutingStatsManager;
 import org.apache.pinot.core.util.DataBlockExtractUtils;
 import org.apache.pinot.core.util.trace.TracedThreadFactory;
+import org.apache.pinot.query.grpc.GrpcKeepAliveConfig;
 import org.apache.pinot.query.mailbox.MailboxService;
 import org.apache.pinot.query.planner.PlanFragment;
 import org.apache.pinot.query.planner.physical.DispatchablePlanFragment;
@@ -124,7 +125,7 @@ public class QueryDispatcher {
   private final TlsConfig _tlsConfig;
   @Nullable
   private final SslContext _clientGrpcSslContext;
-  private final DispatchClient.KeepAliveConfig _keepAliveConfig;
+  private final GrpcKeepAliveConfig _keepAliveConfig;
   // maps broker-generated query id to the set of servers that the query was 
dispatched to
   private final Map<Long, Set<QueryServerInstance>> _serversByQuery;
   private final FailureDetector _failureDetector;
@@ -136,7 +137,7 @@ public class QueryDispatcher {
   public QueryDispatcher(MailboxService mailboxService, FailureDetector 
failureDetector, @Nullable TlsConfig tlsConfig,
       boolean enableCancellation, Duration cancelTimeout) {
     this(mailboxService, failureDetector, tlsConfig, enableCancellation, 
cancelTimeout,
-        DispatchClient.KeepAliveConfig.DISABLED, false, 
CommonConstants.Broker.DEFAULT_STREAM_STATS_DRAIN_MS);
+        GrpcKeepAliveConfig.DISABLED, false, 
CommonConstants.Broker.DEFAULT_STREAM_STATS_DRAIN_MS);
   }
 
   /// Overload that accepts gRPC keep-alive settings for broker dispatch 
channels. A non-positive `keepAliveTimeMs`
@@ -145,12 +146,12 @@ public class QueryDispatcher {
       boolean enableCancellation, Duration cancelTimeout, int keepAliveTimeMs, 
int keepAliveTimeoutMs,
       boolean keepAliveWithoutCalls, boolean streamStatsDefault, long 
statsDrainMs) {
     this(mailboxService, failureDetector, tlsConfig, enableCancellation, 
cancelTimeout,
-        new DispatchClient.KeepAliveConfig(keepAliveTimeMs, 
keepAliveTimeoutMs, keepAliveWithoutCalls),
+        new GrpcKeepAliveConfig(keepAliveTimeMs, keepAliveTimeoutMs, 
keepAliveWithoutCalls),
         streamStatsDefault, statsDrainMs);
   }
 
   private QueryDispatcher(MailboxService mailboxService, FailureDetector 
failureDetector, @Nullable TlsConfig tlsConfig,
-      boolean enableCancellation, Duration cancelTimeout, 
DispatchClient.KeepAliveConfig keepAliveConfig,
+      boolean enableCancellation, Duration cancelTimeout, GrpcKeepAliveConfig 
keepAliveConfig,
       boolean streamStatsDefault, long statsDrainMs) {
     _cancelTimeout = cancelTimeout;
     _statsDrainMs = statsDrainMs;
diff --git 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/grpc/GrpcKeepAliveConfigTest.java
 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/grpc/GrpcKeepAliveConfigTest.java
new file mode 100644
index 00000000000..d520fad322f
--- /dev/null
+++ 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/grpc/GrpcKeepAliveConfigTest.java
@@ -0,0 +1,119 @@
+/**
+ * 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.query.grpc;
+
+import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
+import java.util.Map;
+import org.apache.pinot.spi.env.PinotConfiguration;
+import org.apache.pinot.spi.utils.CommonConstants.MultiStageQueryRunner;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertSame;
+import static org.testng.Assert.assertTrue;
+
+
+public class GrpcKeepAliveConfigTest {
+
+  @Test
+  public void testDisabledWhenTimeNotPositive() {
+    assertFalse(GrpcKeepAliveConfig.DISABLED.isEnabled());
+    assertFalse(new GrpcKeepAliveConfig(0, 30_000, false).isEnabled());
+    assertFalse(new GrpcKeepAliveConfig(-1, 30_000, false).isEnabled());
+  }
+
+  @Test
+  public void testEnabledExposesSettings() {
+    GrpcKeepAliveConfig config = new GrpcKeepAliveConfig(300_000, 30_000, 
true);
+    assertTrue(config.isEnabled());
+    assertEquals(config.timeMs(), 300_000);
+    assertEquals(config.timeoutMs(), 30_000);
+    assertTrue(config.withoutCalls());
+  }
+
+  /// A zero or negative timeout is only rejected when keep-alive is on: gRPC 
would throw on it, while
+  /// [GrpcKeepAliveConfig#DISABLED] must stay constructible whatever the 
timeout is.
+  @Test(expectedExceptions = IllegalArgumentException.class,
+      expectedExceptionsMessageRegExp = ".*keepAliveTimeoutMs must be 
positive.*")
+  public void testRejectsNonPositiveTimeoutWhenEnabled() {
+    new GrpcKeepAliveConfig(300_000, 0, false);
+  }
+
+  @Test
+  public void testDisabledAllowsAnyTimeout() {
+    assertFalse(new GrpcKeepAliveConfig(-1, 0, false).isEnabled());
+  }
+
+  /// The whole point of routing both call sites through `configure`: a 
disabled policy must not reach gRPC, which
+  /// rejects a non-positive `keepAliveTime` with an 
`IllegalArgumentException`.
+  @Test
+  public void testConfigureIsNoOpWhenDisabled() {
+    NettyChannelBuilder builder = NettyChannelBuilder.forAddress("localhost", 
12345).usePlaintext();
+    assertSame(GrpcKeepAliveConfig.DISABLED.configure(builder), builder);
+    builder.build().shutdownNow();
+  }
+
+  @Test
+  public void testConfigureAppliesWhenEnabled() {
+    NettyChannelBuilder builder = NettyChannelBuilder.forAddress("localhost", 
12345).usePlaintext();
+    assertSame(new GrpcKeepAliveConfig(30_000, 5_000, 
true).configure(builder), builder);
+    builder.build().shutdownNow();
+  }
+
+  /// Defaults must be the ones an operator reads in the docs, resolved in one 
place so both the Java
+  /// mailbox and any other engine maintaining its own mailbox channels see 
the same values.
+  @Test
+  public void testForMailboxChannelsDefaults() {
+    GrpcKeepAliveConfig config = GrpcKeepAliveConfig.forMailboxChannels(new 
PinotConfiguration(Map.of()));
+    assertTrue(config.isEnabled(), "mailbox channels must ping by default");
+    assertEquals(config.timeMs(), 
MultiStageQueryRunner.DEFAULT_CHANNEL_KEEP_ALIVE_TIME_MS);
+    assertEquals(config.timeoutMs(), 
MultiStageQueryRunner.DEFAULT_CHANNEL_KEEP_ALIVE_TIMEOUT_MS);
+    // Off by default: pings without an active call are refused by a peer left 
at Netty's own default.
+    assertFalse(config.withoutCalls());
+  }
+
+  /// The default interval must not exceed what an un-upgraded peer permits, 
or an upgraded instance
+  /// would have its mailbox channels torn down with 
`GOAWAY(ENHANCE_YOUR_CALM)` during a rolling
+  /// upgrade.
+  @Test
+  public void testDefaultIntervalIsSafeAgainstNettyServerDefault() {
+    assertTrue(MultiStageQueryRunner.DEFAULT_CHANNEL_KEEP_ALIVE_TIME_MS
+            >= 
MultiStageQueryRunner.DEFAULT_OF_MAILBOX_SERVER_PERMIT_KEEP_ALIVE_TIME_MS,
+        "client keep-alive time must be >= the permit enforced by peers");
+  }
+
+  @Test
+  public void testForMailboxChannelsOverrides() {
+    GrpcKeepAliveConfig config = GrpcKeepAliveConfig.forMailboxChannels(new 
PinotConfiguration(Map.of(
+        MultiStageQueryRunner.KEY_OF_CHANNEL_KEEP_ALIVE_TIME_MS, 30_000,
+        MultiStageQueryRunner.KEY_OF_CHANNEL_KEEP_ALIVE_TIMEOUT_MS, 5_000,
+        MultiStageQueryRunner.KEY_OF_CHANNEL_KEEP_ALIVE_WITHOUT_CALLS, true)));
+    assertEquals(config.timeMs(), 30_000);
+    assertEquals(config.timeoutMs(), 5_000);
+    assertTrue(config.withoutCalls());
+  }
+
+  @Test
+  public void testForMailboxChannelsCanTurnKeepAliveOff() {
+    GrpcKeepAliveConfig config = GrpcKeepAliveConfig.forMailboxChannels(new 
PinotConfiguration(Map.of(
+        MultiStageQueryRunner.KEY_OF_CHANNEL_KEEP_ALIVE_TIME_MS, -1)));
+    assertFalse(config.isEnabled());
+  }
+}
diff --git 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/mailbox/MailboxServiceKeepAliveWiringTest.java
 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/mailbox/MailboxServiceKeepAliveWiringTest.java
new file mode 100644
index 00000000000..309d57fb1aa
--- /dev/null
+++ 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/mailbox/MailboxServiceKeepAliveWiringTest.java
@@ -0,0 +1,82 @@
+/**
+ * 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.query.mailbox;
+
+import java.util.Map;
+import org.apache.pinot.query.grpc.GrpcKeepAliveConfig;
+import org.apache.pinot.query.testutils.QueryTestUtils;
+import org.apache.pinot.spi.config.instance.InstanceType;
+import org.apache.pinot.spi.env.PinotConfiguration;
+import org.apache.pinot.spi.utils.CommonConstants.MultiStageQueryRunner;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+
+
+/// Pins the hop from configuration to transport: the policy [MailboxService] 
resolves must be the one
+/// its [org.apache.pinot.query.mailbox.channel.ChannelManager] hands to every 
channel.
+///
+/// Asserting the parsed values alone cannot show this. A `MailboxService` 
that resolved the config
+/// correctly and then passed [GrpcKeepAliveConfig#DISABLED] to the channel 
manager would satisfy every
+/// parsing test while shipping channels with no keep-alive at all — the exact 
defect this change exists
+/// to prevent.
+public class MailboxServiceKeepAliveWiringTest {
+
+  @Test
+  public void testResolvedPolicyReachesTheChannelManager() {
+    PinotConfiguration config = new PinotConfiguration(Map.of(
+        MultiStageQueryRunner.KEY_OF_CHANNEL_KEEP_ALIVE_TIME_MS, 45_000,
+        MultiStageQueryRunner.KEY_OF_CHANNEL_KEEP_ALIVE_TIMEOUT_MS, 7_000,
+        MultiStageQueryRunner.KEY_OF_CHANNEL_KEEP_ALIVE_WITHOUT_CALLS, true));
+    MailboxService mailboxService = newMailboxService(config);
+    GrpcKeepAliveConfig applied = 
mailboxService.getChannelManager().getKeepAliveConfig();
+    assertEquals(applied, GrpcKeepAliveConfig.forMailboxChannels(config),
+        "the channel manager must get exactly the policy resolved from 
config");
+    assertEquals(applied.timeMs(), 45_000);
+    assertEquals(applied.timeoutMs(), 7_000);
+    assertTrue(applied.withoutCalls());
+  }
+
+  @Test
+  public void testDefaultPolicyReachesTheChannelManager() {
+    MailboxService mailboxService = newMailboxService(new 
PinotConfiguration(Map.of()));
+    GrpcKeepAliveConfig applied = 
mailboxService.getChannelManager().getKeepAliveConfig();
+    assertTrue(applied.isEnabled(), "mailbox channels must ping by default");
+    assertEquals(applied.timeMs(), 
MultiStageQueryRunner.DEFAULT_CHANNEL_KEEP_ALIVE_TIME_MS);
+    assertEquals(applied.timeoutMs(), 
MultiStageQueryRunner.DEFAULT_CHANNEL_KEEP_ALIVE_TIMEOUT_MS);
+    assertFalse(applied.withoutCalls());
+  }
+
+  /// Disabling must reach the transport too, so an operator turning 
keep-alive off gets channels
+  /// without pings rather than channels with the defaults.
+  @Test
+  public void testDisabledPolicyReachesTheChannelManager() {
+    MailboxService mailboxService = newMailboxService(new 
PinotConfiguration(Map.of(
+        MultiStageQueryRunner.KEY_OF_CHANNEL_KEEP_ALIVE_TIME_MS, -1)));
+    
assertFalse(mailboxService.getChannelManager().getKeepAliveConfig().isEnabled());
+  }
+
+  /// Constructed but never started: the channel manager and its policy are 
resolved in the
+  /// constructor, so no port needs binding.
+  private static MailboxService newMailboxService(PinotConfiguration config) {
+    return new MailboxService("localhost", QueryTestUtils.getAvailablePort(), 
InstanceType.SERVER, config);
+  }
+}
diff --git 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/mailbox/channel/ChannelManagerTest.java
 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/mailbox/channel/ChannelManagerTest.java
index 11faa327dc1..0eab4e2bde2 100644
--- 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/mailbox/channel/ChannelManagerTest.java
+++ 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/mailbox/channel/ChannelManagerTest.java
@@ -24,6 +24,7 @@ import java.lang.reflect.Field;
 import java.time.Duration;
 import java.util.concurrent.ConcurrentHashMap;
 import org.apache.commons.lang3.tuple.Pair;
+import org.apache.pinot.query.grpc.GrpcKeepAliveConfig;
 import org.apache.pinot.spi.utils.CommonConstants;
 import org.testng.annotations.Test;
 
@@ -41,7 +42,8 @@ public class ChannelManagerTest {
   public void testResetConnectBackoffNoOpForUnknownChannel() {
     ChannelManager channelManager = new ChannelManager(null, 4_000_000, 
Duration.ofDays(365),
         
CommonConstants.MultiStageQueryRunner.DEFAULT_GRPC_WRITE_BUFFER_HIGH_WATER_MARK_BYTES,
-        
CommonConstants.MultiStageQueryRunner.DEFAULT_GRPC_WRITE_BUFFER_LOW_WATER_MARK_BYTES);
+        
CommonConstants.MultiStageQueryRunner.DEFAULT_GRPC_WRITE_BUFFER_LOW_WATER_MARK_BYTES,
+        GrpcKeepAliveConfig.DISABLED);
     // Should return false and not throw when no channel exists for the given 
host/port
     assertFalse(channelManager.resetConnectBackoff("unknown-host", 12345));
   }
@@ -50,7 +52,8 @@ public class ChannelManagerTest {
   public void testResetConnectBackoffNoOpWhenNotInTransientFailure() {
     ChannelManager channelManager = new ChannelManager(null, 4_000_000, 
Duration.ofDays(365),
         
CommonConstants.MultiStageQueryRunner.DEFAULT_GRPC_WRITE_BUFFER_HIGH_WATER_MARK_BYTES,
-        
CommonConstants.MultiStageQueryRunner.DEFAULT_GRPC_WRITE_BUFFER_LOW_WATER_MARK_BYTES);
+        
CommonConstants.MultiStageQueryRunner.DEFAULT_GRPC_WRITE_BUFFER_LOW_WATER_MARK_BYTES,
+        GrpcKeepAliveConfig.DISABLED);
     // Create a channel by calling getChannel
     ManagedChannel channel = channelManager.getChannel("localhost", 12345);
     try {
@@ -70,7 +73,8 @@ public class ChannelManagerTest {
       throws Exception {
     ChannelManager channelManager = new ChannelManager(null, 4_000_000, 
Duration.ofDays(365),
         
CommonConstants.MultiStageQueryRunner.DEFAULT_GRPC_WRITE_BUFFER_HIGH_WATER_MARK_BYTES,
-        
CommonConstants.MultiStageQueryRunner.DEFAULT_GRPC_WRITE_BUFFER_LOW_WATER_MARK_BYTES);
+        
CommonConstants.MultiStageQueryRunner.DEFAULT_GRPC_WRITE_BUFFER_LOW_WATER_MARK_BYTES,
+        GrpcKeepAliveConfig.DISABLED);
 
     ManagedChannel mockChannel = mock(ManagedChannel.class);
     
when(mockChannel.getState(false)).thenReturn(ConnectivityState.TRANSIENT_FAILURE);
@@ -94,7 +98,7 @@ public class ChannelManagerTest {
   public void testConstructorRejectsZeroWriteBufferLowWaterMark() {
     new ChannelManager(null, 4_000_000, Duration.ofDays(365),
         
CommonConstants.MultiStageQueryRunner.DEFAULT_GRPC_WRITE_BUFFER_HIGH_WATER_MARK_BYTES,
-        0);
+        0, GrpcKeepAliveConfig.DISABLED);
   }
 
   /// Pins the eager `new WriteBufferWaterMark(low, high)` invariant: when 
`low > high`, Netty's own
@@ -105,6 +109,40 @@ public class ChannelManagerTest {
   public void testConstructorRejectsLowWatermarkAboveHighWatermark() {
     new ChannelManager(null, 4_000_000, Duration.ofDays(365),
         32 * 1024 * 1024,  // high
-        64 * 1024 * 1024); // low > high
+        64 * 1024 * 1024, // low > high
+        GrpcKeepAliveConfig.DISABLED);
+  }
+
+  /// Pins that the manager hands its keep-alive policy to every channel it 
builds.
+  ///
+  /// gRPC exposes nothing on a built `ManagedChannel` to read the keep-alive 
settings back, so this asserts the
+  /// policy the manager holds; that the policy reaches the transport is 
covered end to end by
+  /// [MailboxChannelKeepAliveTest].
+  @Test
+  public void testKeepAliveConfigRetained() {
+    GrpcKeepAliveConfig keepAlive = new GrpcKeepAliveConfig(30_000, 5_000, 
true);
+    ChannelManager channelManager = new ChannelManager(null, 4_000_000, 
Duration.ofDays(365),
+        
CommonConstants.MultiStageQueryRunner.DEFAULT_GRPC_WRITE_BUFFER_HIGH_WATER_MARK_BYTES,
+        
CommonConstants.MultiStageQueryRunner.DEFAULT_GRPC_WRITE_BUFFER_LOW_WATER_MARK_BYTES,
 keepAlive);
+    assertSame(channelManager.getKeepAliveConfig(), keepAlive);
+    // Building a channel with keep-alive enabled must not throw: gRPC rejects 
a non-positive keepAliveTime, and the
+    // enabled check that prevents that lives in GrpcKeepAliveConfig rather 
than at this call site.
+    ManagedChannel channel = channelManager.getChannel("localhost", 12346);
+    try {
+      assertSame(channelManager.getChannel("localhost", 12346), channel);
+    } finally {
+      channel.shutdownNow();
+    }
+  }
+
+  /// A disabled policy must leave the builder alone rather than passing `-1` 
to gRPC, which throws.
+  @Test
+  public void testDisabledKeepAliveStillBuildsChannel() {
+    ChannelManager channelManager = new ChannelManager(null, 4_000_000, 
Duration.ofDays(365),
+        
CommonConstants.MultiStageQueryRunner.DEFAULT_GRPC_WRITE_BUFFER_HIGH_WATER_MARK_BYTES,
+        
CommonConstants.MultiStageQueryRunner.DEFAULT_GRPC_WRITE_BUFFER_LOW_WATER_MARK_BYTES,
+        GrpcKeepAliveConfig.DISABLED);
+    ManagedChannel channel = channelManager.getChannel("localhost", 12347);
+    channel.shutdownNow();
   }
 }
diff --git 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/mailbox/channel/MailboxChannelKeepAliveTest.java
 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/mailbox/channel/MailboxChannelKeepAliveTest.java
new file mode 100644
index 00000000000..0205b590903
--- /dev/null
+++ 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/mailbox/channel/MailboxChannelKeepAliveTest.java
@@ -0,0 +1,291 @@
+/**
+ * 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.query.mailbox.channel;
+
+import io.grpc.ManagedChannel;
+import io.grpc.Server;
+import io.grpc.Status;
+import io.grpc.StatusRuntimeException;
+import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
+import io.grpc.stub.StreamObserver;
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.SocketTimeoutException;
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.pinot.common.proto.Mailbox;
+import org.apache.pinot.common.proto.PinotMailboxGrpc;
+import org.apache.pinot.query.grpc.GrpcKeepAliveConfig;
+import org.apache.pinot.spi.utils.CommonConstants;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertTrue;
+
+
+/// Proves that a mailbox channel notices a peer that stops answering 
*without* closing its socket, which is the
+/// failure keep-alive exists for: no `RST` ever arrives, so gRPC keeps the 
cached channel in `READY` and every send
+/// on it parks until the query deadline — for every later query too, since 
channels are cached per peer.
+///
+/// The peer is faked with a TCP relay that stops moving bytes in both 
directions while holding both sockets open.
+/// That is indistinguishable, from the client's side, from a host whose 
kernel is hung or that sits behind a
+/// one-way partition. Killing the server process instead would prove nothing: 
it sends a `RST`, and gRPC has always
+/// handled that.
+///
+/// Both channels in this test are black-holed at the same instant and only 
the keep-alive one is expected to fail,
+/// so the assertion that the other is still `READY` is what keeps this test 
honest — ablate the keep-alive wiring
+/// and the first assertion fails rather than the test passing for the wrong 
reason.
+public class MailboxChannelKeepAliveTest {
+  /// gRPC clamps `keepAliveTime` up to its own 10s floor, so this is as fast 
as detection can be configured.
+  private static final int KEEP_ALIVE_TIME_MS = 10_000;
+  private static final int KEEP_ALIVE_TIMEOUT_MS = 1_000;
+  /// Generous headroom over the ~11s the settings above imply; a loaded CI 
box must not flake this.
+  private static final long DETECTION_BUDGET_MS = 45_000;
+
+  @Test(timeOut = 120_000)
+  public void testKeepAliveDetectsSilentPeer()
+      throws Exception {
+    SilentMailbox silentPeer = new SilentMailbox(2);
+    Server peer = 
NettyServerBuilder.forPort(0).addService(silentPeer).build().start();
+    try (BlackHoleRelay relay = new BlackHoleRelay(peer.getPort())) {
+      ChannelManager keepAliveManager = newChannelManager(
+          new GrpcKeepAliveConfig(KEEP_ALIVE_TIME_MS, KEEP_ALIVE_TIMEOUT_MS, 
false));
+      ChannelManager noKeepAliveManager = 
newChannelManager(GrpcKeepAliveConfig.DISABLED);
+
+      ManagedChannel keepAliveChannel = 
keepAliveManager.getChannel("localhost", relay.getPort());
+      ManagedChannel noKeepAliveChannel = 
noKeepAliveManager.getChannel("localhost", relay.getPort());
+      try {
+        // Keep-alive with `withoutCalls` off only pings while a call is 
active, which is the production default, so
+        // both channels get an open stream. Sending one message is what 
forces the RPC onto the transport.
+        OpenStream withKeepAlive = openStream(keepAliveChannel);
+        OpenStream withoutKeepAlive = openStream(noKeepAliveChannel);
+
+        // Wait until the *peer* has received both streams, not merely until 
the relay accepted both TCP
+        // connections. Accepting happens before a single HTTP/2 byte is 
relayed, so black-holing on that signal can
+        // strand the RPC before it ever starts — and with `withoutCalls` off, 
a channel with no active call is
+        // never pinged, so the test would fail with keep-alive working 
perfectly.
+        assertTrue(silentPeer.awaitStreams(30_000), "streams did not reach the 
peer");
+
+        relay.blackHole();
+
+        assertTrue(withKeepAlive.await(DETECTION_BUDGET_MS),
+            "keep-alive channel never failed: a silent peer must not be able 
to park a mailbox send forever");
+        Throwable error = withKeepAlive._error.get();
+        assertNotNull(error);
+        assertEquals(Status.fromThrowable(error).getCode(), 
Status.Code.UNAVAILABLE,
+            "expected the transport to be declared dead, got: " + error);
+
+        // Same black hole, same elapsed time, keep-alive off: still believed 
healthy. This is the state the
+        // production default used to leave every mailbox channel in.
+        assertNull(withoutKeepAlive._error.get(),
+            "channel without keep-alive should still believe the dead peer is 
healthy");
+      } finally {
+        keepAliveChannel.shutdownNow();
+        noKeepAliveChannel.shutdownNow();
+      }
+    } finally {
+      peer.shutdownNow();
+    }
+  }
+
+  private static ChannelManager newChannelManager(GrpcKeepAliveConfig 
keepAliveConfig) {
+    return new ChannelManager(null, 4_000_000, Duration.ofDays(365),
+        
CommonConstants.MultiStageQueryRunner.DEFAULT_GRPC_WRITE_BUFFER_HIGH_WATER_MARK_BYTES,
+        
CommonConstants.MultiStageQueryRunner.DEFAULT_GRPC_WRITE_BUFFER_LOW_WATER_MARK_BYTES,
 keepAliveConfig);
+  }
+
+  private static OpenStream openStream(ManagedChannel channel) {
+    OpenStream stream = new OpenStream();
+    StreamObserver<Mailbox.MailboxContent> sender = 
PinotMailboxGrpc.newStub(channel).open(stream);
+    sender.onNext(Mailbox.MailboxContent.getDefaultInstance());
+    return stream;
+  }
+
+  /// Client-side observer of one `open` stream: records the terminal error, 
if any.
+  private static final class OpenStream implements 
StreamObserver<Mailbox.MailboxStatus> {
+    private final CountDownLatch _terminated = new CountDownLatch(1);
+    private final AtomicReference<Throwable> _error = new AtomicReference<>();
+
+    @Override
+    public void onNext(Mailbox.MailboxStatus value) {
+    }
+
+    @Override
+    public void onError(Throwable t) {
+      _error.compareAndSet(null, t);
+      _terminated.countDown();
+    }
+
+    @Override
+    public void onCompleted() {
+      _error.compareAndSet(null, new 
StatusRuntimeException(Status.INTERNAL.withDescription("unexpected 
completion")));
+      _terminated.countDown();
+    }
+
+    boolean await(long timeoutMs)
+        throws InterruptedException {
+      return _terminated.await(timeoutMs, TimeUnit.MILLISECONDS);
+    }
+  }
+
+  /// A mailbox peer that accepts `open` and then says nothing at all, so the 
only traffic left on the connection is
+  /// the keep-alive ping this test is about.
+  private static final class SilentMailbox extends 
PinotMailboxGrpc.PinotMailboxImplBase {
+    private final CountDownLatch _streamsReceived;
+
+    private SilentMailbox(int expectedStreams) {
+      _streamsReceived = new CountDownLatch(expectedStreams);
+    }
+
+    boolean awaitStreams(long timeoutMs)
+        throws InterruptedException {
+      return _streamsReceived.await(timeoutMs, TimeUnit.MILLISECONDS);
+    }
+
+    @Override
+    public StreamObserver<Mailbox.MailboxContent> 
open(StreamObserver<Mailbox.MailboxStatus> responseObserver) {
+      return new StreamObserver<>() {
+        private final AtomicBoolean _counted = new AtomicBoolean();
+
+        @Override
+        public void onNext(Mailbox.MailboxContent value) {
+          // Counting the first message rather than `open` itself: a message 
proves the client's stream is live on
+          // the transport, which is precisely the precondition for keep-alive 
pings.
+          if (_counted.compareAndSet(false, true)) {
+            _streamsReceived.countDown();
+          }
+        }
+
+        @Override
+        public void onError(Throwable t) {
+        }
+
+        @Override
+        public void onCompleted() {
+        }
+      };
+    }
+  }
+
+  /// TCP relay that can stop forwarding while keeping every socket open.
+  ///
+  /// Sockets are read with a short `SO_TIMEOUT` rather than blocking 
indefinitely, so a relay thread parked in
+  /// `read` still notices [#blackHole] promptly. Nothing is ever closed until 
[#close], because closing is exactly
+  /// what this fixture must not do: a closed socket produces the `FIN`/`RST` 
that gRPC already detects.
+  private static final class BlackHoleRelay implements Closeable {
+    private final ServerSocket _listener;
+    private final int _upstreamPort;
+    private final ExecutorService _executor = Executors.newCachedThreadPool();
+    private final List<Socket> _sockets = new CopyOnWriteArrayList<>();
+    private final AtomicBoolean _blackHole = new AtomicBoolean();
+    private final AtomicBoolean _closed = new AtomicBoolean();
+
+    BlackHoleRelay(int upstreamPort)
+        throws IOException {
+      _upstreamPort = upstreamPort;
+      _listener = new ServerSocket();
+      _listener.bind(new InetSocketAddress("localhost", 0));
+      _executor.submit(this::acceptLoop);
+    }
+
+    int getPort() {
+      return _listener.getLocalPort();
+    }
+
+    /// Stops moving bytes in both directions, holding all sockets open.
+    void blackHole() {
+      _blackHole.set(true);
+    }
+
+    private void acceptLoop() {
+      while (!_closed.get()) {
+        try {
+          Socket downstream = _listener.accept();
+          Socket upstream = new Socket("localhost", _upstreamPort);
+          downstream.setSoTimeout(100);
+          upstream.setSoTimeout(100);
+          _sockets.add(downstream);
+          _sockets.add(upstream);
+          _executor.submit(() -> relay(downstream, upstream));
+          _executor.submit(() -> relay(upstream, downstream));
+        } catch (IOException e) {
+          return;
+        }
+      }
+    }
+
+    private void relay(Socket from, Socket to) {
+      byte[] buffer = new byte[8192];
+      try {
+        InputStream in = from.getInputStream();
+        OutputStream out = to.getOutputStream();
+        while (!_closed.get()) {
+          if (_blackHole.get()) {
+            // Park instead of returning: returning would let the streams be 
garbage collected and the sockets
+            // closed, which would hand the client the RST this fixture must 
withhold.
+            Thread.sleep(50);
+            continue;
+          }
+          int read;
+          try {
+            read = in.read(buffer);
+          } catch (SocketTimeoutException e) {
+            continue;
+          }
+          if (read < 0) {
+            return;
+          }
+          out.write(buffer, 0, read);
+          out.flush();
+        }
+      } catch (IOException | InterruptedException e) {
+        // Relay is done; the test asserts on the client's view, not on this 
thread.
+      }
+    }
+
+    @Override
+    public void close()
+        throws IOException {
+      _closed.set(true);
+      _listener.close();
+      for (Socket socket : _sockets) {
+        try {
+          socket.close();
+        } catch (IOException e) {
+          // Best effort teardown.
+        }
+      }
+      _executor.shutdownNow();
+    }
+  }
+}
diff --git 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/mailbox/channel/MailboxServerPermitKeepAliveBehaviorTest.java
 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/mailbox/channel/MailboxServerPermitKeepAliveBehaviorTest.java
new file mode 100644
index 00000000000..7e7cc7b64a7
--- /dev/null
+++ 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/mailbox/channel/MailboxServerPermitKeepAliveBehaviorTest.java
@@ -0,0 +1,146 @@
+/**
+ * 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.query.mailbox.channel;
+
+import io.grpc.ManagedChannel;
+import io.grpc.Status;
+import io.grpc.stub.StreamObserver;
+import java.time.Duration;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.pinot.common.proto.Mailbox;
+import org.apache.pinot.common.proto.PinotMailboxGrpc;
+import org.apache.pinot.query.grpc.GrpcKeepAliveConfig;
+import org.apache.pinot.query.mailbox.MailboxService;
+import org.apache.pinot.query.testutils.QueryTestUtils;
+import org.apache.pinot.spi.config.instance.InstanceType;
+import org.apache.pinot.spi.env.PinotConfiguration;
+import org.apache.pinot.spi.utils.CommonConstants.MultiStageQueryRunner;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertTrue;
+
+
+/// Proves the mailbox server's keep-alive **enforcement** actually changes 
behaviour, which the
+/// config-parsing tests in [MailboxServerPermitKeepAliveTest] cannot: delete 
both `permitKeepAlive`
+/// calls from [GrpcMailboxServer] and those still pass, because they only 
assert the values it read.
+///
+/// The enforcement is the half that hurts a live cluster when it is wrong. A 
server whose
+/// `permitKeepAliveTime` is above the client's keep-alive time counts the 
pings as "bad" and, past
+/// gRPC's two-strike threshold, answers `GOAWAY(ENHANCE_YOUR_CALM)` with 
`too_many_pings` — dropping a
+/// mailbox channel mid-query. That is what makes the client interval 
untunable without this knob.
+///
+/// Two servers are driven through one wait window: one permitting a fast 
ping, one left at Netty's
+/// default. Only the second may lose its stream. Ablating the permit wiring 
makes the *first*
+/// assertion fail, so the test cannot pass for the wrong reason.
+public class MailboxServerPermitKeepAliveBehaviorTest {
+  /// gRPC clamps a client keep-alive time up to its own 10s floor, so this is 
the fastest ping a client
+  /// can be made to send, and the enforcement window is sized around it.
+  private static final int CLIENT_KEEP_ALIVE_TIME_MS = 10_000;
+  /// A permit below the client interval: every ping is then acceptable and no 
strike is recorded.
+  private static final int PERMISSIVE_PERMIT_MS = 1_000;
+  /// Three pings at 10s plus slack. gRPC allows two bad pings and sends 
`GOAWAY` on the third, so a
+  /// server that does not permit the rate gives up at roughly 30s.
+  private static final long WINDOW_MS = 60_000;
+
+  @Test(timeOut = 180_000)
+  public void testPermitKeepAliveTimeDecidesWhetherAFastPingerSurvives()
+      throws Exception {
+    MailboxService permissive = startMailboxService(PERMISSIVE_PERMIT_MS);
+    MailboxService restrictive = startMailboxService(-1);  // leaves Netty's 
5-minute default in place
+    ChannelManager channelManager = new ChannelManager(null, 4_000_000, 
Duration.ofDays(365),
+        MultiStageQueryRunner.DEFAULT_GRPC_WRITE_BUFFER_HIGH_WATER_MARK_BYTES,
+        MultiStageQueryRunner.DEFAULT_GRPC_WRITE_BUFFER_LOW_WATER_MARK_BYTES,
+        // `withoutCalls` off, as in production: the open stream below is what 
makes the client ping.
+        new GrpcKeepAliveConfig(CLIENT_KEEP_ALIVE_TIME_MS, 5_000, false));
+
+    ManagedChannel toPermissive = channelManager.getChannel("localhost", 
permissive.getPort());
+    ManagedChannel toRestrictive = channelManager.getChannel("localhost", 
restrictive.getPort());
+    try {
+      // No content is ever sent: `GrpcMailboxServer#open` accepts a stream 
without the mailbox-id
+      // header and only reads it when a message arrives, so the stream stays 
open with no handshake and
+      // the connection carries nothing but keep-alive pings.
+      OpenStream onPermissive = openStream(toPermissive);
+      OpenStream onRestrictive = openStream(toRestrictive);
+
+      assertTrue(onRestrictive.awaitTermination(WINDOW_MS),
+          "a server that does not permit the client's ping rate must end the 
stream; without the "
+              + "permit knob this is what every mailbox channel would do once 
the interval is tuned down");
+      Throwable rejection = onRestrictive._error.get();
+      assertNotNull(rejection);
+      
assertTrue(String.valueOf(Status.fromThrowable(rejection).getDescription()).contains("too_many_pings"),
+          "expected GOAWAY(ENHANCE_YOUR_CALM) for too_many_pings, got: " + 
rejection);
+
+      // Same client, same interval, same window — the only difference is the 
permit.
+      assertNull(onPermissive._error.get(),
+          "the permitted server must keep the stream; if this fails the permit 
is not reaching the "
+              + "gRPC server builder");
+    } finally {
+      toPermissive.shutdownNow();
+      toRestrictive.shutdownNow();
+      permissive.shutdown();
+      restrictive.shutdown();
+    }
+  }
+
+  private static MailboxService startMailboxService(int permitKeepAliveTimeMs) 
{
+    PinotConfiguration config = new PinotConfiguration(Map.of(
+        MultiStageQueryRunner.KEY_OF_MAILBOX_SERVER_PERMIT_KEEP_ALIVE_TIME_MS, 
permitKeepAliveTimeMs));
+    MailboxService mailboxService = new MailboxService(
+        "localhost", QueryTestUtils.getAvailablePort(), InstanceType.SERVER, 
config);
+    mailboxService.start();
+    return mailboxService;
+  }
+
+  private static OpenStream openStream(ManagedChannel channel) {
+    OpenStream stream = new OpenStream();
+    PinotMailboxGrpc.newStub(channel).open(stream);
+    return stream;
+  }
+
+  /// Client-side observer of one `open` stream: records how it ended, if it 
ended.
+  private static final class OpenStream implements 
StreamObserver<Mailbox.MailboxStatus> {
+    private final CountDownLatch _terminated = new CountDownLatch(1);
+    private final AtomicReference<Throwable> _error = new AtomicReference<>();
+
+    @Override
+    public void onNext(Mailbox.MailboxStatus value) {
+    }
+
+    @Override
+    public void onError(Throwable t) {
+      _error.compareAndSet(null, t);
+      _terminated.countDown();
+    }
+
+    @Override
+    public void onCompleted() {
+      _terminated.countDown();
+    }
+
+    boolean awaitTermination(long timeoutMs)
+        throws InterruptedException {
+      return _terminated.await(timeoutMs, TimeUnit.MILLISECONDS);
+    }
+  }
+}
diff --git 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/mailbox/channel/MailboxServerPermitKeepAliveTest.java
 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/mailbox/channel/MailboxServerPermitKeepAliveTest.java
new file mode 100644
index 00000000000..453ace90b0e
--- /dev/null
+++ 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/mailbox/channel/MailboxServerPermitKeepAliveTest.java
@@ -0,0 +1,77 @@
+/**
+ * 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.query.mailbox.channel;
+
+import java.util.Map;
+import org.apache.pinot.query.mailbox.MailboxService;
+import org.apache.pinot.query.testutils.QueryTestUtils;
+import org.apache.pinot.spi.config.instance.InstanceType;
+import org.apache.pinot.spi.env.PinotConfiguration;
+import org.apache.pinot.spi.utils.CommonConstants.MultiStageQueryRunner;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+
+
+/// Pins the keep-alive enforcement the mailbox server applies to its peers.
+///
+/// The two sides are coupled: a peer pinging faster than 
`permitKeepAliveTime` has its pings counted as "bad" and,
+/// past gRPC's strike threshold, is answered with `GOAWAY(ENHANCE_YOUR_CALM)` 
— dropping the mailbox channel
+/// mid-query. Both halves therefore have to be tunable, and the defaults have 
to match Netty's own so that a peer
+/// still on the default client keep-alive time is never punished by an 
upgraded one.
+public class MailboxServerPermitKeepAliveTest {
+
+  @Test
+  public void testPermitKeepAliveDefaultsMatchNettyDefaults() {
+    GrpcMailboxServer server = newServer(new PinotConfiguration(Map.of()));
+    assertEquals(server.getPermitKeepAliveTimeMs(),
+        
MultiStageQueryRunner.DEFAULT_OF_MAILBOX_SERVER_PERMIT_KEEP_ALIVE_TIME_MS);
+    assertFalse(server.isPermitKeepAliveWithoutCalls());
+  }
+
+  /// Values an operator would use to tune detection of a silent peer down 
from minutes to seconds. Both the client
+  /// keep-alive time and this permit have to move together, on every instance.
+  @Test
+  public void testPermitKeepAliveOverridesPickedUpFromConfig() {
+    GrpcMailboxServer server = newServer(new PinotConfiguration(Map.of(
+        MultiStageQueryRunner.KEY_OF_MAILBOX_SERVER_PERMIT_KEEP_ALIVE_TIME_MS, 
30_000,
+        
MultiStageQueryRunner.KEY_OF_MAILBOX_SERVER_PERMIT_KEEP_ALIVE_WITHOUT_CALLS, 
true)));
+    assertEquals(server.getPermitKeepAliveTimeMs(), 30_000);
+    assertTrue(server.isPermitKeepAliveWithoutCalls());
+  }
+
+  /// A non-positive permit must leave Netty's own default in place rather 
than being passed to gRPC, which rejects
+  /// it. Reaching construction at all is the assertion.
+  @Test
+  public void testNonPositivePermitLeavesNettyDefault() {
+    GrpcMailboxServer server = newServer(new PinotConfiguration(Map.of(
+        MultiStageQueryRunner.KEY_OF_MAILBOX_SERVER_PERMIT_KEEP_ALIVE_TIME_MS, 
-1)));
+    assertEquals(server.getPermitKeepAliveTimeMs(), -1);
+  }
+
+  /// Builds the server without starting it: nothing binds a port until 
`start()`, and the values under test are all
+  /// resolved in the constructor.
+  private static GrpcMailboxServer newServer(PinotConfiguration config) {
+    MailboxService mailboxService = new MailboxService(
+        "localhost", QueryTestUtils.getAvailablePort(), InstanceType.BROKER, 
config);
+    return new GrpcMailboxServer(mailboxService, config, null, null, null);
+  }
+}
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 0ef4615ba20..42f66b844c0 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
@@ -2571,6 +2571,68 @@ public class CommonConstants {
     public static final String KEY_OF_CHANNEL_IDLE_TIMEOUT_SECONDS = 
"pinot.query.runner.channel.idle.timeout.seconds";
     public static final long DEFAULT_CHANNEL_IDLE_TIMEOUT_SECONDS = -1;
 
+    /// gRPC keep-alive time for mailbox channels, in milliseconds. Values 
&gt; 0 enable keep-alive pings on the
+    /// server-to-server (and server-to-broker) data channels, so that a peer 
that stopped answering *without*
+    /// closing its socket transitions the channel out of `READY` instead of 
being sent to forever.
+    ///
+    /// The failure this addresses is a peer whose host is hung or unreachable 
one-way: no `RST` is ever received, so
+    /// gRPC keeps the cached channel `READY`, every mailbox send parks until 
the query deadline, and — because gRPC
+    /// only re-resolves DNS when a transport is dropped — a peer that has 
come back at a new address is never
+    /// reached again. The symptom is every multi-stage query timing out while 
the leaf stages complete in
+    /// milliseconds, for as long as the channel is cached, which is 
indefinitely by default (see
+    /// [#KEY_OF_CHANNEL_IDLE_TIMEOUT_SECONDS]).
+    ///
+    /// Defaults are chosen to be safe against a peer running an older 
version, whose mailbox server enforces
+    /// Netty's gRPC defaults (`permitKeepAliveTime` = 5 minutes, 
`permitKeepAliveWithoutCalls` = false) and answers
+    /// a faster ping with `GOAWAY(ENHANCE_YOUR_CALM)`. Operators wanting 
faster detection must lower
+    /// [#KEY_OF_MAILBOX_SERVER_PERMIT_KEEP_ALIVE_TIME_MS] on every peer first.
+    public static final String KEY_OF_CHANNEL_KEEP_ALIVE_TIME_MS = 
"pinot.query.runner.channel.keep.alive.time.ms";
+    public static final int DEFAULT_CHANNEL_KEEP_ALIVE_TIME_MS = 300_000;
+
+    /// gRPC keep-alive timeout for mailbox channels, in milliseconds: how 
long a keep-alive ping may go unanswered
+    /// before the transport is declared dead. Only applies when keep-alive is 
enabled.
+    ///
+    /// Sized to survive a long stop-the-world pause on a loaded peer. A ping 
unanswered for a few seconds is a GC
+    /// pause, not a dead host, and dropping the transport there would fail 
healthy in-flight queries.
+    public static final String KEY_OF_CHANNEL_KEEP_ALIVE_TIMEOUT_MS =
+        "pinot.query.runner.channel.keep.alive.timeout.ms";
+    public static final int DEFAULT_CHANNEL_KEEP_ALIVE_TIMEOUT_MS = 30_000;
+
+    /// Whether to send gRPC keep-alive pings on mailbox channels even when 
there are no active calls. Default is
+    /// `false` because Netty's default gRPC server rejects 
pings-without-calls with `GOAWAY(ENHANCE_YOUR_CALM)`;
+    /// enabling it requires 
[#KEY_OF_MAILBOX_SERVER_PERMIT_KEEP_ALIVE_WITHOUT_CALLS] on every peer.
+    ///
+    /// Leaving this off does not blind the ping to a dead peer. The ping 
deadline is anchored to the last data
+    /// *received* on the transport, not to stream boundaries, so a channel 
whose streams keep dying at their query
+    /// deadline still accumulates it; the ping is merely deferred until a 
stream is open again, and then fires at
+    /// once. The difference the two settings make is who pays for the 
recovery: with pings-without-calls off, the
+    /// transport is torn down by the next query to open a stream, and that 
query fails; with it on, the tear-down
+    /// happens in the background between queries and no query sees the dead 
channel at all.
+    public static final String KEY_OF_CHANNEL_KEEP_ALIVE_WITHOUT_CALLS =
+        "pinot.query.runner.channel.keep.alive.without.calls";
+    public static final boolean DEFAULT_CHANNEL_KEEP_ALIVE_WITHOUT_CALLS = 
false;
+
+    /// Minimum interval, in milliseconds, between client gRPC keep-alive 
pings that the mailbox server
+    /// ([org.apache.pinot.query.mailbox.channel.GrpcMailboxServer]) will 
accept. Pings arriving more frequently than
+    /// this are counted as "bad pings"; once the server's internal threshold 
is exceeded it sends
+    /// `GOAWAY(ENHANCE_YOUR_CALM)` with `too_many_pings` debug data and 
closes the connection.
+    ///
+    /// Defaults to 5 minutes to match Netty's gRPC server default. Lowering
+    /// [#KEY_OF_CHANNEL_KEEP_ALIVE_TIME_MS] for faster detection of a silent 
peer requires this to be less than or
+    /// equal to the configured client keep-alive time **on every instance the 
channel may reach**, otherwise those
+    /// peers will tear the mailbox channel down. A non-positive value leaves 
Netty's gRPC server default in place.
+    public static final String KEY_OF_MAILBOX_SERVER_PERMIT_KEEP_ALIVE_TIME_MS 
=
+        "pinot.query.runner.mailbox.server.permit.keep.alive.time.ms";
+    public static final int 
DEFAULT_OF_MAILBOX_SERVER_PERMIT_KEEP_ALIVE_TIME_MS = 300_000;
+
+    /// Whether the mailbox server permits client gRPC keep-alive pings when 
there are no active RPCs on the
+    /// connection. Defaults to `false` to match Netty's gRPC server default. 
Must be set to `true` on every peer if
+    /// [#KEY_OF_CHANNEL_KEEP_ALIVE_WITHOUT_CALLS] is enabled, otherwise those 
peers will close idle channels with
+    /// `GOAWAY(ENHANCE_YOUR_CALM)`.
+    public static final String 
KEY_OF_MAILBOX_SERVER_PERMIT_KEEP_ALIVE_WITHOUT_CALLS =
+        "pinot.query.runner.mailbox.server.permit.keep.alive.without.calls";
+    public static final boolean 
DEFAULT_OF_MAILBOX_SERVER_PERMIT_KEEP_ALIVE_WITHOUT_CALLS = false;
+
     /// Configuration for server port used to receive query plans.
     public static final String KEY_OF_QUERY_SERVER_PORT = 
"pinot.query.server.port";
     public static final int DEFAULT_QUERY_SERVER_PORT = 0;


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

Reply via email to