This is an automated email from the ASF dual-hosted git repository.

morningman pushed a commit to branch branch-4.0
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/branch-4.0 by this push:
     new 058dc08e620 [branch-4.0] pick some arrow memory leak fix to branch-4.0 
(#66462)
058dc08e620 is described below

commit 058dc08e620942eefe729f359193a73148f20419
Author: yiguolei <[email protected]>
AuthorDate: Wed Aug 5 15:15:25 2026 +0800

    [branch-4.0] pick some arrow memory leak fix to branch-4.0 (#66462)
    
    ### What problem does this PR solve?
    
    pick #64799   #66437
---
 .../java/org/apache/doris/qe/ConnectContext.java   |  34 ++++++
 .../java/org/apache/doris/qe/StmtExecutor.java     |  44 ++++++-
 .../arrowflight/DorisFlightSqlProducer.java        |  12 ++
 .../arrowflight/FlightSqlConnectProcessor.java     |  21 +++-
 .../arrowflight/results/FlightSqlChannel.java      |   1 +
 .../results/FlightSqlResultCacheEntry.java         |   2 +-
 .../sessions/FlightSqlConnectContext.java          |   3 -
 .../sessions/FlightSqlConnectPoolMgr.java          |  20 ++++
 .../org/apache/doris/qe/ConnectContextTest.java    |  58 +++++++++
 .../java/org/apache/doris/qe/StmtExecutorTest.java |  36 ++++++
 .../arrowflight/DorisFlightSqlProducerTest.java    |  65 ++++++++++
 .../sessions/FlightSqlConnectPoolMgrTest.java      |  69 +++++++++++
 .../test_iceberg_arrow_flight_split_source.groovy  | 131 +++++++++++++++++++++
 13 files changed, 485 insertions(+), 11 deletions(-)

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 de2e7c220b1..f9114313cd8 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
@@ -941,6 +941,40 @@ public class ConnectContext {
         return plSqlOperation;
     }
 
+    // Arrow Flight SQL only.
+    // Executors of already-planned queries whose results are produced on the 
BE and pulled later
+    // during the DoGet phase. Their coordinators must stay alive until the BE 
finishes scanning:
+    // an external-table scan in batch mode lazily fetches splits from the FE 
(a batch SplitSource
+    // 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.
+    private final List<StmtExecutor> flightSqlDeferredExecutors = new 
ArrayList<>();
+
+    public void addFlightSqlDeferredExecutor(StmtExecutor executor) {
+        synchronized (flightSqlDeferredExecutors) {
+            flightSqlDeferredExecutors.add(executor);
+        }
+    }
+
+    public void closeFlightSqlDeferredExecutors() {
+        List<StmtExecutor> toClose;
+        synchronized (flightSqlDeferredExecutors) {
+            if (flightSqlDeferredExecutors.isEmpty()) {
+                return;
+            }
+            toClose = new ArrayList<>(flightSqlDeferredExecutors);
+            flightSqlDeferredExecutors.clear();
+        }
+        for (StmtExecutor deferredExecutor : toClose) {
+            try {
+                deferredExecutor.finalizeArrowFlightQuery();
+            } catch (Throwable t) {
+                LOG.warn("failed to finalize deferred arrow flight executor", 
t);
+            }
+        }
+    }
+
     /**
      * This method is idempotent.
      */
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 f97d02423a4..0037fc28aa5 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
@@ -187,6 +187,10 @@ public class StmtExecutor {
 
     @Setter
     private volatile Coordinator coord = null;
+    // Arrow Flight SQL: when true, this query's coordinator is kept alive 
past GetFlightInfo and
+    // is finalized later by ConnectContext (see #62259), so the eager close 
in executeAndSendResult
+    // is skipped.
+    private volatile boolean deferredForArrowFlight = false;
     private MasterOpExecutor masterOpExecutor = null;
     private RedirectStatus redirectStatus = null;
     private Planner planner;
@@ -944,6 +948,23 @@ public class StmtExecutor {
         QeProcessorImpl.INSTANCE.unregisterQuery(context.queryId());
     }
 
+    public boolean isDeferredForArrowFlight() {
+        return deferredForArrowFlight;
+    }
+
+    // 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.
+    public void finalizeArrowFlightQuery() {
+        try {
+            if (coord != null) {
+                coord.close();
+            }
+        } finally {
+            finalizeQuery();
+        }
+    }
+
     private void handleQueryWithRetry(TUniqueId queryId) throws Exception {
         // queue query here
         int retryTime = Config.max_query_retry_time;
@@ -1372,6 +1393,22 @@ public class StmtExecutor {
             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. Point queries use a different 
coordBase (not
+                // deferred). See #62259.
+                if (coordBase == coord) {
+                    deferredForArrowFlight = true;
+                    context.addFlightSqlDeferredExecutor(this);
+                }
                 return;
             }
 
@@ -1487,7 +1524,12 @@ public class StmtExecutor {
             this.coord = null;
             throw e;
         } finally {
-            coordBase.close();
+            // For deferred Arrow Flight queries the coordinator is closed 
later by ConnectContext
+            // (next query / connection teardown), so the BE can still fetch 
splits during DoGet.
+            // See #62259.
+            if (!deferredForArrowFlight) {
+                coordBase.close();
+            }
         }
     }
 
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/service/arrowflight/DorisFlightSqlProducer.java
 
b/fe/fe-core/src/main/java/org/apache/doris/service/arrowflight/DorisFlightSqlProducer.java
index e2d197cb537..e64690a54cb 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/service/arrowflight/DorisFlightSqlProducer.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/service/arrowflight/DorisFlightSqlProducer.java
@@ -189,6 +189,10 @@ public class DorisFlightSqlProducer implements 
FlightSqlProducer, AutoCloseable
         try {
             Preconditions.checkState(null != connectContext);
             Preconditions.checkState(!query.isEmpty());
+            // Finalize the previous query's coordinator on this connection 
whose close was
+            // deferred (Arrow Flight keeps it alive across GetFlightInfo -> 
DoGet so the BE can
+            // fetch external-table splits during DoGet). By now the previous 
DoGet is done. #62259
+            connectContext.closeFlightSqlDeferredExecutors();
             // After the previous query was executed, there was no 
getStreamStatement to take away the result.
             connectContext.getFlightSqlChannel().reset();
             connectContext.clearFlightSqlEndpointsLocations();
@@ -280,6 +284,14 @@ public class DorisFlightSqlProducer implements 
FlightSqlProducer, AutoCloseable
                 }
             }
         } catch (Throwable e) {
+            // GetFlightInfo failed (e.g. the BE Arrow schema fetch above 
timed out or returned an
+            // error) after this query's coordinator may already have been 
deferred during planning.
+            // No FlightInfo is returned, so no DoGet will ever pull this 
query's results; finalize
+            // the deferred coordinator now (releasing its external-table 
batch SplitSource, query
+            // queue slot and query registration) instead of leaking it until 
the next query starts
+            // or the connection is torn down. The previous query's deferred 
coordinator was already
+            // finalized at the top of this method, so this only closes this 
failed query. See #62259.
+            connectContext.closeFlightSqlDeferredExecutors();
             String errMsg = "get flight info statement failed, " + 
e.getMessage() + ", " + Util.getRootCauseMessage(e)
                     + ", error code: " + 
connectContext.getState().getErrorCode() + ", error msg: "
                     + connectContext.getState().getErrorMessage();
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 2e5dfb3259e..9c59e8db606 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
@@ -145,11 +145,11 @@ public class FlightSqlConnectProcessor extends 
ConnectProcessor implements AutoC
                 }
                 endpointLoc.setResultPublicAccessAddr(resultPublicAccessAddr);
                 if (pResult.hasSchema() && pResult.getSchema().size() > 0) {
-                    RootAllocator rootAllocator = new 
RootAllocator(Integer.MAX_VALUE);
-                    ArrowStreamReader arrowStreamReader = new 
ArrowStreamReader(
-                            new 
ByteArrayInputStream(pResult.getSchema().toByteArray()), rootAllocator);
-                    try {
+                    try (RootAllocator rootAllocator = new 
RootAllocator(Integer.MAX_VALUE);
+                            ArrowStreamReader arrowStreamReader = new 
ArrowStreamReader(
+                                    new 
ByteArrayInputStream(pResult.getSchema().toByteArray()), rootAllocator)) {
                         Schema schema;
+                        // SchemaRoot belongs to ArrowStreamReader, it will be 
released when ArrowStreamReader is closed
                         VectorSchemaRoot root = 
arrowStreamReader.getVectorSchemaRoot();
                         List<FieldVector> fieldVectors = 
root.getFieldVectors();
                         if (fieldVectors.size() != resultOutputExprs.size()) {
@@ -195,11 +195,20 @@ public class FlightSqlConnectProcessor extends 
ConnectProcessor implements AutoC
     @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.
         for (StmtExecutor asynExecutor : returnResultFromRemoteExecutor) {
-            asynExecutor.finalizeQuery();
+            if (!asynExecutor.isDeferredForArrowFlight()) {
+                asynExecutor.finalizeQuery();
+            }
         }
         returnResultFromRemoteExecutor.clear();
-        executor.finalizeQuery();
+        if (executor != null && !executor.isDeferredForArrowFlight()) {
+            executor.finalizeQuery();
+        }
         ctx.clear();
         ConnectContext.remove();
     }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/service/arrowflight/results/FlightSqlChannel.java
 
b/fe/fe-core/src/main/java/org/apache/doris/service/arrowflight/results/FlightSqlChannel.java
index 4f54132876b..2781994dfa1 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/service/arrowflight/results/FlightSqlChannel.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/service/arrowflight/results/FlightSqlChannel.java
@@ -168,6 +168,7 @@ public class FlightSqlChannel {
 
     public void close() {
         reset();
+        allocator.close();
     }
 
     private static class ResultRemovalListener implements 
RemovalListener<String, FlightSqlResultCacheEntry> {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/service/arrowflight/results/FlightSqlResultCacheEntry.java
 
b/fe/fe-core/src/main/java/org/apache/doris/service/arrowflight/results/FlightSqlResultCacheEntry.java
index 12ce04ca8ed..6edc868b0dd 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/service/arrowflight/results/FlightSqlResultCacheEntry.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/service/arrowflight/results/FlightSqlResultCacheEntry.java
@@ -42,7 +42,7 @@ public final class FlightSqlResultCacheEntry implements 
AutoCloseable {
 
     @Override
     public void close() throws Exception {
-        vectorSchemaRoot.clear();
+        vectorSchemaRoot.close();
     }
 
     @Override
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/service/arrowflight/sessions/FlightSqlConnectContext.java
 
b/fe/fe-core/src/main/java/org/apache/doris/service/arrowflight/sessions/FlightSqlConnectContext.java
index 75f1c0ee4bf..ceddfaa5639 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/service/arrowflight/sessions/FlightSqlConnectContext.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/service/arrowflight/sessions/FlightSqlConnectContext.java
@@ -62,9 +62,6 @@ public class FlightSqlConnectContext extends ConnectContext {
 
     @Override
     protected void closeChannel() {
-        if (flightSqlChannel != null) {
-            flightSqlChannel.close();
-        }
         
connectScheduler.getFlightSqlConnectPoolMgr().unregisterConnection(this);
     }
 
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/service/arrowflight/sessions/FlightSqlConnectPoolMgr.java
 
b/fe/fe-core/src/main/java/org/apache/doris/service/arrowflight/sessions/FlightSqlConnectPoolMgr.java
index 3002116b386..04982a7fedc 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/service/arrowflight/sessions/FlightSqlConnectPoolMgr.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/service/arrowflight/sessions/FlightSqlConnectPoolMgr.java
@@ -20,6 +20,7 @@ package org.apache.doris.service.arrowflight.sessions;
 import org.apache.doris.qe.ConnectContext;
 import org.apache.doris.qe.ConnectContext.ConnectType;
 import org.apache.doris.qe.ConnectPoolMgr;
+import org.apache.doris.service.arrowflight.results.FlightSqlChannel;
 
 import com.google.common.collect.Maps;
 import org.apache.logging.log4j.LogManager;
@@ -55,6 +56,25 @@ public class FlightSqlConnectPoolMgr extends ConnectPoolMgr {
 
     @Override
     public void unregisterConnection(ConnectContext ctx) {
+        // All Flight SQL session teardown paths (idle/query timeout, bearer 
token expiry, and
+        // explicit CloseSession) reach here. Release channel-cached Arrow 
results before removing
+        // the context from the pool.
+        FlightSqlChannel flightSqlChannel = ctx.getFlightSqlChannel();
+        if (flightSqlChannel != null) {
+            try {
+                flightSqlChannel.close();
+            } catch (Throwable t) {
+                // RootAllocator.close() marks the allocator closed before it 
reports outstanding
+                // bytes. The error is actionable, but session teardown must 
still release the
+                // coordinator, transaction and pool/token bookkeeping below.
+                LOG.warn("failed to close Flight SQL channel while 
unregistering connection {}, peer identity {}",
+                        ctx.getConnectionId(), ctx.getPeerIdentity(), t);
+            }
+        }
+        // Finalize any Arrow Flight query whose coordinator was kept alive 
across the
+        // GetFlightInfo -> DoGet phases (see #62259), releasing its resources 
(e.g. external-table
+        // batch SplitSources and the query queue slot).
+        ctx.closeFlightSqlDeferredExecutors();
         ctx.closeTxn();
         if (connectionMap.remove(ctx.getConnectionId()) != null) {
             numberConnection.decrementAndGet();
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/qe/ConnectContextTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/qe/ConnectContextTest.java
index f02e22ebfa1..b626f6acf8a 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/qe/ConnectContextTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/qe/ConnectContextTest.java
@@ -871,4 +871,62 @@ public class ConnectContextTest {
         Assert.assertNotNull(ctx.getConnectAttributes());
         Assert.assertTrue(ctx.getConnectAttributes().isEmpty());
     }
+
+    // Arrow Flight SQL keeps a query's coordinator alive across GetFlightInfo 
-> DoGet (see #62259).
+    // closeFlightSqlDeferredExecutors() is the single place that releases 
those deferred coordinators
+    // (and with them the external-table batch SplitSource and the query queue 
slot). The following
+    // tests pin the leak-prevention contract of that method: every deferred 
executor is finalized,
+    // the list is cleared so nothing is finalized twice or retained, and one 
failing executor does
+    // not strand the others' resources.
+
+    @Test
+    public void testCloseFlightSqlDeferredExecutorsFinalizesEachExecutor() {
+        ConnectContext ctx = new ConnectContext();
+        StmtExecutor deferred1 = Mockito.mock(StmtExecutor.class);
+        StmtExecutor deferred2 = Mockito.mock(StmtExecutor.class);
+        ctx.addFlightSqlDeferredExecutor(deferred1);
+        ctx.addFlightSqlDeferredExecutor(deferred2);
+
+        ctx.closeFlightSqlDeferredExecutors();
+
+        // Both deferred coordinators must be finalized, otherwise their 
SplitSource and query queue
+        // slot leak after the DoGet phase.
+        Mockito.verify(deferred1).finalizeArrowFlightQuery();
+        Mockito.verify(deferred2).finalizeArrowFlightQuery();
+    }
+
+    @Test
+    public void 
testCloseFlightSqlDeferredExecutorsClearsListSoSecondCallIsNoOp() {
+        ConnectContext ctx = new ConnectContext();
+        StmtExecutor deferred = Mockito.mock(StmtExecutor.class);
+        ctx.addFlightSqlDeferredExecutor(deferred);
+
+        // More than one teardown path can fire for the same connection (e.g. 
the next query cleans
+        // up, then the connection is later torn down). The list must be 
cleared after the first
+        // call so the executor is finalized exactly once and is not retained 
(leaked) afterwards.
+        ctx.closeFlightSqlDeferredExecutors();
+        ctx.closeFlightSqlDeferredExecutors();
+
+        Mockito.verify(deferred, Mockito.times(1)).finalizeArrowFlightQuery();
+    }
+
+    @Test
+    public void 
testCloseFlightSqlDeferredExecutorsFinalizesRemainingWhenOneFails() {
+        ConnectContext ctx = new ConnectContext();
+        StmtExecutor failing = Mockito.mock(StmtExecutor.class);
+        StmtExecutor healthy = Mockito.mock(StmtExecutor.class);
+        Mockito.doThrow(new RuntimeException("finalize 
failed")).when(failing).finalizeArrowFlightQuery();
+        ctx.addFlightSqlDeferredExecutor(failing);
+        ctx.addFlightSqlDeferredExecutor(healthy);
+
+        // A single bad coordinator must not abort the cleanup: the call must 
not throw, and the
+        // healthy executor must still be finalized so its resources are 
released.
+        ctx.closeFlightSqlDeferredExecutors();
+        Mockito.verify(healthy).finalizeArrowFlightQuery();
+
+        // The list is cleared up front, so neither executor is reprocessed on 
a later teardown.
+        ctx.closeFlightSqlDeferredExecutors();
+        Mockito.verify(failing, Mockito.times(1)).finalizeArrowFlightQuery();
+        Mockito.verify(healthy, Mockito.times(1)).finalizeArrowFlightQuery();
+    }
 }
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 8635e1c0082..296a8df7cb8 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
@@ -26,6 +26,8 @@ import org.apache.doris.mysql.MysqlChannel;
 import org.apache.doris.mysql.MysqlSerializer;
 import org.apache.doris.qe.CommonResultSet.CommonResultSetMetaData;
 import org.apache.doris.qe.ConnectContext.ConnectType;
+import org.apache.doris.thrift.TQueryOptions;
+import org.apache.doris.thrift.TUniqueId;
 import org.apache.doris.utframe.TestWithFeService;
 
 import com.google.common.collect.Lists;
@@ -66,6 +68,40 @@ public class StmtExecutorTest extends TestWithFeService {
         Assert.assertEquals(QueryState.MysqlStateType.OK, 
connectContext.getState().getStateType());
     }
 
+    // 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()
+    // throws, the query registration must still be released (the 
try/finally), otherwise the query
+    // leaks in QeProcessorImpl forever. The thrown error is expected to 
propagate to the caller
+    // (ConnectContext.closeFlightSqlDeferredExecutors), which catches and 
logs it.
+    @Test
+    public void 
testFinalizeArrowFlightQueryUnregistersQueryEvenIfCoordCloseThrows() throws 
Exception {
+        StmtExecutor stmtExecutor = new StmtExecutor(connectContext, "");
+        TUniqueId queryId = new TUniqueId(0x6226259L, 0x62259L);
+        connectContext.setQueryId(queryId);
+
+        Coordinator coord = Mockito.mock(Coordinator.class);
+        Mockito.when(coord.getQueryOptions()).thenReturn(new TQueryOptions());
+        Mockito.doThrow(new RuntimeException("coord close 
failed")).when(coord).close();
+        stmtExecutor.setCoord(coord);
+
+        // Simulate the in-flight query whose results DoGet is still pulling.
+        QeProcessorImpl.INSTANCE.registerQuery(queryId, new 
QeProcessorImpl.QueryInfo(coord));
+        Assert.assertNotNull(QeProcessorImpl.INSTANCE.getCoordinator(queryId));
+
+        try {
+            stmtExecutor.finalizeArrowFlightQuery();
+            Assert.fail("expected coord.close() failure to propagate after the 
query is unregistered");
+        } catch (RuntimeException e) {
+            Assert.assertEquals("coord close failed", e.getMessage());
+        }
+
+        // The coordinator close was attempted (releases SplitSource + query 
queue slot) ...
+        Mockito.verify(coord).close();
+        // ... and despite it failing, the query registration was still 
released (no leak).
+        Assert.assertNull(QeProcessorImpl.INSTANCE.getCoordinator(queryId));
+    }
+
     @Test
     public void testKill() throws Exception {
         StmtExecutor stmtExecutor = new StmtExecutor(connectContext, "");
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/service/arrowflight/DorisFlightSqlProducerTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/service/arrowflight/DorisFlightSqlProducerTest.java
index 3a4b0facace..eede1268851 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/service/arrowflight/DorisFlightSqlProducerTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/service/arrowflight/DorisFlightSqlProducerTest.java
@@ -19,19 +19,23 @@ package org.apache.doris.service.arrowflight;
 
 import org.apache.doris.common.FeConstants;
 import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.StmtExecutor;
 import org.apache.doris.service.arrowflight.results.FlightSqlChannel;
 import org.apache.doris.service.arrowflight.sessions.FlightSessionsManager;
 import org.apache.doris.service.arrowflight.sessions.FlightSqlConnectContext;
 
+import org.apache.arrow.flight.FlightDescriptor;
 import org.apache.arrow.flight.FlightProducer.CallContext;
 import org.apache.arrow.flight.FlightProducer.StreamListener;
 import org.apache.arrow.flight.Location;
 import org.apache.arrow.flight.Result;
 import 
org.apache.arrow.flight.sql.impl.FlightSql.ActionCreatePreparedStatementRequest;
+import org.apache.arrow.flight.sql.impl.FlightSql.CommandStatementQuery;
 import org.junit.After;
 import org.junit.Assert;
 import org.junit.Before;
 import org.junit.Test;
+import org.mockito.MockedConstruction;
 import org.mockito.Mockito;
 
 import java.util.concurrent.CountDownLatch;
@@ -142,4 +146,65 @@ public class DorisFlightSqlProducerTest {
             producer.close();
         }
     }
+
+    // Arrow Flight SQL keeps a query's coordinator alive across GetFlightInfo 
-> DoGet (see #62259):
+    // executeAndSendResult() registers it as a deferred executor on the 
ConnectContext right after
+    // submitting it to the BE. GetFlightInfo then still has to fetch the 
Arrow schema from the BE.
+    // If that fetch fails (timeout / non-OK / empty / mismatched schema / RPC 
error), no FlightInfo
+    // is returned, so no DoGet will ever pull this query's results. The 
deferred coordinator must be
+    // finalized on this error path; otherwise its external-table batch 
SplitSource, query queue slot
+    // and query registration leak until the next query starts or the 
connection is torn down.
+    @Test
+    public void 
testGetFlightInfoFinalizesDeferredExecutorWhenSchemaFetchFails() throws 
Exception {
+        // A flight ConnectContext whose getFlightSqlChannel() works (the base 
context throws).
+        ConnectContext ctx = Mockito.spy(new ConnectContext());
+        
Mockito.doReturn(Mockito.mock(FlightSqlChannel.class)).when(ctx).getFlightSqlChannel();
+
+        // Stands in for the just-planned external-table query whose results 
DoGet would pull from BE.
+        StmtExecutor deferred = Mockito.mock(StmtExecutor.class);
+
+        FlightSessionsManager sessionsManager = 
Mockito.mock(FlightSessionsManager.class);
+        
Mockito.when(sessionsManager.getConnectContext(Mockito.anyString())).thenReturn(ctx);
+
+        CallContext callContext = Mockito.mock(CallContext.class);
+        Mockito.when(callContext.peerIdentity()).thenReturn("token");
+
+        DorisFlightSqlProducer producer = new DorisFlightSqlProducer(
+                Location.forGrpcInsecure("127.0.0.1", 9090), sessionsManager);
+        try (MockedConstruction<FlightSqlConnectProcessor> mocked = 
Mockito.mockConstruction(
+                FlightSqlConnectProcessor.class, (mock, context) -> {
+                    // handleQuery plans + submits to BE and defers the 
coordinator (coordBase == coord),
+                    // exactly as executeAndSendResult() does for an Arrow 
Flight external-table scan.
+                    Mockito.doAnswer(invocation -> {
+                        ctx.setReturnResultFromLocal(false);
+                        ctx.addFlightSqlDeferredExecutor(deferred);
+                        return null;
+                    }).when(mock).handleQuery(Mockito.anyString());
+                    // The Arrow schema fetch fails after the coordinator was 
already deferred.
+                    Mockito.doThrow(new RuntimeException("fetch arrow flight 
schema timeout"))
+                            
.when(mock).fetchArrowFlightSchema(Mockito.anyInt());
+                })) {
+            CommandStatementQuery request = 
CommandStatementQuery.newBuilder().setQuery("select 1").build();
+            FlightDescriptor descriptor = FlightDescriptor.command(new 
byte[0]);
+
+            try {
+                producer.getFlightInfoStatement(request, callContext, 
descriptor);
+                Assert.fail("expected the schema fetch failure to propagate as 
a CallStatus");
+            } catch (Throwable expected) {
+                // GetFlightInfo is expected to fail; the point of the test is 
what happens to the
+                // deferred coordinator, not the thrown status itself.
+            }
+
+            // The deferred coordinator of the failed query is finalized on 
the error path instead of
+            // leaking until the next query / connection teardown.
+            Mockito.verify(deferred).finalizeArrowFlightQuery();
+
+            // It is also removed from the deferred list, so a later teardown 
does not finalize it
+            // again (no double-close, no retained reference).
+            ctx.closeFlightSqlDeferredExecutors();
+            Mockito.verify(deferred, 
Mockito.times(1)).finalizeArrowFlightQuery();
+        } finally {
+            producer.close();
+        }
+    }
 }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/service/arrowflight/sessions/FlightSqlConnectPoolMgrTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/service/arrowflight/sessions/FlightSqlConnectPoolMgrTest.java
new file mode 100644
index 00000000000..0513221569d
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/service/arrowflight/sessions/FlightSqlConnectPoolMgrTest.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.service.arrowflight.sessions;
+
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.service.arrowflight.results.FlightSqlChannel;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+public class FlightSqlConnectPoolMgrTest {
+
+    // Arrow Flight SQL keeps a query's coordinator alive across GetFlightInfo 
-> DoGet (see #62259).
+    // unregisterConnection() is the catch-all teardown path: idle/query 
timeout, bearer token expiry
+    // and explicit CloseSession all reach here. It must finalize the deferred 
coordinators so an
+    // abandoned connection cannot leak them (the external-table batch 
SplitSource and the query
+    // queue slot the coordinator holds).
+    @Test
+    public void testUnregisterConnectionFinalizesDeferredExecutors() {
+        FlightSqlConnectPoolMgr poolMgr = new FlightSqlConnectPoolMgr(100);
+        ConnectContext ctx = Mockito.mock(ConnectContext.class);
+        FlightSqlChannel channel = Mockito.mock(FlightSqlChannel.class);
+        Mockito.when(ctx.getFlightSqlChannel()).thenReturn(channel);
+
+        poolMgr.unregisterConnection(ctx);
+
+        // The deferred coordinators must be released on teardown even though 
this connection was
+        // never registered in the pool (an abandoned connection is still 
cleaned up, not leaked).
+        Mockito.verify(channel).close();
+        Mockito.verify(ctx).closeFlightSqlDeferredExecutors();
+    }
+
+    // Cleanup must run before the connection bookkeeping (closeTxn / map 
removal), so that a failure
+    // there cannot strand the deferred coordinators. Verify the deferred 
executors are finalized
+    // even when the context is the one stored in the pool.
+    @Test
+    public void testUnregisterRegisteredConnectionFinalizesDeferredExecutors() 
{
+        FlightSqlConnectPoolMgr poolMgr = new FlightSqlConnectPoolMgr(100);
+        ConnectContext ctx = Mockito.mock(ConnectContext.class);
+        FlightSqlChannel channel = Mockito.mock(FlightSqlChannel.class);
+        Mockito.when(ctx.getFlightSqlChannel()).thenReturn(channel);
+        Mockito.when(ctx.getConnectionId()).thenReturn(7);
+        
Mockito.when(ctx.getConnectType()).thenReturn(ConnectContext.ConnectType.ARROW_FLIGHT_SQL);
+        Mockito.when(ctx.getPeerIdentity()).thenReturn("token-7");
+        poolMgr.getConnectionMap().put(7, ctx);
+
+        poolMgr.unregisterConnection(ctx);
+
+        Mockito.verify(channel).close();
+        Mockito.verify(ctx).closeFlightSqlDeferredExecutors();
+        Assert.assertNull(poolMgr.getConnectionMap().get(7));
+    }
+}
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
new file mode 100644
index 00000000000..e721d0e9d8e
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_arrow_flight_split_source.groovy
@@ -0,0 +1,131 @@
+// 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.
+
+import java.sql.Connection
+import java.sql.DriverManager
+
+import org.apache.doris.regression.util.JdbcUtils
+
+// Regression for https://github.com/apache/doris/issues/62259
+//
+// Querying an Iceberg external table over Arrow Flight SQL in batch split 
mode used to fail
+// (BE crash / "Split source X is released"). Arrow Flight executes a query in 
two phases:
+// GetFlightInfo (plan + submit to BE) then DoGet (the client pulls results 
from the BE). In
+// batch split mode the BE keeps scanning during DoGet and lazily fetches file 
splits from the
+// FE via the fetchSplitBatch RPC, using an async SplitSource that the FE 
coordinator holds. The
+// FE used to release that SplitSource at the end of GetFlightInfo, before the 
BE's DoGet, so the
+// split fetch failed. The MySQL protocol is unaffected because plan + execute 
share one request.
+//
+// This test forces batch split mode on the Arrow Flight session and scans the 
table, which must
+// now return all rows.
+suite("test_iceberg_arrow_flight_split_source", "p0,external") {
+    String enabled = context.config.otherConfigs.get("enableIcebergTest")
+    if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+        logger.info("disable iceberg test.")
+        return
+    }
+
+    // The bug only manifests over the Arrow Flight SQL protocol. Skip when it 
is not configured.
+    String arrowFlightHost = 
context.config.otherConfigs.get("extArrowFlightSqlHost")
+    if (arrowFlightHost == null || arrowFlightHost.isEmpty()) {
+        logger.info("extArrowFlightSqlHost is not configured, skip the test.")
+        return
+    }
+
+    // The framework's arrow_flight_sql() helper always dials 
extArrowFlightSqlPort, but that
+    // configured value does not always match this cluster's real Arrow Flight 
SQL port (e.g. the
+    // external pipeline serves Arrow Flight on the default 8070 while 
extArrowFlightSqlPort is
+    // 8081). Read the live port from SHOW FRONTENDS and open our own 
connection against it, like
+    // the remote_doris tests do, so the test connects to this cluster's 
actual endpoint.
+    def frontends = sql """ show frontends """
+    String arrowFlightPort = frontends[0][6].toString()
+    if (!arrowFlightPort.isInteger() || (arrowFlightPort as int) <= 0) {
+        logger.info("Arrow Flight SQL is disabled on this cluster 
(port=${arrowFlightPort}), skip the test.")
+        return
+    }
+    String arrowFlightUser = 
context.config.otherConfigs.get("extArrowFlightSqlUser")
+    String arrowFlightPassword = 
context.config.otherConfigs.get("extArrowFlightSqlPassword")
+    Class.forName("org.apache.arrow.driver.jdbc.ArrowFlightJdbcDriver")
+    String arrowFlightUrl = 
"jdbc:arrow-flight-sql://${arrowFlightHost}:${arrowFlightPort}" +
+            "/?useServerPrepStmts=false&useSSL=false&useEncryption=false"
+
+    String rest_port = context.config.otherConfigs.get("iceberg_rest_uri_port")
+    String minio_port = context.config.otherConfigs.get("iceberg_minio_port")
+    String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+    String catalog_name = "test_iceberg_arrow_flight_split_source"
+    // sample_cow_orc has 1000 rows; with num_files_in_batch_mode=1 a plain 
scan uses batch mode.
+    String table = "${catalog_name}.format_v2.sample_cow_orc"
+
+    sql """drop catalog if exists ${catalog_name}"""
+    sql """CREATE CATALOG ${catalog_name} PROPERTIES (
+            'type'='iceberg',
+            'iceberg.catalog.type'='rest',
+            'uri' = 'http://${externalEnvIp}:${rest_port}',
+            "s3.access_key" = "admin",
+            "s3.secret_key" = "password",
+            "s3.endpoint" = "http://${externalEnvIp}:${minio_port}";,
+            "s3.region" = "us-east-1"
+        );"""
+
+    Connection flightConn = null
+    try {
+        // Baseline over the MySQL protocol (works regardless of the bug).
+        def expected = sql """ select count(*) from ${table}; """
+        long expectedRows = (expected[0][0] as long)
+        assert expectedRows > 0 : "precondition: ${table} should not be empty"
+
+        // A dedicated Arrow Flight SQL connection to this cluster's real 
port. Run a statement over
+        // it the same way arrow_flight_sql() does, via 
JdbcUtils.executeToList.
+        flightConn = DriverManager.getConnection(arrowFlightUrl, 
arrowFlightUser, arrowFlightPassword)
+        def flightSql = { String stmt ->
+            def (rows, meta) = JdbcUtils.executeToList(flightConn, stmt)
+            return rows
+        }
+
+        // Force batch split mode on the Arrow Flight session (a separate 
session from the MySQL
+        // connection above, so the variables must be set here). With 
num_files_in_batch_mode=1
+        // even a single-file scan builds the async SplitSource that triggers 
#62259.
+        flightSql """ set enable_external_table_batch_mode = true """
+        flightSql """ set num_files_in_batch_mode = 1 """
+
+        // Make sure the Arrow Flight session really uses the batch 
SplitSource path, so the test
+        // cannot silently pass on the non-batch path. "approximate" only 
appears in batch mode.
+        def explainRows = flightSql """ explain select * from ${table} """
+        boolean isBatch = explainRows.any { row ->
+            row.any { cell -> cell != null && 
cell.toString().contains("approximate") }
+        }
+        assert isBatch : "expected batch split mode (approximate) in the Arrow 
Flight plan, got: ${explainRows}"
+
+        // The regression: a real data scan over Arrow Flight SQL (not 
count(*), which is pushed
+        // down and bypasses batch mode). Before the fix this failed with 
"Split source X is
+        // released" or crashed the BE; now it must return all rows.
+        def flightResult = flightSql """ select * from ${table} """
+        assertEquals(expectedRows, (flightResult.size() as long))
+
+        // A second scan on the same connection also exercises cleanup of the 
previous query's
+        // 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()}"
+    } finally {
+        // 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) {
+            try { flightConn.close() } catch (Throwable ignore) {}
+        }
+        sql """drop catalog if exists ${catalog_name}"""
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to