xiangfu0 commented on code in PR #20022:
URL: https://github.com/apache/pinot/pull/20022#discussion_r4366307520


##########
pinot-core/src/main/java/org/apache/pinot/core/transport/ServerChannels.java:
##########
@@ -227,35 +418,99 @@ protected void initChannel(SocketChannel ch) {
                       _serverRoutingInstance));
             }
           });
+      if (isTcpUserTimeoutApplied()) {
+        _bootstrap.option(EpollChannelOption.TCP_USER_TIMEOUT, 
_tcpUserTimeoutMs);
+      }
     }
 
     void closeChannel() {
-      if (_channel != null) {
-        _channel.close();
+      Channel channel = _channel;
+      if (channel != null) {
+        channel.close();
       }
     }
 
+    /// See [ServerChannels#evictChannel]. Flags the entry first, then closes 
whatever channel it holds; no lock is
+    /// taken so that the routing manager's Helix callback is never blocked 
behind a connect or write in progress.
+    void evict() {
+      _evicted = true;
+      Channel channel = _channel;
+      if (channel != null) {
+        closeLocally(channel);
+      }
+    }
+
+    /// Closes a channel the broker decided to drop (eviction after a restart, 
replacement of a suspect channel).
+    /// The queries still in flight on it fail through `channelInactive` as on 
any closed channel, but the server is
+    /// not reported for it: the close was the broker's decision, not evidence 
against the server.
+    private void closeLocally(Channel channel) {
+      DataTableHandler dataTableHandler = 
channel.pipeline().get(DataTableHandler.class);
+      if (dataTableHandler != null) {
+        dataTableHandler.markClosedByBroker();
+      }
+      channel.close();
+    }
+
+    void recordResponse() {
+      _lastResponseTimeMs = System.currentTimeMillis();
+      _unansweredQueries.set(0);
+    }
+
+    /// Runs on the liveness thread a grace period after a query written at 
`submitTimeMs` timed out. See
+    /// [ServerChannels#recordUnansweredQuery].
+    void judgeUnansweredQuery(long submitTimeMs) {
+      // Answered something since the query was written (a fresh channel 
counts as answered from its connect time, so
+      // queries written to the channel it replaced do not count against it), 
or no longer in use. Strictly after:
+      // the connect and the first write can share a millisecond, while a real 
answer never shares one with its
+      // request.
+      if (_evicted || _lastResponseTimeMs > submitTimeMs) {
+        return;
+      }
+      int unansweredQueries = _unansweredQueries.incrementAndGet();
+      if (unansweredQueries < _unansweredQueriesThreshold) {
+        return;
+      }
+      _unansweredQueries.set(0);
+      _suspect = true;

Review Comment:
   Fixed, and a little more than the re-read: the liveness bookkeeping 
(`_lastResponseTimeMs`, the unanswered count, the pending judgment) now sits 
behind a small lock on the channel entry, taken briefly by `recordResponse` on 
the event loop, by the connect path when it resets a fresh channel, and by the 
judgment on the liveness thread. The judgment re-checks the last response time 
and flips `_suspect` under that lock, so a response landing while it runs is 
never lost: either it is seen and the judgment bails, or it runs after and 
zeroes the count. The lock is uncontended in practice (one short critical 
section per response).



##########
pinot-common/src/main/java/org/apache/pinot/common/config/NettyConfig.java:
##########
@@ -18,13 +18,56 @@
  */
 package org.apache.pinot.common.config;
 
+import com.google.common.base.Preconditions;
 import org.apache.pinot.spi.env.PinotConfiguration;
 
 
 /// Container object for netty configuration of pinot clients and servers 
(netty, grizzly, etc.)
+///
+/// Every setting is read as `<namespace>.<suffix>`, where the namespace is 
`pinot.broker.netty` on the broker and
+/// `pinot.server.netty` on the server. The channel liveness settings below 
only apply to the client side, i.e. the
+/// broker-to-server channels opened by `ServerChannels`; the server-side 
`QueryServer` ignores them.
 public class NettyConfig {
   private static final String NATIVE_TRANSPORTS_ENABLED = 
"native.transports.enabled";
+
+  /// Upper bound on how long a request write may stay pending before the 
channel is closed, in milliseconds. A write
+  /// is pending while the kernel has not accepted the bytes, which on a 
half-open or stalled socket is forever (the
+  /// peer stops acknowledging, the send buffer fills, and `isActive()` keeps 
reporting `true` until the kernel gives
+  /// up on retransmits, typically after many minutes). Closing the channel 
fails the in-flight queries on it fast
+  /// and lets the failure detector quarantine the server. Non-positive 
disables the check. Broker key:
+  /// `pinot.broker.netty.write.timeout.ms`.
+  public static final String WRITE_TIMEOUT_MS = "write.timeout.ms";
+  public static final long DEFAULT_WRITE_TIMEOUT_MS = 10_000L;
+
+  /// Linux `TCP_USER_TIMEOUT`, in milliseconds: how long transmitted data may 
remain unacknowledged before the
+  /// kernel closes the connection, replacing the default retransmission 
timeout of several minutes. Covers the
+  /// case the write timeout cannot see: small writes that the kernel accepted 
into a send buffer that the peer never
+  /// drains. Only applied when the broker uses the Epoll transport 
(`native.transports.enabled` on Linux); NIO and
+  /// KQueue have no such socket option. Non-positive leaves the kernel 
default. Broker key:
+  /// `pinot.broker.netty.tcp.user.timeout.ms`.
+  public static final String TCP_USER_TIMEOUT_MS = "tcp.user.timeout.ms";
+  public static final int DEFAULT_TCP_USER_TIMEOUT_MS = 10_000;

Review Comment:
   Agreed that this deserves to be explicit, so #20021 now carries a "Release 
note" section covering the three new defaults, what each does, when it fires, 
and how to raise or disable it, plus the rolling-upgrade note (broker-only, no 
server or wire change, so brokers can be upgraded in any order and rolled back 
independently).
   
   Why on by default, briefly: all three only act on a socket whose peer has 
stopped taking bytes. The write timeout is now on progress rather than 
completion (see the thread above): it fires only when the kernel has accepted 
nothing for 10 s, which on a healthy link needs a receiver that stopped 
reading, not a slow one. `TCP_USER_TIMEOUT` (Epoll only) fires only when 
transmitted data has gone unacknowledged for 10 s; on a lossy link Linux 
retransmits well within that, and a path that cannot deliver an ACK in 10 s has 
already failed every query on it at the default 10 s broker timeout. The 
watermark rejection needs 16 MB queued beyond what the kernel accepted, which a 
healthy peer drains in milliseconds. The failure each prevents is silent and 
long (minutes of lost queries, direct-memory exhaustion that takes healthy 
channels down with it), while a false positive costs one query and a reconnect, 
so the defaults err towards acting. Operators on unusual links can raise each 
one, or set
  it non-positive to disable it, as the release note says.



##########
pinot-core/src/main/java/org/apache/pinot/core/transport/ServerChannels.java:
##########
@@ -169,6 +273,71 @@ public void connect(ServerRoutingInstance 
serverRoutingInstance)
     _serverToChannelMap.computeIfAbsent(serverRoutingInstance, 
ServerChannel::new).connect();
   }
 
+  /// Marks the cached channel to the given routing instance suspect: the 
server stopped answering queries on it
+  /// while the socket still looked active. The next query to it, or the next 
[#probe], replaces it with a fresh
+  /// connection instead of trusting `isActive()`.
+  public void markSuspect(ServerRoutingInstance serverRoutingInstance) {
+    ServerChannel serverChannel = 
_serverToChannelMap.get(serverRoutingInstance);
+    if (serverChannel != null) {
+      serverChannel._suspect = true;
+    }
+  }
+
+  /// The failure detector's retry probe for one channel. Does nothing and 
returns `false` when this broker has not
+  /// queried the server over this channel (see [#hasChannel]); otherwise 
reconnects the channel if it is down,
+  /// replaces it with a fresh connection if it was marked suspect, and 
returns `true`, throwing if the connection
+  /// cannot be established.
+  public boolean probe(ServerRoutingInstance serverRoutingInstance)
+      throws InterruptedException, TimeoutException {
+    ServerChannel serverChannel = 
_serverToChannelMap.get(serverRoutingInstance);
+    if (serverChannel == null || !serverChannel._openedByQuery) {
+      return false;
+    }
+    serverChannel.connect();
+    return true;
+  }
+
+  /// Records a response from the server on the given channel: it is alive, 
whatever it answered.
+  public void recordResponse(ServerRoutingInstance serverRoutingInstance) {
+    ServerChannel serverChannel = 
_serverToChannelMap.get(serverRoutingInstance);
+    if (serverChannel != null) {
+      serverChannel.recordResponse();
+    }
+  }
+
+  /// Records that a query written to the given channel at `submitTimeMs` 
timed out unanswered. The verdict waits
+  /// out the grace period: the query only counts against the channel if by 
then the server has still sent nothing
+  /// on it since the query was written. That lets the server's own late 
timeout response through, so a burst of
+  /// slow queries, or a broker pause that delays reading the responses, does 
not look like a dead channel: only a
+  /// channel the server has gone silent on does. At the threshold the channel 
is marked suspect and the server is
+  /// reported through [QueryRouter#reportUnansweredServer]. No-op when the 
check is disabled.
+  public void recordUnansweredQuery(ServerRoutingInstance 
serverRoutingInstance, long submitTimeMs) {

Review Comment:
   Good call. The judgments queued per channel are now capped at `max(16, 2 × 
threshold)`: a timed-out query beyond that is simply not scheduled, because the 
ones already queued are enough to cross the threshold on their own, so the 
outcome under a dead server at high QPS is unchanged while the liveness queue 
stays at a few dozen tasks per channel instead of the inflight count. Each 
judgment still gets its own full grace period, which is what keeps the burst 
case honest, and a channel already marked suspect is not reported again until 
it has been replaced, so one burst means one report. Covered by 
`ServerChannelsTest#testPendingJudgmentsPerChannelAreCapped`.



##########
pinot-core/src/main/java/org/apache/pinot/core/transport/ServerChannels.java:
##########
@@ -264,17 +519,40 @@ void sendRequest(String rawTableName, AsyncQueryResponse 
asyncQueryResponse,
       }
     }
 
+    private void checkNotEvicted() {
+      if (_evicted) {
+        closeChannel();
+        throw new IllegalStateException("Channel to server: " + 
_serverRoutingInstance + " has been evicted");
+      }
+    }
+
     /// Lazy query path: opens the TCP connection only. Any TLS handshake is 
left to proceed
     /// asynchronously so the channel lock is released as soon as the socket 
is up, keeping the first
     /// query's critical section short. Startup pre-connect uses 
[#preConnectWithoutLocking(long)]
     /// instead, which additionally pays the handshake.
+    ///
+    /// Also replaces a channel marked suspect: the server stopped answering 
on it while the socket still looked
+    /// active, which is what a half-open socket looks like until the kernel 
gives up on it, so `isActive()` is not
+    /// trusted. The suspect channel is dropped before the connect, so a 
failed connect does not leave it in place,
+    /// and whatever is still in flight on it fails without the server being 
reported (nothing on it was being
+    /// answered anyway).
     void connectWithoutLocking()
         throws InterruptedException {
-      if (_channel == null || !_channel.isActive()) {
-        long startTime = System.currentTimeMillis();
-        _channel = _bootstrap.connect().sync().channel();
-        recordConnectTime(System.currentTimeMillis() - startTime);
+      Channel channel = _channel;
+      if (channel != null && channel.isActive() && !_suspect) {
+        return;
       }
+      if (channel != null && _suspect) {
+        LOGGER.info("Replacing suspect channel to server: {} with a fresh 
connection", _serverRoutingInstance);
+        _channel = null;
+        closeLocally(channel);
+      }
+      _suspect = false;
+      long startTime = System.currentTimeMillis();
+      _channel = _bootstrap.connect().sync().channel();

Review Comment:
   Right, and I kept it that way deliberately. The lazy path has always paid 
the connect under `_channelLock` (first query, and every reconnect after a 
channel went inactive), and a suspect replacement is the same reconnect taken a 
little earlier. Concurrent senders wait at most their own `tryLock(timeoutMs)`, 
after which they fail with the channel-lock timeout rather than hanging, and 
since the server has usually been reported and taken out of routing by then 
there are few of them. Moving the connect out from under the lock would need a 
second connection-establishment path with its own races for a case that lasts 
one round trip; not worth it in this PR. Bounding `CONNECT_TIMEOUT_MILLIS` on 
the lazy path (Netty's 30 s default today) would shorten the worst case for 
everyone and is a reasonable small follow-up.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to