Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions fe/fe-core/src/main/java/org/apache/doris/qe/ConnectContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -934,6 +934,40 @@ public void clear() {
statementContext = null;
}

// 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Make terminal teardown atomic with deferred registration

Token eviction or CloseSession can call unregisterConnection() on another thread while this query is still inside coordBase.exec(). If teardown drains the currently empty list and removes the context/token mapping first, the query then returns, adds itself here, and FlightSqlConnectProcessor.close() skips it because it is deferred. No later request or timeout can retrieve that removed context, so its Qe registration/instance accounting, queue token, coordinator, and batch SplitSources remain indefinitely. The monitor prevents list corruption but does not close this drain-then-add window. Please mark terminal teardown under the same synchronization and make a late add finalize/reject the executor, with a latch test for this ordering.

}
}

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.
*/
Expand Down
44 changes: 43 additions & 1 deletion fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,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;
Expand Down Expand Up @@ -1035,6 +1039,23 @@ public void finalizeQuery() {
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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Unregister the deferred executor's captured query ID

This calls finalizeQuery(), which removes context.queryId() rather than the ID owned by this executor. closeFlightSqlDeferredExecutors() clears q1 from the list and finalizes it outside the monitor; if token teardown races an already-admitted next request, q2 can see the empty list and handleQuery() can reset/set the shared ID before q1 reaches this line. Q1 then remains registered (or the null removal throws), while q2 may be unregistered and lose status/profile/cancel routing; instance accounting and Hive transaction deregistration use the same wrong key. Please capture the immutable query ID when this executor is registered/deferred and finalize that exact ID, with a blocked-q1-close/q2-ID latch test.

}
}

private void handleQueryWithRetry(TUniqueId queryId) throws Exception {
// queue query here
int retryTime = Config.max_query_retry_time;
Expand Down Expand Up @@ -1470,6 +1491,22 @@ 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. Point queries use a different coordBase (not
// deferred). See #62259.
if (coordBase == coord) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Do not leave every BE-result query attached to the Flight session

This gate also defers internal-table and non-batch queries even though only an external batch scan needs a live SplitSource. Skipping Coordinator.close() retains the workload-group token, and delaying unregisterQuery() retains per-user instance accounting (and Hive transaction deregistration). After the client fully drains DoGet, the FE receives no completion callback. The Arrow Java 19 driver used by the new regression sends CloseSession from close() only when an optional catalog is present (source); this URL has none, so without another statement the resources remain until the default 28,800-second idle timeout. With workload-group max concurrency 1, one completed q1 therefore leaves q2 on another session queued for up to eight hours. Please restrict deferral to plans that actually own batch SplitSources and provide completion-driven cleanup/decoupled ownership for those plans; add a two-session queue-release test.

deferredForArrowFlight = true;
context.addFlightSqlDeferredExecutor(this);
}
return;
}

Expand Down Expand Up @@ -1579,7 +1616,12 @@ public void executeAndSendResult(boolean isOutfileQuery, boolean isSendFields,
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();
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,10 @@ private FlightInfo executeQueryStatement(String peerIdentity, ConnectContext con
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new cleanup assumes that starting the next statement means the previous DoGet has finished, but Flight RPCs are independent and this method has no completion signal from the BE result stream. A client can get FlightInfo for q1, keep reading q1's BE endpoint, and issue GetFlightInfo for q2 on the same session; this call will close q1's deferred coordinator. Closing the coordinator stops the scan nodes and removes the batch split sources, while q1's BE scan can still call fetchSplitBatch, which then fails with Split source X is released. This reintroduces the issue for pipelined/interleaved clients. Please keep the deferred coordinator until the result stream is actually complete or the session is closed, or reject/serialize a new statement while a remote Arrow Flight result is still live.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked into this and don't think it's a regression this PR introduces, nor something that can be properly fixed within this PR's scope. Details:

Not a new failure mode / not a regression. Before this PR, every external-table batch-split query over Arrow Flight already failed with Split source X is released, because the coordinator was closed at the end of GetFlightInfo — sequential queries included (that is exactly #62259). After this PR the sequential case works; the interleaved case you describe fails as it did before. So nothing is "reintroduced" — interleaving never worked.

Interleaving two queries on one session is not a supported usage. A bearer token maps to a single shared ConnectContext (FlightSessionsWithTokenManager), and that context is single-query-by-construction: each GetFlightInfo resets the shared FlightSqlChannel, clears the single flightSqlEndpointsLocations list, and reuses one queryId. Two concurrent/interleaved queries on one session corrupt all of that, not just the deferred coordinator — the same way sharing one JDBC Connection across threads is unsafe. The supported pattern (drain the result, then issue the next query) makes "the previous DoGet is done by the time the next query starts" hold, since the BE has consumed the SplitSource by the time the client reaches end-of-stream.

The suggested fix needs a signal the FE does not have. For an external-table scan the result DoGet endpoint points directly at the BE; the FE is not in that data path and gets no completion signal when the BE-side stream finishes. "Keep the coordinator until the result stream completes" or "serialize/reject a new statement while a result is live" both require a new BE -> FE completion notification (or session-level serialization keyed on it), which is a separate architectural change out of scope here.

Leaks are already bounded. The "or the session is closed" fallback is implemented: FlightSqlConnectPoolMgr.unregisterConnection() -> closeFlightSqlDeferredExecutors() covers idle/query timeout, bearer-token expiry and explicit CloseSession. And the BE-side hardening in #64797 makes a stale fetchSplitBatch fail gracefully instead of crashing the BE.

If first-class concurrent Flight clients are ever needed, the right follow-up is a BE -> FE completion signal plus session serialization; I can track that separately.

connectContext.getFlightSqlChannel().reset();
connectContext.clearFlightSqlEndpointsLocations();
Expand Down Expand Up @@ -280,6 +284,14 @@ private FlightInfo executeQueryStatement(String peerIdentity, ConnectContext con
}
}
} 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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,11 +196,20 @@ 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.
for (StmtExecutor asynExecutor : returnResultFromRemoteExecutor) {
asynExecutor.finalizeQuery();
if (!asynExecutor.isDeferredForArrowFlight()) {
asynExecutor.finalizeQuery();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard also skips cleanup when GetFlightInfo fails after a remote executor has already been marked deferred. In that flow, StmtExecutor.executeAndSendResult() adds the executor to the deferred list after coordBase.exec(), then executeQueryStatement() still has to fetch the Arrow schema. If that schema fetch times out, returns non-OK/empty/mismatched schema, or hits an RPC error, try-with-resources calls this close(), but both deferred executors are skipped and the outer catch only rethrows. No FlightInfo is returned, so no DoGet can need the coordinator, but the query registration, query queue slot, and split sources stay alive until a later query or session/token teardown. Please explicitly close the deferred executors on this error path, and add a test that fails schema fetch after deferral.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — this is a real (bounded) leak. If the Arrow schema fetch in executeQueryStatement fails after the coordinator was already deferred during planning, FlightSqlConnectProcessor.close() skips the deferred executor and the outer catch only rethrows, so the coordinator (its external-table batch SplitSource, the query queue slot and the query registration) stays alive until the next query starts or the connection is torn down — even though no DoGet will ever pull this query's results.

Fixed in 41a79a6 by finalizing the deferred coordinator on the GetFlightInfo error path: executeQueryStatement's catch now calls connectContext.closeFlightSqlDeferredExecutors(). At that point the list holds only this failed query's coordinator (the previous one was already finalized at the top of the method), and it covers every post-deferral failure, not just the schema fetch.

Added DorisFlightSqlProducerTest.testGetFlightInfoFinalizesDeferredExecutorWhenSchemaFetchFails, which defers a coordinator then fails the schema fetch and asserts the deferred executor is finalized (verified to fail without the fix).

}
returnResultFromRemoteExecutor.clear();
executor.finalizeQuery();
if (executor != null && !executor.isDeferredForArrowFlight()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Keep deferred queries reachable by cancellation

This branch keeps q1 active for the later DoGet, but ctx.clear() immediately nulls ConnectContext.executor. During DoGet, query-ID lookup still finds this context; however, KILL QUERY calls FlightSqlConnectContext.kill(false) -> cancelQuery(), which now sees no executor and does nothing, after which KILL reports success. REST and workload-policy cancellation use the same no-op path, and none falls back to the retained Qe coordinator/private deferred list, so the BE fragments continue. Please keep deferred executors addressable by their captured query ID (also aligning with stable ownership in the other finding) and route cancellation to the matching executor/coordinator; add a test that closes the processor, cancels q1 during DoGet, and verifies coordinator cancellation plus later single finalization.

executor.finalizeQuery();
}
ctx.clear();
ConnectContext.remove();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ public int registerConnection(ConnectContext ctx) {

@Override
public void unregisterConnection(ConnectContext ctx) {
// 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) when the connection is torn down: idle/query
// timeout, bearer token expiry, or explicit CloseSession all reach here.
ctx.closeFlightSqlDeferredExecutors();
ctx.closeTxn();
if (connectionMap.remove(ctx.getConnectionId()) != null) {
numberConnection.decrementAndGet();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -753,4 +753,62 @@ public void testConnectAttributesSetNull() {
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();
}
}
36 changes: 36 additions & 0 deletions fe/fe-core/src/test/java/org/apache/doris/qe/StmtExecutorTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
import org.apache.doris.planner.ResultFileSink;
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;
Expand Down Expand Up @@ -71,6 +73,40 @@ public void testShowNull() throws Exception {
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, "");
Expand Down
Loading
Loading