xiangfu0 commented on code in PR #20021:
URL: https://github.com/apache/pinot/pull/20021#discussion_r4451374155
##########
pinot-broker/src/main/java/org/apache/pinot/broker/routing/manager/BaseBrokerRoutingManager.java:
##########
@@ -557,6 +620,50 @@ private static boolean isEnabledServer(ZNRecord
instanceConfigZNRecord) {
return true;
}
+ private void notifyServerEnabled(ServerInstance serverInstance) {
+ for (Consumer<ServerInstance> callback : _serverEnabledCallbacks) {
+ try {
+ callback.accept(serverInstance);
+ } catch (Exception e) {
+ LOGGER.error("Caught exception in server enabled callback for server:
{}, ignoring it",
+ serverInstance.getInstanceId(), e);
+ }
+ }
+ }
+
+ private void notifyServerRemoved(ServerInstance serverInstance) {
+ LOGGER.info("Server: {} has been removed from the cluster",
serverInstance.getInstanceId());
+ invokeServerRemovedCallbacks(serverInstance);
+ }
+
+ /// A server back under the same id at a different address (hostname, Netty
port or Netty TLS port) has left its
+ /// previous address for good. Listeners key what they cache by address
rather than by id, so they get the removed
+ /// callback with the previous instance: otherwise its cached channels would
never be dropped, and one gone
+ /// half-open would be picked up again if the server ever moved back to that
address. Channels at an address that
Review Comment:
Fixed in b6d544adbc. When the channel turns out to be closed and the entry
has been evicted, `sendRequestWithoutLocking` now returns null, so the caller
retries on a fresh entry like the other eviction races do, instead of failing
the query with the server set as the failed one. A close the broker didn't make
(a server FIN) still throws as before.
`ServerChannelsTest#testEvictionAfterConnectRetriesInsteadOfFailing` covers it.
##########
pinot-core/src/main/java/org/apache/pinot/core/transport/ServerChannels.java:
##########
@@ -146,10 +174,67 @@ public Channel sendRequest(String rawTableName,
AsyncQueryResponse asyncQueryRes
ServerRoutingInstance serverRoutingInstance, InstanceRequest
instanceRequest, long timeoutMs)
throws Exception {
byte[] requestBytes =
THREAD_LOCAL_T_SERIALIZER.get().serialize(instanceRequest);
- ServerChannel serverChannel =
_serverToChannelMap.computeIfAbsent(serverRoutingInstance, ServerChannel::new);
- // This server is now query-carrying, whoever opened the channel. See
[#hasChannel].
- serverChannel._openedByQuery = true;
- return serverChannel.sendRequest(rawTableName, asyncQueryResponse,
serverRoutingInstance, requestBytes, timeoutMs);
+ // One deadline for the whole call: a sender that races an eviction
retries on a fresh entry, but the lock waits
+ // and connects of all its attempts together stay within the caller's
timeout.
+ long deadlineMs = System.currentTimeMillis() + timeoutMs;
+ for (int attempt = 1; attempt <= MAX_SEND_ATTEMPTS; attempt++) {
+ ServerChannel serverChannel =
_serverToChannelMap.computeIfAbsent(serverRoutingInstance, ServerChannel::new);
+ // This server is now query-carrying, whoever opened the channel. See
[#hasChannel].
+ serverChannel._openedByQuery = true;
+ Channel channel = serverChannel.sendRequest(rawTableName,
asyncQueryResponse, serverRoutingInstance,
+ requestBytes, Math.max(0L, deadlineMs - System.currentTimeMillis()));
+ if (channel != null) {
+ return channel;
+ }
+ // The entry was evicted between the lookup and the send (see
[#evictChannel]); it is already out of the map, so
+ // the next lookup creates a fresh one.
+ LOGGER.info("Channel to server: {} was evicted while sending a request,
retrying on a fresh channel (attempt {})",
+ serverRoutingInstance, attempt);
+ }
+ throw new IllegalStateException(
+ "Channel to server: " + serverRoutingInstance + " kept being evicted
while sending a request");
+ }
+
+ /// Closes and forgets the cached channel to the given routing instance, if
there is one. Returns `true` if that
+ /// closed a live connection. An entry whose channel had already closed (the
server sent a FIN as it shut down) or
+ /// never connected is dropped all the same but not counted, so that
+ /// [BrokerMeter#NETTY_CONNECTION_CHANNEL_EVICTIONS] reads as connections
that were still open, possibly half-open,
+ /// when the broker gave up on them, and a graceful rolling restart does not
bump it for every server.
+ ///
+ /// Called when the routing manager enables a server, which is what a
restart looks like through Helix, and when it
+ /// sees a server removed from the cluster. The cached channel then belongs
to a previous process: if that process
+ /// went away without a FIN (node flap, OOM kill, a network path that
stopped forwarding), the socket is half-open,
+ /// `isActive()` keeps reporting `true` until the kernel gives up on
retransmits, and every query routed to the
+ /// readmitted server would be written into it and lost. Evicting here makes
the next query reconnect, to the new
+ /// process at whatever address its hostname now resolves to.
+ ///
+ /// Queries in flight on the evicted channel fail fast through
`channelInactive` rather than waiting out their
+ /// timeout: the process they were sent to is gone. The entry is flagged
before it is closed so that a sender that
+ /// already holds it does not reconnect it after it has left the map, which
would leak a channel nobody routes to
+ /// (see [ServerChannel#sendRequest]).
+ public boolean evictChannel(ServerRoutingInstance serverRoutingInstance) {
+ ServerChannel serverChannel =
_serverToChannelMap.remove(serverRoutingInstance);
+ if (serverChannel == null) {
+ return false;
+ }
+ // Read before the eviction closes it; a snapshot is enough, as it only
decides what is counted
+ Channel channel = serverChannel._channel;
+ boolean wasConnected = channel != null && channel.isActive();
+ serverChannel.evict();
+ if (!wasConnected) {
+ LOGGER.debug("Dropped cached entry without a live channel to server:
{}", serverRoutingInstance);
+ return false;
+ }
+ LOGGER.info("Evicted live cached channel to server: {}",
serverRoutingInstance);
+
_brokerMetrics.addMeteredGlobalValue(BrokerMeter.NETTY_CONNECTION_CHANNEL_EVICTIONS,
1);
+ return true;
+ }
+
+ /// Whether [NettyConfig#TCP_USER_TIMEOUT_MS] is set on the channels: it is
a Linux socket option that Netty only
+ /// exposes on the Epoll transport.
+ @VisibleForTesting
+ boolean isTcpUserTimeoutApplied() {
+ return _tcpUserTimeoutMs > 0 && _channelClass == EpollSocketChannel.class;
Review Comment:
Done in b6d544adbc, along the lines you suggested. The routing manager now
also tells the handler when an enabled server gets disabled
(`addServerDisabledCallback`), and the broker notes the disable on that
server's cached channels. On re-enable, a channel is kept if it received data
after the disable and still has queries waiting on it, so a toggle leaves those
queries to finish.
A restart doesn't take that path. A server sets the shutdown flag as it
starts up, even after a crash, so the channel to the old process hears nothing
after the disable and gets evicted. After a graceful shutdown that channel is
already closed by the time the server comes back. I added the "still has
queries waiting" condition so that a channel which answered during a graceful
drain and then went half-open (the host dying mid-shutdown) isn't kept for
nothing. Evicting an idle channel only costs a reconnect.
Tests:
`ServerChannelsTest#testEnabledServerKeepsOnlyChannelHeardFromSinceDisabledThatCarriesQueries`,
`QueryRoutingTest#testEnabledServerKeepsChannelThatCarriesQueriesAndAnsweredSinceDisabled`
against a real server, and
`BrokerRoutingManagerTest#testDisabledCallbackInvokedWhenEnabledServerIsDisabled`.
##########
pinot-broker/src/main/java/org/apache/pinot/broker/routing/manager/BaseBrokerRoutingManager.java:
##########
@@ -461,19 +501,22 @@ private void processInstanceConfigChangeInternal() {
// hit the identity fast path
instanceId = serverInstance.getInstanceId();
enabledServers.add(instanceId);
- if (_enabledServerInstanceMap.put(instanceId, serverInstance) ==
null) {
+ ServerInstance previousInstance =
_enabledServerInstanceMap.put(instanceId, serverInstance);
+ if (previousInstance == null) {
newEnabledServers.add(instanceId);
// NOTE: Remove new enabled server from excluded servers because
the server is likely being restarted
if (_excludedServers.remove(instanceId)) {
LOGGER.info("Got excluded server: {} re-enabled, including it
into the routing", instanceId);
}
- // The server likely restarted, possibly at a new address. The
callback refreshes the GRPC channel to it:
- // it clears any reconnection backoff, without which MSE queries
to this server may fail fast until the
- // next backoff retry succeeds, and it makes the channel resolve
the hostname again.
- if (_serverEnabledCallback != null) {
- _serverEnabledCallback.accept(serverInstance);
- }
+ notifyIfAddressChanged(_disabledServers.remove(instanceId),
serverInstance);
+ // The server likely restarted, possibly at a new address. The
callbacks drop what may be tied to its
+ // previous process: the GRPC channel's reconnection backoff,
without which MSE queries to this server may
+ // fail fast until the next backoff retry succeeds, and its stale
resolved address; cached Netty channels
+ // that may be half-open.
+ notifyServerEnabled(serverInstance);
Review Comment:
I took the first option in b6d544adbc. The rejection now throws its own
`UnwritableChannelException`, and `QueryRouter` fails the query (or skips the
server) without recording the server as the failed one, so the failure detector
never hears about it. A real stall is still judged by the write timeout, which
closes the channel, and that close reports the server as before.
Pings now skip the check and queue behind the full queue instead. They're
tiny, and whether one gets answered is exactly what tells a healthy server
draining a big request from a stalled one, so rejecting it would fail it either
way.
I didn't add a grace wait: it would hold the channel lock and the query
thread while the window is closed, which is what the rejection is there to
avoid. The watermark is configurable if 16 MiB is too low for a deployment with
very large requests. #20022 also no longer marks the channel suspect on a
rejection, for the same reason.
Tests: `QueryRoutingTest#testUnwritableRejectionDoesNotReportServer` (with
and without `skipUnavailableServers`) and
`ServerChannelsTest#testPingIsQueuedOnUnwritableChannel`.
##########
pinot-core/src/main/java/org/apache/pinot/core/transport/ServerChannels.java:
##########
@@ -365,11 +496,31 @@ private void awaitTlsHandshake(Channel channel, long
timeoutMs)
}
}
- void sendRequestWithoutLocking(String rawTableName, AsyncQueryResponse
asyncQueryResponse,
+ /// Returns the channel the request was written to.
+ Channel sendRequestWithoutLocking(String rawTableName, AsyncQueryResponse
asyncQueryResponse,
ServerRoutingInstance serverRoutingInstance, byte[] requestBytes) {
- long startTimeMs = System.currentTimeMillis();
- // The listener runs later, by when a reconnect may have replaced
_channel: on failure it closes this connection.
+ // Pin the channel: the completion listener must act on the channel it
wrote to, not on whatever channel this
+ // entry holds by the time the write completes, or a late failure would
close a healthy reconnected channel.
Channel channel = _channel;
+ if (!channel.isWritable()) {
Review Comment:
Agreed, fixed in b6d544adbc. When a channel is given, only requests pinned
to that channel are affected now. An unpinned request hasn't been written yet,
and its own send settles it: either it gets pinned to a live channel, after
which that channel's events apply, or its write or connect fails and fails it.
The variant without a channel, used for broker-side shedding, still covers the
whole server. I updated
`AsyncQueryResponseTest#testMarkServerUnavailableIsScopedToTheRequestChannel`
and `#testBrokerInitiatedCloseDoesNotBlameTheServer` to match.
##########
pinot-core/src/main/java/org/apache/pinot/core/transport/AsyncQueryResponse.java:
##########
@@ -237,8 +263,27 @@ void cancelQuery(ServerRoutingInstance
serverRoutingInstance, Exception exceptio
/// Returns `true` if a server-down event should be acted on: the server was
queried by this response and has not
/// responded yet
private boolean shouldActOnServerDown(ServerRoutingInstance
serverRoutingInstance) {
+ return shouldActOnServerDown(serverRoutingInstance, null, true);
+ }
+
+ /// As above, and when `channel` is given the request must have been written
to that channel. A request whose
+ /// channel is not known yet (it is about to be written, possibly to a
channel that is still being connected) is
+ /// still taken down by a genuine failure of the server, as before, but
never by a close the broker itself made:
+ /// that close only concerns the queries demonstrably written to the closed
channel.
+ private boolean shouldActOnServerDown(ServerRoutingInstance
serverRoutingInstance, @Nullable Channel channel,
+ boolean reportServer) {
ServerResponse serverResponse = _responseMap.get(serverRoutingInstance);
- return serverResponse != null && serverResponse.getDataTable() == null;
+ if (serverResponse == null || serverResponse.getDataTable() != null) {
+ return false;
+ }
+ if (channel == null) {
+ return true;
+ }
+ Channel requestChannel = serverResponse.getChannel();
+ if (requestChannel == null) {
+ return reportServer;
Review Comment:
Done in b6d544adbc. `NETTY_CONNECTION_UNWRITABLE_REJECTIONS`,
`NETTY_CONNECTION_WRITE_TIMEOUTS` and `NETTY_CONNECTION_CHANNEL_EVICTIONS` are
now tagged with the server's short name through `addMeteredValue`, the same way
as `NETTY_CONNECTION_CHANNEL_INACTIVE`. They're new in this PR, so no existing
metric changes name.
--
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]