diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/doris/source/RemoteDorisFlightSession.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/doris/source/RemoteDorisFlightSession.java new file mode 100644 index 00000000000000..ef1557c321613a --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/doris/source/RemoteDorisFlightSession.java @@ -0,0 +1,153 @@ +// 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.datasource.doris.source; + +import org.apache.doris.common.Pair; +import org.apache.doris.common.UserException; + +import com.google.common.annotations.VisibleForTesting; +import org.apache.arrow.flight.CallOptions; +import org.apache.arrow.flight.CloseSessionRequest; +import org.apache.arrow.flight.FlightClient; +import org.apache.arrow.flight.FlightInfo; +import org.apache.arrow.flight.Location; +import org.apache.arrow.flight.grpc.CredentialCallOption; +import org.apache.arrow.flight.sql.FlightSqlClient; +import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.memory.RootAllocator; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.Closeable; +import java.net.URI; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +/** + * The Flight SQL session a {@link RemoteDorisScanNode} opens on a remote Doris frontend for one + * scan, and ends with a CloseSession once the scan is over. + * + *

The handshake ({@code authenticateBasicToken}) opens a session on the remote frontend: a + * connection in its pool, counted against {@code qe_max_connection}, the Arrow Flight SQL sub-quota + * and the catalog user's {@code max_user_connections}, that only a CloseSession, a KILL or + * {@code wait_timeout} ends. Closing the gRPC channel does not. A session opened per scan and never + * closed therefore stays for hours and, one scan at a time, exhausts the catalog user's connection + * quota on the remote frontend - refusing that user's MySQL connections there as well. + * + *

The session outlives GetFlightInfo on purpose: the query it ran serves the BE's DoGet of the + * endpoints, and the remote frontend cancels whatever a closed session was still running - the + * query itself, when the remote table is an external table scanned in batch mode and the query is + * therefore deferred there. So {@link #close()} is called from {@link RemoteDorisScanNode#stop()}, + * when the coordinator of the local query closes, and that coordinator is kept alive until the BE + * has finished scanning ({@link RemoteDorisScanNode#coordinatorMustOutliveDispatch()}). + */ +class RemoteDorisFlightSession implements Closeable { + private static final Logger LOG = LogManager.getLogger(RemoteDorisFlightSession.class); + + // A bound on the CloseSession round trip. close() runs on the local query's teardown path, + // which must not hang on a remote frontend that has stopped answering; the session is then left + // to the remote frontend's wait_timeout, as every session was before this class existed. + @VisibleForTesting + static final int CLOSE_SESSION_TIMEOUT_SECONDS = 5; + + private final Pair hostAndPort; + private final BufferAllocator allocator; + private final FlightSqlClient client; + private final CredentialCallOption credential; + private boolean closed = false; + + private RemoteDorisFlightSession(Pair hostAndPort, BufferAllocator allocator, + FlightSqlClient client, CredentialCallOption credential) { + this.hostAndPort = hostAndPort; + this.allocator = allocator; + this.client = client; + this.credential = credential; + } + + /** + * Opens a session on the remote frontend at {@code hostAndPort} with the catalog's credentials. + * Nothing is left behind when this fails: a handshake that was refused opened no session, and + * the channel and allocator are released before the exception propagates. + */ + static RemoteDorisFlightSession open(Pair hostAndPort, String user, String password) + throws Exception { + BufferAllocator allocator = new RootAllocator(); + FlightClient flightClient = null; + try { + URI uri = new URI("grpc", null, hostAndPort.first, hostAndPort.second, null, null, null); + flightClient = FlightClient.builder(allocator, new Location(uri)).build(); + Optional credential = flightClient.authenticateBasicToken(user, password); + if (!credential.isPresent()) { + throw new UserException("Authenticates with a username and password failure"); + } + return new RemoteDorisFlightSession(hostAndPort, allocator, new FlightSqlClient(flightClient), + credential.get()); + } catch (Throwable t) { + closeQuietly(flightClient, allocator, hostAndPort); + throw t; + } + } + + /** Runs {@code sql} on the remote frontend; the endpoints of the result are where the BE reads it. */ + FlightInfo execute(String sql, int timeoutSec) { + return client.execute(sql, credential, CallOptions.timeout(timeoutSec, TimeUnit.SECONDS)); + } + + Pair getHostAndPort() { + return hostAndPort; + } + + /** + * Ends the session on the remote frontend (CloseSession), then releases the channel and the + * allocator. Never throws: this runs on the local query's teardown path, and a session the + * remote frontend could not be told to close is only left to its wait_timeout. Idempotent. + */ + @Override + public synchronized void close() { + if (closed) { + return; + } + closed = true; + try { + client.closeSession(new CloseSessionRequest(), credential, + CallOptions.timeout(CLOSE_SESSION_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + } catch (Throwable t) { + LOG.warn("failed to close the Arrow Flight SQL session on remote Doris frontend {}:{}, it stays open there" + + " until its wait_timeout", hostAndPort.first, hostAndPort.second, t); + } + closeQuietly(client, allocator, hostAndPort); + } + + private static void closeQuietly(AutoCloseable client, BufferAllocator allocator, + Pair hostAndPort) { + try { + if (client != null) { + client.close(); + } + } catch (Throwable t) { + LOG.warn("failed to close the Arrow Flight client to remote Doris frontend {}:{}", + hostAndPort.first, hostAndPort.second, t); + } + try { + allocator.close(); + } catch (Throwable t) { + LOG.warn("failed to close the Arrow allocator of the Flight client to remote Doris frontend {}:{}", + hostAndPort.first, hostAndPort.second, t); + } + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/doris/source/RemoteDorisScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/doris/source/RemoteDorisScanNode.java index 729eb7f91ee853..b91bbdccd1851f 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/doris/source/RemoteDorisScanNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/doris/source/RemoteDorisScanNode.java @@ -44,29 +44,21 @@ import org.apache.doris.thrift.TRemoteDorisFileDesc; import org.apache.doris.thrift.TTableFormatFileDesc; +import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Joiner; import com.google.common.collect.Lists; -import org.apache.arrow.flight.CallOptions; -import org.apache.arrow.flight.FlightClient; import org.apache.arrow.flight.FlightEndpoint; import org.apache.arrow.flight.FlightInfo; import org.apache.arrow.flight.Location; -import org.apache.arrow.flight.grpc.CredentialCallOption; -import org.apache.arrow.flight.sql.FlightSqlClient; -import org.apache.arrow.memory.BufferAllocator; -import org.apache.arrow.memory.RootAllocator; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import java.net.URI; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Set; -import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; public class RemoteDorisScanNode extends FileQueryScanNode { @@ -79,6 +71,18 @@ public class RemoteDorisScanNode extends FileQueryScanNode { private RemoteDorisSource source; + // The Flight SQL session this scan opened on the remote frontend, from getSplits until stop() + // closes it (see RemoteDorisFlightSession for why it must live that long and no longer). All + // three guarded by this: stop() may run on another thread than the one that planned the query - + // a KILL, the timeout checker - and more than once (cancel, then close). + private RemoteDorisFlightSession flightSession; + private boolean stopped; + // Whether stop() ended a session this scan had opened: the endpoints handed to the backend + // belong to that session's query, and a plan dispatched again with them (the same-plan retry + // of StmtExecutor.handleQueryWithRetry) would read what the remote frontend may have torn + // down with the session. + private boolean sessionClosedByStop; + public RemoteDorisScanNode(PlanNodeId id, TupleDescriptor desc, boolean needCheckColumnPriv, SessionVariable sv, ScanContext scanContext) { super(id, desc, "REMOTE_DORIS_SCAN_NODE", scanContext, needCheckColumnPriv, sv); @@ -177,7 +181,8 @@ private List> executeQuery() { source.nextHostAndArrowPort(), source.getCatalog().getUsername(), source.getCatalog().getPassword(), - queryStr + queryStr, + source.getCatalog().getQueryTimeoutSec() ); } catch (Exception e) { LOG.warn("arrow request node [{}] failures {}, try next nodes", @@ -189,17 +194,98 @@ private List> executeQuery() { throw new RuntimeException("Failed to execute query: " + queryStr, lastException); } - private List> executeFlightSqlQuery(Pair hostAndPort, - String user, String psw, String sql) throws Exception { - try ( - BufferAllocator allocatorFE = new RootAllocator(); - FlightClient clientFE = createFlightClient(allocatorFE, hostAndPort); - FlightSqlClient sqlClientFE = new FlightSqlClient(clientFE) - ) { - CredentialCallOption credentialCallOption = authenticate(clientFE, user, psw); - FlightInfo info = executeSqlWithTimeout(sqlClientFE, sql, credentialCallOption); - - return processFlightEndpoints(info.getEndpoints()); + // Opens a Flight SQL session on the remote frontend and runs the query in it. The session is + // kept until stop(): its query serves the BE's DoGet of the endpoints returned here. A session + // whose query failed is closed right away, so a retry on the next node leaves nothing behind. + @VisibleForTesting + List> executeFlightSqlQuery(Pair hostAndPort, + String user, String psw, String sql, int timeoutSec) throws Exception { + RemoteDorisFlightSession session = RemoteDorisFlightSession.open(hostAndPort, user, psw); + FlightInfo info; + try { + info = session.execute(sql, timeoutSec); + } catch (Throwable t) { + session.close(); + throw t; + } + keepFlightSession(session); + return processFlightEndpoints(info.getEndpoints()); + } + + /** + * Holds {@code session} until {@link #stop()}. A session handed over after stop() already ran, + * or on top of one still held, is closed at once instead: this scan owns one session at most, + * and none once stopped. The statement registers the node as well: stop() is the coordinator's + * to call, but a plan that never gets one, or whose coordinator nobody closes, is stopped when + * the statement ends instead ({@link org.apache.doris.nereids.StatementContext#stopScanNodeAtClose}). + */ + @VisibleForTesting + void keepFlightSession(RemoteDorisFlightSession session) { + RemoteDorisFlightSession toClose; + synchronized (this) { + if (stopped) { + toClose = session; + } else { + toClose = flightSession; + flightSession = session; + } + } + if (toClose != null) { + toClose.close(); + } + if (toClose != session) { + ConnectContext.get().getStatementContext().stopScanNodeAtClose(this); + } + } + + /** + * True once {@link #stop()} ended the session this scan opened: the endpoints in its scan + * ranges belong to that session's query on the remote frontend, so the same plan must not be + * dispatched again (see {@link ScanNode#cannotBeRedispatched()}). + */ + @Override + public boolean cannotBeRedispatched() { + synchronized (this) { + return sessionClosedByStop; + } + } + + /** + * True while this scan holds a Flight SQL session on the remote frontend: the local coordinator + * has to stay alive until the BE has finished reading the remote query, since closing it is what + * ends the session ({@link #stop()}) - and the remote frontend cancels what a closed session was + * still running. Without this, an Arrow Flight SQL query on this frontend would close its + * coordinator right after dispatch (#67503), while its BE may still be reading. + */ + @Override + public boolean coordinatorMustOutliveDispatch() { + if (super.coordinatorMustOutliveDispatch()) { + return true; + } + synchronized (this) { + return flightSession != null; + } + } + + /** + * Ends the Flight SQL session on the remote frontend, in addition to what {@code FileQueryScanNode} + * releases. Called by the coordinator when the local query closes or is cancelled, i.e. when the + * BE is done with (or gave up on) the remote query's endpoints. + */ + @Override + public void stop() { + super.stop(); + RemoteDorisFlightSession session; + synchronized (this) { + stopped = true; + session = flightSession; + flightSession = null; + if (session != null) { + sessionClosedByStop = true; + } + } + if (session != null) { + session.close(); } } @@ -290,27 +376,6 @@ private boolean isExplainStatement() { .trim().toLowerCase().startsWith("explain"); } - private FlightClient createFlightClient(BufferAllocator allocator, - Pair hostAndPort) throws Exception { - URI uri = new URI("grpc", null, hostAndPort.first, hostAndPort.second, null, null, null); - return FlightClient.builder(allocator, new Location(uri)).build(); - } - - private CredentialCallOption authenticate(FlightClient client, String user, String psw) throws UserException { - Optional credentialCallOption = client.authenticateBasicToken(user, psw); - if (!credentialCallOption.isPresent()) { - throw new UserException("Authenticates with a username and password failure"); - } - return credentialCallOption.get(); - } - - private FlightInfo executeSqlWithTimeout(FlightSqlClient sqlClient, String sql, - CredentialCallOption credentialCallOption) { - int timeoutSec = source.getCatalog().getQueryTimeoutSec(); - return sqlClient.execute(sql, credentialCallOption, - CallOptions.timeout(timeoutSec, TimeUnit.SECONDS)); - } - private List> processFlightEndpoints(List endpoints) { List> uniquePairs = new ArrayList<>(); Set seenPairs = new HashSet<>(); diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java index d6ea7ebb9767cc..4903a00147ac23 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java @@ -65,6 +65,7 @@ import org.apache.doris.nereids.trees.plans.logical.LogicalCTEProducer; import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; import org.apache.doris.nereids.util.RelationUtil; +import org.apache.doris.planner.ScanNode; import org.apache.doris.qe.ConnectContext; import org.apache.doris.qe.GlobalVariable; import org.apache.doris.qe.OriginStatement; @@ -96,6 +97,7 @@ import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; +import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -218,6 +220,19 @@ public enum TableFrom { // table locks private final Stack plannerResources = new Stack<>(); + // Scan nodes that hold something on this frontend for the backend (a remote Doris scan's Flight + // SQL session on the other frontend) and release it in ScanNode.stop(), which the coordinator + // of the statement calls when it closes. Not every plan gets a coordinator, and not every + // coordinator is closed: a plan probed and discarded (INSERT OVERWRITE), a statement failing + // between planning and dispatch (a SQL block rule on the scan, an INSERT whose transaction + // cannot begin), a load job created from the plan. close() stops what is still registered here + // as the fallback (stop() is idempotent, so a coordinator that already closed costs nothing). + // A coordinator that outlives the statement on purpose - an Arrow Flight SQL query kept alive + // until DoGet, StmtExecutor.deferForArrowFlight - takes its nodes out first + // (handOverScanNodesToDeferredCoordinator). Guarded by its own monitor: registered on the + // planning thread, closed on the statement's thread or the forwarded-request finally. + private final Set scanNodesToStopAtClose = Collections.newSetFromMap(new IdentityHashMap<>()); + // placeholder params for prepared statement private List placeholders = new ArrayList<>(); @@ -1093,6 +1108,48 @@ public synchronized void releasePlannerResources() { } } + /** + * Registers a scan node whose {@link ScanNode#stop()} must have run by the time this statement + * ends: the coordinator of the statement runs it when it closes, and {@link #close()} runs it + * for a plan that never got a coordinator or whose coordinator nobody closed (see + * {@link #scanNodesToStopAtClose}). + */ + public void stopScanNodeAtClose(ScanNode scanNode) { + synchronized (scanNodesToStopAtClose) { + scanNodesToStopAtClose.add(scanNode); + } + } + + /** + * The coordinator of the statement outlives it on purpose (an Arrow Flight SQL query kept alive + * until the client has pulled its result, see {@code StmtExecutor.deferForArrowFlight}) and + * takes over these nodes: their {@link ScanNode#stop()} runs when that coordinator closes, not + * when this statement ends. + */ + public void handOverScanNodesToDeferredCoordinator(Collection scanNodes) { + synchronized (scanNodesToStopAtClose) { + scanNodesToStopAtClose.removeAll(scanNodes); + } + } + + // The fallback of scanNodesToStopAtClose. Never throws: this runs on the statement's teardown + // path, after the statement's outcome is decided, and one node failing to stop must not keep + // the next from stopping. + private void stopScanNodesLeftBehind() { + List leftBehind; + synchronized (scanNodesToStopAtClose) { + leftBehind = new ArrayList<>(scanNodesToStopAtClose); + scanNodesToStopAtClose.clear(); + } + for (ScanNode scanNode : leftBehind) { + try { + scanNode.stop(); + } catch (Throwable t) { + LOG.warn("failed to stop scan node {} at the end of the statement", scanNode.getId(), t); + } + } + } + // CHECKSTYLE OFF @Override protected void finalize() throws Throwable { @@ -1107,6 +1164,9 @@ protected void finalize() throws Throwable { @Override public void close() { releasePlannerResources(); + // After the table locks: stopping a remote Doris scan's node sends a CloseSession to the other + // frontend, which must not be waited for under a lock. + stopScanNodesLeftBehind(); // Fallback deterministic close of the per-statement connector scope, for statements that never reach the // query-finish callback: external DDL / SHOW / DESCRIBE / EXPLAIN / foreground ANALYZE run via Command.run // with no coordinator, so PluginDrivenScanNode.getSplits never registers a primary close for them. close() diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertOverwriteTableCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertOverwriteTableCommand.java index 5d67020025d797..fdbc056143e0ee 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertOverwriteTableCommand.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertOverwriteTableCommand.java @@ -61,6 +61,7 @@ import org.apache.doris.nereids.trees.plans.physical.PhysicalOlapTableSink; import org.apache.doris.nereids.trees.plans.physical.PhysicalTableSink; import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor; +import org.apache.doris.planner.ScanNode; import org.apache.doris.qe.ConnectContext; import org.apache.doris.qe.QueryState.MysqlStateType; import org.apache.doris.qe.StmtExecutor; @@ -176,6 +177,13 @@ public void run(ConnectContext ctx, StmtExecutor executor) throws Exception { NereidsPlanner planner = new NereidsPlanner(ctx.getStatementContext()); LineageInfoExtractor.registerAnalyzePlanHook(ctx.getStatementContext(), planner); planner.plan(logicalPlanAdapter, ctx.getSessionVariable().toThrift()); + // This plan only locates the sink and the partitions; the insert below plans again and runs + // that plan. No coordinator ever takes this one, so what its scan nodes opened for the + // backend while planning (a remote Doris scan's Flight SQL session on the other frontend, a + // batch split source) is released here, before the real insert opens its own. + for (ScanNode scanNode : planner.getScanNodes()) { + scanNode.stop(); + } Plan analyzedPlan = planner.getAnalyzedPlan(); lineagePlan = Optional.ofNullable(analyzedPlan); executor.checkBlockRules(); diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/ScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/ScanNode.java index ead7960a5a106e..d594fbbdbaec6d 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/ScanNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/ScanNode.java @@ -135,16 +135,31 @@ public TupleDescriptor getTupleDesc() { } /** - * Whether this scan hands out its splits lazily through a batch {@link SplitSource} that the - * BE fetches from the FE while it is scanning (external-table batch mode, see - * {@link SplitGenerator#isBatchMode()}). Such a scan needs its coordinator alive until the BE - * has finished scanning, even after the FE is done dispatching the query: closing the - * coordinator releases the split source ({@link #stop()}) and the BE's next split fetch fails. + * Whether the BE still depends on something this scan node holds on the FE while it is + * scanning, so that the coordinator - closing it releases what the node holds, through + * {@link #stop()} - has to stay alive until the BE has finished scanning, even after the FE is + * done dispatching the query. Here: a batch {@link SplitSource} the BE fetches its splits from + * lazily (external-table batch mode, see {@link SplitGenerator#isBatchMode()}); the BE's next + * split fetch fails once the source is released. A subclass holding another such resource + * adds its own reason, e.g. the Flight SQL session a remote Doris scan keeps open on the other + * frontend for the query the BE reads (RemoteDorisScanNode). */ - public boolean hasBatchSplitSource() { + public boolean coordinatorMustOutliveDispatch() { return splitAssignment != null; } + /** + * Whether {@link #stop()} has released something the BE would need again if the same plan were + * dispatched once more, so that a retry of the query has to plan again rather than reuse this + * node's scan ranges (StmtExecutor.handleQueryWithRetry re-dispatches the plan of a failed + * attempt whose coordinator was cancelled, and cancel() stops the scan nodes). A remote Doris + * scan's ranges are the endpoints of the query its Flight SQL session ran on the other frontend, + * gone with the session; a batch split source has the same property but is left as it is here. + */ + public boolean cannotBeRedispatched() { + return false; + } + protected abstract void createScanRangeLocations() throws UserException; /** diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/Coordinator.java b/fe/fe-core/src/main/java/org/apache/doris/qe/Coordinator.java index b3005119418039..0acfe3d7635087 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/qe/Coordinator.java +++ b/fe/fe-core/src/main/java/org/apache/doris/qe/Coordinator.java @@ -797,18 +797,21 @@ public void exec() throws Exception { } /** - * Whether the BE keeps calling back into this coordinator after {@link #exec()} returned: an - * external-table scan in batch mode fetches its splits lazily from the split source that its - * scan node holds, so the coordinator must not be closed until the BE has finished scanning. - * Arrow Flight SQL uses this to decide whether a query's coordinator has to outlive - * GetFlightInfo, the client pulling the results from the BE later in DoGet. See #62259. + * Whether the BE still depends on this coordinator after {@link #exec()} returned, so it must + * not be closed until the BE has finished scanning: one of its scan nodes holds something on + * the FE that the BE scans with and that {@link #close()} releases + * ({@link ScanNode#coordinatorMustOutliveDispatch()}) - the split source an external-table + * scan in batch mode fetches its splits from lazily, or the Flight SQL session a remote Doris + * scan keeps open on the other frontend. Arrow Flight SQL uses this to decide whether a + * query's coordinator has to outlive GetFlightInfo, the client pulling the results from the BE + * later in DoGet. See #62259. */ - public boolean hasBatchSplitSource() { + public boolean mustOutliveDispatch() { if (scanNodes == null) { return false; } for (ScanNode scanNode : scanNodes) { - if (scanNode.hasBatchSplitSource()) { + if (scanNode.coordinatorMustOutliveDispatch()) { return true; } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java b/fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java index 2dc45ab298c2f1..8227950bcd2b26 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java +++ b/fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java @@ -814,6 +814,21 @@ public void checkBlockRulesByRegex(OriginStatement originStmt) throws AnalysisEx originStmt.originStmt, context.getSqlHash(), context.getQualifiedUser()); } + // Whether a scan node of the current plan released, when the failed attempt was cancelled, what + // the BE would scan with again if handleQueryWithRetry dispatched the same plan once more + // (ScanNode.cannotBeRedispatched). + private boolean planCannotBeRedispatched() { + if (planner == null) { + return false; + } + for (ScanNode scanNode : planner.getScanNodes()) { + if (scanNode.cannotBeRedispatched()) { + return true; + } + } + return false; + } + public void checkBlockRulesByScan(Planner planner) throws AnalysisException { if (planner == null) { return; @@ -1174,8 +1189,9 @@ void deferForArrowFlight() { } // Finalize an Arrow Flight query whose coordinator was kept alive across the - // GetFlightInfo -> DoGet phases: close the coordinator (releasing external-table batch - // SplitSources and the query queue slot) and then unregister the query. See #62259. + // GetFlightInfo -> DoGet phases: close the coordinator (releasing what its scan nodes held for + // the BE - external-table batch SplitSources, a remote Doris scan's Flight SQL session - and + // the query queue slot) and then unregister the query. See #62259. public void finalizeArrowFlightQuery() { try { if (coord != null) { @@ -1273,6 +1289,15 @@ private void handleQueryWithRetry(TUniqueId queryId) throws Exception { } } } + if (isNeedRetry && planCannotBeRedispatched()) { + // The failed attempt's cancel() stopped the scan nodes, and one of them released + // what the BE scans with: a remote Doris scan's session on the other frontend, + // whose query the scan ranges point at. The same plan cannot be dispatched again. + LOG.warn("not retrying query {} with the same plan: a scan node released what the backend" + + " scans with when the failed attempt was cancelled. stmt: {}", + DebugUtil.printId(context.queryId()), parsedStmt.getOrigStmt().originStmt); + throw e; + } if (i != retryTime - 1 && isNeedRetry && context.getProtocolAdapter().canRetryQuery(context)) { LOG.warn("retry {} times. stmt: {}", (i + 1), parsedStmt.getOrigStmt().originStmt); } else { @@ -1667,20 +1692,26 @@ public void executeAndSendResult(boolean isOutfileQuery, boolean isSendFields, if (!context.isReturnResultFromLocal()) { profile.getSummaryProfile().setTempStartTime(); - // The client pulls the results from the BE later (Arrow Flight SQL's DoGet). Only an - // external-table scan in batch mode still needs the coordinator after this point: - // the BE fetches its splits lazily from the split source the coordinator holds, so - // closing the coordinator here would release that source too early and break DoGet - // (#62259). Such a coordinator is closed later by ConnectContext: on the session's - // next query, on teardown, or by the idle reaper in checkTimeout. The trade-off is - // that its query queue slot and query registration stay held until then. Every - // other query closes its coordinator in the finally block below and releases both - // right away, the BE buffering its results independently of the coordinator - // (#67503). A short-circuit point query is the one case with a different coordBase, - // and it cannot reach here: it has no Arrow result on either side, so + // The client pulls the results from the BE later (Arrow Flight SQL's DoGet). Only a + // scan the BE keeps depending on the FE for still needs the coordinator after this + // point: an external-table scan in batch mode fetches its splits lazily from the + // split source the coordinator holds (#62259), and a remote Doris scan keeps the + // Flight SQL session open on the other frontend whose query the BE reads + // (RemoteDorisScanNode); closing the coordinator here would release either too early + // and break DoGet. Such a coordinator is closed later by ConnectContext: on the + // session's next query, on teardown, or by the idle reaper in checkTimeout. The + // trade-off is that its query queue slot and query registration stay held until + // then. Every other query closes its coordinator in the finally block below and + // releases both right away, the BE buffering its results independently of the + // coordinator (#67503). A short-circuit point query is the one case with a different + // coordBase, and it cannot reach here: it has no Arrow result on either side, so // LogicalResultSinkToShortCircuitPointQuery keeps a Flight session on the normal // execution path (ProtocolAdapter.supportsShortCircuitPointQuery, #67368). - if (coordBase == coord && coord.hasBatchSplitSource()) { + if (coordBase == coord && coord.mustOutliveDispatch()) { + // The coordinator outlives this statement, and with it what its scan nodes hold + // for the BE: the statement's own end must not stop them (StatementContext.close + // is the fallback for a plan no coordinator owns), the coordinator's close does. + statementContext.handOverScanNodesToDeferredCoordinator(planner.getScanNodes()); deferForArrowFlight(); } return; diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/doris/source/RemoteDorisScanNodeTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/doris/source/RemoteDorisScanNodeTest.java new file mode 100644 index 00000000000000..f947afeae2cb04 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/doris/source/RemoteDorisScanNodeTest.java @@ -0,0 +1,271 @@ +// 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.datasource.doris.source; + +import org.apache.doris.analysis.DescriptorTable; +import org.apache.doris.common.Pair; +import org.apache.doris.nereids.StatementContext; +import org.apache.doris.planner.PlanFragment; +import org.apache.doris.planner.ScanNode; +import org.apache.doris.qe.ConnectContext; +import org.apache.doris.qe.Coordinator; +import org.apache.doris.qe.OriginStatement; +import org.apache.doris.thrift.TUniqueId; + +import com.google.common.collect.Lists; +import org.apache.arrow.flight.CallStatus; +import org.apache.arrow.flight.CloseSessionRequest; +import org.apache.arrow.flight.CloseSessionResult; +import org.apache.arrow.flight.FlightDescriptor; +import org.apache.arrow.flight.FlightEndpoint; +import org.apache.arrow.flight.FlightInfo; +import org.apache.arrow.flight.FlightServer; +import org.apache.arrow.flight.Location; +import org.apache.arrow.flight.Ticket; +import org.apache.arrow.flight.auth2.BasicCallHeaderAuthenticator; +import org.apache.arrow.flight.auth2.GeneratedBearerTokenAuthenticator; +import org.apache.arrow.flight.sql.NoOpFlightSqlProducer; +import org.apache.arrow.flight.sql.impl.FlightSql.CommandStatementQuery; +import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.memory.RootAllocator; +import org.apache.arrow.vector.types.pojo.Schema; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * The Flight SQL session a remote Doris scan opens on the remote frontend lives exactly as long as + * the scan: opened for the query in getSplits, ended with a CloseSession when the coordinator stops + * the scan node - or when the statement ends, for a plan no coordinator ever took - and never left + * behind - not by a failed query, not by a stop() that came first. The remote frontend is an + * in-process Flight SQL server that counts what the scan does to it. + */ +public class RemoteDorisScanNodeTest { + private static final String USER = "catalog_user"; + private static final String PASSWORD = "catalog_password"; + + /** A remote frontend that records the queries it ran and the sessions it was asked to close. */ + private static class RecordingRemoteFrontend extends NoOpFlightSqlProducer { + final List queries = new CopyOnWriteArrayList<>(); + final List closedSessions = new CopyOnWriteArrayList<>(); + + @Override + public FlightInfo getFlightInfoStatement(CommandStatementQuery command, CallContext context, + FlightDescriptor descriptor) { + String query = command.getQuery(); + queries.add(query); + if (query.contains("boom")) { + throw CallStatus.INTERNAL.withDescription("query failed on the remote frontend").toRuntimeException(); + } + FlightEndpoint endpoint = new FlightEndpoint(new Ticket(query.getBytes(StandardCharsets.UTF_8)), + Location.forGrpcInsecure("127.0.0.1", 9999)); + return new FlightInfo(new Schema(Collections.emptyList()), descriptor, + Collections.singletonList(endpoint), -1, -1); + } + + @Override + public void closeSession(CloseSessionRequest request, CallContext context, + StreamListener listener) { + closedSessions.add(context.peerIdentity()); + listener.onNext(new CloseSessionResult(CloseSessionResult.Status.CLOSED)); + listener.onCompleted(); + } + } + + private BufferAllocator serverAllocator; + private RecordingRemoteFrontend remote; + private FlightServer server; + private Pair hostAndPort; + // The statement the scan plans under: a scan node registers itself with it when it keeps a + // session, so that a statement no coordinator ever takes the plan of still ends the session. + private StatementContext statementContext; + + @BeforeEach + public void startRemoteFrontend() throws Exception { + ConnectContext ctx = new ConnectContext(); + statementContext = new StatementContext(ctx, new OriginStatement("select 1", 0)); + ctx.setStatementContext(statementContext); + ctx.setThreadLocalInfo(); + serverAllocator = new RootAllocator(); + remote = new RecordingRemoteFrontend(); + // The handshake the scan performs (authenticateBasicToken) opens a session and issues a bearer + // token for it, and the peer identity a later call carries is the one authenticated then. + server = FlightServer.builder(serverAllocator, Location.forGrpcInsecure("127.0.0.1", 0), remote) + .headerAuthenticator(new GeneratedBearerTokenAuthenticator( + new BasicCallHeaderAuthenticator((user, password) -> { + if (USER.equals(user) && PASSWORD.equals(password)) { + return () -> user; + } + throw CallStatus.UNAUTHENTICATED.withDescription("bad credentials").toRuntimeException(); + }))) + .build() + .start(); + hostAndPort = Pair.of("127.0.0.1", server.getPort()); + } + + @AfterEach + public void stopRemoteFrontend() throws Exception { + // The statement ends while the remote frontend is still up, as it does in production; a + // session a test left to the statement is closed here, one it already ended is a no-op. + statementContext.close(); + ConnectContext.remove(); + server.close(); + serverAllocator.close(); + } + + private static RemoteDorisScanNode scanNode() { + // The node under test is only its session bookkeeping; the planner state a real node carries + // (descriptors, the source, the catalog) plays no part in it. + return Mockito.mock(RemoteDorisScanNode.class, Mockito.CALLS_REAL_METHODS); + } + + private static Coordinator coordinator(ScanNode... scanNodes) { + return new Coordinator(1L, new TUniqueId(1L, 2L), new DescriptorTable(), Lists.newArrayList(), + Lists.newArrayList(scanNodes), "UTC", false, false); + } + + @Test + public void testSessionLivesFromTheQueryUntilTheScanStops() throws Exception { + RemoteDorisScanNode node = scanNode(); + List> endpoints = node.executeFlightSqlQuery(hostAndPort, USER, PASSWORD, + "select 1", 10); + + Assertions.assertEquals(1, endpoints.size()); + Assertions.assertEquals(Collections.singletonList("select 1"), remote.queries); + // Open on the remote frontend, so the local coordinator has to outlive dispatch: its close is + // what ends the session, and the BE may still be reading the remote query until then. + Assertions.assertTrue(remote.closedSessions.isEmpty()); + Assertions.assertTrue(node.coordinatorMustOutliveDispatch()); + Assertions.assertTrue(coordinator(node).mustOutliveDispatch()); + + node.stop(); + + Assertions.assertEquals(Collections.singletonList(USER), remote.closedSessions); + Assertions.assertFalse(node.coordinatorMustOutliveDispatch()); + Assertions.assertFalse(coordinator(node).mustOutliveDispatch()); + + // cancel() then close() both stop the scan node; the session is closed once. + node.stop(); + Assertions.assertEquals(1, remote.closedSessions.size()); + } + + @Test + public void testFailedQueryClosesItsSessionAtOnce() { + RemoteDorisScanNode node = scanNode(); + + Assertions.assertThrows(Exception.class, + () -> node.executeFlightSqlQuery(hostAndPort, USER, PASSWORD, "select boom", 10)); + + // The retry on the next node must not leave this node's session behind. + Assertions.assertEquals(Collections.singletonList(USER), remote.closedSessions); + Assertions.assertFalse(node.coordinatorMustOutliveDispatch()); + } + + @Test + public void testRefusedHandshakeOpensNoSession() { + Assertions.assertThrows(Exception.class, + () -> RemoteDorisFlightSession.open(hostAndPort, USER, "wrong password")); + + Assertions.assertTrue(remote.queries.isEmpty()); + Assertions.assertTrue(remote.closedSessions.isEmpty()); + } + + @Test + public void testSessionHandedOverAfterStopIsClosedAtOnce() throws Exception { + RemoteDorisScanNode node = scanNode(); + node.stop(); + + RemoteDorisFlightSession late = RemoteDorisFlightSession.open(hostAndPort, USER, PASSWORD); + node.keepFlightSession(late); + + Assertions.assertEquals(Collections.singletonList(USER), remote.closedSessions); + Assertions.assertFalse(node.coordinatorMustOutliveDispatch()); + } + + @Test + public void testSessionOfAScanNoCoordinatorTakesEndsWithTheStatement() throws Exception { + RemoteDorisScanNode node = scanNode(); + node.executeFlightSqlQuery(hostAndPort, USER, PASSWORD, "select 1", 10); + Assertions.assertTrue(remote.closedSessions.isEmpty()); + + // The statement fails after planning (a SQL block rule on the scan, an INSERT whose + // transaction cannot begin) or discards the plan (the INSERT OVERWRITE probe): no + // coordinator ever calls stop(), the statement's end does. + statementContext.close(); + + Assertions.assertEquals(Collections.singletonList(USER), remote.closedSessions); + Assertions.assertFalse(node.coordinatorMustOutliveDispatch()); + // A coordinator closing afterwards finds nothing left to end. + node.stop(); + Assertions.assertEquals(1, remote.closedSessions.size()); + } + + @Test + public void testSessionHandedToADeferredCoordinatorOutlivesTheStatement() throws Exception { + RemoteDorisScanNode node = scanNode(); + node.executeFlightSqlQuery(hostAndPort, USER, PASSWORD, "select 1", 10); + + // An Arrow Flight SQL query keeps its coordinator past the statement (deferForArrowFlight): + // the BE reads the remote query after the statement ended, so the statement's end must not + // close the session; the coordinator's close does, later. + statementContext.handOverScanNodesToDeferredCoordinator(Collections.singletonList(node)); + statementContext.close(); + Assertions.assertTrue(remote.closedSessions.isEmpty()); + Assertions.assertTrue(node.coordinatorMustOutliveDispatch()); + + node.stop(); + Assertions.assertEquals(Collections.singletonList(USER), remote.closedSessions); + } + + @Test + public void testAPlanWhoseSessionStopEndedCannotBeRedispatched() throws Exception { + RemoteDorisScanNode node = scanNode(); + Assertions.assertFalse(node.cannotBeRedispatched()); + node.executeFlightSqlQuery(hostAndPort, USER, PASSWORD, "select 1", 10); + Assertions.assertFalse(node.cannotBeRedispatched()); + + // cancel() of a failed attempt stops the node: the endpoints in its scan ranges belong to + // the query of a session that is gone, so the same-plan retry must not dispatch them again. + node.stop(); + Assertions.assertTrue(node.cannotBeRedispatched()); + + // A node that never held a session releases nothing when stopped. + RemoteDorisScanNode idle = scanNode(); + idle.stop(); + Assertions.assertFalse(idle.cannotBeRedispatched()); + } + + @Test + public void testSessionCloseIsIdempotent() throws Exception { + RemoteDorisFlightSession session = RemoteDorisFlightSession.open(hostAndPort, USER, PASSWORD); + session.execute("select 1", 10); + + session.close(); + session.close(); + + Assertions.assertEquals(Collections.singletonList(USER), remote.closedSessions); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/qe/ArrowFlightDeferralGateTest.java b/fe/fe-core/src/test/java/org/apache/doris/qe/ArrowFlightDeferralGateTest.java index a02de20f14f635..9f8a06a70939bb 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/qe/ArrowFlightDeferralGateTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/qe/ArrowFlightDeferralGateTest.java @@ -33,8 +33,9 @@ /** * The predicate behind the Arrow Flight deferral gate in StmtExecutor.executeAndSendResult (#67503): - * a coordinator has to outlive GetFlightInfo only when one of its scans still hands out splits to - * the BE lazily, i.e. an external-table scan in batch mode holding a batch split source (#62259). + * a coordinator has to outlive GetFlightInfo only when the BE still depends on one of its scans + * after dispatch - here an external-table scan in batch mode holding a batch split source (#62259). + * The other reason, a remote Doris scan's Flight SQL session, is covered by RemoteDorisScanNodeTest. */ public class ArrowFlightDeferralGateTest { @@ -55,15 +56,15 @@ private static Coordinator coordinator(List scanNodes) { } @Test - public void testScanNodeHasBatchSplitSourceOnlyWhenSplitsAreHandedOutLazily() throws Exception { - Assertions.assertFalse(scanNode(false).hasBatchSplitSource()); - Assertions.assertTrue(scanNode(true).hasBatchSplitSource()); + public void testScanNodeMustOutliveDispatchOnlyWhenSplitsAreHandedOutLazily() throws Exception { + Assertions.assertFalse(scanNode(false).coordinatorMustOutliveDispatch()); + Assertions.assertTrue(scanNode(true).coordinatorMustOutliveDispatch()); } @Test - public void testCoordinatorHasBatchSplitSourceIfAnyScanDoes() throws Exception { - Assertions.assertFalse(coordinator(Lists.newArrayList()).hasBatchSplitSource()); - Assertions.assertFalse(coordinator(Lists.newArrayList(scanNode(false), scanNode(false))).hasBatchSplitSource()); - Assertions.assertTrue(coordinator(Lists.newArrayList(scanNode(false), scanNode(true))).hasBatchSplitSource()); + public void testCoordinatorMustOutliveDispatchIfAnyScanRequiresIt() throws Exception { + Assertions.assertFalse(coordinator(Lists.newArrayList()).mustOutliveDispatch()); + Assertions.assertFalse(coordinator(Lists.newArrayList(scanNode(false), scanNode(false))).mustOutliveDispatch()); + Assertions.assertTrue(coordinator(Lists.newArrayList(scanNode(false), scanNode(true))).mustOutliveDispatch()); } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/qe/StmtExecutorTest.java b/fe/fe-core/src/test/java/org/apache/doris/qe/StmtExecutorTest.java index a37f1b05c05b25..95b7d17734f1c8 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/qe/StmtExecutorTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/qe/StmtExecutorTest.java @@ -101,7 +101,7 @@ public void testShowNull() throws Exception { } // The deferral gate (#67503): a coordinator is kept alive past GetFlightInfo only when the BE - // still fetches splits from it (Coordinator.hasBatchSplitSource), and the execution timeout it + // still depends on it (Coordinator.mustOutliveDispatch), and the execution timeout it // ran with is frozen at that moment. SET_VAR hint values are reverted when execute() ends, so // the idle reaper must not read the session value later. @Test diff --git a/regression-test/framework/src/main/groovy/org/apache/doris/regression/RegressionTest.groovy b/regression-test/framework/src/main/groovy/org/apache/doris/regression/RegressionTest.groovy index 64f8d8fa2b5ec5..c4b01932d9276f 100644 --- a/regression-test/framework/src/main/groovy/org/apache/doris/regression/RegressionTest.groovy +++ b/regression-test/framework/src/main/groovy/org/apache/doris/regression/RegressionTest.groovy @@ -38,6 +38,7 @@ import org.apache.doris.regression.util.TeamcityUtils import groovy.util.logging.Slf4j import org.apache.commons.cli.* import org.apache.commons.lang3.concurrent.BasicThreadFactory; +import org.awaitility.Awaitility import org.codehaus.groovy.control.CompilerConfiguration import org.codehaus.groovy.vmplugin.v8.IndyInterface import org.slf4j.LoggerFactory @@ -162,6 +163,19 @@ class RegressionTest { static void initGroovyEnv(Config config) { log.info("parallel = ${config.parallel}, suiteParallel = ${config.suiteParallel}, actionParallel = ${config.actionParallel}") + // Evaluate every Awaitility condition on the thread that awaits it, as Suite.awaitUntil already + // does. A suite's connections are ThreadLocal to the thread that opened them (see + // SuiteContext.getConnection), so a `sql` inside `Awaitility.await()...until { }` on Awaitility's + // own polling thread opened a fresh connection that nothing closed when that thread died with + // the await(): one connection leaked on the frontend per await(), held until the client JVM + // garbage-collected it, and enough of them at once reach the user's max_user_connections. + // Polled on the suite thread, the condition reuses the suite's connection. The trade-off: an + // atMost() no longer bounds a condition that blocks - the poll runs to completion before the + // bound is checked. A condition that runs statements is bounded by their timeouts (the + // frontend's query_timeout, the framework's socketTimeout); a condition that waits on + // anything else has to bound that wait itself, as SuiteCluster does for its doris-compose + // subprocesses. + Awaitility.pollInSameThread() classloader = new GroovyClassLoader() compileConfig = new CompilerConfiguration() compileConfig.setScriptBaseClass((SuiteScript as Class).name) diff --git a/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/Suite.groovy b/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/Suite.groovy index 6ef166d94598eb..7ea4fffc180c53 100644 --- a/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/Suite.groovy +++ b/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/Suite.groovy @@ -403,6 +403,13 @@ class Suite implements GroovyInterceptable { context.threadLocalConn.remove() actionSupplier.call() } finally { + // The connection the action opened to the docker cluster is unreachable once the original + // one is put back, so close it rather than leave it to the suite's end. (Still the + // original one when the cluster failed to start before the action ran.) + ConnectionInfo dockerConnection = context.threadLocalConn.get() + if (dockerConnection != null && !dockerConnection.is(originConnection)) { + context.closeDorisConnection(dockerConnection.conn, "docker cluster connection") + } if (originConnection == null) { context.threadLocalConn.remove() } else { @@ -536,13 +543,18 @@ class Suite implements GroovyInterceptable { // Wait for BE to report Thread.sleep(5000) - Connection originConnection = context.threadLocalConn.get() + ConnectionInfo originConnection = context.threadLocalConn.get() context.threadLocalConn.remove() context.isMultiDockerClusterRunning = true try { actionSupplier.call(clusters) } finally { context.isMultiDockerClusterRunning = false + // As in dockerImpl: the action's connection to a docker cluster is closed here. + ConnectionInfo dockerConnection = context.threadLocalConn.get() + if (dockerConnection != null && !dockerConnection.is(originConnection)) { + context.closeDorisConnection(dockerConnection.conn, "docker cluster connection") + } if (originConnection == null) { context.threadLocalConn.remove() } else { diff --git a/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/SuiteCluster.groovy b/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/SuiteCluster.groovy index 689547f78fec2f..354528af0eb2ef 100644 --- a/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/SuiteCluster.groovy +++ b/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/SuiteCluster.groovy @@ -23,14 +23,12 @@ import org.apache.doris.regression.util.JdbcUtils import org.apache.doris.regression.util.NodeType import com.google.common.collect.Maps -import org.awaitility.Awaitility import org.slf4j.Logger import org.slf4j.LoggerFactory import groovy.json.JsonSlurper import groovy.transform.CompileStatic import groovy.util.logging.Slf4j -import static java.util.concurrent.TimeUnit.SECONDS import java.util.stream.Collectors import java.sql.Connection @@ -912,16 +910,34 @@ class SuiteCluster { runCmd(cmd, timeoutSecond) } + // Waits for a doris-compose process to exit and its output to be read, for at most timeoutSecond; + // one that outlives that is destroyed and the command fails, the way atMost() failed it when the + // wait ran on Awaitility's own thread. The framework polls Awaitility conditions on the calling + // thread (RegressionTest.initGroovyEnv), so an atMost() around a blocking call no longer bounds + // it: a condition that waits on something other than a statement bounds the wait itself. The + // wait runs on a helper thread so that the timeout can be enforced from here; waitForProcessOutput + // joins the two stream readers, so the buffers are complete once the thread has ended. + private static void waitForDorisCompose(Process proc, StringBuilder outBuf, StringBuilder errBuf, + int timeoutSecond) throws Exception { + Thread waiter = Thread.start('doris-compose-wait') { + proc.waitForProcessOutput(outBuf, errBuf) + } + waiter.join(timeoutSecond * 1000L) + if (waiter.isAlive()) { + proc.destroyForcibly() + waiter.join(10 * 1000L) + throw new Exception(String.format('doris compose cmd did not finish within %d seconds and was killed,' + + ' stdout: %s, stderr: %s', timeoutSecond, outBuf.toString(), errBuf.toString())) + } + } + private Object runCmd(String cmd, int timeoutSecond = 60) throws Exception { def fullCmd = String.format('python -W ignore %s %s -v --output-json', config.dorisComposePath, cmd) logger.info('Run doris compose cmd: {}', fullCmd) def proc = fullCmd.execute() def outBuf = new StringBuilder() def errBuf = new StringBuilder() - Awaitility.await().atMost(timeoutSecond, SECONDS).until({ - proc.waitForProcessOutput(outBuf, errBuf) - return true - }) + waitForDorisCompose(proc, outBuf, errBuf, timeoutSecond) if (proc.exitValue() != 0) { throw new Exception(String.format('Exit value: %s != 0, stdout: %s, stderr: %s', proc.exitValue(), outBuf.toString(), errBuf.toString())) @@ -969,10 +985,7 @@ class SuiteCluster { def proc = fullCmdList.execute() def outBuf = new StringBuilder() def errBuf = new StringBuilder() - Awaitility.await().atMost(timeoutSecond, SECONDS).until({ - proc.waitForProcessOutput(outBuf, errBuf) - return true - }) + waitForDorisCompose(proc, outBuf, errBuf, timeoutSecond) if (proc.exitValue() != 0) { throw new Exception(String.format('Exit value: %s != 0, stdout: %s, stderr: %s', proc.exitValue(), outBuf.toString(), errBuf.toString())) diff --git a/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/SuiteContext.groovy b/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/SuiteContext.groovy index fcd59cb3a7337e..019b82a89bbdb2 100644 --- a/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/SuiteContext.groovy +++ b/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/SuiteContext.groovy @@ -30,6 +30,7 @@ import groovy.util.logging.Slf4j import java.lang.reflect.UndeclaredThrowableException import java.sql.Connection import java.sql.DriverManager +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ExecutorService import java.util.function.Function import org.apache.doris.regression.util.JdbcUtils @@ -55,6 +56,13 @@ class SuiteContext implements Closeable { public final ThreadLocal threadHiveRemoteConn = new ThreadLocal<>() public final ThreadLocal threadSparkIcebergConn = new ThreadLocal<>() public final ThreadLocal threadDB2DockerConn = new ThreadLocal<>() + // Every Doris connection the thread-local accessors above opened, with the thread that opened it. + // Only the suite thread and the threads Suite.thread() runs end with closeThreadLocal(); a thread + // the suite created itself (a Thread, an Executors pool) never does, so its connection stayed open + // on the frontend until the client JVM garbage-collected it - seconds or minutes later, at the + // JVM's whim. Now the next connection the suite opens closes the connections of threads that have + // finished (closeConnectionsOfFinishedThreads), and close() closes whatever is left. + private final Map openedDorisConnections = new ConcurrentHashMap<>() private final ThreadLocal syncer = new ThreadLocal<>() public final Config config public final File dataPath @@ -148,10 +156,11 @@ class SuiteContext implements Closeable { // jdbc:mysql Connection getConnection() { + closeConnectionsOfFinishedThreads() def threadConnInfo = threadLocalConn.get() if (threadConnInfo == null) { threadConnInfo = new ConnectionInfo() - threadConnInfo.conn = getConnectionByDbName(dbName) + threadConnInfo.conn = trackDorisConnection(getConnectionByDbName(dbName)) threadConnInfo.username = config.jdbcUser threadConnInfo.password = config.jdbcPassword threadLocalConn.set(threadConnInfo) @@ -159,6 +168,59 @@ class SuiteContext implements Closeable { return threadConnInfo.conn } + private Connection trackDorisConnection(Connection conn) { + openedDorisConnections.put(conn, Thread.currentThread()) + return conn + } + + // Closes a connection one of the thread-local accessors opened, and forgets it (see openedDorisConnections). + void closeDorisConnection(Connection conn, String what) { + openedDorisConnections.remove(conn) + closeQuietly(conn, what) + } + + private static void closeQuietly(Connection conn, String what) { + try { + conn.close() + } catch (Throwable t) { + log.warn("Close ${what} failed".toString(), t) + } + } + + // A thread the suite created itself took its thread-local connection to the grave: nothing on that + // thread runs closeThreadLocal() once it has finished. Called on every statement, this closes those + // connections, so a suite that starts a thread per step (Thread.start { streamLoad ... }; join) holds + // at most the connections of the threads still running, not one per step until the suite ends. + private void closeConnectionsOfFinishedThreads() { + int closed = 0 + for (Map.Entry entry : openedDorisConnections.entrySet()) { + if (!entry.value.isAlive() && openedDorisConnections.remove(entry.key, entry.value)) { + closeQuietly(entry.key, "connection of finished thread ${entry.value.name}".toString()) + closed++ + } + } + if (closed > 0) { + log.info("Closed ${closed} connection(s) opened on threads of suite ${suiteName} that have finished" + .toString()) + } + } + + // The connections still open once the suite is over, whichever thread opened them (see + // openedDorisConnections). The warning names the suite: a `sql` on a thread the suite created + // itself and left running (an Executors pool it never shut down) is what leaves them behind. + private void closeLeftoverDorisConnections() { + List leftover = new ArrayList<>(openedDorisConnections.keySet()) + openedDorisConnections.clear() + if (leftover.isEmpty()) { + return + } + log.warn("Suite ${suiteName} left ${leftover.size()} connection(s) open on threads of its own, " + + "closing them now".toString()) + for (Connection conn : leftover) { + closeQuietly(conn, "leftover connection") + } + } + Connection getConnectionByDbName(String dbName) { def jdbcUrl = getJdbcUrl() def jdbcConn = DriverManager.getConnection(jdbcUrl, config.jdbcUser, config.jdbcPassword) @@ -192,7 +254,7 @@ class SuiteContext implements Closeable { def threadConnInfo = threadLocalMasterConn.get() if (threadConnInfo == null) { threadConnInfo = new ConnectionInfo() - threadConnInfo.conn = getMasterConnectionByDbName(dbName) + threadConnInfo.conn = trackDorisConnection(getMasterConnectionByDbName(dbName)) threadConnInfo.username = config.jdbcUser threadConnInfo.password = config.jdbcPassword threadLocalMasterConn.set(threadConnInfo) @@ -204,7 +266,7 @@ class SuiteContext implements Closeable { def threadConnInfo = threadArrowFlightSqlConn.get() if (threadConnInfo == null) { threadConnInfo = new ConnectionInfo() - threadConnInfo.conn = config.getConnectionByArrowFlightSqlDbName(dbName) + threadConnInfo.conn = trackDorisConnection(config.getConnectionByArrowFlightSqlDbName(dbName)) threadConnInfo.username = config.jdbcUser threadConnInfo.password = config.jdbcPassword threadArrowFlightSqlConn.set(threadConnInfo) @@ -482,15 +544,11 @@ class SuiteContext implements Closeable { ConnectionInfo oldConn = threadLocalConn.get() if (oldConn != null) { threadLocalConn.remove() - try { - oldConn.conn.close() - } catch (Throwable t) { - log.warn("Close connection failed", t) - } + closeDorisConnection(oldConn.conn, "connection") } def newConnInfo = new ConnectionInfo() - newConnInfo.conn = DriverManager.getConnection(url, username, password) + newConnInfo.conn = trackDorisConnection(DriverManager.getConnection(url, username, password)) newConnInfo.username = username newConnInfo.password = password threadLocalConn.set(newConnInfo) @@ -585,31 +643,19 @@ class SuiteContext implements Closeable { ConnectionInfo conn = threadLocalConn.get() if (conn != null) { threadLocalConn.remove() - try { - conn.conn.close() - } catch (Throwable t) { - log.warn("Close connection failed", t) - } + closeDorisConnection(conn.conn, "connection") } ConnectionInfo master_conn = threadLocalMasterConn.get() if (master_conn != null) { threadLocalMasterConn.remove() - try { - master_conn.conn.close() - } catch (Throwable t) { - log.warn("Close master connection failed", t) - } + closeDorisConnection(master_conn.conn, "master connection") } ConnectionInfo arrow_flight_sql_conn = threadArrowFlightSqlConn.get() if (arrow_flight_sql_conn != null) { threadArrowFlightSqlConn.remove() - try { - arrow_flight_sql_conn.conn.close() - } catch (Throwable t) { - log.warn("Close connection failed", t) - } + closeDorisConnection(arrow_flight_sql_conn.conn, "arrow flight sql connection") } Connection hive2_docker_conn = threadHive2DockerConn.get() @@ -677,6 +723,7 @@ class SuiteContext implements Closeable { @Override void close() { closeThreadLocal() + closeLeftoverDorisConnections() if (outputBlocksWriter != null) { outputBlocksWriter.close() diff --git a/regression-test/suites/external_table_p0/remote_doris/test_remote_doris_flight_session.groovy b/regression-test/suites/external_table_p0/remote_doris/test_remote_doris_flight_session.groovy new file mode 100644 index 00000000000000..b1d7cc7fd533cd --- /dev/null +++ b/regression-test/suites/external_table_p0/remote_doris/test_remote_doris_flight_session.groovy @@ -0,0 +1,130 @@ +// 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. + +// A remote Doris scan opens an Arrow Flight SQL session on the remote frontend for the query the BE +// reads, and has to close it once the local query is over. A session left behind stays in the remote +// frontend's connection pool until wait_timeout and counts against the catalog user's +// max_user_connections there, so that user - MySQL clients included - is refused after a hundred +// scans. The remote frontend here is this one, and the catalog logs in as a user of its own, so the +// Flight sessions of that user in the processlist are exactly the ones this suite's scans opened. +suite("test_remote_doris_flight_session", "p0,external") { + String host = context.config.otherConfigs.get("extArrowFlightSqlHost") + def frontends = sql "show frontends" + String arrowPort = frontends[0][6] + String httpPort = frontends[0][3] + String thriftPort = frontends[0][5] + log.info("show frontends = ${frontends}, arrow: ${arrowPort}, http: ${httpPort}, thrift: ${thriftPort}") + + String user = "test_remote_doris_flight_session_user" + String pwd = "C123_567p" + String db = "test_remote_doris_flight_session_db" + String table = "test_remote_doris_flight_session_t" + String catalog = "test_remote_doris_flight_session_catalog" + + sql """DROP CATALOG IF EXISTS `${catalog}`""" + sql """DROP USER IF EXISTS '${user}'@'%'""" + sql """DROP DATABASE IF EXISTS `${db}`""" + sql """CREATE DATABASE `${db}`""" + sql """ + CREATE TABLE `${db}`.`${table}` ( + `id` int NOT NULL, + `v` varchar(16) NULL + ) ENGINE=OLAP + DUPLICATE KEY(`id`) + DISTRIBUTED BY HASH(`id`) BUCKETS 1 + PROPERTIES ( + "replication_allocation" = "tag.location.default: 1" + ); + """ + sql """INSERT INTO `${db}`.`${table}` VALUES (1, 'a'), (2, 'b'), (3, 'c')""" + + // What the remote frontend asks of the catalog user: the Flight handshake takes any user, the + // metadata REST calls need SHOW on the database, the query it runs needs SELECT on the table. + sql """CREATE USER '${user}'@'%' IDENTIFIED BY '${pwd}'""" + sql """GRANT SELECT_PRIV ON internal.`${db}`.* TO '${user}'@'%'""" + if (isCloudMode()) { + def clusters = sql " SHOW CLUSTERS; " + assertTrue(!clusters.isEmpty()) + def validCluster = clusters[0][0] + sql """GRANT USAGE_PRIV ON CLUSTER `${validCluster}` TO '${user}'@'%'""" + } + + sql """ + CREATE CATALOG `${catalog}` PROPERTIES ( + 'type' = 'doris', + 'fe_thrift_hosts' = '${host}:${thriftPort}', + 'fe_http_hosts' = 'http://${host}:${httpPort}', + 'fe_arrow_hosts' = '${host}:${arrowPort}', + 'user' = '${user}', + 'password' = '${pwd}', + 'use_arrow_flight' = 'true' + ); + """ + + def flightSessionsOfCatalogUser = { -> + def rows = sql """ + SELECT COUNT(*) FROM information_schema.processlist + WHERE User = '${user}' AND Protocol = 'ArrowFlightSQL' + """ + return rows[0][0] as long + } + assertEquals(0L, flightSessionsOfCatalogUser()) + + try { + // Every scan opens one session on the remote frontend. It is closed when the local query's + // coordinator closes, which happens before the result reaches the client, so none is left by + // the time the next statement runs - however many scans in a row. + for (int i = 0; i < 5; i++) { + def rows = sql """SELECT id, v FROM `${catalog}`.`${db}`.`${table}` ORDER BY id""" + assertEquals([[1, 'a'], [2, 'b'], [3, 'c']], rows) + assertEquals(0L, flightSessionsOfCatalogUser()) + } + + // A plan no coordinator ever takes is released when the statement ends instead. INSERT + // OVERWRITE plans the query once only to find the target partitions and discards that plan + // (its scan opened a session on the remote frontend) before the insert plans again ... + sql """DROP TABLE IF EXISTS `${db}`.`${table}_sink`""" + sql """ + CREATE TABLE `${db}`.`${table}_sink` ( + `id` int NOT NULL, + `v` varchar(16) NULL + ) ENGINE=OLAP + DUPLICATE KEY(`id`) + DISTRIBUTED BY HASH(`id`) BUCKETS 1 + PROPERTIES ( + "replication_allocation" = "tag.location.default: 1" + ); + """ + sql """INSERT OVERWRITE TABLE `${db}`.`${table}_sink` SELECT id, v FROM `${catalog}`.`${db}`.`${table}`""" + assertEquals([[3L]], sql("""SELECT COUNT(*) FROM `${db}`.`${table}_sink`""")) + assertEquals(0L, flightSessionsOfCatalogUser()) + + // ... and a statement that fails after planning - here an INSERT whose label was already + // used, refused when its transaction begins - has built no coordinator to close the session. + sql """INSERT INTO `${db}`.`${table}_sink` WITH LABEL test_remote_doris_flight_session_label SELECT id, v FROM `${catalog}`.`${db}`.`${table}`""" + assertEquals(0L, flightSessionsOfCatalogUser()) + test { + sql """INSERT INTO `${db}`.`${table}_sink` WITH LABEL test_remote_doris_flight_session_label SELECT id, v FROM `${catalog}`.`${db}`.`${table}`""" + exception "already been used" + } + assertEquals(0L, flightSessionsOfCatalogUser()) + } finally { + sql """DROP CATALOG IF EXISTS `${catalog}`""" + sql """DROP USER IF EXISTS '${user}'@'%'""" + sql """DROP DATABASE IF EXISTS `${db}`""" + } +}