diff --git a/fe/fe-common/src/main/java/org/apache/doris/common/Config.java b/fe/fe-common/src/main/java/org/apache/doris/common/Config.java index c0f2a1b76be716..a56912a77cdd28 100644 --- a/fe/fe-common/src/main/java/org/apache/doris/common/Config.java +++ b/fe/fe-common/src/main/java/org/apache/doris/common/Config.java @@ -2644,6 +2644,17 @@ public class Config extends ConfigBase { @ConfField(description = "Maximum number of connections for the Arrow Flight Server per FE.") public static int arrow_flight_max_connections = 4096; + @ConfField(mutable = true, description = "Arrow Flight SQL only. A query that scans an external table in " + + "batch mode keeps its FE coordinator alive after GetFlightInfo, so the BE can keep fetching splits " + + "while the client pulls the results (DoGet); that coordinator is normally released when the " + + "session runs its next query or is closed. Most Flight clients never close a session, so the " + + "coordinator, and with it the query's workload group queue slot and its active_queries entry, " + + "would otherwise stay held until wait_timeout. If the session stays idle for longer than this " + + "many seconds after the query started, the coordinator is released anyway. The bound is never " + + "shorter than the query's own execution timeout, and the session itself is not killed " + + "(wait_timeout still governs that). 0 disables the bound.") + public static int arrow_flight_deferred_query_idle_timeout_second = 3600; + @ConfField(mutable = true, masterOnly = true, description = "In auto bucketing, the number of buckets is " + "estimated based on the partition size. For storage " + "and computing integration, a partition size of 5GB " + "is estimated as one bucket, but for cloud, a " 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 efa5d7e5406228..98d9056e1af08c 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 @@ -134,6 +134,17 @@ public TupleDescriptor getTupleDesc() { return desc; } + /** + * 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. + */ + public boolean hasBatchSplitSource() { + return splitAssignment != null; + } + protected abstract void createScanRangeLocations() throws UserException; /** diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectContext.java b/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectContext.java index 428f86c957e92d..80ac4cb1170480 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectContext.java +++ b/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectContext.java @@ -1006,7 +1006,9 @@ public void clear() { // held by the coordinator's scan nodes), so closing the coordinator at the end of // GetFlightInfo would release the SplitSource too early and make the BE's fetchSplitBatch fail // with "Split source X is released". These executors are finalized when the next query starts - // on this connection, or when the connection is torn down. See #62259. + // on this connection, when the connection is torn down, or by the idle reaper in checkTimeout + // once the connection has been sleeping for arrow_flight_deferred_query_idle_timeout_second. + // See #62259 and #67503. private final List flightSqlDeferredExecutors = new ArrayList<>(); public void addFlightSqlDeferredExecutor(StmtExecutor executor) { @@ -1033,6 +1035,45 @@ public void closeFlightSqlDeferredExecutors() { } } + /** + * How long, in seconds, a sleeping connection may keep its deferred Arrow Flight executors + * before the timeout checker finalizes them without killing the connection + * (Config.arrow_flight_deferred_query_idle_timeout_second). A Flight client that opens a + * session per query and never closes it would otherwise pin each deferred query's query queue + * slot and query registration until wait_timeout (8h by default). The bound is never shorter + * than the execution timeout the deferred query was run with: the client may still be pulling + * that query's results from the BE, which still needs the batch split source the coordinator + * holds. Returns -1 when the bound is disabled or nothing is deferred. + */ + public long getFlightSqlDeferredExecutorsIdleTimeoutS() { + int configTimeoutS = Config.arrow_flight_deferred_query_idle_timeout_second; + if (configTimeoutS <= 0) { + return -1; + } + long execTimeoutS = -1; + synchronized (flightSqlDeferredExecutors) { + if (flightSqlDeferredExecutors.isEmpty()) { + return -1; + } + for (StmtExecutor deferredExecutor : flightSqlDeferredExecutors) { + execTimeoutS = Math.max(execTimeoutS, deferredExecutor.getDeferredExecTimeoutS()); + } + } + return Math.max(configTimeoutS, execTimeoutS); + } + + // Called by the timeout checker for a sleeping connection that is not past wait_timeout yet. + private void reapIdleFlightSqlDeferredExecutors(long idleMs) { + long timeoutS = getFlightSqlDeferredExecutorsIdleTimeoutS(); + if (timeoutS < 0 || idleMs <= timeoutS * 1000L) { + return; + } + LOG.warn("release deferred arrow flight query of idle connection, connectionId: {}, remote: {}, " + + "idle: {}ms, idle timeout: {}s", + connectionId, getRemoteHostPortString(), idleMs, timeoutS); + closeFlightSqlDeferredExecutors(); + } + /** * This method is idempotent. */ @@ -1305,6 +1346,8 @@ public void checkTimeout(long now) { // Need kill this connection. killFlag = true; killConnection = true; + } else { + reapIdleFlightSqlDeferredExecutors(delta); } } else { String timeoutTag = "query"; 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 4a9e58949ca870..7502e4a57534b3 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 @@ -810,6 +810,25 @@ public void exec() throws Exception { execInternal(); } + /** + * 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. + */ + public boolean hasBatchSplitSource() { + if (scanNodes == null) { + return false; + } + for (ScanNode scanNode : scanNodes) { + if (scanNode.hasBatchSplitSource()) { + return true; + } + } + return false; + } + @Override public void close() { // NOTE: all close method should be no exception 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 113985e1cd198b..bc0faf28758845 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 @@ -208,6 +208,10 @@ public class StmtExecutor { // is finalized later by ConnectContext (see #62259), so the eager close in executeAndSendResult // is skipped. private volatile boolean deferredForArrowFlight = false; + // The execution timeout in effect when the coordinator was deferred. Captured at that moment + // because per-statement SET_VAR values are reverted at the end of execute(), so reading + // ConnectContext.getExecTimeoutS() later would report the session value instead. + private volatile int deferredExecTimeoutS = -1; private MasterOpExecutor masterOpExecutor = null; // Optional forward target for cancellations issued on this executor: statements that // spawn a nested internal executor with its own query id (e.g. IVM dry-run delta @@ -1084,6 +1088,21 @@ public boolean isDeferredForArrowFlight() { return deferredForArrowFlight; } + // Execution timeout (seconds) the deferred query was run with; -1 when the query is not deferred. + public int getDeferredExecTimeoutS() { + return deferredExecTimeoutS; + } + + // Keep this query's coordinator alive past GetFlightInfo (see the gate in executeAndSendResult) + // and hand it to the ConnectContext, which finalizes it later. Records the execution timeout in + // effect right now: it floors the idle reaper's bound and must be the value the query actually + // ran with, not the session value left behind after SET_VAR hints are reverted. + void deferForArrowFlight() { + deferredForArrowFlight = true; + deferredExecTimeoutS = context.getExecTimeoutS(); + context.addFlightSqlDeferredExecutor(this); + } + // 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. @@ -1563,23 +1582,21 @@ public void executeAndSendResult(boolean isOutfileQuery, boolean isSendFields, if (context.getConnectType().equals(ConnectType.ARROW_FLIGHT_SQL)) { Preconditions.checkState(!context.isReturnResultFromLocal()); profile.getSummaryProfile().setTempStartTime(); - // Defer closing the coordinator to ConnectContext (closed on the next query or - // connection teardown) instead of in the finally block below. This gate covers - // every Arrow Flight query whose results are produced on the BE (coordBase == - // coord) -- internal-table and external, batch or not. It is REQUIRED only for an - // external-table scan in batch mode, where the BE lazily fetches splits from the FE - // during the later DoGet phase, so closing the coordinator here would release its - // batch SplitSource too early and break DoGet. Other remote-result queries do not - // need deferral (the BE buffers their result independently) but are captured by the - // same gate; the trade-off is their coordinator, query queue slot and query - // registration stay held until the next query / teardown instead of being released - // at the end of GetFlightInfo. A short-circuit point query is the one case with a - // different coordBase, and it can no longer reach here: it has no Arrow result on - // either side, so LogicalResultSinkToShortCircuitPointQuery keeps Arrow Flight SQL - // on the normal execution path. See #62259 and #67368. - if (coordBase == coord) { - deferredForArrowFlight = true; - context.addFlightSqlDeferredExecutor(this); + // The client pulls the results from the BE later (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 + // can no longer reach here: it has no Arrow result on either side, so + // LogicalResultSinkToShortCircuitPointQuery keeps Arrow Flight SQL on the normal + // execution path (#67368). + if (coordBase == coord && coord.hasBatchSplitSource()) { + deferForArrowFlight(); } return; } diff --git a/fe/fe-core/src/main/java/org/apache/doris/service/arrowflight/FlightSqlConnectProcessor.java b/fe/fe-core/src/main/java/org/apache/doris/service/arrowflight/FlightSqlConnectProcessor.java index 24e30c3942f20f..2296986c535dd3 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/service/arrowflight/FlightSqlConnectProcessor.java +++ b/fe/fe-core/src/main/java/org/apache/doris/service/arrowflight/FlightSqlConnectProcessor.java @@ -196,11 +196,12 @@ public void fetchArrowFlightSchema(int timeoutMs) { @Override public void close() throws Exception { ctx.setCommand(MysqlCommand.COM_SLEEP); - // Executors whose results are pulled from the BE keep their coordinator alive past - // GetFlightInfo (registered as deferred executors on the ConnectContext) so the BE can - // still fetch external-table splits during DoGet. Do NOT finalize those here; they are - // finalized when the next query starts or the connection is torn down. Executors that are - // not deferred (local results, or a query that already failed) are finalized now. See #62259. + // An external-table scan in batch mode keeps its coordinator alive past GetFlightInfo + // (registered as a deferred executor on the ConnectContext) so the BE can still fetch its + // splits during DoGet. Do NOT finalize those here; they are finalized when the next query + // starts, when the connection is torn down, or by the idle reaper in + // ConnectContext.checkTimeout. Every other executor (local results, results the BE buffers + // on its own, or a query that already failed) is finalized now. See #62259 and #67503. for (StmtExecutor asynExecutor : returnResultFromRemoteExecutor) { if (!asynExecutor.isDeferredForArrowFlight()) { asynExecutor.finalizeQuery(); 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 new file mode 100644 index 00000000000000..a02de20f14f635 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/qe/ArrowFlightDeferralGateTest.java @@ -0,0 +1,69 @@ +// 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.qe; + +import org.apache.doris.analysis.DescriptorTable; +import org.apache.doris.datasource.split.SplitAssignment; +import org.apache.doris.planner.PlanFragment; +import org.apache.doris.planner.ScanNode; +import org.apache.doris.thrift.TUniqueId; + +import com.google.common.collect.Lists; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.lang.reflect.Field; +import java.util.List; + +/** + * 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). + */ +public class ArrowFlightDeferralGateTest { + + private static ScanNode scanNode(boolean batchSplitSource) throws Exception { + ScanNode node = Mockito.mock(ScanNode.class, Mockito.CALLS_REAL_METHODS); + if (batchSplitSource) { + // FileQueryScanNode.createScanRangeLocations sets this only in batch mode. + Field field = ScanNode.class.getDeclaredField("splitAssignment"); + field.setAccessible(true); + field.set(node, Mockito.mock(SplitAssignment.class)); + } + return node; + } + + private static Coordinator coordinator(List scanNodes) { + return new Coordinator(1L, new TUniqueId(1L, 2L), new DescriptorTable(), Lists.newArrayList(), + scanNodes, "UTC", false, false); + } + + @Test + public void testScanNodeHasBatchSplitSourceOnlyWhenSplitsAreHandedOutLazily() throws Exception { + Assertions.assertFalse(scanNode(false).hasBatchSplitSource()); + Assertions.assertTrue(scanNode(true).hasBatchSplitSource()); + } + + @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()); + } +} 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 fd40fb68dad421..dac844f33b00a1 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 @@ -78,6 +78,39 @@ public void testShowNull() throws Exception { Assertions.assertEquals(QueryState.MysqlStateType.OK, connectContext.getState().getStateType()); } + // 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 + // 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 + public void testDeferForArrowFlightFreezesExecTimeoutInEffect() throws Exception { + int savedQueryTimeout = connectContext.getSessionVariable().getQueryTimeoutS(); + int savedIdleTimeout = Config.arrow_flight_deferred_query_idle_timeout_second; + connectContext.setQueryId(new TUniqueId(0x67503L, 0x1L)); + try { + Config.arrow_flight_deferred_query_idle_timeout_second = 1; + connectContext.getSessionVariable().setQueryTimeoutS(1234); + StmtExecutor stmtExecutor = new StmtExecutor(connectContext, ""); + Assertions.assertFalse(stmtExecutor.isDeferredForArrowFlight()); + Assertions.assertEquals(-1, stmtExecutor.getDeferredExecTimeoutS()); + + stmtExecutor.deferForArrowFlight(); + + Assertions.assertTrue(stmtExecutor.isDeferredForArrowFlight()); + Assertions.assertEquals(1234, stmtExecutor.getDeferredExecTimeoutS()); + // the reaper's bound is floored at the frozen value ... + Assertions.assertEquals(1234L, connectContext.getFlightSqlDeferredExecutorsIdleTimeoutS()); + // ... even after the session value moved on, as it does when a SET_VAR hint is reverted + connectContext.getSessionVariable().setQueryTimeoutS(5); + Assertions.assertEquals(1234, stmtExecutor.getDeferredExecTimeoutS()); + Assertions.assertEquals(1234L, connectContext.getFlightSqlDeferredExecutorsIdleTimeoutS()); + } finally { + connectContext.closeFlightSqlDeferredExecutors(); + connectContext.getSessionVariable().setQueryTimeoutS(savedQueryTimeout); + Config.arrow_flight_deferred_query_idle_timeout_second = savedIdleTimeout; + } + } + // Arrow Flight SQL keeps a query's coordinator alive across GetFlightInfo -> DoGet (see #62259); // it is released later by finalizeArrowFlightQuery(), which closes the coordinator and then // unregisters the query. The close and the unregister must be independent: if coord.close() diff --git a/fe/fe-core/src/test/java/org/apache/doris/service/arrowflight/sessions/FlightSqlDeferredQueryIdleTimeoutTest.java b/fe/fe-core/src/test/java/org/apache/doris/service/arrowflight/sessions/FlightSqlDeferredQueryIdleTimeoutTest.java new file mode 100644 index 00000000000000..211f2ab984908a --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/service/arrowflight/sessions/FlightSqlDeferredQueryIdleTimeoutTest.java @@ -0,0 +1,156 @@ +// 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.service.arrowflight.sessions; + +import org.apache.doris.common.Config; +import org.apache.doris.common.FeConstants; +import org.apache.doris.mysql.MysqlCommand; +import org.apache.doris.qe.ConnectContext; +import org.apache.doris.qe.StmtExecutor; + +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; + +/** + * The idle reaper for deferred Arrow Flight queries (#67503). A sleeping Flight session whose last + * query kept its coordinator alive (an external-table scan in batch mode, see #62259) gets that + * coordinator finalized by the connection timeout checker once the session has been idle for + * arrow_flight_deferred_query_idle_timeout_second, floored at the execution timeout the query ran + * with. The session itself is not killed, wait_timeout still governs that, and a MySQL session is + * untouched. + */ +public class FlightSqlDeferredQueryIdleTimeoutTest { + private int savedIdleTimeout; + private boolean savedRunningUnitTest; + + @BeforeEach + public void setUp() { + savedIdleTimeout = Config.arrow_flight_deferred_query_idle_timeout_second; + savedRunningUnitTest = FeConstants.runningUnitTest; + // ConnectContext.init() registers the session with Env unless running as a unit test. + FeConstants.runningUnitTest = true; + } + + @AfterEach + public void tearDown() { + Config.arrow_flight_deferred_query_idle_timeout_second = savedIdleTimeout; + FeConstants.runningUnitTest = savedRunningUnitTest; + } + + private static StmtExecutor deferredExecutor(int execTimeoutS) { + StmtExecutor executor = Mockito.mock(StmtExecutor.class); + Mockito.when(executor.getDeferredExecTimeoutS()).thenReturn(execTimeoutS); + return executor; + } + + // A Flight session that ran a query and has been sleeping since; the client never closed it. + private static FlightSqlConnectContext sleepingFlightSession(StmtExecutor... deferred) { + FlightSqlConnectContext ctx = new FlightSqlConnectContext("test-peer-identity"); + ctx.setCommand(MysqlCommand.COM_SLEEP); + ctx.setStartTime(); + for (StmtExecutor executor : deferred) { + ctx.addFlightSqlDeferredExecutor(executor); + } + return ctx; + } + + @Test + public void testIdleSessionReleasesDeferredQueryButIsNotKilled() { + Config.arrow_flight_deferred_query_idle_timeout_second = 7; + StmtExecutor deferred = deferredExecutor(5); + FlightSqlConnectContext ctx = sleepingFlightSession(deferred); + long start = ctx.getStartTime(); + Assertions.assertEquals(7L, ctx.getFlightSqlDeferredExecutorsIdleTimeoutS()); + + // not idle for long enough yet + ctx.checkTimeout(start + 7_000L); + Mockito.verify(deferred, Mockito.never()).finalizeArrowFlightQuery(); + Assertions.assertFalse(ctx.isKilled()); + + // past the bound: the deferred coordinator is finalized and the session survives + ctx.checkTimeout(start + 7_001L); + Mockito.verify(deferred).finalizeArrowFlightQuery(); + Assertions.assertFalse(ctx.isKilled()); + Assertions.assertEquals(-1L, ctx.getFlightSqlDeferredExecutorsIdleTimeoutS()); + + // a later tick has nothing left to release + ctx.checkTimeout(start + 60_000L); + Mockito.verify(deferred, Mockito.times(1)).finalizeArrowFlightQuery(); + Assertions.assertFalse(ctx.isKilled()); + } + + @Test + public void testBoundIsFlooredAtTheExecTimeoutTheDeferredQueryRanWith() { + Config.arrow_flight_deferred_query_idle_timeout_second = 3; + StmtExecutor shortQuery = deferredExecutor(5); + StmtExecutor longQuery = deferredExecutor(20); + FlightSqlConnectContext ctx = sleepingFlightSession(shortQuery, longQuery); + long start = ctx.getStartTime(); + // the longest deferred query wins: a client may still be pulling its results from the BE + Assertions.assertEquals(20L, ctx.getFlightSqlDeferredExecutorsIdleTimeoutS()); + + ctx.checkTimeout(start + 19_999L); + Mockito.verify(shortQuery, Mockito.never()).finalizeArrowFlightQuery(); + Mockito.verify(longQuery, Mockito.never()).finalizeArrowFlightQuery(); + + ctx.checkTimeout(start + 20_001L); + Mockito.verify(shortQuery).finalizeArrowFlightQuery(); + Mockito.verify(longQuery).finalizeArrowFlightQuery(); + Assertions.assertFalse(ctx.isKilled()); + } + + @Test + public void testZeroDisablesTheReaper() { + Config.arrow_flight_deferred_query_idle_timeout_second = 0; + StmtExecutor deferred = deferredExecutor(5); + FlightSqlConnectContext ctx = sleepingFlightSession(deferred); + Assertions.assertEquals(-1L, ctx.getFlightSqlDeferredExecutorsIdleTimeoutS()); + + // idle for almost the whole wait_timeout: nothing is released and the session is alive + long waitTimeoutMs = ctx.getSessionVariable().getWaitTimeoutS() * 1000L; + ctx.checkTimeout(ctx.getStartTime() + waitTimeoutMs - 1); + Mockito.verify(deferred, Mockito.never()).finalizeArrowFlightQuery(); + Assertions.assertFalse(ctx.isKilled()); + } + + @Test + public void testNothingDeferredMeansNoBound() { + Config.arrow_flight_deferred_query_idle_timeout_second = 7; + FlightSqlConnectContext ctx = sleepingFlightSession(); + Assertions.assertEquals(-1L, ctx.getFlightSqlDeferredExecutorsIdleTimeoutS()); + + ctx.checkTimeout(ctx.getStartTime() + 3_600_000L); + Assertions.assertFalse(ctx.isKilled()); + } + + @Test + public void testMysqlSessionIsUntouched() { + Config.arrow_flight_deferred_query_idle_timeout_second = 1; + ConnectContext ctx = new ConnectContext(); + ctx.setCommand(MysqlCommand.COM_SLEEP); + ctx.setStartTime(); + Assertions.assertEquals(-1L, ctx.getFlightSqlDeferredExecutorsIdleTimeoutS()); + + // idle far beyond the Flight bound but within wait_timeout: still alive + ctx.checkTimeout(ctx.getStartTime() + 3_600_000L); + Assertions.assertFalse(ctx.isKilled()); + } +} diff --git a/regression-test/suites/arrow_flight_sql_p0/test_arrow_flight_query_release.groovy b/regression-test/suites/arrow_flight_sql_p0/test_arrow_flight_query_release.groovy new file mode 100644 index 00000000000000..0913164a1eb933 --- /dev/null +++ b/regression-test/suites/arrow_flight_sql_p0/test_arrow_flight_query_release.groovy @@ -0,0 +1,88 @@ +// 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. + +// Regression for https://github.com/apache/doris/issues/67503 +// +// Over Arrow Flight SQL a query runs in two phases: GetFlightInfo (plan and start it on the BE) +// and DoGet (the client pulls the results from the BE). Only an external-table scan in batch mode +// needs its FE coordinator after GetFlightInfo (#62259). Every other query has to release its +// coordinator, and with it the workload group queue slot and the active_queries entry, at the end +// of GetFlightInfo: most Flight clients never close their session, so a coordinator that waited +// for the session's next query kept one queue slot per finished query until wait_timeout. +// +// The framework's Flight session behaves like such a client: it is reused across statements and +// never closed. +suite("test_arrow_flight_query_release", "arrow_flight_sql") { + def tableName = "test_arrow_flight_query_release_tbl" + def wgName = "test_arrow_flight_query_release_wg" + + def forComputeGroupStr = "" + if (isCloudMode()) { + def clusters = sql " SHOW CLUSTERS; " + assertTrue(!clusters.isEmpty()) + forComputeGroupStr = " for ${clusters[0][0]} " + } + + sql "DROP TABLE IF EXISTS ${tableName}" + sql """ + CREATE TABLE ${tableName} (id int, name varchar(20)) + DUPLICATE KEY(id) DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES ("replication_num" = "1") + """ + sql "INSERT INTO ${tableName} VALUES (1, 'a'), (2, 'b'), (3, 'c')" + + sql "ADMIN SET FRONTEND CONFIG ('enable_workload_group' = 'true')" + sql "DROP WORKLOAD GROUP IF EXISTS ${wgName} ${forComputeGroupStr}" + // One running query at a time and no waiting queue: while a query still holds the slot, the + // next scanning query in the group fails at once with "query waiting queue is full". + sql """ + CREATE WORKLOAD GROUP ${wgName} ${forComputeGroupStr} + PROPERTIES ('max_concurrency' = '1', 'max_queue_size' = '0', 'queue_timeout' = '0') + """ + try { + // The Flight session is a session of its own, so it is bound to the group separately. + sql "SET workload_group = '${wgName}'" + arrow_flight_sql "SET workload_group = '${wgName}'" + + // A scanning query over Arrow Flight SQL. The session stays open afterwards. + def flightRows = arrow_flight_sql "SELECT id, name FROM ${tableName} ORDER BY id" + assertEquals(3, flightRows.size()) + + // Its coordinator was released at the end of GetFlightInfo, so the query is gone from + // active_queries. The LIKE pattern is assembled with CONCAT so that this statement's own + // text does not match it. + def registered = sql """ + SELECT QUERY_ID, SQL FROM information_schema.active_queries + WHERE SQL LIKE CONCAT('%FROM ${tableName}', ' ORDER BY id%') + """ + assertTrue(registered.isEmpty(), "finished Arrow Flight query is still registered: ${registered}") + + // ... and its queue slot is free again: a scanning query in the same group runs instead + // of failing with "query waiting queue is full". + def mysqlRows = sql "SELECT id FROM ${tableName} ORDER BY id" + assertEquals(3, mysqlRows.size()) + } finally { + sql "SET workload_group = 'normal'" + try { + arrow_flight_sql "SET workload_group = 'normal'" + } catch (Throwable ignore) { + // best effort: the Flight session must not keep pointing at the dropped group + } + sql "DROP WORKLOAD GROUP IF EXISTS ${wgName} ${forComputeGroupStr}" + sql "DROP TABLE IF EXISTS ${tableName}" + } +} diff --git a/regression-test/suites/external_table_p0/iceberg/test_iceberg_arrow_flight_split_source.groovy b/regression-test/suites/external_table_p0/iceberg/test_iceberg_arrow_flight_split_source.groovy index e721d0e9d8eab6..67b9a431b825be 100644 --- a/regression-test/suites/external_table_p0/iceberg/test_iceberg_arrow_flight_split_source.groovy +++ b/regression-test/suites/external_table_p0/iceberg/test_iceberg_arrow_flight_split_source.groovy @@ -81,6 +81,12 @@ suite("test_iceberg_arrow_flight_split_source", "p0,external") { "s3.region" = "us-east-1" );""" + // #67503: the idle reaper for a deferred batch-mode scan (see below). Set the bound low, and + // restore the FE's original value afterwards. + int idleTimeoutS = 10 + def origIdleTimeout = sql """ ADMIN SHOW FRONTEND CONFIG LIKE 'arrow_flight_deferred_query_idle_timeout_second' """ + assert origIdleTimeout.size() == 1 : "arrow_flight_deferred_query_idle_timeout_second not found in FE config" + Connection flightConn = null try { // Baseline over the MySQL protocol (works regardless of the bug). @@ -120,7 +126,74 @@ suite("test_iceberg_arrow_flight_split_source", "p0,external") { // deferred coordinator when the next query starts. def flightLimited = flightSql """ select * from ${table} limit 10 """ assert flightLimited.size() > 0 && flightLimited.size() <= 10 : "unexpected row count: ${flightLimited.size()}" + + // #67503, the other side of the deferral gate: the SAME external table scanned WITHOUT + // batch mode is not deferred. Its coordinator, and with it the query's workload group queue + // slot and its active_queries entry, is released at the end of GetFlightInfo, before the + // client pulls anything. That is the case the gate actually moved, so it needs its own + // coverage here: every other Flight query in this suite runs in batch mode. + flightSql """ set enable_external_table_batch_mode = false """ + + // Negative control, mirroring the batch assertion above: "(approximate)" is emitted only + // when isBatchMode(), so its absence proves this really is the synchronous split path and + // the assertions below cannot silently pass on the batch path. + def explainNonBatch = flightSql """ explain select * from ${table} """ + boolean stillBatch = explainNonBatch.any { row -> + row.any { cell -> cell != null && cell.toString().contains("approximate") } + } + assert !stillBatch : "expected the non-batch split path in the Arrow Flight plan, got: ${explainNonBatch}" + + // The scan must still be complete: the FE closed the coordinator at the end of + // GetFlightInfo, and the BE buffers the result independently of it. + def flightNonBatch = flightSql """ select * from ${table} """ + assertEquals(expectedRows, (flightNonBatch.size() as long)) + + // ... and the release really was eager, unlike the batch-mode scan below. A distinct limit + // keeps this query's text apart from the other scans, and the LIKE pattern is assembled + // with CONCAT so that the probe statement's own text does not match it. No polling is + // needed: finalizeQuery() runs inside GetFlightInfo, so it has already happened by the time + // the client has the rows. + def flightNonBatchLimited = flightSql """ select * from ${table} limit 17 """ + assertEquals(17, flightNonBatchLimited.size()) + def nonBatchRegistered = sql """ select QUERY_ID from information_schema.active_queries + where SQL like CONCAT('%from ${table} limit', ' 17%') """ + assert nonBatchRegistered.isEmpty() : "a non-batch Flight query must release its coordinator at the end of GetFlightInfo, still registered: ${nonBatchRegistered}" + + // Back to batch mode: the idle reaper below needs a deferred coordinator to release. + flightSql """ set enable_external_table_batch_mode = true """ + + // #67503: a batch-mode scan keeps its coordinator (and with it the query's workload group + // queue slot and its active_queries entry) alive after GetFlightInfo, until the session + // runs its next query or is closed. A client that does neither would hold them until + // wait_timeout, so the FE releases the coordinator once the session has been idle for + // arrow_flight_deferred_query_idle_timeout_second, never before the query's own execution + // timeout, and without killing the session. + sql """ ADMIN SET FRONTEND CONFIG ('arrow_flight_deferred_query_idle_timeout_second' = '${idleTimeoutS}') """ + flightSql """ set query_timeout = ${idleTimeoutS} """ + def flightReap = flightSql """ select * from ${table} limit 13 """ + assertEquals(13, flightReap.size()) + + // The LIKE pattern is assembled with CONCAT so that this statement's own text does not + // match it. + def deferredQuery = { -> + sql """ select QUERY_ID from information_schema.active_queries + where SQL like CONCAT('%from ${table} limit', ' 13%') """ + } + // Right after the scan the query is still registered: its coordinator is deferred. + assert deferredQuery().size() == 1 : "expected the batch-mode Flight query to stay registered until the idle reaper releases it" + + // Once the session has been idle for the bound, the reaper releases it ... + long deadline = System.currentTimeMillis() + 60_000L + while (!deferredQuery().isEmpty() && System.currentTimeMillis() < deadline) { + Thread.sleep(1000) + } + assert deferredQuery().isEmpty() : "the idle reaper did not release the deferred Flight query within 60s" + + // ... and the session survives: it still runs queries. + def afterReap = flightSql """ select * from ${table} limit 1 """ + assertEquals(1, afterReap.size()) } finally { + sql """ ADMIN SET FRONTEND CONFIG ('arrow_flight_deferred_query_idle_timeout_second' = '${origIdleTimeout[0][1]}') """ // Close our own connection (best effort) so a dead endpoint cannot mask the real failure, // then drop the catalog over the reliable MySQL connection. if (flightConn != null) {