morningman commented on code in PR #69996:
URL: https://github.com/apache/doris/pull/69996#discussion_r4466352092
##########
be/src/service/arrow_flight/arrow_flight_batch_reader.cpp:
##########
@@ -103,6 +105,37 @@ void ArrowFlightBatchReaderBase::close(const Status&
reason) {
_statement->result_addr.port == config::brpc_port) {
env->result_mgr()->cancel_arrow_flight_query(_statement->query_id,
reason);
}
+ // A result endpoint can outlive its local fragment. Find the owning FE by
buffer ID,
+ // then use its query-wide cancellation route, including for older result
BEs.
+ for (const auto& [address, info] : env->get_running_frontends()) {
+ Status status;
+ // One retry: the FE keeps the route when an attempt fails (for
example a BE missed the
+ // FE's one-second deadline), and a cached connection to the FE may
have gone stale.
+ for (int attempt = 0; attempt < 2; ++attempt) {
+ FrontendServiceConnection client(env->frontend_client_cache(),
address, 2000, &status);
Review Comment:
Fixed in `152c56c38cc`. Agreed, and the cache worked against this call in
both directions. A reused transport keeps whatever its opener set: profile and
status reports open theirs with `thrift_rpc_timeout_ms` (60 s), and the
information_schema scanners open theirs to the master FE with the query's
execution timeout (900 s by default). The other way round, this call's 2 s
transports, and the 1 s ones that `reopen(1000)` made after any thrift error
(an older FE's unknown-method reply included), went back to the shared cache,
where `fetchSplitBatch`, whose FE side holds the call for up to
`fetch_splits_max_wait_time` (1 s by default), or a status report could take
them. An FE that has stopped also stays on every BE's list: the master sends it
with process UUID 0, which `get_running_frontends()` counts as running, so when
it sorts before the FE that planned the query, every early close on every BE
waits for it.
The cancellation now keeps FE connections of its own
(`ExecEnv::flight_cancel_frontend_client_cache`, at most two kept per FE), as
`MasterServerClient` does, so each transport it uses was opened with its 2 s
timeout and none reaches another caller. Acquiring a connection does not back
off (`max_retries = 0`), a transport is reopened with the same 2 s, and an FE
that cannot be connected to gets no second attempt, since neither reason for
the retry applies to it. An FE ahead of the planning one now costs an early
close about 0.1 s when it refuses connections, 3.1 s when it does not answer
the connect (`thrift_connect_timeout_seconds`), and 4 s when it accepts the
connection and does not answer. The local cleanup of #69945 still runs first.
The Cost paragraph of the description understated this wait and is corrected.
Tests: `CloseIgnoresLongTimeoutSharedTransport` leaves a 60 s transport to
an FE whose first answer stalls for 5 s in the shared cache and requires the
early close to finish within 4 s, through the retry;
`CloseMovesPastUnreachableFrontend` puts an FE that refuses connections first
and requires the close to reach the planning FE within 2 s. Before this change
they took 5.0 s and 14.7 s.
##########
fe/fe-core/src/main/java/org/apache/doris/arrowflight/FlightSqlQueryCancellation.java:
##########
@@ -0,0 +1,187 @@
+// 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.doris.arrowflight;
+
+import org.apache.doris.arrowflight.results.FlightSqlEndpointsLocation;
+import org.apache.doris.common.Status;
+import org.apache.doris.proto.InternalService.PCancelPlanFragmentResult;
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.Coordinator;
+import org.apache.doris.rpc.BackendServiceProxy;
+import org.apache.doris.thrift.TNetworkAddress;
+import org.apache.doris.thrift.TStatus;
+import org.apache.doris.thrift.TStatusCode;
+import org.apache.doris.thrift.TUniqueId;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.function.LongSupplier;
+import java.util.stream.Collectors;
+
+/**
+ * Routes the early close of a Flight result stream, which a BE reports
through cancelFlightQuery, to every BE of
+ * the stream's query. A route outlives the request and the session that
registered it: the client reads the
+ * results from the BEs, so it can still hold a stream of the query open after
its session has started another
+ * request or has been torn down, and close that stream early then. A route
goes when its cancellation succeeds,
+ * when its query's execution timeout (plus 5 s) has passed, and when {@link
#MAX_ROUTED_RESULTS} newer result ids
+ * push it out. Those go oldest first, as the oldest routes are the likeliest
to belong to streams that have
+ * ended; a query whose route was pushed out ends at its timeout, as every
Flight query did before routes existed.
+ */
+public final class FlightSqlQueryCancellation {
+ private static final Logger LOG =
LogManager.getLogger(FlightSqlQueryCancellation.class);
+ // Counted in result ids, which cost a few hundred bytes each, so the
registry stays within some tens of MB
+ // however many Flight queries are registered within one execution timeout.
+ static final int MAX_ROUTED_RESULTS = 100_000;
+ public static final FlightSqlQueryCancellation INSTANCE =
+ new FlightSqlQueryCancellation(System::nanoTime,
MAX_ROUTED_RESULTS);
+ private final LongSupplier nanoClock;
+ private final int capacity;
+ // In registration order, so the eldest entry belongs to the oldest route.
Guarded by this.
+ private final LinkedHashMap<TUniqueId, Route> results = new
LinkedHashMap<>();
+
+ FlightSqlQueryCancellation(LongSupplier nanoClock, int capacity) {
+ this.nanoClock = nanoClock;
+ this.capacity = capacity;
+ }
+
+ /**
+ * Registers the route of a Flight query whose results stay on the BEs,
from the result ids of the endpoints
+ * the session collected for it, before its coordinator is closed or
deferred. Nothing of the session is
+ * kept, so neither its next request nor its teardown drops the route.
+ */
+ public void register(ConnectContext context, Coordinator coord) {
+ register(coord.getQueryId(),
context.getFlightSqlEndpointsLocations().stream()
+
.map(FlightSqlEndpointsLocation::getFinstId).distinct().collect(Collectors.toList()),
+ coord.getBackendBrpcAddresses(),
coord.getQueryOptions().getExecutionTimeout());
+ }
+
+ public void register(TUniqueId queryId, List<TUniqueId> resultIds,
+ List<TNetworkAddress> backends, int timeoutSeconds) {
+ if (backends.isEmpty()) {
+ throw new IllegalArgumentException("Flight query has no
cancellation backends");
+ }
+ // Keep only cancellation addresses, not the coordinator, scan state,
or query queue slot.
+ // Ordinary Flight coordinators are unregistered when GetFlightInfo
returns.
+ Route route = new Route(queryId.deepCopy(),
+
resultIds.stream().map(TUniqueId::deepCopy).distinct().collect(Collectors.toList()),
+
backends.stream().map(TNetworkAddress::deepCopy).distinct().collect(Collectors.toList()),
+ nanoClock.getAsLong() + TimeUnit.SECONDS.toNanos(Math.max(0L,
timeoutSeconds) + 5));
+ synchronized (this) {
+ for (TUniqueId resultId : route.resultIds) {
+ results.put(resultId, route);
+ }
+ // Whole routes leave from the eldest end: the expired ones there,
then as many as the bound requires.
+ long now = nanoClock.getAsLong();
+ while (!results.isEmpty()) {
+ Route eldest = results.values().iterator().next();
+ if (results.size() <= capacity && !eldest.isExpired(now)) {
Review Comment:
Fixed in `1c9a89481ed`. Agreed. The result ids of a query already share one
route and its address list, but the bound counted only the ids, and in parallel
result mode a query has one. The registry now also bounds the BE addresses that
its routes hold, at 1,000,000 (about 30 MB), and drops whole routes, oldest
first, while either bound is exceeded; a route's addresses are released when
its last result id leaves, also when a newer registration takes the id over.
Under the old bound, a route held about 3 KB with 100 BEs and 29 KB with 1,000,
so a full registry held about 0.3 GB and 2.9 GB; now it stays within about 55
MB whatever the cluster size. Large clusters give up retention instead: with
1,000 BEs the registry keeps the routes of the last 1,000 queries, about 9 s of
them at 110 queries/s, and a query whose route was pushed out ends at its
timeout, as before this PR. Dropping a route when its streams reach EOF would
need the BEs to report every EOF to the FE as well, one more F
E call per endpoint and query; I left that out.
Tests: `boundCountsRetainedBackends` requires a registration whose addresses
exceed the bound to drop the oldest route as a whole, and
`reregisteredResultReleasesOldRouteBackends` requires a route whose result id
another registration took over to release its addresses once.
--
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]