This is an automated email from the ASF dual-hosted git repository.
shauryachats 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 bbbed251444 [broker] Honor skipUnavailableServers on mid-query server
channel-inactive (#19064)
bbbed251444 is described below
commit bbbed251444e00d58bdcbe59a6f233902eb7e79c
Author: Anurag Rai <[email protected]>
AuthorDate: Sat Aug 29 09:46:32 2026 +0530
[broker] Honor skipUnavailableServers on mid-query server channel-inactive
(#19064)
skipUnavailableServers=true only degraded to partial results when a server
was unreachable at request send time (Path 1). If a server's Netty channel
went inactive after dispatch (Path 2), or a write to it failed, the broker
force-failed the whole query with BROKER_REQUEST_SEND (425) instead of
returning partial results from the healthy servers. This was the root cause
of a logging zone-outage incident: a single impacted zone dropped all query
results.
Changes:
- Split markServerDown into markServerUnavailable (skippable: degrades to
partial results under the flag, records the down server so the failure
detector can quarantine it) and markServerCancelled (direct-memory OOM,
always fails the query - no partial data to return).
- Make the latch decrement idempotent: a server can be reported down through
more than one path (write failure closes the channel, which also fires
channelInactive), so a repeat report must not drop a healthy server's
slot.
- Track a single failed server per query; getFailedServer() may now be set
on
a partial success, so it no longer implies failure on its own.
- Add broker meters: SERVER_MARKED_DOWN_SKIPPED (global),
BROKER_RESPONSES_WITH_SEND_EXCEPTIONS (per table), and per-server
NETTY_CONNECTION_CHANNEL_ACTIVE / NETTY_CONNECTION_CHANNEL_INACTIVE tagged
with the server short name, for observability into channel flaps.
Tests: AsyncQueryResponseTest (skip/cancel/idempotency), QueryRoutingTest
Path-2 channel-inactive with and without the flag plus per-server meter
emission, ServerChannelsTest updated for the rename.
---
.../SingleConnectionBrokerRequestHandler.java | 4 +
.../apache/pinot/common/metrics/BrokerMeter.java | 15 ++
.../pinot/core/transport/AsyncQueryResponse.java | 62 +++++--
.../pinot/core/transport/DataTableHandler.java | 8 +-
.../pinot/core/transport/DirectOOMHandler.java | 2 +-
.../apache/pinot/core/transport/QueryResponse.java | 6 +-
.../apache/pinot/core/transport/QueryRouter.java | 22 ++-
.../pinot/core/transport/ServerChannels.java | 6 +-
.../core/transport/AsyncQueryResponseTest.java | 123 +++++++++++++
.../pinot/core/transport/QueryRoutingTest.java | 191 +++++++++++++++++++++
.../pinot/core/transport/ServerChannelsTest.java | 5 +-
.../apache/pinot/spi/utils/CommonConstants.java | 5 +-
12 files changed, 425 insertions(+), 24 deletions(-)
diff --git
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/SingleConnectionBrokerRequestHandler.java
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/SingleConnectionBrokerRequestHandler.java
index b8e0cbd3af5..b9d3ad23e19 100644
---
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/SingleConnectionBrokerRequestHandler.java
+++
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/SingleConnectionBrokerRequestHandler.java
@@ -192,6 +192,7 @@ public class SingleConnectionBrokerRequestHandler extends
BaseSingleStageBrokerR
if (scatterResult.getSendException() != null) {
brokerResponse.addException(new
QueryProcessingException(QueryErrorCode.BROKER_REQUEST_SEND,
scatterResult.getSendException().getMessage()));
+ _brokerMetrics.addMeteredTableValue(rawTableName,
BrokerMeter.BROKER_RESPONSES_WITH_SEND_EXCEPTIONS, 1);
}
List<ServerRoutingInstance> serversNotResponded =
scatterResult.getServersNotResponded();
if (!serversNotResponded.isEmpty()) {
@@ -324,6 +325,9 @@ public class SingleConnectionBrokerRequestHandler extends
BaseSingleStageBrokerR
brokerResponse.addException(
new QueryProcessingException(QueryErrorCode.BROKER_REQUEST_SEND,
materializedViewSendException.getMessage()));
}
+ if (baseSendException != null || materializedViewSendException != null) {
+ _brokerMetrics.addMeteredTableValue(rawTableName,
BrokerMeter.BROKER_RESPONSES_WITH_SEND_EXCEPTIONS, 1);
+ }
int numServersNotResponded = serversNotResponded.size();
if (numServersNotResponded != 0) {
diff --git
a/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerMeter.java
b/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerMeter.java
index a4ae4cf5f99..18fa4545f91 100644
---
a/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerMeter.java
+++
b/pinot-common/src/main/java/org/apache/pinot/common/metrics/BrokerMeter.java
@@ -139,6 +139,15 @@ public class BrokerMeter implements AbstractMetrics.Meter {
public static final BrokerMeter
SECONDARY_WORKLOAD_BROKER_RESPONSES_WITH_PARTIAL_SERVERS_RESPONDED = create(
"SECONDARY_WORKLOAD_BROKER_RESPONSES_WITH_PARTIAL_SERVERS_RESPONDED",
"badResponses", false);
+ // This metric tracks the number of times an in-flight server was skipped
(its channel went inactive or a request
+ // send to it failed) because the query was submitted with
skipUnavailableServers=true.
+ public static final BrokerMeter SERVER_MARKED_DOWN_SKIPPED =
+ create("SERVER_MARKED_DOWN_SKIPPED", "count", true);
+
+ // This metric tracks the number of broker responses carrying a
BROKER_REQUEST_SEND (425) error
+ public static final BrokerMeter BROKER_RESPONSES_WITH_SEND_EXCEPTIONS =
create(
+ "BROKER_RESPONSES_WITH_SEND_EXCEPTIONS", "badResponses", false);
+
public static final BrokerMeter BROKER_RESPONSES_WITH_TIMEOUTS = create(
"BROKER_RESPONSES_WITH_TIMEOUTS", "badResponses", false);
@@ -196,6 +205,12 @@ public class BrokerMeter implements AbstractMetrics.Meter {
"NETTY_CONNECTION_BYTES_RECEIVED", "nettyConnection", true);
public static final BrokerMeter NETTY_CONNECTION_SEND_REQUEST_FAILURES =
create(
"NETTY_CONNECTION_SEND_REQUEST_FAILURES", "nettyConnection", true);
+ // These track server channels transitioning to active/inactive on the
broker (Netty channelActive/channelInactive).
+ // Non-global: emitted per server and tagged with the server short name (see
DataTableHandler).
+ public static final BrokerMeter NETTY_CONNECTION_CHANNEL_ACTIVE = create(
+ "NETTY_CONNECTION_CHANNEL_ACTIVE", "nettyConnection", false);
+ public static final BrokerMeter NETTY_CONNECTION_CHANNEL_INACTIVE = create(
+ "NETTY_CONNECTION_CHANNEL_INACTIVE", "nettyConnection", false);
public static final BrokerMeter PROACTIVE_CLUSTER_CHANGE_CHECK = create(
"PROACTIVE_CLUSTER_CHANGE_CHECK", "proactiveClusterChangeCheck", true);
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/transport/AsyncQueryResponse.java
b/pinot-core/src/main/java/org/apache/pinot/core/transport/AsyncQueryResponse.java
index 3469ea9752d..e0b946b4a9f 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/transport/AsyncQueryResponse.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/transport/AsyncQueryResponse.java
@@ -45,14 +45,20 @@ public class AsyncQueryResponse implements QueryResponse {
private final long _maxEndTimeMs;
private final long _timeoutMs;
private final ServerRoutingStatsManager _serverRoutingStatsManager;
+ private final boolean _skipUnavailableServers;
+ // Servers whose latch slot has already been released via a down-path
(skipServerResponse or markServerUnavailable).
+ // A server can be reported down more than once for the same query
+ private final Set<ServerRoutingInstance> _countedDownServers =
ConcurrentHashMap.newKeySet();
private volatile ServerRoutingInstance _failedServer;
private volatile Exception _exception;
public AsyncQueryResponse(QueryRouter queryRouter, long requestId,
Set<ServerRoutingInstance> serversQueried,
- long startTimeMs, long timeoutMs, ServerRoutingStatsManager
serverRoutingStatsManager) {
+ long startTimeMs, long timeoutMs, ServerRoutingStatsManager
serverRoutingStatsManager,
+ boolean skipUnavailableServers) {
_queryRouter = queryRouter;
_requestId = requestId;
+ _skipUnavailableServers = skipUnavailableServers;
int numServersQueried = serversQueried.size();
_responseMap = new
ConcurrentHashMap<>(HashUtil.getHashMapCapacity(numServersQueried));
_serverRoutingStatsManager = serverRoutingStatsManager;
@@ -189,6 +195,7 @@ public class AsyncQueryResponse implements QueryResponse {
void markQueryFailed(ServerRoutingInstance serverRoutingInstance, Exception
exception) {
_status.set(Status.FAILED);
_failedServer = serverRoutingInstance;
+ _countedDownServers.add(serverRoutingInstance);
_exception = exception;
int count = (int) _countDownLatch.getCount();
for (int i = 0; i < count; i++) {
@@ -196,18 +203,51 @@ public class AsyncQueryResponse implements QueryResponse {
}
}
- /// NOTE: the server might not be hit by the query. Only fail the query if
the query was sent to the server and the
- /// server hasn't responded yet.
- void markServerDown(ServerRoutingInstance serverRoutingInstance, Exception
exception) {
- ServerResponse serverResponse = _responseMap.get(serverRoutingInstance);
- if (serverResponse != null && serverResponse.getDataTable() == null) {
- markQueryFailed(serverRoutingInstance, exception);
+ /// Marks a server as unavailable while the query is in flight - its Netty
channel went inactive or a request write
+ /// to it failed. This is a genuine server-unavailability event that is
eligible for `skipUnavailableServers`.
+ /// NOTE: the server might not have been hit by this query. Only acts if the
query was sent to the server and the
+ /// server has not responded yet.
+ boolean markServerUnavailable(ServerRoutingInstance serverRoutingInstance,
Exception exception) {
+ if (!shouldActOnServerDown(serverRoutingInstance)) {
+ return false;
+ }
+ if (_skipUnavailableServers) {
+ // Best-effort: degrade to partial results. Record the down server so
the failure detector can quarantine it from
+ // routing, but do NOT set the query-global exception/status (no
BROKER_REQUEST_SEND error, query stays
+ // COMPLETED)
+ if (_countedDownServers.add(serverRoutingInstance)) {
+ _failedServer = serverRoutingInstance;
+ _countDownLatch.countDown();
+ return true;
+ }
+ return false;
}
+ markQueryFailed(serverRoutingInstance, exception);
+ return false;
}
- /// Wait for one less server response. This is used when the server is
skipped, as
- /// query submission will have failed we do not want to wait for the
response.
- void skipServerResponse() {
- _countDownLatch.countDown();
+ /// Cancels the query because the broker is shedding it (eg. direct-memory
OOM), not because a server is unavailable
+ void cancelQuery(ServerRoutingInstance serverRoutingInstance, Exception
exception) {
+ if (!shouldActOnServerDown(serverRoutingInstance)) {
+ return;
+ }
+ markQueryFailed(serverRoutingInstance, exception);
+ }
+
+ /// 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) {
+ ServerResponse serverResponse = _responseMap.get(serverRoutingInstance);
+ return serverResponse != null && serverResponse.getDataTable() == null;
+ }
+
+ /// Releases the latch slot for a server that was skipped at
request-submission time (the send failed and the
+ /// query was submitted with `skipUnavailableServers=true`), so the query
does not wait for a response that will
+ /// never come.
+ void skipServerResponse(ServerRoutingInstance serverRoutingInstance) {
+ if (_countedDownServers.add(serverRoutingInstance)) {
+ _failedServer = serverRoutingInstance;
+ _countDownLatch.countDown();
+ }
}
}
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/transport/DataTableHandler.java
b/pinot-core/src/main/java/org/apache/pinot/core/transport/DataTableHandler.java
index 42abeb9028c..30bc698661f 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/transport/DataTableHandler.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/transport/DataTableHandler.java
@@ -54,12 +54,16 @@ public class DataTableHandler extends
SimpleChannelInboundHandler<ByteBuf> {
@Override
public void channelActive(ChannelHandlerContext ctx) {
LOGGER.info("Channel for server: {} is now active",
_serverRoutingInstance);
+
_brokerMetrics.addMeteredValue(BrokerMeter.NETTY_CONNECTION_CHANNEL_ACTIVE, 1,
+ _serverRoutingInstance.getShortName());
}
@Override
public void channelInactive(ChannelHandlerContext ctx) {
- LOGGER.error("Channel for server: {} is now inactive, marking server
down", _serverRoutingInstance);
- _queryRouter.markServerDown(_serverRoutingInstance,
+ LOGGER.error("Channel for server: {} is now inactive, marking server
unavailable", _serverRoutingInstance);
+
_brokerMetrics.addMeteredValue(BrokerMeter.NETTY_CONNECTION_CHANNEL_INACTIVE, 1,
+ _serverRoutingInstance.getShortName());
+ _queryRouter.markServerUnavailable(_serverRoutingInstance,
new RuntimeException(String.format("Channel for server: %s is
inactive", _serverRoutingInstance)));
}
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/transport/DirectOOMHandler.java
b/pinot-core/src/main/java/org/apache/pinot/core/transport/DirectOOMHandler.java
index 17424a9f707..d80847e99d3 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/transport/DirectOOMHandler.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/transport/DirectOOMHandler.java
@@ -128,7 +128,7 @@ public class DirectOOMHandler extends
ChannelInboundHandlerAdapter {
removed.closeChannel();
removed.setSilentShutdown();
});
- _queryRouter.markServerDown(_serverRoutingInstance,
+ _queryRouter.cancelQuery(_serverRoutingInstance,
new QueryCancelledException("Query cancelled as broker is out
of direct memory"));
} else if (_allChannels != null && !_allChannels.isEmpty()) { //
server side direct OOM handler
LOGGER.error("Closing channel from broker, as we are running out
of direct memory "
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/transport/QueryResponse.java
b/pinot-core/src/main/java/org/apache/pinot/core/transport/QueryResponse.java
index b0e8b157cf1..42688f4a928 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/transport/QueryResponse.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/transport/QueryResponse.java
@@ -55,7 +55,11 @@ public interface QueryResponse {
/// @return
long getServerResponseDelayMs(ServerRoutingInstance serverRoutingInstance);
- /// Returns the failed server if the query fails.
+ /// Returns the server that went down during the query. Set when the query
fails, and also when the query returns
+ /// partial results under `skipUnavailableServers` (used by the failure
detector to quarantine the server from
+ /// routing). Because it can be set on a partial success, a non-null value
does not by itself imply the query
+ /// failed; check [#getException()] / [#getStatus()] for that. At most one
server is tracked per query -
+ /// when several servers go down, this holds the most recent one.
@Nullable
ServerRoutingInstance getFailedServer();
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/transport/QueryRouter.java
b/pinot-core/src/main/java/org/apache/pinot/core/transport/QueryRouter.java
index 0f7daf4345b..e885a14788a 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/transport/QueryRouter.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/transport/QueryRouter.java
@@ -100,7 +100,7 @@ public class QueryRouter {
// Create the asynchronous query response with the request map
AsyncQueryResponse asyncQueryResponse =
new AsyncQueryResponse(this, requestId, requestMap.keySet(),
System.currentTimeMillis(), timeoutMs,
- _serverRoutingStatsManager);
+ _serverRoutingStatsManager, skipUnavailableServers);
_asyncQueryResponseMap.put(requestId, asyncQueryResponse);
for (Map.Entry<ServerRoutingInstance, InstanceRequest> entry :
requestMap.entrySet()) {
ServerRoutingInstance serverRoutingInstance = entry.getKey();
@@ -118,7 +118,7 @@ public class QueryRouter {
} catch (Exception e) {
_brokerMetrics.addMeteredTableValue(rawTableName,
BrokerMeter.REQUEST_SEND_EXCEPTIONS, 1);
if (skipUnavailableServers) {
- asyncQueryResponse.skipServerResponse();
+ asyncQueryResponse.skipServerResponse(serverRoutingInstance);
} else {
markQueryFailed(requestId, serverRoutingInstance,
asyncQueryResponse, e);
break;
@@ -188,9 +188,23 @@ public class QueryRouter {
}
}
- void markServerDown(ServerRoutingInstance serverRoutingInstance, Exception
exception) {
+ /// Marks a server as unavailable for every in-flight query. Called when a
server's channel goes inactive
+ /// ([DataTableHandler]) or a request write to it fails ([ServerChannels]).
Queries submitted with
+ /// `skipUnavailableServers=true` degrade to partial results for this
genuine unavailability; others are failed.
+ void markServerUnavailable(ServerRoutingInstance serverRoutingInstance,
Exception exception) {
for (AsyncQueryResponse asyncQueryResponse :
_asyncQueryResponseMap.values()) {
- asyncQueryResponse.markServerDown(serverRoutingInstance, exception);
+ if (asyncQueryResponse.markServerUnavailable(serverRoutingInstance,
exception)) {
+
_brokerMetrics.addMeteredGlobalValue(BrokerMeter.SERVER_MARKED_DOWN_SKIPPED, 1);
+ }
+ }
+ }
+
+ /// Cancels every in-flight query. Unlike
+ /// [#markServerUnavailable], this always fails the queries even under
`skipUnavailableServers`: all
+ /// channels are being closed so there is no partial data to return.
+ void cancelQuery(ServerRoutingInstance serverRoutingInstance, Exception
exception) {
+ for (AsyncQueryResponse asyncQueryResponse :
_asyncQueryResponseMap.values()) {
+ asyncQueryResponse.cancelQuery(serverRoutingInstance, exception);
}
}
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/transport/ServerChannels.java
b/pinot-core/src/main/java/org/apache/pinot/core/transport/ServerChannels.java
index d1eab6e2e3f..2bf34540cf3 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/transport/ServerChannels.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/transport/ServerChannels.java
@@ -256,8 +256,10 @@ public class ServerChannels {
} else {
LOGGER.error("Write failure to server: {} for table: {}",
serverRoutingInstance, rawTableName, f.cause());
_brokerMetrics.addMeteredGlobalValue(BrokerMeter.NETTY_CONNECTION_SEND_REQUEST_FAILURES,
1);
- asyncQueryResponse.markServerDown(serverRoutingInstance,
- new RuntimeException("Failed to send request to server: " +
serverRoutingInstance, f.cause()));
+ if (asyncQueryResponse.markServerUnavailable(serverRoutingInstance,
+ new RuntimeException("Failed to send request to server: " +
serverRoutingInstance, f.cause()))) {
+
_brokerMetrics.addMeteredGlobalValue(BrokerMeter.SERVER_MARKED_DOWN_SKIPPED, 1);
+ }
_channel.close();
}
});
diff --git
a/pinot-core/src/test/java/org/apache/pinot/core/transport/AsyncQueryResponseTest.java
b/pinot-core/src/test/java/org/apache/pinot/core/transport/AsyncQueryResponseTest.java
new file mode 100644
index 00000000000..7d4c242ff45
--- /dev/null
+++
b/pinot-core/src/test/java/org/apache/pinot/core/transport/AsyncQueryResponseTest.java
@@ -0,0 +1,123 @@
+/**
+ * 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.core.transport;
+
+import java.util.Map;
+import java.util.Set;
+import
org.apache.pinot.core.transport.server.routing.stats.ServerRoutingStatsManager;
+import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.exception.QueryCancelledException;
+import org.testng.annotations.Test;
+
+import static org.mockito.Mockito.mock;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertTrue;
+
+
+public class AsyncQueryResponseTest {
+ private static final ServerRoutingInstance SERVER_1 =
+ new ServerRoutingInstance("localhost", 11001, TableType.OFFLINE);
+ private static final ServerRoutingInstance SERVER_2 =
+ new ServerRoutingInstance("localhost", 11002, TableType.OFFLINE);
+
+ private AsyncQueryResponse newResponse(Set<ServerRoutingInstance> servers,
long timeoutMs,
+ boolean skipUnavailableServers) {
+ return new AsyncQueryResponse(mock(QueryRouter.class), 1L, servers,
System.currentTimeMillis(), timeoutMs,
+ mock(ServerRoutingStatsManager.class), skipUnavailableServers);
+ }
+
+ @Test
+ public void testMarkServerUnavailableSkipsUnderFlag() {
+ AsyncQueryResponse response = newResponse(Set.of(SERVER_1, SERVER_2),
10_000L, true);
+
+ boolean skipped = response.markServerUnavailable(SERVER_1, new
RuntimeException("channel inactive"));
+
+ assertTrue(skipped);
+ // The skip path must not fail the query: no exception, status not moved
to FAILED.
+ assertNull(response.getException());
+ assertEquals(response.getStatus(), QueryResponse.Status.IN_PROGRESS);
+ // The down server is recorded for the failure detector.
+ assertEquals(response.getFailedServer(), SERVER_1);
+ }
+
+ @Test
+ public void testMarkServerCancelledAlwaysFailsEvenWithSkip() {
+ // Even with skipUnavailableServers=true, a broker-initiated OOM
cancellation must fail the whole query. This guards
+ // against a future refactor silently making OOM honor the skip flag.
+ AsyncQueryResponse response = newResponse(Set.of(SERVER_1, SERVER_2),
10_000L, true);
+
+ response.cancelQuery(SERVER_1,
+ new QueryCancelledException("Query cancelled as broker is out of
direct memory"));
+
+ assertEquals(response.getStatus(), QueryResponse.Status.FAILED);
+ assertNotNull(response.getException());
+ assertEquals(response.getFailedServer(), SERVER_1);
+ }
+
+ @Test
+ public void testMarkServerUnavailableWithoutFlagFails() {
+ // Genuine unavailability but the flag is off: the whole query must still
fail.
+ AsyncQueryResponse response = newResponse(Set.of(SERVER_1, SERVER_2),
10_000L, false);
+
+ boolean skipped = response.markServerUnavailable(SERVER_1, new
RuntimeException("channel inactive"));
+
+ assertFalse(skipped);
+ assertEquals(response.getStatus(), QueryResponse.Status.FAILED);
+ assertNotNull(response.getException());
+ }
+
+ @Test
+ public void testMarkServerUnavailableIsIdempotent()
+ throws Exception {
+ // A server can be reported down twice for the same query
+ AsyncQueryResponse response = newResponse(Set.of(SERVER_1, SERVER_2),
500L, true);
+
+ assertTrue(response.markServerUnavailable(SERVER_1, new
RuntimeException("write failure")));
+ // Second report for the same server is a no-op for the latch.
+ assertFalse(response.markServerUnavailable(SERVER_1, new
RuntimeException("channel inactive")));
+
+ // SERVER_2 never responds
+ Map<ServerRoutingInstance, ServerResponse> responses =
response.getFinalResponses();
+ assertEquals(responses.size(), 2);
+ assertEquals(response.getStatus(), QueryResponse.Status.TIMED_OUT);
+ assertNull(response.getException());
+ assertEquals(response.getFailedServer(), SERVER_1);
+ }
+
+ @Test
+ public void testSkipServerResponseThenUnavailableIsIdempotent()
+ throws Exception {
+ AsyncQueryResponse response = newResponse(Set.of(SERVER_1, SERVER_2),
500L, true);
+
+ response.skipServerResponse(SERVER_1);
+ // The later channel-inactive report for the same server must be a no-op
for the latch.
+ assertFalse(response.markServerUnavailable(SERVER_1, new
RuntimeException("channel inactive")));
+
+ // Only SERVER_1's single slot was released; SERVER_2 never responds, so
the query must TIME OUT, not COMPLETE.
+ Map<ServerRoutingInstance, ServerResponse> responses =
response.getFinalResponses();
+ assertEquals(responses.size(), 2);
+ assertEquals(response.getStatus(), QueryResponse.Status.TIMED_OUT);
+ assertNull(response.getException());
+ // The send-time skip also records the server for quarantine.
+ assertEquals(response.getFailedServer(), SERVER_1);
+ }
+}
diff --git
a/pinot-core/src/test/java/org/apache/pinot/core/transport/QueryRoutingTest.java
b/pinot-core/src/test/java/org/apache/pinot/core/transport/QueryRoutingTest.java
index c21ac8c6a29..86a12592192 100644
---
a/pinot-core/src/test/java/org/apache/pinot/core/transport/QueryRoutingTest.java
+++
b/pinot-core/src/test/java/org/apache/pinot/core/transport/QueryRoutingTest.java
@@ -19,12 +19,15 @@
package org.apache.pinot.core.transport;
import com.google.common.util.concurrent.Futures;
+import io.netty.channel.ChannelHandlerContext;
import java.net.InetSocketAddress;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.TimeUnit;
import org.apache.pinot.common.datatable.DataTable;
import org.apache.pinot.common.datatable.DataTable.MetadataKey;
+import org.apache.pinot.common.metrics.BrokerMeter;
import org.apache.pinot.common.metrics.BrokerMetrics;
import org.apache.pinot.common.metrics.ServerMetrics;
import org.apache.pinot.common.request.BrokerRequest;
@@ -39,6 +42,8 @@ import org.apache.pinot.spi.accounting.ThreadAccountantUtils;
import org.apache.pinot.spi.config.table.TableType;
import org.apache.pinot.spi.env.PinotConfiguration;
import org.apache.pinot.spi.exception.QueryErrorCode;
+import org.apache.pinot.spi.metrics.PinotMetricUtils;
+import org.apache.pinot.spi.metrics.PinotMetricsRegistry;
import org.apache.pinot.spi.utils.CommonConstants;
import org.apache.pinot.sql.parsers.CalciteSqlCompiler;
import org.apache.pinot.util.TestUtils;
@@ -140,6 +145,15 @@ public class QueryRoutingTest {
return queryScheduler;
}
+ private QueryRouter newIsolatedQueryRouter() {
+ Map<String, Object> properties = new HashMap<>();
+
properties.put(CommonConstants.Broker.AdaptiveServerSelector.CONFIG_OF_ENABLE_STATS_COLLECTION,
true);
+ ServerRoutingStatsManager statsManager =
+ new ServerRoutingStatsManager(new PinotConfiguration(properties),
mock(BrokerMetrics.class));
+ statsManager.init();
+ return new QueryRouter("testBroker", null, null, statsManager,
ThreadAccountantUtils.getNoOpAccountant());
+ }
+
@Test
public void testValidResponse()
throws Exception {
@@ -555,6 +569,183 @@ public class QueryRoutingTest {
_serverRoutingStatsManager.fetchNumInFlightRequestsForServer(serverInstance2.getInstanceId()).intValue(),
0);
}
+ @Test
+ public void testSkipUnavailableServerChannelInactive()
+ throws Exception {
+ long requestId = 123;
+ DataSchema dataSchema =
+ new DataSchema(new String[]{"column1"}, new
DataSchema.ColumnDataType[]{DataSchema.ColumnDataType.STRING});
+ DataTableBuilder builder =
DataTableBuilderFactory.getDataTableBuilder(dataSchema);
+ builder.startRow();
+ builder.setColumn(0, "value1");
+ builder.finishRow();
+ DataTable dataTableSuccess = builder.build();
+ dataTableSuccess.getMetadata().put(MetadataKey.REQUEST_ID.getName(),
Long.toString(requestId));
+ byte[] successResponseBytes = dataTableSuccess.toBytes();
+
+ // The healthy server responds after a delay; the second server has a warm
channel established during submitQuery
+ // and its channel then goes inactive mid-flight (Path 2) before it can
respond.
+ long healthyDelayMs = 1000L;
+ long timeoutMs = 10_000L;
+ _queryServer = getQueryServer((int) healthyDelayMs, successResponseBytes,
0);
+ int healthyPort = startAndGetPort(_queryServer);
+ // Long delay so this server never actually responds during the test
+ QueryServer unavailableServer = getQueryServer(60_000,
successResponseBytes, 0);
+ int unavailablePort = startAndGetPort(unavailableServer);
+ // Isolated broker
+ QueryRouter queryRouter = newIsolatedQueryRouter();
+
+ try {
+ ServerInstance healthyInstance = new ServerInstance("localhost",
healthyPort);
+ ServerInstance unavailableInstance = new ServerInstance("localhost",
unavailablePort);
+ ServerRoutingInstance healthyRoutingInstance =
+ healthyInstance.toServerRoutingInstance(TableType.OFFLINE,
ServerInstance.RoutingType.NETTY);
+ ServerRoutingInstance unavailableRoutingInstance =
+ unavailableInstance.toServerRoutingInstance(TableType.OFFLINE,
ServerInstance.RoutingType.NETTY);
+ Map<ServerInstance, SegmentsToQuery> routingTable =
+ Map.of(healthyInstance, new SegmentsToQuery(List.of(), List.of()),
+ unavailableInstance, new SegmentsToQuery(List.of(), List.of()));
+
+ BrokerRequest brokerRequest =
+ CalciteSqlCompiler.compileToBrokerRequest("SET
skipUnavailableServers=true; SELECT * FROM testTable");
+ long startTime = System.currentTimeMillis();
+ AsyncQueryResponse asyncQueryResponse =
+ queryRouter.submitQuery(requestId, "testTable", brokerRequest,
routingTable, null, null, timeoutMs);
+ // Confirm the request was actually dispatched to the unavailable server
(its channel went live) so this is a
+ // genuine Path 2 (channel dies AFTER dispatch), not a send-time
failure. The server is up, so the write succeeds
+ // within milliseconds.
+ ServerResponse unavailableDispatch =
asyncQueryResponse.getCurrentResponses().get(unavailableRoutingInstance);
+ TestUtils.waitForCondition(aVoid ->
unavailableDispatch.getRequestSentDelayMs() >= 0, 10L, 5000L,
+ "Request was not dispatched to the unavailable server");
+ // Drive the mid-flight channel-inactive event through the real broker
inbound handler
+ new DataTableHandler(queryRouter,
ThreadAccountantUtils.getNoOpAccountant(), unavailableRoutingInstance)
+ .channelInactive(mock(ChannelHandlerContext.class));
+
+ Map<ServerRoutingInstance, ServerResponse> response =
asyncQueryResponse.getFinalResponses();
+ long elapsed = System.currentTimeMillis() - startTime;
+
+ assertEquals(response.size(), 2);
+ ServerResponse healthyResponse = response.get(healthyRoutingInstance);
+ ServerResponse unavailableResponse =
response.get(unavailableRoutingInstance);
+ // The healthy server returned data; the unavailable server did not.
+ assertNotNull(healthyResponse.getDataTable());
+ assertNull(unavailableResponse.getDataTable());
+ // No BROKER_REQUEST_SEND (425): the query degraded to partial results
instead of failing.
+ assertNull(asyncQueryResponse.getException());
+ assertEquals(asyncQueryResponse.getStatus(),
QueryResponse.Status.COMPLETED);
+ // The down server is still recorded so the failure detector can
quarantine it from routing.
+ assertEquals(asyncQueryResponse.getFailedServer(),
unavailableRoutingInstance);
+ // We waited for the healthy server (the latch was not force-drained)
and returned well before the timeout.
+ // If the bug were present, markQueryFailed would force-drain the latch
and return almost immediately.
+ assertTrue(elapsed >= healthyDelayMs, "Expected to wait for the healthy
server, elapsed=" + elapsed);
+ assertTrue(elapsed < timeoutMs, "Expected to return before timeout,
elapsed=" + elapsed);
+ } finally {
+ unavailableServer.shutDown();
+ queryRouter.shutDown();
+ }
+ }
+
+ /// Control for [#testSkipUnavailableServerChannelInactive]: the same
channel-inactive-mid-flight scenario but
+ /// WITHOUT `skipUnavailableServers` must still fail the whole query
+ @Test
+ public void testChannelInactiveWithoutSkipFailsQuery()
+ throws Exception {
+ long requestId = 123;
+ DataSchema dataSchema =
+ new DataSchema(new String[]{"column1"}, new
DataSchema.ColumnDataType[]{DataSchema.ColumnDataType.STRING});
+ DataTableBuilder builder =
DataTableBuilderFactory.getDataTableBuilder(dataSchema);
+ builder.startRow();
+ builder.setColumn(0, "value1");
+ builder.finishRow();
+ DataTable dataTableSuccess = builder.build();
+ dataTableSuccess.getMetadata().put(MetadataKey.REQUEST_ID.getName(),
Long.toString(requestId));
+ byte[] successResponseBytes = dataTableSuccess.toBytes();
+
+ long timeoutMs = 10_000L;
+ // Healthy server delay is set well above the deterministic fail-fast path
+ long healthyDelayMs = 4000L;
+ _queryServer = getQueryServer((int) healthyDelayMs, successResponseBytes,
0);
+ int healthyPort = startAndGetPort(_queryServer);
+ // Long delay so this server never actually responds during the test
+ QueryServer unavailableServer = getQueryServer(60_000,
successResponseBytes, 0);
+ int unavailablePort = startAndGetPort(unavailableServer);
+ // Isolated broker
+ QueryRouter queryRouter = newIsolatedQueryRouter();
+
+ try {
+ ServerInstance healthyInstance = new ServerInstance("localhost",
healthyPort);
+ ServerInstance unavailableInstance = new ServerInstance("localhost",
unavailablePort);
+ ServerRoutingInstance unavailableRoutingInstance =
+ unavailableInstance.toServerRoutingInstance(TableType.OFFLINE,
ServerInstance.RoutingType.NETTY);
+ Map<ServerInstance, SegmentsToQuery> routingTable =
+ Map.of(healthyInstance, new SegmentsToQuery(List.of(), List.of()),
+ unavailableInstance, new SegmentsToQuery(List.of(), List.of()));
+
+ // No skipUnavailableServers option set.
+ long startTime = System.currentTimeMillis();
+ AsyncQueryResponse asyncQueryResponse =
+ queryRouter.submitQuery(requestId, "testTable", BROKER_REQUEST,
routingTable, null, null, timeoutMs);
+ // Confirm the request was actually dispatched to the unavailable server
(its channel went live) so this is a
+ // genuine Path 2 (channel dies AFTER dispatch), not a send-time failure.
+ ServerResponse unavailableDispatch =
asyncQueryResponse.getCurrentResponses().get(unavailableRoutingInstance);
+ TestUtils.waitForCondition(aVoid ->
unavailableDispatch.getRequestSentDelayMs() >= 0, 10L, 5000L,
+ "Request was not dispatched to the unavailable server");
+ // Drive the mid-flight channel-inactive event through the real broker
inbound handler, deterministically (see the
+ // rationale in testSkipUnavailableServerChannelInactive).
+ new DataTableHandler(queryRouter,
ThreadAccountantUtils.getNoOpAccountant(), unavailableRoutingInstance)
+ .channelInactive(mock(ChannelHandlerContext.class));
+
+ Map<ServerRoutingInstance, ServerResponse> response =
asyncQueryResponse.getFinalResponses();
+ long elapsed = System.currentTimeMillis() - startTime;
+
+ assertEquals(response.size(), 2);
+ // Without the flag the query fails: the exception is set (becomes a 425
downstream) and status is FAILED.
+ assertNotNull(asyncQueryResponse.getException());
+ assertEquals(asyncQueryResponse.getStatus(),
QueryResponse.Status.FAILED);
+ assertEquals(asyncQueryResponse.getFailedServer(),
unavailableRoutingInstance);
+ // The latch is force-drained, so the query fails fast — it returns
before the healthy server's delay elapses
+ // rather than waiting it out (and well before the timeout).
+ assertTrue(elapsed < healthyDelayMs, "Expected to fail fast before the
healthy server responded, elapsed="
+ + elapsed);
+ } finally {
+ unavailableServer.shutDown();
+ queryRouter.shutDown();
+ }
+ }
+
+ @Test
+ public void testChannelActiveInactiveEmitPerServerTaggedMeters() {
+ PinotMetricUtils.init(new PinotConfiguration());
+ // register() is a compareAndSet against NOOP with no deregister, so
another test class may already have registered
+ // a real instance. Either way, read from whatever DataTableHandler will
read via BrokerMetrics.get().
+ BrokerMetrics.register(new
BrokerMetrics(PinotMetricUtils.getPinotMetricsRegistry()));
+ BrokerMetrics brokerMetrics = BrokerMetrics.get();
+ PinotMetricsRegistry registry = brokerMetrics.getMetricsRegistry();
+
+ ServerInstance serverInstance = new ServerInstance("localhost", 12345);
+ ServerRoutingInstance routingInstance =
+ serverInstance.toServerRoutingInstance(TableType.OFFLINE,
ServerInstance.RoutingType.NETTY);
+ String shortName = routingInstance.getShortName();
+ DataTableHandler handler =
+ new DataTableHandler(_queryRouter,
ThreadAccountantUtils.getNoOpAccountant(), routingInstance);
+
+ long activeBefore = taggedMeterCount(registry,
BrokerMeter.NETTY_CONNECTION_CHANNEL_ACTIVE, shortName);
+ handler.channelActive(mock(ChannelHandlerContext.class));
+ assertEquals(taggedMeterCount(registry,
BrokerMeter.NETTY_CONNECTION_CHANNEL_ACTIVE, shortName), activeBefore + 1);
+
+ // channelInactive also calls markServerUnavailable, but no query is in
flight on _queryRouter so that is a no-op.
+ long inactiveBefore = taggedMeterCount(registry,
BrokerMeter.NETTY_CONNECTION_CHANNEL_INACTIVE, shortName);
+ handler.channelInactive(mock(ChannelHandlerContext.class));
+ assertEquals(taggedMeterCount(registry,
BrokerMeter.NETTY_CONNECTION_CHANNEL_INACTIVE, shortName),
+ inactiveBefore + 1);
+ }
+
+ private static long taggedMeterCount(PinotMetricsRegistry registry,
BrokerMeter meter, String tag) {
+ String fullName = CommonConstants.Broker.DEFAULT_METRICS_NAME_PREFIX +
meter.getMeterName() + "." + tag;
+ return PinotMetricUtils.makePinotMeter(registry,
+ PinotMetricUtils.makePinotMetricName(BrokerMetrics.class, fullName),
meter.getUnit(), TimeUnit.SECONDS).count();
+ }
+
private void waitForStatsUpdate(long taskCount) {
TestUtils.waitForCondition(aVoid -> {
return (_serverRoutingStatsManager.getCompletedTaskCount() == taskCount);
diff --git
a/pinot-core/src/test/java/org/apache/pinot/core/transport/ServerChannelsTest.java
b/pinot-core/src/test/java/org/apache/pinot/core/transport/ServerChannelsTest.java
index 0dd1bbff383..e2b7c89db5f 100644
---
a/pinot-core/src/test/java/org/apache/pinot/core/transport/ServerChannelsTest.java
+++
b/pinot-core/src/test/java/org/apache/pinot/core/transport/ServerChannelsTest.java
@@ -154,7 +154,8 @@ public class ServerChannelsTest {
}
verify(mockChannel).close();
-
verify(asyncQueryResponse).markServerDown(any(ServerRoutingInstance.class),
any(Exception.class));
+
+
verify(asyncQueryResponse).markServerUnavailable(any(ServerRoutingInstance.class),
any(Exception.class));
verify(asyncQueryResponse,
never()).markRequestSent(any(ServerRoutingInstance.class), any(Integer.class));
serverChannels.shutDown();
@@ -191,7 +192,7 @@ public class ServerChannelsTest {
}
verify(asyncQueryResponse).markRequestSent(any(ServerRoutingInstance.class),
any(Integer.class));
- verify(asyncQueryResponse,
never()).markServerDown(any(ServerRoutingInstance.class), any(Exception.class));
+ verify(asyncQueryResponse,
never()).markServerUnavailable(any(ServerRoutingInstance.class),
any(Exception.class));
verify(mockChannel, never()).close();
serverChannels.shutDown();
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 7cdf8a56398..0ef4615ba20 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
@@ -920,7 +920,10 @@ public class CommonConstants {
// divided across all servers processing the query.
public static final String MAX_QUERY_RESPONSE_SIZE_BYTES =
"maxQueryResponseSizeBytes";
- // If query submission causes an exception, still continue to submit
the query to other servers
+ // If a server is unavailable, still return results from the other
servers instead of failing the query. This
+ // covers both a send-time failure at request submission and a
mid-query channel-inactive / write failure: the
+ // unavailable server is skipped, its down status is recorded so the
failure detector can quarantine it from
+ // routing, and the query returns partial results
public static final String SKIP_UNAVAILABLE_SERVERS =
"skipUnavailableServers";
// Ignore server-side segment missing errors and proceed without
marking the query as failed.
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]