This is an automated email from the ASF dual-hosted git repository.
morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 46e1c671220 [refactor](arrow-flight) One connection pool for MySQL
connections and Arrow Flight SQL sessions (#68101)
46e1c671220 is described below
commit 46e1c67122085ad624c715a96a38899fe1d8e9c6
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Sun Sep 20 15:46:53 2026 +0800
[refactor](arrow-flight) One connection pool for MySQL connections and
Arrow Flight SQL sessions (#68101)
### What problem does this PR solve?
Issue Number: #67577
Related PR: #67966 (the Flight session teardown contract this keeps,
D-13)
Problem Summary:
Stage 3, item 1 of #67577 (connection governance): one connection pool
for both protocols.
Arrow Flight SQL sessions lived in a pool of their own,
`FlightSqlConnectPoolMgr`, held to `arrow_flight_max_connections` alone:
not to `qe_max_connection`, not to the user's `max_user_connections`,
refused with a sentence of their own ("the number of arrow flight bearer
tokens should be equal to arrow flight sql max connections"), and summed
into every scheduler-wide answer — the processlist, `KILL`, the timeout
checker, the connection metrics — by `ConnectScheduler` walking two
pools.
There is one pool now:
- `ConnectPoolMgr` registers a connection of either protocol against
`qe_max_connection` and the user's `max_user_connections`, and a Flight
session additionally against its sub-quota. It indexes Flight sessions
by their peer identity (the bearer token), since that is how Flight
requests name their session. `FlightSqlConnectPoolMgr` is deleted;
`ConnectScheduler` delegates to the one pool.
- Admission is one atomic transition:
`ConnectPoolMgr.registerConnection` checks and reserves the pool, Flight
sub-quota and per-user quotas under a single lock (`admissionLock`), so
a doomed attempt cannot hold one counter's reservation across another
attempt's checks. Session creation is serialized too
(`sessionCreationLock` in `FlightSessionsWithTokenManager`): exactly one
`ConnectContext` is published per bearer token, and after registering it
the token is re-validated under the same lock and the session
unregistered if the token was invalidated meanwhile, so no session
lingers in the pool without its token.
- The bearer token cache size floors only the effective sub-quota at 1
(a legal sub-quota of 0 at `qe_max_connection = 1` still needs one token
so the first request reaches the pool and is refused with
`RESOURCE_EXHAUSTED`); an illegal `arrow_flight_token_cache_size` (<= 0)
is left as-is, keeping the existing startup failure.
- `unregisterConnection` is where every teardown path of a connection
meets — a MySQL channel closing, a Flight token expiring or being
evicted, `CloseSession`, `KILL`, the timeout checker — so it is where
the protocol releases what it still holds for the session, through the
new `ProtocolAdapter.releaseSession`: for Flight the channel-cached
results and the deferred query coordinators
(`FlightProtocolAdapter.tearDown`, unchanged from #67966), nothing for
MySQL. The transitional `ProtocolAdapter.connectPool` goes.
- A Flight session refused for a limit is refused in the words a MySQL
client is refused in (`Reach limit of connections. Total: %d, User: %d,
Current: %d`, naming the Flight sub-quota only when it is the tighter
one), as the `RESOURCE_EXHAUSTED` status of the request that would have
opened it, and its bearer token is invalidated with the refusal.
- `arrow_flight_max_connections` becomes the sub-quota of Flight
sessions within the pool: `-1`, the new default, is half of
`qe_max_connection`; an explicit value never exceeds it.
`connection_max` reports the pool's limit,
`arrow_flight_connection_total` / `arrow_flight_connection_max` the
Flight share of it. The token cache is sized from the effective
sub-quota and is otherwise untouched: the token's lifecycle is the next
PR's.
---
.../main/java/org/apache/doris/common/Config.java | 34 ++-
.../doris/arrowflight/DorisFlightSqlService.java | 42 ++-
.../protocol/FlightProtocolAdapter.java | 27 +-
.../sessions/FlightSessionsWithTokenManager.java | 63 ++++-
.../sessions/FlightSqlConnectPoolMgr.java | 99 -------
.../arrowflight/tokens/FlightTokenManagerImpl.java | 29 +-
.../java/org/apache/doris/metric/MetricRepo.java | 8 +-
.../org/apache/doris/mysql/AcceptListener.java | 9 +-
.../doris/mysql/protocol/MysqlProtocolAdapter.java | 6 +-
.../java/org/apache/doris/qe/ConnectContext.java | 17 +-
.../java/org/apache/doris/qe/ConnectPoolMgr.java | 133 ++++++++-
.../java/org/apache/doris/qe/ConnectScheduler.java | 81 ++----
.../apache/doris/qe/protocol/ProtocolAdapter.java | 11 +-
.../arrowflight/DorisFlightSqlProducerTest.java | 7 +-
.../arrowflight/DorisFlightSqlServiceTest.java | 42 +++
.../protocol/FlightProtocolAdapterTest.java | 52 +++-
.../sessions/FlightSqlConnectPoolMgrTest.java | 70 -----
.../java/org/apache/doris/metric/MetricsTest.java | 37 ++-
.../apache/doris/mysql/ConnectionExceedTest.java | 128 +++++++--
.../mysql/protocol/MysqlProtocolAdapterTest.java | 4 +-
.../org/apache/doris/qe/ConnectPoolMgrTest.java | 314 +++++++++++++++++++++
.../apache/doris/qe/ConnectPoolTestSupport.java | 65 +++++
.../test_connection_quota.groovy | 246 ++++++++++++++++
23 files changed, 1171 insertions(+), 353 deletions(-)
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 7d020f42c35..c13a29dc507 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
@@ -758,7 +758,9 @@ public class Config extends ConfigBase {
+ "Set long enough to fit your tablet size.")
public static long check_consistency_default_timeout_second = 600; // 10
min
- @ConfField(description = "Maximum number of MySQL server connections per
FE.")
+ @ConfField(description = "Maximum number of connections per FE. MySQL
connections and Arrow Flight SQL "
+ + "sessions share this one pool (see arrow_flight_max_connections
for the share Flight sessions "
+ + "may take of it: half by default).")
public static int qe_max_connection = 1024;
@ConfField(mutable = true, description = "Colocate join PlanFragment
instance memory limit penalty factor. The "
@@ -2650,8 +2652,23 @@ public class Config extends ConfigBase {
+ "automatically. Set to 0 or negative value to disable " + "this
limit for user-specified buckets.")
public static int max_bucket_num_per_partition = 768;
- @ConfField(description = "Maximum number of connections for the Arrow
Flight Server per FE.")
- public static int arrow_flight_max_connections = 4096;
+ @ConfField(description = "Arrow Flight SQL sessions share the one
connection pool with MySQL connections:"
+ + " both count against qe_max_connection and the user's
max_user_connections. This is the sub-quota of"
+ + " Arrow Flight SQL sessions within that pool: -1 (the default)
is half of qe_max_connection (512 with"
+ + " the default pool of 1024), and an explicit value never exceeds
qe_max_connection (a larger one is"
+ + " capped, with a warning at startup). Mind how a Flight session
ends: with CloseSession, a KILL"
+ + " CONNECTION from another connection, wait_timeout, or the
expiry or eviction of its bearer token"
+ + " (arrow_flight_token_alive_time_second; the token cache, see
below; and per user at most"
+ + " max_user_connections / 2 tokens). Most Flight clients never
send CloseSession, so a session whose"
+ + " client has gone stays in the pool until wait_timeout (8 hours
by default) or its token's expiry"
+ + " (24 hours by default), whichever comes first; the default
leaves the other half of the pool to"
+ + " MySQL connections however many such sessions there are. Raise
it with qe_max_connection, or set"
+ + " it to qe_max_connection on an FE that serves Arrow Flight SQL
only. The bearer token cache is"
+ + " sized to this sub-quota (capped by
arrow_flight_token_cache_size), so the Flight limit shows as"
+ + " the eviction of the oldest token and its session rather than
as a refusal. -1 is accepted from"
+ + " this version on: an older FE that serves Arrow Flight SQL
exits at startup with -1 in fe.conf;"
+ + " remove the setting or set a positive value before a
downgrade.")
+ public static int arrow_flight_max_connections = -1;
@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 "
@@ -2679,10 +2696,13 @@ public class Config extends ConfigBase {
+ "an abnormal case and triggers an alert.")
public static double autobucket_out_of_bounds_percent_threshold = 0.5;
- @ConfField(description = "(Deprecated, replaced by
arrow_flight_max_connection) The cache limit of all user "
- + "tokens in Arrow Flight Server, which will be eliminated by LRU
rules after exceeding "
- + "the limit. Arrow Flight SQL is a stateless protocol; the
connection is usually not "
- + "actively disconnected. A bearer token evicted from the cache
will unregister its " + "ConnectContext.")
+ @ConfField(description = "The cap of the bearer token cache of the Arrow
Flight SQL server. The cache holds"
+ + " as many tokens as the Arrow Flight SQL sub-quota of the
connection pool allows"
+ + " (arrow_flight_max_connections, half of qe_max_connection by
default) but never more than"
+ + " this; beyond that the oldest token is evicted by LRU, and the
session it names is closed with it."
+ + " Arrow Flight SQL clients rarely close their session, so the
effective cache size - the sub-quota"
+ + " unless this is smaller - is what bounds their sessions in
practice, and per user"
+ + " max_user_connections / 2 tokens. The effective cache size is
logged when the server starts.")
public static int arrow_flight_token_cache_size = 4096;
@ConfField(description = "The alive time of the user token in Arrow Flight
Server (expire after write), in "
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/arrowflight/DorisFlightSqlService.java
b/fe/fe-core/src/main/java/org/apache/doris/arrowflight/DorisFlightSqlService.java
index 4bc5f1e48cd..d1548bd7db0 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/arrowflight/DorisFlightSqlService.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/arrowflight/DorisFlightSqlService.java
@@ -24,8 +24,10 @@ import
org.apache.doris.arrowflight.sessions.FlightSessionsWithTokenManager;
import org.apache.doris.arrowflight.tokens.FlightTokenManager;
import org.apache.doris.arrowflight.tokens.FlightTokenManagerImpl;
import org.apache.doris.common.Config;
+import org.apache.doris.qe.ConnectPoolMgr;
import org.apache.doris.service.FrontendOptions;
+import com.google.common.annotations.VisibleForTesting;
import io.grpc.ServerBuilder;
import org.apache.arrow.flight.FlightServer;
import org.apache.arrow.flight.Location;
@@ -48,12 +50,40 @@ public class DorisFlightSqlService {
private final FlightSessionsManager flightSessionsManager;
private volatile boolean running;
+ /**
+ * The bearer token cache size: the effective Flight sub-quota (a session
opens on a token, so the
+ * sub-quota is what bounds live sessions), capped by {@code
arrow_flight_token_cache_size}. The
+ * sub-quota is floored at 1 -- a legal sub-quota of 0 ({@code
qe_max_connection = 1}) still needs
+ * one token so the first request reaches the pool and is refused with
RESOURCE_EXHAUSTED, instead
+ * of a {@code maximumSize(0)} cache evicting the freshly issued token and
answering UNAUTHENTICATED.
+ * The floor is NOT applied to {@code arrow_flight_token_cache_size}: an
illegal value there
+ * ({@literal <= 0}) stays the loud failure it is on the base (Guava
rejects a negative maximumSize;
+ * 0 evicts every token) rather than the FE silently running on a
one-token cache.
+ */
+ @VisibleForTesting
+ static int effectiveTokenCacheSize(int flightMaxConnections, int
tokenCacheConfig) {
+ return Math.min(Math.max(1, flightMaxConnections), tokenCacheConfig);
+ }
+
public DorisFlightSqlService(int port) {
BufferAllocator allocator = new RootAllocator();
// arrow flight sql is a stateless protocol, connection is usually not
actively disconnected.
// bearer token is evict from the cache will unregister ConnectContext.
- this.flightTokenManager = new FlightTokenManagerImpl(
- Math.min(Config.arrow_flight_max_connections,
Config.arrow_flight_token_cache_size),
+ int flightMaxConnections =
ConnectPoolMgr.effectiveFlightMaxConnections(
+ Config.qe_max_connection, Config.arrow_flight_max_connections);
+ if (Config.arrow_flight_max_connections > Config.qe_max_connection) {
+ // A fe.conf from before the pools were merged may still carry the
old default, 4096: capped
+ // to the whole pool, that is exactly the sharing the default of
half is there to prevent.
+ LOG.warn("arrow_flight_max_connections={} exceeds
qe_max_connection={}: Arrow Flight SQL sessions are"
+ + " connections of the one pool, so the sub-quota
is capped at {}, the whole pool."
+ + " Flight sessions, which their clients mostly
never close, can then hold every"
+ + " connection until wait_timeout and refuse MySQL
logins. On an FE that serves both"
+ + " protocols, remove the setting (the default is
half of qe_max_connection; 4096 was"
+ + " the default before the pools were merged) or
set it below qe_max_connection",
+ Config.arrow_flight_max_connections,
Config.qe_max_connection, flightMaxConnections);
+ }
+ int tokenCacheSize = effectiveTokenCacheSize(flightMaxConnections,
Config.arrow_flight_token_cache_size);
+ this.flightTokenManager = new FlightTokenManagerImpl(tokenCacheSize,
Config.arrow_flight_token_alive_time_second);
this.flightSessionsManager = new
FlightSessionsWithTokenManager(flightTokenManager);
@@ -63,9 +93,11 @@ public class DorisFlightSqlService {
.transportHint(GRPC_BUILDER_CONSUMER,
(Consumer<ServerBuilder<?>>) builder ->
builder.addStreamTracerFactory(new
FlightRemoteIpServerStreamTracer.Factory()))
.headerAuthenticator(new
FlightBearerTokenAuthenticator(flightTokenManager)).build();
- LOG.info("Arrow Flight SQL service is created, port: {},
arrow_flight_max_connections: {},"
- + "arrow_flight_token_alive_time_second: {}", port,
Config.arrow_flight_max_connections,
- Config.arrow_flight_token_alive_time_second);
+ LOG.info("Arrow Flight SQL service is created, port: {},
arrow_flight_max_connections: {} (effective: {},"
+ + " within qe_max_connection: {}), token cache size:
{} (arrow_flight_token_cache_size: {}),"
+ + " arrow_flight_token_alive_time_second: {}", port,
+ Config.arrow_flight_max_connections, flightMaxConnections,
Config.qe_max_connection,
+ tokenCacheSize, Config.arrow_flight_token_cache_size,
Config.arrow_flight_token_alive_time_second);
}
// start Arrow Flight SQL service, return true if success, otherwise false
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/arrowflight/protocol/FlightProtocolAdapter.java
b/fe/fe-core/src/main/java/org/apache/doris/arrowflight/protocol/FlightProtocolAdapter.java
index 7f8a376dba5..340e8058c43 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/arrowflight/protocol/FlightProtocolAdapter.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/arrowflight/protocol/FlightProtocolAdapter.java
@@ -24,10 +24,9 @@ import org.apache.doris.common.Config;
import org.apache.doris.common.ErrorCode;
import org.apache.doris.common.Status;
import org.apache.doris.common.util.DebugUtil;
+import org.apache.doris.common.util.TokenMasker;
import org.apache.doris.qe.ConnectContext;
import org.apache.doris.qe.ConnectContext.ConnectType;
-import org.apache.doris.qe.ConnectPoolMgr;
-import org.apache.doris.qe.ConnectScheduler;
import org.apache.doris.qe.QueryState;
import org.apache.doris.qe.ShowResultSet;
import org.apache.doris.qe.StmtExecutor;
@@ -266,9 +265,27 @@ public class FlightProtocolAdapter implements
ProtocolAdapter {
public void fillForwardRequest(ConnectContext ctx, TMasterOpRequest
request) {
}
+ /**
+ * Every Flight SQL session teardown path - the idle timeout
(wait_timeout), bearer token expiry
+ * or eviction, CloseSession, a KILL CONNECTION from another connection -
reaches here through
+ * the pool's unregisterConnection. The
+ * channel-cached Arrow results go first, then the session is closed for
good: teardown does not
+ * wait for a command that may still be running, and what that command
defers afterwards is
+ * finalized on the spot ({@link #tearDown}).
+ */
@Override
- public ConnectPoolMgr connectPool(ConnectScheduler scheduler) {
- return scheduler.getFlightSqlConnectPoolMgr();
+ public void releaseSession(ConnectContext ctx) {
+ try {
+ channel.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. The peer
identity IS the bearer
+ // token, so it is logged as a masked id, the same one
FlightTokenManagerImpl uses.
+ LOG.warn("failed to close Flight SQL channel while unregistering
connection {}, peer identity {}",
+ ctx.getConnectionId(), TokenMasker.tokenId(peerIdentity),
t);
+ }
+ tearDown();
}
/**
@@ -345,7 +362,7 @@ public class FlightProtocolAdapter implements
ProtocolAdapter {
@Override
public void closeConnection(ConnectContext ctx) {
// Releases the channel, the deferred executors and the transaction of
the session.
- connectPool(ctx.getConnectScheduler()).unregisterConnection(ctx);
+
ctx.getConnectScheduler().getConnectPoolMgr().unregisterConnection(ctx);
}
public String getPeerIdentity() {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/arrowflight/sessions/FlightSessionsWithTokenManager.java
b/fe/fe-core/src/main/java/org/apache/doris/arrowflight/sessions/FlightSessionsWithTokenManager.java
index 0327c24b61f..14a20989e56 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/arrowflight/sessions/FlightSessionsWithTokenManager.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/arrowflight/sessions/FlightSessionsWithTokenManager.java
@@ -23,10 +23,12 @@ import org.apache.doris.common.ErrorCode;
import org.apache.doris.common.util.TokenMasker;
import org.apache.doris.common.util.Util;
import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.ConnectPoolMgr;
import org.apache.doris.qe.ConnectScheduler;
import org.apache.doris.service.ExecuteEnv;
import org.apache.arrow.flight.CallStatus;
+import org.apache.arrow.flight.FlightRuntimeException;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -34,6 +36,8 @@ public class FlightSessionsWithTokenManager implements
FlightSessionsManager {
private static final Logger LOG =
LogManager.getLogger(FlightSessionsWithTokenManager.class);
private final FlightTokenManager flightTokenManager;
+ // Serializes session creation so exactly one ConnectContext is published
per bearer token.
+ private final Object sessionCreationLock = new Object();
public FlightSessionsWithTokenManager(FlightTokenManager
flightTokenManager) {
this.flightTokenManager = flightTokenManager;
@@ -42,13 +46,27 @@ public class FlightSessionsWithTokenManager implements
FlightSessionsManager {
@Override
public ConnectContext getConnectContext(String peerIdentity) {
try {
- ConnectContext connectContext =
ExecuteEnv.getInstance().getScheduler().getFlightSqlConnectPoolMgr()
- .getContextWithFlightToken(peerIdentity);
- if (null == connectContext) {
- connectContext = createConnectContext(peerIdentity);
+ ConnectContext connectContext =
ExecuteEnv.getInstance().getScheduler()
+ .getContextWithPeerIdentity(peerIdentity);
+ if (null != connectContext) {
return connectContext;
}
- return connectContext;
+ // Publish exactly one context per bearer token. Two concurrent
first requests would both
+ // find none above and, without this, both build and register one:
two pool slots and quota
+ // counters for one token, with the peer-identity index naming
only the last, so evicting
+ // that one leaves the other orphaned in the pool until
wait_timeout. Serialize creation and
+ // re-check the index inside, so the loser returns the winner's
published context.
+ synchronized (sessionCreationLock) {
+ connectContext = ExecuteEnv.getInstance().getScheduler()
+ .getContextWithPeerIdentity(peerIdentity);
+ if (null != connectContext) {
+ return connectContext;
+ }
+ return createConnectContext(peerIdentity);
+ }
+ } catch (FlightRuntimeException e) {
+ // Already the status the client is meant to see (a connection
refused for its limit).
+ throw e;
} catch (Exception e) {
LOG.warn("get ConnectContext failed, " + e.getMessage(), e);
throw
CallStatus.INTERNAL.withDescription(Util.getRootCauseMessage(e)).withCause(e).toRuntimeException();
@@ -71,15 +89,34 @@ public class FlightSessionsWithTokenManager implements
FlightSessionsManager {
flightTokenDetails.getUserIdentity(),
flightTokenDetails.getRemoteIp());
ConnectScheduler connectScheduler =
ExecuteEnv.getInstance().getScheduler();
connectScheduler.submit(connectContext);
- int res =
connectScheduler.getFlightSqlConnectPoolMgr().registerConnection(connectContext);
+ // The one pool every protocol registers in: qe_max_connection, the
user's
+ // max_user_connections and the Arrow Flight SQL sub-quota, refused in
the words a MySQL
+ // client is refused in. The token goes with the refusal, so that the
client does not keep a
+ // credential that can never open a session.
+ ConnectPoolMgr pool = connectScheduler.getConnectPoolMgr();
+ int res = pool.registerConnection(connectContext);
if (res >= 0) {
- String errMsg = String.format(
- "Register arrow flight sql connection failed, Unknown
Error, the number of arrow flight "
- + "bearer tokens should be equal to arrow flight
sql max connections, "
- + "max connections: %d, used: %d.",
-
connectScheduler.getFlightSqlConnectPoolMgr().getMaxConnections(), res);
- connectContext.getState().setError(ErrorCode.ERR_UNKNOWN_ERROR,
errMsg);
- throw new IllegalArgumentException(errMsg);
+ String errMsg = pool.limitReachedMessage(connectContext, res);
+
connectContext.getState().setError(ErrorCode.ERR_TOO_MANY_USER_CONNECTIONS,
errMsg);
+ // The refused session never entered the pool, so nothing else
releases what its
+ // adapter allocated (the channel's allocator).
+ connectContext.releaseProtocolSession();
+ flightTokenManager.invalidateToken(peerIdentity);
+ LOG.warn("refuse arrow flight sql session, bearer token id: {},
user: {}: {}",
+ TokenMasker.tokenId(peerIdentity),
connectContext.getQualifiedUser(), errMsg);
+ throw
CallStatus.RESOURCE_EXHAUSTED.withDescription(errMsg).toRuntimeException();
+ }
+ // Called under sessionCreationLock (see getConnectContext). The token
can be invalidated between
+ // the validateToken above and publishing the session here -- a
concurrent CloseSession, or the
+ // same-segment LRU evicting it -- which would leave a session in the
pool whose token no longer
+ // exists, holding its pool/user/Flight slots until wait_timeout.
Re-check the token and, if it is
+ // gone, unregister the session so no orphan lingers. (The token
lifecycle moves under one owner
+ // in the next PR, which closes the residual window after this
re-check.)
+ try {
+ flightTokenManager.validateToken(peerIdentity);
+ } catch (IllegalArgumentException e) {
+
connectScheduler.getConnectPoolMgr().unregisterConnection(connectContext);
+ throw e;
}
return connectContext;
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/arrowflight/sessions/FlightSqlConnectPoolMgr.java
b/fe/fe-core/src/main/java/org/apache/doris/arrowflight/sessions/FlightSqlConnectPoolMgr.java
deleted file mode 100644
index a65a73b23cd..00000000000
---
a/fe/fe-core/src/main/java/org/apache/doris/arrowflight/sessions/FlightSqlConnectPoolMgr.java
+++ /dev/null
@@ -1,99 +0,0 @@
-// Licensed to the Apache Software Foundation (ASF) under one
-// or more contributor license agreements. See the NOTICE file
-// distributed with this work for additional information
-// regarding copyright ownership. The ASF licenses this file
-// to you under the Apache License, Version 2.0 (the
-// "License"); you may not use this file except in compliance
-// with the License. You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing,
-// software distributed under the License is distributed on an
-// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-// KIND, either express or implied. See the License for the
-// specific language governing permissions and limitations
-// under the License.
-
-package org.apache.doris.arrowflight.sessions;
-
-import org.apache.doris.arrowflight.results.FlightSqlChannel;
-import org.apache.doris.common.util.TokenMasker;
-import org.apache.doris.qe.ConnectContext;
-import org.apache.doris.qe.ConnectContext.ConnectType;
-import org.apache.doris.qe.ConnectPoolMgr;
-
-import com.google.common.collect.Maps;
-import org.apache.logging.log4j.LogManager;
-import org.apache.logging.log4j.Logger;
-
-import java.util.Map;
-
-public class FlightSqlConnectPoolMgr extends ConnectPoolMgr {
- private static final Logger LOG = LogManager.getLogger(
- FlightSqlConnectPoolMgr.class);
- private final Map<String, Integer> flightToken2ConnectionId =
Maps.newConcurrentMap();
-
- public FlightSqlConnectPoolMgr(int maxConnections) {
- super(maxConnections);
- }
-
- // Register one connection with its connection id.
- // Return -1 means register OK
- // Return >=0 means register failed, and return value is current
connection num.
- @Override
- public int registerConnection(ConnectContext ctx) {
- if (numberConnection.incrementAndGet() > maxConnections) {
- numberConnection.decrementAndGet();
- return numberConnection.get();
- }
- // not check user
- connectionMap.put(ctx.getConnectionId(), ctx);
- if (ctx.getConnectType().equals(ConnectType.ARROW_FLIGHT_SQL)) {
- flightToken2ConnectionId.put(ctx.getPeerIdentity(),
ctx.getConnectionId());
- }
- return -1;
- }
-
- @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.
- // For an Arrow Flight SQL connection the peer identity IS the
bearer token, so it is
- // logged as a masked id, the same one FlightTokenManagerImpl
uses.
- LOG.warn("failed to close Flight SQL channel while
unregistering connection {}, peer identity {}",
- ctx.getConnectionId(),
TokenMasker.tokenId(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), and close the session
for good: teardown
- // does not wait for a command that may still be running, and what
that command defers
- // afterwards is finalized on the spot
(FlightProtocolAdapter.tearDown).
- ctx.tearDownFlightSqlSession();
- ctx.closeTxn();
- if (connectionMap.remove(ctx.getConnectionId()) != null) {
- numberConnection.decrementAndGet();
- if (ctx.getConnectType().equals(ConnectType.ARROW_FLIGHT_SQL)) {
- flightToken2ConnectionId.remove(ctx.getPeerIdentity());
- }
- }
- }
-
- public ConnectContext getContextWithFlightToken(String flightToken) {
- if (flightToken2ConnectionId.containsKey(flightToken)) {
- int connectionId = flightToken2ConnectionId.get(flightToken);
- return getContext(connectionId);
- }
- return null;
- }
-}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/arrowflight/tokens/FlightTokenManagerImpl.java
b/fe/fe-core/src/main/java/org/apache/doris/arrowflight/tokens/FlightTokenManagerImpl.java
index ca2487b8379..7d24692f52f 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/arrowflight/tokens/FlightTokenManagerImpl.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/arrowflight/tokens/FlightTokenManagerImpl.java
@@ -21,9 +21,11 @@ package org.apache.doris.arrowflight.tokens;
import org.apache.doris.arrowflight.auth2.FlightAuthResult;
import org.apache.doris.catalog.Env;
+import org.apache.doris.common.Config;
import org.apache.doris.common.CustomThreadFactory;
import org.apache.doris.common.util.TokenMasker;
import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.ConnectPoolMgr;
import org.apache.doris.service.ExecuteEnv;
import com.google.common.base.Preconditions;
@@ -60,10 +62,12 @@ public class FlightTokenManagerImpl implements
FlightTokenManager {
private ScheduledExecutorService cleanupExecutor;
public FlightTokenManagerImpl(final int cacheSize, final int
cacheExpiration) {
- // The cache size of all user tokens in Arrow Flight Server. which
will be eliminated by
- // LRU rules after exceeding the limit, the default value is
arrow_flight_max_connections,
- // arrow flight sql is a stateless protocol, the connection is usually
not actively
- // disconnected, bearer token is evict from the cache will unregister
ConnectContext.
+ // The cache size of all user tokens in Arrow Flight Server, which
will be eliminated by
+ // LRU rules after exceeding the limit. The size is the Arrow Flight
SQL sub-quota of the
+ // connection pool (arrow_flight_max_connections, half of
qe_max_connection by default),
+ // capped by arrow_flight_token_cache_size - see
DorisFlightSqlService. Arrow flight sql is a
+ // stateless protocol, the connection is usually not actively
disconnected; a bearer token
+ // evicted from the cache unregisters its ConnectContext.
this.cacheSize = cacheSize;
this.cacheExpiration = cacheExpiration;
@@ -75,10 +79,10 @@ public class FlightTokenManagerImpl implements
FlightTokenManager {
// TODO: broadcast this message to other FE
String token = notification.getKey();
FlightTokenDetails tokenDetails =
notification.getValue();
- ConnectContext context =
ExecuteEnv.getInstance().getScheduler().getFlightSqlConnectPoolMgr()
- .getContextWithFlightToken(token);
+ ConnectContext context =
ExecuteEnv.getInstance().getScheduler()
+ .getContextWithPeerIdentity(token);
if (context != null) {
-
ExecuteEnv.getInstance().getScheduler().getFlightSqlConnectPoolMgr()
+
ExecuteEnv.getInstance().getScheduler().getConnectPoolMgr()
.unregisterConnection(context);
LOG.info("evict bearer token: " +
TokenMasker.tokenId(token) + " from tokenCache, "
+ "reason: " + notification.getCause()
@@ -155,9 +159,14 @@ public class FlightTokenManagerImpl implements
FlightTokenManager {
if (value.getToken().equals("")) {
throw new IllegalArgumentException("invalid bearer token, token
id: " + TokenMasker.tokenId(token)
+ ", try reconnect, bearer token may not be created, or
may have been evict, search for this "
- + "token id in fe.log to see the evict reason. currently
in fe.conf, "
- + "`arrow_flight_max_connections`=" + this.cacheSize
- + ", `arrow_flight_token_alive_time_second`=" +
this.cacheExpiration);
+ + "token id in fe.log to see the evict reason. the token
cache is sized to " + this.cacheSize
+ + " tokens = min(max(1, effective Arrow Flight SQL
sub-quota of the connection pool "
+ +
ConnectPoolMgr.effectiveFlightMaxConnections(Config.qe_max_connection,
+ Config.arrow_flight_max_connections)
+ + " [fe.conf `arrow_flight_max_connections`=" +
Config.arrow_flight_max_connections
+ + " within `qe_max_connection`=" +
Config.qe_max_connection + "]), `arrow_flight_token_cache_size`="
+ + Config.arrow_flight_token_cache_size + "),
`arrow_flight_token_alive_time_second`="
+ + this.cacheExpiration);
}
if (System.currentTimeMillis() >= value.getExpiresAt()) {
tokenCache.invalidate(token);
diff --git a/fe/fe-core/src/main/java/org/apache/doris/metric/MetricRepo.java
b/fe/fe-core/src/main/java/org/apache/doris/metric/MetricRepo.java
index 7c7d5a54e9e..38328622119 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/metric/MetricRepo.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/metric/MetricRepo.java
@@ -457,11 +457,13 @@ public final class MetricRepo {
}
};
DORIS_METRIC_REGISTER.addMetrics(connections);
+ // Arrow Flight SQL sessions are connections of the one pool: they are
counted in
+ // connection_total and held to connection_max, and these two report
their share of it.
GAUGE_ARROW_FLIGHT_CONNECTIONS = new
GaugeMetric<Integer>("arrow_flight_connection_total",
MetricUnit.CONNECTIONS, "total arrow flight connections") {
@Override
public Integer getValue() {
- return
ExecuteEnv.getInstance().getScheduler().getFlightSqlConnectPoolMgr().getConnectionNum();
+ return
ExecuteEnv.getInstance().getScheduler().getConnectPoolMgr().getFlightConnectionNum();
}
};
DORIS_METRIC_REGISTER.addMetrics(GAUGE_ARROW_FLIGHT_CONNECTIONS);
@@ -469,7 +471,7 @@ public final class MetricRepo {
MetricUnit.CONNECTIONS, "max connections") {
@Override
public Integer getValue() {
- return Config.qe_max_connection +
Config.arrow_flight_max_connections;
+ return
ExecuteEnv.getInstance().getScheduler().getConnectPoolMgr().getMaxConnections();
}
};
DORIS_METRIC_REGISTER.addMetrics(GAUGE_CONNECTION_MAX);
@@ -477,7 +479,7 @@ public final class MetricRepo {
MetricUnit.CONNECTIONS, "max arrow flight connections") {
@Override
public Integer getValue() {
- return Config.arrow_flight_max_connections;
+ return
ExecuteEnv.getInstance().getScheduler().getConnectPoolMgr().getFlightMaxConnections();
}
};
DORIS_METRIC_REGISTER.addMetrics(GAUGE_ARROW_FLIGHT_CONNECTION_MAX);
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/mysql/AcceptListener.java
b/fe/fe-core/src/main/java/org/apache/doris/mysql/AcceptListener.java
index 0a796cb9367..b6aae649dab 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mysql/AcceptListener.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mysql/AcceptListener.java
@@ -115,10 +115,11 @@ public class AcceptListener implements
ChannelListener<AcceptingChannel<StreamCo
connection.setCloseListener(
streamConnection ->
connectScheduler.getConnectPoolMgr().unregisterConnection(context));
} else {
- long userConnLimit =
context.getEnv().getAuth().getMaxConn(context.getQualifiedUser());
- String errMsg = String.format(
- "Reach limit of connections. Total: %d, User: %d,
Current: %d",
-
connectScheduler.getConnectPoolMgr().getMaxConnections(), userConnLimit, res);
+ String errMsg =
connectScheduler.getConnectPoolMgr().limitReachedMessage(context, res);
+ // The refused client sees the message; the operator finds it
here, since the login
+ // that would have shown the pool's state in SHOW PROCESSLIST
is the one refused.
+ LOG.warn("refused MySQL connection of user {} from {}: {}",
context.getQualifiedUser(),
+ context.getRemoteHostPortString(), errMsg);
context.getState().setError(ErrorCode.ERR_TOO_MANY_USER_CONNECTIONS, errMsg);
MysqlProto.sendResponsePacket(context);
throw new AfterConnectedException(errMsg);
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/mysql/protocol/MysqlProtocolAdapter.java
b/fe/fe-core/src/main/java/org/apache/doris/mysql/protocol/MysqlProtocolAdapter.java
index d7f997ffc3e..5f76d55772f 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/mysql/protocol/MysqlProtocolAdapter.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/mysql/protocol/MysqlProtocolAdapter.java
@@ -33,9 +33,7 @@ import org.apache.doris.nereids.glue.LogicalPlanAdapter;
import org.apache.doris.nereids.stats.StatsErrorEstimator;
import org.apache.doris.qe.ConnectContext;
import org.apache.doris.qe.ConnectContext.ConnectType;
-import org.apache.doris.qe.ConnectPoolMgr;
import org.apache.doris.qe.ConnectProcessor;
-import org.apache.doris.qe.ConnectScheduler;
import org.apache.doris.qe.QueryState.MysqlStateType;
import org.apache.doris.qe.ShowResultSet;
import org.apache.doris.qe.StmtExecutor;
@@ -216,8 +214,8 @@ public class MysqlProtocolAdapter implements
ProtocolAdapter {
}
@Override
- public ConnectPoolMgr connectPool(ConnectScheduler scheduler) {
- return scheduler.getConnectPoolMgr();
+ public void releaseSession(ConnectContext ctx) {
+ // Nothing is held for a MySQL session beyond its channel, which
closeConnection closes.
}
/**
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 8d96b1b2f7a..dbce88cd992 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
@@ -1032,13 +1032,14 @@ public class ConnectContext {
}
}
- // The session is over (see FlightProtocolAdapter.tearDown): its deferred
executors are
- // finalized, and it keeps none and runs no command from now on. Nothing
to do for a
- // connection of any other protocol.
- public void tearDownFlightSqlSession() {
- if (protocolAdapter instanceof FlightProtocolAdapter) {
- ((FlightProtocolAdapter) protocolAdapter).tearDown();
- }
+ /**
+ * Releases what the protocol still holds for this session, when the
connection leaves the pool
+ * (see {@link ConnectPoolMgr#unregisterConnection}): for Arrow Flight SQL
the channel-cached
+ * results and the deferred executors, after which the session keeps none
and runs no command
+ * (see {@code FlightProtocolAdapter.tearDown}); nothing for a MySQL
connection. Idempotent.
+ */
+ public void releaseProtocolSession() {
+ protocolAdapter.releaseSession(this);
}
// A snapshot; empty for a connection of any other protocol.
@@ -1182,7 +1183,7 @@ public class ConnectContext {
}
this.queryId = queryId;
if (connectScheduler != null && !Strings.isNullOrEmpty(traceId)) {
-
protocolAdapter.connectPool(connectScheduler).putTraceId2QueryId(traceId,
queryId);
+ connectScheduler.getConnectPoolMgr().putTraceId2QueryId(traceId,
queryId);
}
}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectPoolMgr.java
b/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectPoolMgr.java
index 643908e52c1..e4e614bdf41 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectPoolMgr.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectPoolMgr.java
@@ -22,6 +22,7 @@ import org.apache.doris.catalog.Env;
import org.apache.doris.common.Status;
import org.apache.doris.common.util.DebugUtil;
import org.apache.doris.mysql.privilege.PrivPredicate;
+import org.apache.doris.qe.ConnectContext.ConnectType;
import org.apache.doris.qe.ConnectContext.ThreadInfo;
import org.apache.doris.thrift.TUniqueId;
@@ -36,21 +37,66 @@ import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
+/**
+ * The one pool of every connection this frontend serves, whatever protocol it
speaks: MySQL
+ * connections and Arrow Flight SQL sessions share {@code qe_max_connection},
the per-user
+ * {@code max_user_connections}, the processlist, KILL, the timeout checker
and the connection
+ * metrics. Arrow Flight SQL sessions additionally count against their own
sub-quota
+ * ({@code arrow_flight_max_connections}, half of the pool's limit unless set)
and are indexed by
+ * their peer identity, the bearer token, since that is how Flight requests
name their session.
+ *
+ * <p>{@link #unregisterConnection} is where every teardown path of a
connection meets - a MySQL
+ * channel closing, a Flight bearer token expiring or being evicted,
CloseSession, a KILL CONNECTION
+ * from another connection, the timeout checker past wait_timeout - so it is
where the protocol
+ * releases what it still holds for the session.
+ */
public class ConnectPoolMgr {
private static final Logger LOG =
LogManager.getLogger(ConnectPoolMgr.class);
protected final int maxConnections;
+ private final int flightMaxConnections;
protected final AtomicInteger numberConnection;
+ private final AtomicInteger numberFlightConnection = new AtomicInteger(0);
protected final Map<Integer, ConnectContext> connectionMap =
Maps.newConcurrentMap();
protected final Map<String, AtomicInteger> connByUser =
Maps.newConcurrentMap();
+ // Arrow Flight SQL: peer identity (bearer token) -> connection id
+ private final Map<String, Integer> peerIdentity2ConnectionId =
Maps.newConcurrentMap();
+ // Serializes the check-and-reserve of the pool/Flight/user quotas in
registerConnection, so a
+ // doomed attempt cannot hold one counter's reservation across another
attempt's checks.
+ private final Object admissionLock = new Object();
// valid trace id -> query id
protected final Map<String, TUniqueId> traceId2QueryId =
Maps.newConcurrentMap();
public ConnectPoolMgr(int maxConnections) {
+ this(maxConnections, -1);
+ }
+
+ /**
+ * @param maxConnections the pool's limit, {@code qe_max_connection}
+ * @param flightMaxConnections the Arrow Flight SQL sub-quota, {@code
arrow_flight_max_connections};
+ * negative is half of {@code maxConnections}
+ */
+ public ConnectPoolMgr(int maxConnections, int flightMaxConnections) {
this.maxConnections = maxConnections;
+ this.flightMaxConnections =
effectiveFlightMaxConnections(maxConnections, flightMaxConnections);
numberConnection = new AtomicInteger(0);
}
+ /**
+ * The Arrow Flight SQL sub-quota as enforced: a negative setting (the
default) is half of the
+ * pool's limit, so that Flight sessions - which their clients mostly
never close, and which
+ * therefore stay until a timeout - cannot take the half MySQL clients
connect through; an
+ * explicit one can never exceed the pool's limit, since every Flight
session is a connection of
+ * the pool too.
+ */
+ public static int effectiveFlightMaxConnections(int maxConnections, int
flightMaxConnections) {
+ return flightMaxConnections < 0 ? maxConnections / 2 :
Math.min(maxConnections, flightMaxConnections);
+ }
+
+ private static boolean isFlight(ConnectContext ctx) {
+ return ctx.getConnectType() == ConnectType.ARROW_FLIGHT_SQL;
+ }
+
public void timeoutChecker(long now) {
for (ConnectContext connectContext : connectionMap.values()) {
try {
@@ -66,23 +112,64 @@ public class ConnectPoolMgr {
// Return -1 means register OK
// Return >=0 means register failed, and return value is current
connection num.
public int registerConnection(ConnectContext ctx) {
- if (numberConnection.incrementAndGet() > maxConnections) {
- numberConnection.decrementAndGet();
- return numberConnection.get();
- }
- // Check user
- connByUser.putIfAbsent(ctx.getQualifiedUser(), new AtomicInteger(0));
- AtomicInteger conns = connByUser.get(ctx.getQualifiedUser());
- if (conns.incrementAndGet() >
ctx.getEnv().getAuth().getMaxConn(ctx.getQualifiedUser())) {
- conns.decrementAndGet();
- numberConnection.decrementAndGet();
- return numberConnection.get();
+ boolean flight = isFlight(ctx);
+ String qualifiedUser = ctx.getQualifiedUser();
+ long userLimit = ctx.getEnv().getAuth().getMaxConn(qualifiedUser);
+ // Check and reserve the pool, Flight sub-quota and per-user quotas as
one transition. Doing it
+ // under a lock (rather than the earlier increment-check-rollback per
counter) keeps a doomed
+ // attempt from reserving one counter across another attempt's checks:
a second Flight attempt
+ // that will fail its sub-quota could otherwise briefly hold the last
pool slot and make a
+ // concurrent MySQL connection - which fits in every serial ordering -
be refused. Registration
+ // is once per connection, not per query, so this is not a hot path.
+ synchronized (admissionLock) {
+ if (numberConnection.get() >= maxConnections) {
+ return numberConnection.get();
+ }
+ if (flight && numberFlightConnection.get() >=
flightMaxConnections) {
+ return numberConnection.get();
+ }
+ AtomicInteger conns = connByUser.computeIfAbsent(qualifiedUser, u
-> new AtomicInteger(0));
+ if (conns.get() >= userLimit) {
+ return numberConnection.get();
+ }
+ numberConnection.incrementAndGet();
+ if (flight) {
+ numberFlightConnection.incrementAndGet();
+ }
+ conns.incrementAndGet();
+ connectionMap.put(ctx.getConnectionId(), ctx);
+ if (flight) {
+ peerIdentity2ConnectionId.put(ctx.getPeerIdentity(),
ctx.getConnectionId());
+ }
}
- connectionMap.put(ctx.getConnectionId(), ctx);
return -1;
}
+ /**
+ * The refusal a client is told when {@link #registerConnection} returned
a count instead of -1:
+ * the same sentence for every protocol, naming the limits a connection is
held to. The Flight
+ * sub-quota and its usage are named whenever Flight sessions hold part of
the pool - a MySQL
+ * client refused by a pool that Flight sessions filled is told where the
connections went -
+ * and to a Flight client also when the sub-quota is tighter than the
pool's limit, since then
+ * it can be the one that was reached.
+ */
+ public String limitReachedMessage(ConnectContext ctx, int current) {
+ long userLimit =
ctx.getEnv().getAuth().getMaxConn(ctx.getQualifiedUser());
+ String message = String.format("Reach limit of connections. Total: %d,
User: %d, Current: %d",
+ maxConnections, userLimit, current);
+ int flightCurrent = numberFlightConnection.get();
+ if (flightCurrent > 0 || (isFlight(ctx) && flightMaxConnections <
maxConnections)) {
+ message += String.format(", Arrow Flight SQL: %d (current: %d)",
flightMaxConnections, flightCurrent);
+ }
+ return message;
+ }
+
public void unregisterConnection(ConnectContext ctx) {
+ // First, before any bookkeeping that could fail: what the protocol
holds for the session -
+ // for Arrow Flight SQL the channel-cached results and the deferred
query coordinators
+ // (see FlightProtocolAdapter.tearDown) - is released whether or not
the connection is
+ // still in the pool, so an abandoned session is cleaned up rather
than leaked.
+ ctx.releaseProtocolSession();
ctx.closeTxn();
if (connectionMap.remove(ctx.getConnectionId()) != null) {
AtomicInteger conns = connByUser.get(ctx.getQualifiedUser());
@@ -92,6 +179,12 @@ public class ConnectPoolMgr {
if (ctx.traceId() != null) {
traceId2QueryId.remove(ctx.traceId());
}
+ if (isFlight(ctx)) {
+ // Only this connection's own entry: a second session created
under the same token by
+ // a concurrent first request must not lose its index to the
first one's teardown.
+ peerIdentity2ConnectionId.remove(ctx.getPeerIdentity(),
ctx.getConnectionId());
+ numberFlightConnection.decrementAndGet();
+ }
numberConnection.decrementAndGet();
}
}
@@ -100,6 +193,12 @@ public class ConnectPoolMgr {
return connectionMap.get(connectionId);
}
+ /** The Arrow Flight SQL session registered under this peer identity
(bearer token), or null. */
+ public ConnectContext getContextWithPeerIdentity(String peerIdentity) {
+ Integer connectionId = peerIdentity2ConnectionId.get(peerIdentity);
+ return connectionId == null ? null : getContext(connectionId);
+ }
+
public ConnectContext getContextWithQueryId(String queryId) {
for (ConnectContext context : connectionMap.values()) {
if (queryId.equals(DebugUtil.printId(context.queryId)) ||
queryId.equals(context.traceId())) {
@@ -124,6 +223,11 @@ public class ConnectPoolMgr {
return numberConnection.get();
}
+ /** How many of the pool's connections are Arrow Flight SQL sessions. */
+ public int getFlightConnectionNum() {
+ return numberFlightConnection.get();
+ }
+
public List<ThreadInfo> listConnection(String user, boolean isFull) {
List<ConnectContext.ThreadInfo> infos = Lists.newArrayList();
for (ConnectContext ctx : connectionMap.values()) {
@@ -180,4 +284,9 @@ public class ConnectPoolMgr {
public int getMaxConnections() {
return maxConnections;
}
+
+ /** The Arrow Flight SQL sub-quota as enforced (see {@link
#effectiveFlightMaxConnections}). */
+ public int getFlightMaxConnections() {
+ return flightMaxConnections;
+ }
}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectScheduler.java
b/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectScheduler.java
index 06e544971f6..7a2b99dcd4c 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectScheduler.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectScheduler.java
@@ -18,19 +18,14 @@
package org.apache.doris.qe;
import org.apache.doris.analysis.UserIdentity;
-import org.apache.doris.arrowflight.sessions.FlightSqlConnectPoolMgr;
import org.apache.doris.common.Config;
import org.apache.doris.common.Status;
import org.apache.doris.common.ThreadPoolManager;
import org.apache.doris.qe.ConnectContext.ThreadInfo;
-import com.google.common.base.Strings;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
-import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -45,8 +40,8 @@ import java.util.concurrent.atomic.AtomicInteger;
public class ConnectScheduler {
private static final Logger LOG =
LogManager.getLogger(ConnectScheduler.class);
private final AtomicInteger nextConnectionId;
+ // Every connection of every protocol: see ConnectPoolMgr.
private final ConnectPoolMgr connectPoolMgr;
- private final FlightSqlConnectPoolMgr flightSqlConnectPoolMgr;
// Use a thread to check whether connection is timeout. Because
// 1. If use a scheduler, the task maybe a huge number when query is messy.
@@ -55,25 +50,25 @@ public class ConnectScheduler {
private final ScheduledExecutorService checkTimer =
ThreadPoolManager.newDaemonScheduledThreadPool(1,
"connect-scheduler-check-timer", true);
- public ConnectScheduler(int commonMaxConnections, int
flightSqlMaxConnections) {
+ /**
+ * @param maxConnections the pool's limit, {@code qe_max_connection}
+ * @param flightMaxConnections the Arrow Flight SQL sub-quota, {@code
arrow_flight_max_connections};
+ * negative is half of {@code maxConnections}
+ */
+ public ConnectScheduler(int maxConnections, int flightMaxConnections) {
nextConnectionId = new AtomicInteger(0);
- this.connectPoolMgr = new ConnectPoolMgr(commonMaxConnections);
- this.flightSqlConnectPoolMgr = new
FlightSqlConnectPoolMgr(flightSqlMaxConnections);
+ this.connectPoolMgr = new ConnectPoolMgr(maxConnections,
flightMaxConnections);
checkTimer.scheduleAtFixedRate(new TimeoutChecker(), 0, 1000L,
TimeUnit.MILLISECONDS);
}
- public ConnectScheduler(int commonMaxConnections) {
- this(commonMaxConnections, Config.arrow_flight_max_connections);
+ public ConnectScheduler(int maxConnections) {
+ this(maxConnections, Config.arrow_flight_max_connections);
}
public ConnectPoolMgr getConnectPoolMgr() {
return connectPoolMgr;
}
- public FlightSqlConnectPoolMgr getFlightSqlConnectPoolMgr() {
- return flightSqlConnectPoolMgr;
- }
-
// submit one MysqlContext to this scheduler.
// return true, if this connection has been successfully submitted,
otherwise return false.
// Caller should close ConnectContext if return false.
@@ -87,83 +82,57 @@ public class ConnectScheduler {
}
public ConnectContext getContext(int connectionId) {
- ConnectContext ctx = connectPoolMgr.getContext(connectionId);
- if (ctx == null) {
- ctx = flightSqlConnectPoolMgr.getContext(connectionId);
- }
- return ctx;
+ return connectPoolMgr.getContext(connectionId);
+ }
+
+ /** The Arrow Flight SQL session registered under this peer identity
(bearer token), or null. */
+ public ConnectContext getContextWithPeerIdentity(String peerIdentity) {
+ return connectPoolMgr.getContextWithPeerIdentity(peerIdentity);
}
public ConnectContext getContextWithQueryId(String queryId) {
- ConnectContext ctx = connectPoolMgr.getContextWithQueryId(queryId);
- if (ctx == null) {
- ctx = flightSqlConnectPoolMgr.getContextWithQueryId(queryId);
- }
- return ctx;
+ return connectPoolMgr.getContextWithQueryId(queryId);
}
public boolean cancelQuery(String queryId, Status cancelReason) {
- boolean ret = connectPoolMgr.cancelQuery(queryId, cancelReason);
- if (!ret) {
- ret = flightSqlConnectPoolMgr.cancelQuery(queryId, cancelReason);
- }
- return ret;
+ return connectPoolMgr.cancelQuery(queryId, cancelReason);
}
public int getConnectionNum() {
- return connectPoolMgr.getConnectionNum() +
flightSqlConnectPoolMgr.getConnectionNum();
+ return connectPoolMgr.getConnectionNum();
}
public List<ThreadInfo> listConnection(String user, boolean isFull) {
- List<ConnectContext.ThreadInfo> infos = Lists.newArrayList();
- infos.addAll(connectPoolMgr.listConnection(user, isFull));
- infos.addAll(flightSqlConnectPoolMgr.listConnection(user, isFull));
- return infos;
+ return connectPoolMgr.listConnection(user, isFull);
}
// used for thrift
public List<List<String>> listConnectionForRpc(UserIdentity userIdentity,
boolean isShowFullSql,
Optional<String> timeZone) {
- List<List<String>> list = new ArrayList<>();
- list.addAll(connectPoolMgr.listConnectionForRpc(userIdentity,
isShowFullSql, timeZone));
- list.addAll(flightSqlConnectPoolMgr.listConnectionForRpc(userIdentity,
isShowFullSql, timeZone));
- return list;
+ return connectPoolMgr.listConnectionForRpc(userIdentity,
isShowFullSql, timeZone);
}
public String getQueryIdByTraceId(String traceId) {
- String queryId = connectPoolMgr.getQueryIdByTraceId(traceId);
- if (Strings.isNullOrEmpty(queryId)) {
- queryId = flightSqlConnectPoolMgr.getQueryIdByTraceId(traceId);
- }
- return queryId;
+ return connectPoolMgr.getQueryIdByTraceId(traceId);
}
public void removeOldTraceId(String traceId) {
connectPoolMgr.removeTraceId(traceId);
- flightSqlConnectPoolMgr.removeTraceId(traceId);
}
public Map<Integer, ConnectContext> getConnectionMap() {
- Map<Integer, ConnectContext> map = Maps.newConcurrentMap();
- map.putAll(connectPoolMgr.getConnectionMap());
- map.putAll(flightSqlConnectPoolMgr.getConnectionMap());
- return map;
+ return connectPoolMgr.getConnectionMap();
}
public Map<String, AtomicInteger> getUserConnectionMap() {
- Map<String, AtomicInteger> map = Maps.newConcurrentMap();
- map.putAll(connectPoolMgr.getUserConnectionMap());
- map.putAll(flightSqlConnectPoolMgr.getUserConnectionMap());
- return map;
+ return connectPoolMgr.getUserConnectionMap();
}
private class TimeoutChecker extends TimerTask {
@Override
public void run() {
try {
- long now = System.currentTimeMillis();
- connectPoolMgr.timeoutChecker(now);
- flightSqlConnectPoolMgr.timeoutChecker(now);
+ connectPoolMgr.timeoutChecker(System.currentTimeMillis());
} catch (Throwable t) {
LOG.warn("failed to check connection timeout", t);
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/qe/protocol/ProtocolAdapter.java
b/fe/fe-core/src/main/java/org/apache/doris/qe/protocol/ProtocolAdapter.java
index 94c418ee5a2..0d77ecd1a1b 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/protocol/ProtocolAdapter.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/protocol/ProtocolAdapter.java
@@ -19,8 +19,6 @@ package org.apache.doris.qe.protocol;
import org.apache.doris.qe.ConnectContext;
import org.apache.doris.qe.ConnectContext.ConnectType;
-import org.apache.doris.qe.ConnectPoolMgr;
-import org.apache.doris.qe.ConnectScheduler;
import org.apache.doris.qe.StmtExecutor;
import org.apache.doris.thrift.TMasterOpRequest;
import org.apache.doris.thrift.TResultSinkType;
@@ -148,10 +146,13 @@ public interface ProtocolAdapter {
throws IOException;
/**
- * The pool this connection is registered in. Each protocol still keeps
its own pool; this
- * goes away when they are merged.
+ * Releases what the protocol still holds for the session when the
connection leaves the pool
+ * ({@code ConnectPoolMgr.unregisterConnection}, where every teardown path
of a connection
+ * meets): for Arrow Flight SQL the channel-cached results and the
deferred query coordinators,
+ * nothing for MySQL, whose channel {@link #closeConnection} closes. Must
be idempotent, and
+ * must not depend on the connection being in the pool.
*/
- ConnectPoolMgr connectPool(ConnectScheduler scheduler);
+ void releaseSession(ConnectContext ctx);
/**
* Called from {@link ConnectContext#clear()} once the response of a
statement has been sent,
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/arrowflight/DorisFlightSqlProducerTest.java
b/fe/fe-core/src/test/java/org/apache/doris/arrowflight/DorisFlightSqlProducerTest.java
index 6fb16d62fbf..59bcec4f768 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/arrowflight/DorisFlightSqlProducerTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/arrowflight/DorisFlightSqlProducerTest.java
@@ -17,6 +17,7 @@
package org.apache.doris.arrowflight;
+import org.apache.doris.analysis.UserIdentity;
import org.apache.doris.arrowflight.protocol.FlightProtocolAdapter;
import org.apache.doris.arrowflight.results.FlightSqlChannel;
import org.apache.doris.arrowflight.sessions.FlightSessionsManager;
@@ -25,6 +26,7 @@ import org.apache.doris.common.FeConstants;
import org.apache.doris.common.IncrWindowNotReadyException;
import org.apache.doris.mysql.MysqlCommand;
import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.ConnectPoolTestSupport;
import org.apache.doris.qe.ConnectScheduler;
import org.apache.doris.qe.QueryState;
import org.apache.doris.qe.StmtExecutor;
@@ -434,11 +436,12 @@ public class DorisFlightSqlProducerTest {
// statement runs, all start wait_timeout over.
@Test
public void testSessionOptionActionsKeepTheSessionAlive() throws Exception
{
- ConnectContext ctx = ConnectContext.forFlight("token");
+ ConnectContext ctx =
ConnectPoolTestSupport.flightSession(ConnectPoolTestSupport.envAllowing(100),
+ UserIdentity.ROOT, "token");
ConnectScheduler scheduler = new ConnectScheduler(10, 10);
ctx.setConnectScheduler(scheduler);
scheduler.submit(ctx);
- Assertions.assertEquals(-1,
scheduler.getFlightSqlConnectPoolMgr().registerConnection(ctx));
+ Assertions.assertEquals(-1,
scheduler.getConnectPoolMgr().registerConnection(ctx));
ctx.setCommand(MysqlCommand.COM_SLEEP);
ctx.setStartTime();
long waitTimeoutMs = ctx.getSessionVariable().getWaitTimeoutS() *
1000L;
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/arrowflight/DorisFlightSqlServiceTest.java
b/fe/fe-core/src/test/java/org/apache/doris/arrowflight/DorisFlightSqlServiceTest.java
new file mode 100644
index 00000000000..ea615cfe391
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/arrowflight/DorisFlightSqlServiceTest.java
@@ -0,0 +1,42 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.arrowflight;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class DorisFlightSqlServiceTest {
+
+ // The token cache size floors the effective Flight sub-quota at 1, but
never the configured cap:
+ // a legal sub-quota of 0 (qe_max_connection = 1) keeps one token so the
first request reaches the
+ // pool and is refused with RESOURCE_EXHAUSTED, while an illegal
arrow_flight_token_cache_size (<= 0)
+ // is passed through so the base's loud failure (Guava rejects a negative
maximumSize; 0 evicts every
+ // token) is preserved rather than the FE silently running on a one-token
cache.
+ @Test
+ public void testEffectiveTokenCacheSizeFloorsOnlyTheSubQuota() {
+ // Legal sub-quota of 0 -> floored to 1.
+ Assertions.assertEquals(1,
DorisFlightSqlService.effectiveTokenCacheSize(0, 4096));
+ // Normal: the smaller of the sub-quota and the cap.
+ Assertions.assertEquals(512,
DorisFlightSqlService.effectiveTokenCacheSize(512, 4096));
+ Assertions.assertEquals(4096,
DorisFlightSqlService.effectiveTokenCacheSize(8192, 4096));
+ Assertions.assertEquals(10,
DorisFlightSqlService.effectiveTokenCacheSize(512, 10));
+ // An illegal cap is not floored: it stays <= 0 for the base's loud
failure.
+ Assertions.assertEquals(0,
DorisFlightSqlService.effectiveTokenCacheSize(0, 0));
+ Assertions.assertEquals(-1,
DorisFlightSqlService.effectiveTokenCacheSize(512, -1));
+ }
+}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/arrowflight/protocol/FlightProtocolAdapterTest.java
b/fe/fe-core/src/test/java/org/apache/doris/arrowflight/protocol/FlightProtocolAdapterTest.java
index 02630b9c22e..0028dcd4d3f 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/arrowflight/protocol/FlightProtocolAdapterTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/arrowflight/protocol/FlightProtocolAdapterTest.java
@@ -17,6 +17,7 @@
package org.apache.doris.arrowflight.protocol;
+import org.apache.doris.analysis.UserIdentity;
import org.apache.doris.arrowflight.results.FlightSqlEndpointsLocation;
import org.apache.doris.catalog.Column;
import org.apache.doris.catalog.ScalarType;
@@ -27,6 +28,7 @@ import org.apache.doris.common.util.DebugUtil;
import org.apache.doris.mysql.protocol.MysqlProtocolAdapter;
import org.apache.doris.qe.ConnectContext;
import org.apache.doris.qe.ConnectContext.ConnectType;
+import org.apache.doris.qe.ConnectPoolTestSupport;
import org.apache.doris.qe.ConnectScheduler;
import org.apache.doris.qe.QueryState.MysqlStateType;
import org.apache.doris.qe.ShowResultSet;
@@ -63,8 +65,9 @@ import java.util.concurrent.TimeUnit;
/**
* An Arrow Flight SQL session is a ConnectContext bound to a
FlightProtocolAdapter. The adapter
- * owns what only that protocol has (result cache, endpoints, deferred
executors, the pool the
- * session is registered in) and serializes the session's commands, which gRPC
does not do.
+ * owns what only that protocol has (result cache, endpoints, deferred
executors) and serializes
+ * the session's commands, which gRPC does not do; the session is registered
in the one connection
+ * pool every protocol shares, which asks the adapter to release those on
teardown.
*/
public class FlightProtocolAdapterTest {
private boolean savedRunningUnitTest;
@@ -83,7 +86,10 @@ public class FlightProtocolAdapterTest {
}
private static ConnectContext flightSession() {
- return ConnectContext.forFlight("test-peer-identity");
+ // Registrable in the one pool: the pool files a session under its
user and asks its Env
+ // for the user's connection limit.
+ return
ConnectPoolTestSupport.flightSession(ConnectPoolTestSupport.envAllowing(100),
UserIdentity.ROOT,
+ "test-peer-identity");
}
// A command that blocks on a latch, run from a plain thread.
@@ -129,7 +135,7 @@ public class FlightProtocolAdapterTest {
}
@Test
- public void testSessionRegistersItsTraceIdInTheFlightPool() {
+ public void testSessionRegistersItsTraceIdInThePool() {
ConnectScheduler scheduler = new ConnectScheduler(10, 10);
ConnectContext ctx = flightSession();
ctx.setConnectScheduler(scheduler);
@@ -138,26 +144,52 @@ public class FlightProtocolAdapterTest {
ctx.setQueryId(queryId);
- Assertions.assertEquals(DebugUtil.printId(queryId),
-
scheduler.getFlightSqlConnectPoolMgr().getQueryIdByTraceId("trace-1"));
- Assertions.assertEquals("",
scheduler.getConnectPoolMgr().getQueryIdByTraceId("trace-1"));
+ Assertions.assertEquals(DebugUtil.printId(queryId),
scheduler.getQueryIdByTraceId("trace-1"));
}
@Test
- public void testKillUnregistersTheSessionFromTheFlightPool() {
+ public void testKillUnregistersTheSessionFromThePool() {
ConnectScheduler scheduler = new ConnectScheduler(10, 10);
ConnectContext ctx = flightSession();
ctx.setConnectScheduler(scheduler);
scheduler.submit(ctx);
- Assertions.assertEquals(-1,
scheduler.getFlightSqlConnectPoolMgr().registerConnection(ctx));
+ Assertions.assertEquals(-1,
scheduler.getConnectPoolMgr().registerConnection(ctx));
Assertions.assertSame(ctx,
scheduler.getContext(ctx.getConnectionId()));
+ Assertions.assertSame(ctx,
scheduler.getContextWithPeerIdentity(ctx.getPeerIdentity()));
ctx.kill(true);
Assertions.assertTrue(ctx.isKilled());
Assertions.assertNull(scheduler.getContext(ctx.getConnectionId()));
+
Assertions.assertNull(scheduler.getContextWithPeerIdentity(ctx.getPeerIdentity()));
}
+ // Every Flight session teardown path meets in the pool's
unregisterConnection, which asks the
+ // protocol to release what it holds: the channel-cached results first,
then the deferred
+ // executors (tearDown), whether or not the session was ever registered.
+ @Test
+ public void testReleaseSessionClosesTheChannelAndTearsTheSessionDown() {
+ ConnectContext ctx = flightSession();
+ FlightProtocolAdapter adapter = FlightProtocolAdapter.of(ctx);
+ StmtExecutor deferred = Mockito.mock(StmtExecutor.class);
+ ctx.addFlightSqlDeferredExecutor(deferred);
+ // A result the client never pulled: off-heap Arrow buffers the
channel holds.
+ adapter.getChannel().addOKResult("query-1", "SELECT 1");
+ Assertions.assertEquals(1, adapter.getChannel().resultNum());
+ Assertions.assertTrue(adapter.getChannel().getAllocatedMemory() > 0);
+
+ ctx.releaseProtocolSession();
+
+ // The channel's results are released with their buffers, and the
deferred query finalized.
+ Assertions.assertEquals(0, adapter.getChannel().resultNum());
+ Assertions.assertEquals(0, adapter.getChannel().getAllocatedMemory());
+ Assertions.assertTrue(ctx.getFlightSqlDeferredExecutors().isEmpty());
+ // Idempotent: the second release finds nothing to do, throws nothing,
and finalizes nothing twice.
+ Assertions.assertDoesNotThrow(ctx::releaseProtocolSession);
+ Mockito.verify(deferred, Mockito.times(1)).finalizeArrowFlightQuery();
+ }
+
+
@Test
public void testCommandsOfOneSessionRunOneAtATime() throws Exception {
ConnectContext ctx = flightSession();
@@ -468,7 +500,7 @@ public class FlightProtocolAdapterTest {
ConnectContext ctx = flightSession();
ctx.setConnectScheduler(scheduler);
scheduler.submit(ctx);
- Assertions.assertEquals(-1,
scheduler.getFlightSqlConnectPoolMgr().registerConnection(ctx));
+ Assertions.assertEquals(-1,
scheduler.getConnectPoolMgr().registerConnection(ctx));
FlightProtocolAdapter adapter = FlightProtocolAdapter.of(ctx);
StmtExecutor before = Mockito.mock(StmtExecutor.class);
StmtExecutor late = Mockito.mock(StmtExecutor.class);
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/arrowflight/sessions/FlightSqlConnectPoolMgrTest.java
b/fe/fe-core/src/test/java/org/apache/doris/arrowflight/sessions/FlightSqlConnectPoolMgrTest.java
deleted file mode 100644
index 3dd5b94b4f1..00000000000
---
a/fe/fe-core/src/test/java/org/apache/doris/arrowflight/sessions/FlightSqlConnectPoolMgrTest.java
+++ /dev/null
@@ -1,70 +0,0 @@
-// Licensed to the Apache Software Foundation (ASF) under one
-// or more contributor license agreements. See the NOTICE file
-// distributed with this work for additional information
-// regarding copyright ownership. The ASF licenses this file
-// to you under the Apache License, Version 2.0 (the
-// "License"); you may not use this file except in compliance
-// with the License. You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing,
-// software distributed under the License is distributed on an
-// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-// KIND, either express or implied. See the License for the
-// specific language governing permissions and limitations
-// under the License.
-
-package org.apache.doris.arrowflight.sessions;
-
-import org.apache.doris.arrowflight.results.FlightSqlChannel;
-import org.apache.doris.qe.ConnectContext;
-
-import org.junit.jupiter.api.Assertions;
-import org.junit.jupiter.api.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 tear the Flight
session down -- finalize the
- // deferred coordinators, so an abandoned connection cannot leak them (the
external-table batch
- // SplitSource and the query queue slot the coordinator holds), and close
the session to what a
- // still-running command defers afterwards.
- @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).tearDownFlightSqlSession();
- }
-
- // 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).tearDownFlightSqlSession();
- Assertions.assertNull(poolMgr.getConnectionMap().get(7));
- }
-}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/metric/MetricsTest.java
b/fe/fe-core/src/test/java/org/apache/doris/metric/MetricsTest.java
index ddb71021794..448bc5430ab 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/metric/MetricsTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/metric/MetricsTest.java
@@ -17,6 +17,7 @@
package org.apache.doris.metric;
+import org.apache.doris.analysis.UserIdentity;
import org.apache.doris.catalog.Env;
import org.apache.doris.cloud.CloudWarmUpJob;
import org.apache.doris.cloud.JobWarmUpStats;
@@ -37,6 +38,10 @@ import org.apache.doris.monitor.jvm.JvmService;
import org.apache.doris.monitor.jvm.JvmStats;
import org.apache.doris.mysql.privilege.Auth;
import org.apache.doris.mysql.privilege.UserProperty;
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.ConnectPoolTestSupport;
+import org.apache.doris.qe.ConnectScheduler;
+import org.apache.doris.service.ExecuteEnv;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.collect.Lists;
@@ -44,6 +49,8 @@ import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
@@ -86,22 +93,34 @@ public class MetricsTest {
@Test
public void testConnectionMaxMetrics() throws Exception {
- int originQeMaxConnection = Config.qe_max_connection;
- int originArrowFlightMaxConnections =
Config.arrow_flight_max_connections;
- try {
- Config.qe_max_connection = 4321;
- Config.arrow_flight_max_connections = 8765;
+ // One pool for every protocol: connection_max is its limit,
connection_total every
+ // connection in it, and the two arrow_flight gauges the Flight share
of each. A scheduler
+ // of known numbers, with one connection of each protocol registered,
so that each gauge is
+ // pinned to a literal and not to the getter it reads.
+ ConnectScheduler scheduler = new ConnectScheduler(4321, 300);
+ Env env = ConnectPoolTestSupport.envAllowing(100);
+ ConnectContext mysql = ConnectPoolTestSupport.mysqlConnection(env,
UserIdentity.ROOT);
+ ConnectContext flight = ConnectPoolTestSupport.flightSession(env,
UserIdentity.ROOT, "metric-token");
+ scheduler.submit(mysql);
+ scheduler.submit(flight);
+ Assertions.assertEquals(-1,
scheduler.getConnectPoolMgr().registerConnection(mysql));
+ Assertions.assertEquals(-1,
scheduler.getConnectPoolMgr().registerConnection(flight));
+ ExecuteEnv executeEnv = Mockito.mock(ExecuteEnv.class);
+ Mockito.when(executeEnv.getScheduler()).thenReturn(scheduler);
+ try (MockedStatic<ExecuteEnv> mockedExecuteEnv =
Mockito.mockStatic(ExecuteEnv.class)) {
+
mockedExecuteEnv.when(ExecuteEnv::getInstance).thenReturn(executeEnv);
MetricRepo.updateUserConnectionMaxMetric("metric_user", 321L);
MetricVisitor visitor = new PrometheusMetricVisitor();
MetricRepo.DORIS_METRIC_REGISTER.accept(visitor);
String metricResult = visitor.finish();
Assertions.assertTrue(metricResult.contains("# TYPE
doris_fe_connection_max gauge"));
-
Assertions.assertTrue(metricResult.contains("doris_fe_connection_max 13086"));
+
Assertions.assertTrue(metricResult.contains("doris_fe_connection_max 4321\n"),
metricResult);
+
Assertions.assertTrue(metricResult.contains("doris_fe_connection_total 2\n"),
metricResult);
Assertions.assertTrue(metricResult.contains("# TYPE
doris_fe_arrow_flight_connection_total gauge"));
-
Assertions.assertTrue(metricResult.contains("doris_fe_arrow_flight_connection_total
0"));
+
Assertions.assertTrue(metricResult.contains("doris_fe_arrow_flight_connection_total
1\n"), metricResult);
Assertions.assertTrue(metricResult.contains("# TYPE
doris_fe_arrow_flight_connection_max gauge"));
-
Assertions.assertTrue(metricResult.contains("doris_fe_arrow_flight_connection_max
8765"));
+
Assertions.assertTrue(metricResult.contains("doris_fe_arrow_flight_connection_max
300\n"), metricResult);
Assertions.assertTrue(metricResult.contains("# TYPE
doris_fe_user_connection_max gauge"));
Assertions.assertTrue(metricResult.contains("doris_fe_user_connection_max{user=\"metric_user\"}
321"));
@@ -123,8 +142,6 @@ public class MetricsTest {
Assertions.assertTrue(metricResult.contains("doris_fe_user_connection_max{user=\"root\"}
456"));
Assertions.assertFalse(metricResult.contains("doris_fe_user_connection_max{user=\"root\"}
789"));
} finally {
- Config.qe_max_connection = originQeMaxConnection;
- Config.arrow_flight_max_connections =
originArrowFlightMaxConnections;
MetricRepo.removeUserConnectionMaxMetric("metric_user");
Env.getServingEnv().getAuth().updateUserPropertyInternal(Auth.ROOT_USER,
Lists.newArrayList(
Pair.of(UserProperty.PROP_MAX_USER_CONNECTIONS, "100")),
true);
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/mysql/ConnectionExceedTest.java
b/fe/fe-core/src/test/java/org/apache/doris/mysql/ConnectionExceedTest.java
index 813a796c856..35345eb3fe8 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/mysql/ConnectionExceedTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/mysql/ConnectionExceedTest.java
@@ -32,8 +32,11 @@ import org.apache.doris.qe.ConnectScheduler;
import org.apache.doris.qe.QueryState;
import org.apache.doris.service.ExecuteEnv;
+import org.apache.arrow.flight.FlightRuntimeException;
+import org.apache.arrow.flight.FlightStatusCode;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
import org.mockito.InOrder;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
@@ -42,6 +45,10 @@ import org.xnio.XnioIoThread;
import org.xnio.XnioWorker;
import org.xnio.conduits.ConduitStreamSourceChannel;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.RejectedExecutionException;
public class ConnectionExceedTest {
@@ -128,15 +135,24 @@ public class ConnectionExceedTest {
}
}
+ // An Arrow Flight SQL session is a connection of the one pool: refused at
the pool's limit, the
+ // user's limit or the Flight sub-quota, in the words a MySQL client is
refused in, as the
+ // RESOURCE_EXHAUSTED status of the request that would have opened it -
and the bearer token
+ // goes with the refusal, since it could never open a session.
@Test
public void testFlightSessionConnectionExceed() throws Exception {
- try (MockedStatic<ExecuteEnv> mockedExecEnv =
Mockito.mockStatic(ExecuteEnv.class)) {
- // Create a scheduler with small max connections
+ try (MockedStatic<ExecuteEnv> mockedExecEnv =
Mockito.mockStatic(ExecuteEnv.class);
+ MockedStatic<Env> mockedEnvStatic =
Mockito.mockStatic(Env.class)) {
+ // A pool of 1000 with a Flight sub-quota of 2
ConnectScheduler scheduler = new ConnectScheduler(1000, 2);
// Setup expectations
Mockito.when(mockEnv.getInternalCatalog()).thenReturn(mockCatalog);
Mockito.when(mockCatalog.getName()).thenReturn("internal");
+
Mockito.when(mockAuth.getMaxConn(Mockito.anyString())).thenReturn(100L);
+ Mockito.when(mockEnv.getAuth()).thenReturn(mockAuth);
+ // The session the token manager builds takes its Env from
Env.getCurrentEnv().
+ mockedEnvStatic.when(Env::getCurrentEnv).thenReturn(mockEnv);
mockedExecEnv.when(ExecuteEnv::getInstance).thenReturn(mockExecuteEnv);
Mockito.when(mockExecuteEnv.getScheduler()).thenReturn(scheduler);
@@ -151,35 +167,91 @@ public class ConnectionExceedTest {
);
Mockito.when(mockTokenManager.validateToken("test_token")).thenReturn(tokenDetails);
- // Create first context and register
- ConnectContext context1 = new ConnectContext();
- context1.setEnv(mockEnv);
-
context1.setCurrentUserIdentity(UserIdentity.createAnalyzedUserIdentWithIp("test_user",
"%"));
- Assertions.assertTrue(scheduler.submit(context1));
- Assertions.assertEquals(-1,
scheduler.getFlightSqlConnectPoolMgr().registerConnection(context1));
-
- // Create second context and register
- ConnectContext context2 = new ConnectContext();
- context2.setEnv(mockEnv);
-
context2.setCurrentUserIdentity(UserIdentity.createAnalyzedUserIdentWithIp("test_user",
"%"));
- Assertions.assertTrue(scheduler.submit(context2));
- Assertions.assertEquals(-1,
scheduler.getFlightSqlConnectPoolMgr().registerConnection(context2));
+ // Two Flight sessions fill the sub-quota, next to a MySQL
connection of the same user: the
+ // refusal has to tell the Flight usage from the pool's count.
+ ConnectContext mysql = new ConnectContext();
+ mysql.setEnv(mockEnv);
+
mysql.setCurrentUserIdentity(UserIdentity.createAnalyzedUserIdentWithIp("test_user",
"%"));
+ Assertions.assertTrue(scheduler.submit(mysql));
+ Assertions.assertEquals(-1,
scheduler.getConnectPoolMgr().registerConnection(mysql));
+ for (String token : new String[] {"token-1", "token-2"}) {
+ ConnectContext session = ConnectContext.forFlight(token);
+ session.setEnv(mockEnv);
+
session.setCurrentUserIdentity(UserIdentity.createAnalyzedUserIdentWithIp("test_user",
"%"));
+ Assertions.assertTrue(scheduler.submit(session));
+ Assertions.assertEquals(-1,
scheduler.getConnectPoolMgr().registerConnection(session));
+ }
// Create FlightSessionsWithTokenManager and try to create a new
connection
FlightSessionsWithTokenManager manager = new
FlightSessionsWithTokenManager(mockTokenManager);
- try {
- manager.createConnectContext("test_token");
- Assertions.fail("Should throw IllegalArgumentException");
- } catch (IllegalArgumentException e) {
- // Verify error message is set correctly
- String expectedMsg = String.format(
- "Register arrow flight sql connection failed, Unknown
Error, the number of arrow flight "
- + "bearer tokens should be equal to arrow
flight sql max connections, "
- + "max connections: %d, used: %d.",
-
scheduler.getFlightSqlConnectPoolMgr().getMaxConnections(),
- scheduler.getConnectionNum());
- Assertions.assertEquals(expectedMsg, e.getMessage());
- }
+ FlightRuntimeException refused =
Assertions.assertThrows(FlightRuntimeException.class,
+ () -> manager.getConnectContext("test_token"));
+ Assertions.assertEquals(FlightStatusCode.RESOURCE_EXHAUSTED,
refused.status().code());
+ Assertions.assertEquals(
+ "Reach limit of connections. Total: 1000, User: 100,
Current: 3, Arrow Flight SQL: 2 (current: 2)",
+ refused.status().description());
+ Mockito.verify(mockTokenManager).invalidateToken("test_token");
+ Assertions.assertEquals(3, scheduler.getConnectionNum());
+ Assertions.assertEquals(2,
scheduler.getConnectPoolMgr().getFlightConnectionNum());
+ }
+ }
+
+ // Two first requests carrying the same bearer token race session
creation. getConnectContext
+ // serializes creation and re-checks the peer-identity index, so exactly
one ConnectContext is
+ // published for the token and every caller gets it. Without that (both
build and register one, or
+ // the loser hits the created-session guard) the pool would hold a second,
orphaned session, or a
+ // caller would fail. Each worker installs its own static mocks because
Mockito's mockStatic is
+ // thread-local; the ConnectScheduler is the one shared instance they
publish into.
+ @Test
+ @Timeout(60)
+ public void testConcurrentFirstRequestsPublishExactlyOneSessionPerToken()
throws Exception {
+ ConnectScheduler scheduler = new ConnectScheduler(1000, 100);
+ Mockito.when(mockEnv.getInternalCatalog()).thenReturn(mockCatalog);
+ Mockito.when(mockCatalog.getName()).thenReturn("internal");
+
Mockito.when(mockAuth.getMaxConn(Mockito.anyString())).thenReturn(100L);
+ Mockito.when(mockEnv.getAuth()).thenReturn(mockAuth);
+ Mockito.when(mockExecuteEnv.getScheduler()).thenReturn(scheduler);
+
+ UserIdentity userIdentity =
UserIdentity.createAnalyzedUserIdentWithIp("test_user", "%");
+ FlightTokenDetails tokenDetails = new FlightTokenDetails(
+ "shared_token", "test_user",
+ System.currentTimeMillis(), System.currentTimeMillis() +
3600000,
+ userIdentity, "127.0.0.1");
+
Mockito.when(mockTokenManager.validateToken("shared_token")).thenReturn(tokenDetails);
+
+ FlightSessionsWithTokenManager manager = new
FlightSessionsWithTokenManager(mockTokenManager);
+
+ int threadCount = 6;
+ CyclicBarrier barrier = new CyclicBarrier(threadCount);
+ List<ConnectContext> results = Collections.synchronizedList(new
ArrayList<>());
+ List<Throwable> errors = Collections.synchronizedList(new
ArrayList<>());
+ List<Thread> threads = new ArrayList<>();
+ for (int i = 0; i < threadCount; i++) {
+ Thread t = new Thread(() -> {
+ try (MockedStatic<ExecuteEnv> execEnv =
Mockito.mockStatic(ExecuteEnv.class);
+ MockedStatic<Env> env = Mockito.mockStatic(Env.class))
{
+
execEnv.when(ExecuteEnv::getInstance).thenReturn(mockExecuteEnv);
+ env.when(Env::getCurrentEnv).thenReturn(mockEnv);
+ barrier.await();
+ results.add(manager.getConnectContext("shared_token"));
+ } catch (Throwable e) {
+ errors.add(e);
+ }
+ });
+ threads.add(t);
+ t.start();
+ }
+ for (Thread t : threads) {
+ t.join();
+ }
+
+ Assertions.assertTrue(errors.isEmpty(), "getConnectContext threw: " +
errors);
+ Assertions.assertEquals(1, scheduler.getConnectionNum(),
+ "concurrent first requests on one token must publish exactly
one session");
+ ConnectContext published =
scheduler.getContextWithPeerIdentity("shared_token");
+ Assertions.assertNotNull(published);
+ for (ConnectContext c : results) {
+ Assertions.assertSame(published, c, "every caller must get the one
published session");
}
}
}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/mysql/protocol/MysqlProtocolAdapterTest.java
b/fe/fe-core/src/test/java/org/apache/doris/mysql/protocol/MysqlProtocolAdapterTest.java
index a9f05fedb63..bc4fcb7ea71 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/mysql/protocol/MysqlProtocolAdapterTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/mysql/protocol/MysqlProtocolAdapterTest.java
@@ -118,7 +118,7 @@ public class MysqlProtocolAdapterTest {
}
@Test
- public void testConnectionRegistersItsTraceIdInTheMysqlPool() {
+ public void testConnectionRegistersItsTraceIdInThePool() {
ConnectScheduler scheduler = new ConnectScheduler(10, 10);
ConnectContext ctx = new ConnectContext();
ctx.setConnectScheduler(scheduler);
@@ -128,7 +128,7 @@ public class MysqlProtocolAdapterTest {
ctx.setQueryId(queryId);
Assertions.assertEquals(DebugUtil.printId(queryId),
scheduler.getConnectPoolMgr().getQueryIdByTraceId("trace-1"));
- Assertions.assertEquals("",
scheduler.getFlightSqlConnectPoolMgr().getQueryIdByTraceId("trace-1"));
+ Assertions.assertEquals(DebugUtil.printId(queryId),
scheduler.getQueryIdByTraceId("trace-1"));
}
@Test
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/qe/ConnectPoolMgrTest.java
b/fe/fe-core/src/test/java/org/apache/doris/qe/ConnectPoolMgrTest.java
new file mode 100644
index 00000000000..9cc903b46d3
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/qe/ConnectPoolMgrTest.java
@@ -0,0 +1,314 @@
+// 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.UserIdentity;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.datasource.InternalCatalog;
+import org.apache.doris.mysql.privilege.Auth;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.mockito.InOrder;
+import org.mockito.Mockito;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * One pool for every protocol: MySQL connections and Arrow Flight SQL
sessions share the pool's
+ * limit and the user's limit, Flight sessions additionally their sub-quota,
and every teardown
+ * path meets in unregisterConnection.
+ */
+public class ConnectPoolMgrTest {
+
+ private static final UserIdentity ALICE =
UserIdentity.createAnalyzedUserIdentWithIp("alice", "%");
+ private static final UserIdentity BOB =
UserIdentity.createAnalyzedUserIdentWithIp("bob", "%");
+ private static final UserIdentity CAROL =
UserIdentity.createAnalyzedUserIdentWithIp("carol", "%");
+
+ private static ConnectContext registered(ConnectPoolMgr pool,
ConnectContext ctx, int connectionId) {
+ ctx.setConnectionId(connectionId);
+ Assertions.assertEquals(-1, pool.registerConnection(ctx));
+ return ctx;
+ }
+
+ @Test
+ public void testBothProtocolsShareThePoolsLimit() {
+ Env env = ConnectPoolTestSupport.envAllowing(100);
+ // The sub-quota is the whole pool here: only the pool's limit is in
play.
+ ConnectPoolMgr pool = new ConnectPoolMgr(2, 2);
+
+ ConnectContext mysql = registered(pool,
ConnectPoolTestSupport.mysqlConnection(env, ALICE), 1);
+ ConnectContext flight = registered(pool,
ConnectPoolTestSupport.flightSession(env, BOB, "token-2"), 2);
+ Assertions.assertEquals(2, pool.getConnectionNum());
+ Assertions.assertEquals(1, pool.getFlightConnectionNum());
+
+ // The third connection is refused whichever protocol it speaks, with
the count it was refused at,
+ // and a refused registration changes no count: not the pool's, not
the Flight share, not the user's.
+ Assertions.assertEquals(2,
pool.registerConnection(ConnectPoolTestSupport.mysqlConnection(env, ALICE)));
+ Assertions.assertEquals(2,
+
pool.registerConnection(ConnectPoolTestSupport.flightSession(env, ALICE,
"token-3")));
+ Assertions.assertEquals(2, pool.getConnectionNum());
+ Assertions.assertEquals(1, pool.getFlightConnectionNum());
+ Assertions.assertEquals(1,
pool.getUserConnectionMap().get(ALICE.getQualifiedUser()).get());
+ Assertions.assertEquals(1,
pool.getUserConnectionMap().get(BOB.getQualifiedUser()).get());
+
+ Assertions.assertSame(flight,
pool.getContextWithPeerIdentity("token-2"));
+ Assertions.assertNull(pool.getContextWithPeerIdentity("token-3"));
+ Assertions.assertSame(mysql, pool.getContext(1));
+
+ // Unregistering the MySQL connection frees its slot and touches
nothing of the Flight share.
+ pool.unregisterConnection(mysql);
+ Assertions.assertEquals(1, pool.getConnectionNum());
+ Assertions.assertEquals(1, pool.getFlightConnectionNum());
+ Assertions.assertEquals(-1,
+
pool.registerConnection(ConnectPoolTestSupport.flightSession(env, ALICE,
"token-3")));
+ Assertions.assertEquals(2, pool.getFlightConnectionNum());
+ }
+
+ @Test
+ public void testAUsersLimitCountsBothProtocols() {
+ Env env = ConnectPoolTestSupport.envAllowing(1);
+ ConnectPoolMgr pool = new ConnectPoolMgr(10);
+
+ registered(pool, ConnectPoolTestSupport.mysqlConnection(env, ALICE),
1);
+ // Alice's one connection is taken by MySQL: her Flight session is
refused...
+ Assertions.assertEquals(1,
pool.registerConnection(ConnectPoolTestSupport.flightSession(env, ALICE, "a")));
+ // ...and nothing of the refused registration lingers: not in the
pool, not counted anywhere.
+ Assertions.assertEquals(1, pool.getConnectionNum());
+ Assertions.assertEquals(0, pool.getFlightConnectionNum());
+ Assertions.assertEquals(1,
pool.getUserConnectionMap().get(ALICE.getQualifiedUser()).get());
+ Assertions.assertNull(pool.getContextWithPeerIdentity("a"));
+ // Bob is not affected.
+ registered(pool, ConnectPoolTestSupport.flightSession(env, BOB, "b"),
2);
+ }
+
+ @Test
+ public void testTheFlightSubQuotaIsEnforcedWithinThePoolsLimit() {
+ Env env = ConnectPoolTestSupport.envAllowing(100);
+ ConnectPoolMgr pool = new ConnectPoolMgr(10, 1);
+ Assertions.assertEquals(1, pool.getFlightMaxConnections());
+
+ registered(pool, ConnectPoolTestSupport.flightSession(env, ALICE,
"a"), 1);
+ Assertions.assertEquals(1,
pool.registerConnection(ConnectPoolTestSupport.flightSession(env, BOB, "b")));
+ Assertions.assertEquals(1, pool.getConnectionNum());
+ Assertions.assertEquals(1, pool.getFlightConnectionNum());
+ // MySQL connections are not held to the Flight sub-quota.
+ registered(pool, ConnectPoolTestSupport.mysqlConnection(env, BOB), 2);
+ Assertions.assertEquals(2, pool.getConnectionNum());
+ }
+
+ // Unset, the sub-quota is half of the pool's limit: Flight sessions,
which their clients mostly
+ // never close, cannot take the half MySQL clients connect through. Set,
it never exceeds the limit.
+ @Test
+ public void
testTheFlightSubQuotaIsHalfThePoolsLimitUnlessSetAndNeverExceedsIt() {
+ Assertions.assertEquals(5,
ConnectPoolMgr.effectiveFlightMaxConnections(10, -1));
+ Assertions.assertEquals(5, new
ConnectPoolMgr(10).getFlightMaxConnections());
+ Assertions.assertEquals(512,
ConnectPoolMgr.effectiveFlightMaxConnections(1024, -1));
+ Assertions.assertEquals(0,
ConnectPoolMgr.effectiveFlightMaxConnections(1, -1));
+ Assertions.assertEquals(3,
ConnectPoolMgr.effectiveFlightMaxConnections(10, 3));
+ Assertions.assertEquals(10,
ConnectPoolMgr.effectiveFlightMaxConnections(10, 10));
+ Assertions.assertEquals(10,
ConnectPoolMgr.effectiveFlightMaxConnections(10, 4096));
+
+ // The default half is enforced like any sub-quota: the sixth Flight
session of a pool of ten is
+ // refused while MySQL connections still get in.
+ Env env = ConnectPoolTestSupport.envAllowing(100);
+ ConnectPoolMgr pool = new ConnectPoolMgr(10);
+ for (int i = 1; i <= 5; i++) {
+ registered(pool, ConnectPoolTestSupport.flightSession(env, ALICE,
"token-" + i), i);
+ }
+ Assertions.assertEquals(5,
pool.registerConnection(ConnectPoolTestSupport.flightSession(env, BOB, "b")));
+ registered(pool, ConnectPoolTestSupport.mysqlConnection(env, BOB), 6);
+ Assertions.assertEquals(6, pool.getConnectionNum());
+ Assertions.assertEquals(5, pool.getFlightConnectionNum());
+ }
+
+ @Test
+ public void testTheRefusalReadsTheSameForEveryProtocol() {
+ Env env = ConnectPoolTestSupport.envAllowing(5);
+ // A sub-quota equal to the pool's limit and no Flight session in the
pool: the plain sentence.
+ ConnectPoolMgr pool = new ConnectPoolMgr(10, 10);
+ Assertions.assertEquals("Reach limit of connections. Total: 10, User:
5, Current: 10",
+
pool.limitReachedMessage(ConnectPoolTestSupport.mysqlConnection(env, ALICE),
10));
+ Assertions.assertEquals("Reach limit of connections. Total: 10, User:
5, Current: 10",
+
pool.limitReachedMessage(ConnectPoolTestSupport.flightSession(env, ALICE, "a"),
10));
+
+ // Once Flight sessions hold part of the pool, both protocols are told
the sub-quota and its
+ // usage - a MySQL client refused by a pool that Flight sessions
filled reads where they went.
+ // One connection of each protocol, so that the Flight usage cannot
pass for the pool's count.
+ ConnectPoolMgr quota = new ConnectPoolMgr(10, 2);
+ registered(quota, ConnectPoolTestSupport.flightSession(env, BOB, "b"),
1);
+ registered(quota, ConnectPoolTestSupport.mysqlConnection(env, BOB), 2);
+ Assertions.assertEquals(
+ "Reach limit of connections. Total: 10, User: 5, Current: 2,
Arrow Flight SQL: 2 (current: 1)",
+
quota.limitReachedMessage(ConnectPoolTestSupport.flightSession(env, ALICE,
"a"), 2));
+ Assertions.assertEquals(
+ "Reach limit of connections. Total: 10, User: 5, Current: 2,
Arrow Flight SQL: 2 (current: 1)",
+
quota.limitReachedMessage(ConnectPoolTestSupport.mysqlConnection(env, ALICE),
2));
+ ConnectPoolMgr halved = new ConnectPoolMgr(10);
+ registered(halved, ConnectPoolTestSupport.flightSession(env, BOB,
"b"), 1);
+ registered(halved, ConnectPoolTestSupport.mysqlConnection(env, BOB),
2);
+ Assertions.assertEquals(
+ "Reach limit of connections. Total: 10, User: 5, Current: 10,
Arrow Flight SQL: 5 (current: 1)",
+
halved.limitReachedMessage(ConnectPoolTestSupport.mysqlConnection(env, ALICE),
10));
+
+ // A sub-quota tighter than the pool's limit is named to a Flight
client even while no
+ // Flight session is in the pool: it is the limit that refused it.
+ ConnectPoolMgr none = new ConnectPoolMgr(10, 0);
+ Assertions.assertEquals(
+ "Reach limit of connections. Total: 10, User: 5, Current: 0,
Arrow Flight SQL: 0 (current: 0)",
+
none.limitReachedMessage(ConnectPoolTestSupport.flightSession(env, ALICE, "a"),
0));
+ Assertions.assertEquals("Reach limit of connections. Total: 10, User:
5, Current: 0",
+
none.limitReachedMessage(ConnectPoolTestSupport.mysqlConnection(env, ALICE),
0));
+ }
+
+ // Arrow Flight SQL keeps a query's coordinator alive across GetFlightInfo
-> DoGet (see #62259).
+ // unregisterConnection() is the catch-all teardown path: the idle timeout
(wait_timeout), bearer
+ // token expiry or eviction, CloseSession and a KILL CONNECTION from
another connection all reach
+ // here (a query timeout only cancels the query). The protocol must
release what it holds for the
+ // session -- for Flight the channel-cached results and the deferred
coordinators -- even for a
+ // connection that was never registered (an abandoned connection is
cleaned up, not leaked), and
+ // before the bookkeeping, so that a failure there cannot strand the
coordinators.
+ @Test
+ public void
testUnregisterReleasesTheProtocolSessionFirstEvenWhenNotRegistered() {
+ ConnectPoolMgr pool = new ConnectPoolMgr(100);
+ ConnectContext ctx = Mockito.mock(ConnectContext.class);
+
+ pool.unregisterConnection(ctx);
+
+ InOrder inOrder = Mockito.inOrder(ctx);
+ inOrder.verify(ctx).releaseProtocolSession();
+ inOrder.verify(ctx).closeTxn();
+ }
+
+ @Test
+ public void testUnregisterRemovesAFlightSessionAndItsPeerIdentity() {
+ Env env = ConnectPoolTestSupport.envAllowing(100);
+ ConnectPoolMgr pool = new ConnectPoolMgr(100, 1);
+ ConnectContext ctx = registered(pool,
ConnectPoolTestSupport.flightSession(env, ALICE, "token-7"), 7);
+ Assertions.assertSame(ctx, pool.getContextWithPeerIdentity("token-7"));
+
+ pool.unregisterConnection(ctx);
+
+ Assertions.assertNull(pool.getContext(7));
+ Assertions.assertNull(pool.getContextWithPeerIdentity("token-7"));
+ Assertions.assertEquals(0, pool.getConnectionNum());
+ Assertions.assertEquals(0, pool.getFlightConnectionNum());
+ Assertions.assertEquals(0,
pool.getUserConnectionMap().get(ALICE.getQualifiedUser()).get());
+ // The sub-quota slot is free again.
+ registered(pool, ConnectPoolTestSupport.flightSession(env, ALICE,
"token-8"), 8);
+ // Unregistering twice is harmless.
+ pool.unregisterConnection(ctx);
+ Assertions.assertEquals(1, pool.getConnectionNum());
+ }
+
+ // Two sessions under one bearer token, as two concurrent first requests
of a token can open:
+ // the index names the later one, and the earlier one's teardown must not
take that entry away.
+ @Test
+ public void testUnregisterRemovesOnlyTheConnectionsOwnPeerIdentityEntry() {
+ Env env = ConnectPoolTestSupport.envAllowing(100);
+ ConnectPoolMgr pool = new ConnectPoolMgr(100, 2);
+ ConnectContext earlier = registered(pool,
ConnectPoolTestSupport.flightSession(env, ALICE, "token-x"), 1);
+ ConnectContext later = registered(pool,
ConnectPoolTestSupport.flightSession(env, ALICE, "token-x"), 2);
+ Assertions.assertSame(later,
pool.getContextWithPeerIdentity("token-x"));
+
+ pool.unregisterConnection(earlier);
+
+ Assertions.assertNull(pool.getContext(1));
+ Assertions.assertSame(later,
pool.getContextWithPeerIdentity("token-x"));
+ Assertions.assertEquals(1, pool.getConnectionNum());
+ Assertions.assertEquals(1, pool.getFlightConnectionNum());
+
+ pool.unregisterConnection(later);
+ Assertions.assertNull(pool.getContextWithPeerIdentity("token-x"));
+ Assertions.assertEquals(0, pool.getFlightConnectionNum());
+ }
+
+ // qe_max_connection = 1 makes the default Flight sub-quota 0 (half,
floored). No Flight session may
+ // open: the first one is refused at the pool (returns the count, not -1)
while a MySQL connection
+ // still fits. The service floors the token cache at 1 so this refusal is
what the client meets as
+ // RESOURCE_EXHAUSTED, instead of the freshly issued token being evicted
first (see
+ // DorisFlightSqlService); here we cover the pool half of that boundary.
+ @Test
+ public void testAZeroFlightSubQuotaRefusesEveryFlightSessionButNotMysql() {
+ Assertions.assertEquals(0,
ConnectPoolMgr.effectiveFlightMaxConnections(1, -1));
+ Env env = ConnectPoolTestSupport.envAllowing(100);
+ ConnectPoolMgr pool = new ConnectPoolMgr(1);
+ Assertions.assertEquals(0, pool.getFlightMaxConnections());
+
+
Assertions.assertTrue(pool.registerConnection(ConnectPoolTestSupport.flightSession(env,
ALICE, "t")) >= 0,
+ "a Flight session must be refused when the sub-quota is 0");
+ Assertions.assertEquals(0, pool.getFlightConnectionNum());
+ Assertions.assertNull(pool.getContextWithPeerIdentity("t"));
+
+ // The one pool slot is still open to a MySQL connection.
+ Assertions.assertEquals(-1,
pool.registerConnection(ConnectPoolTestSupport.mysqlConnection(env, BOB)));
+ Assertions.assertEquals(1, pool.getConnectionNum());
+ }
+
+ // One attempt must not hold the last pool slot across another attempt's
checks. Freeze one
+ // registration inside getMaxConn (the user check) -- where the earlier
increment-check-rollback code
+ // was already holding the pool increment -- and register a connection
that fits on another thread:
+ // it must be admitted. The staged code refused it (the frozen attempt's
pool increment made the pool
+ // look full); the single admission critical section admits it, because
the pool is reserved only
+ // inside the lock and getMaxConn is read before the lock. Without the
synchronized block this fails.
+ @Test
+ @Timeout(30)
+ public void
testConcurrentAdmissionDoesNotRefuseAFittingConnectionWhileAnotherIsInFlight()
throws Exception {
+ CountDownLatch frozenInGetMaxConn = new CountDownLatch(1);
+ CountDownLatch releaseFrozen = new CountDownLatch(1);
+ Auth auth = Mockito.mock(Auth.class);
+ Mockito.when(auth.getMaxConn(Mockito.anyString())).thenAnswer(inv -> {
+ if (ALICE.getQualifiedUser().equals(inv.getArgument(0))) {
+ frozenInGetMaxConn.countDown();
+ releaseFrozen.await();
+ }
+ return 100L;
+ });
+ Env env = Mockito.mock(Env.class);
+ Mockito.when(env.getAuth()).thenReturn(auth);
+ InternalCatalog internalCatalog = Mockito.mock(InternalCatalog.class);
+
Mockito.when(internalCatalog.getName()).thenReturn(InternalCatalog.INTERNAL_CATALOG_NAME);
+ Mockito.when(env.getInternalCatalog()).thenReturn(internalCatalog);
+
+ ConnectPoolMgr pool = new ConnectPoolMgr(2);
+ registered(pool, ConnectPoolTestSupport.mysqlConnection(env, CAROL),
1); // one slot left
+
+ ConnectContext frozen = ConnectPoolTestSupport.mysqlConnection(env,
ALICE);
+ frozen.setConnectionId(2);
+ ConnectContext fitting = ConnectPoolTestSupport.mysqlConnection(env,
BOB);
+ fitting.setConnectionId(3);
+
+ Thread frozenThread = new Thread(() ->
pool.registerConnection(frozen));
+ frozenThread.start();
+ Assertions.assertTrue(frozenInGetMaxConn.await(10, TimeUnit.SECONDS),
+ "the frozen attempt never reached getMaxConn");
+
+ AtomicInteger fittingResult = new AtomicInteger(Integer.MIN_VALUE);
+ Thread fittingThread = new Thread(() ->
fittingResult.set(pool.registerConnection(fitting)));
+ fittingThread.start();
+ fittingThread.join();
+ Assertions.assertEquals(-1, fittingResult.get(),
+ "a connection that fits the pool was refused while another
attempt's admission was in flight");
+
+ releaseFrozen.countDown();
+ frozenThread.join();
+ }
+}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/qe/ConnectPoolTestSupport.java
b/fe/fe-core/src/test/java/org/apache/doris/qe/ConnectPoolTestSupport.java
new file mode 100644
index 00000000000..7928b3198d3
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/qe/ConnectPoolTestSupport.java
@@ -0,0 +1,65 @@
+// 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.UserIdentity;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.datasource.InternalCatalog;
+import org.apache.doris.mysql.privilege.Auth;
+
+import org.mockito.Mockito;
+
+/**
+ * Connections as the pool sees them, for tests that register real contexts:
the pool asks the
+ * connection's Env for the user's connection limit and files the connection
under its user, so a
+ * context needs both before it can be registered.
+ */
+public final class ConnectPoolTestSupport {
+
+ private ConnectPoolTestSupport() {
+ }
+
+ /** An Env whose Auth allows every user {@code maxUserConnections}
connections. */
+ public static Env envAllowing(long maxUserConnections) {
+ Env env = Mockito.mock(Env.class);
+ Auth auth = Mockito.mock(Auth.class);
+
Mockito.when(auth.getMaxConn(Mockito.anyString())).thenReturn(maxUserConnections);
+ Mockito.when(env.getAuth()).thenReturn(auth);
+ // ConnectContext.setEnv starts the connection in the internal catalog.
+ InternalCatalog internalCatalog = Mockito.mock(InternalCatalog.class);
+
Mockito.when(internalCatalog.getName()).thenReturn(InternalCatalog.INTERNAL_CATALOG_NAME);
+ Mockito.when(env.getInternalCatalog()).thenReturn(internalCatalog);
+ return env;
+ }
+
+ /** A MySQL connection of {@code user}, registrable in a pool. */
+ public static ConnectContext mysqlConnection(Env env, UserIdentity user) {
+ ConnectContext ctx = new ConnectContext();
+ ctx.setEnv(env);
+ ctx.setCurrentUserIdentity(user);
+ return ctx;
+ }
+
+ /** An Arrow Flight SQL session of {@code user} under the bearer token
{@code peerIdentity}, registrable in a pool. */
+ public static ConnectContext flightSession(Env env, UserIdentity user,
String peerIdentity) {
+ ConnectContext ctx = ConnectContext.forFlight(peerIdentity);
+ ctx.setEnv(env);
+ ctx.setCurrentUserIdentity(user);
+ return ctx;
+ }
+}
diff --git
a/regression-test/suites/arrow_flight_sql_p0/test_connection_quota.groovy
b/regression-test/suites/arrow_flight_sql_p0/test_connection_quota.groovy
new file mode 100644
index 00000000000..3067a060a4b
--- /dev/null
+++ b/regression-test/suites/arrow_flight_sql_p0/test_connection_quota.groovy
@@ -0,0 +1,246 @@
+// 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.DriverManager
+import java.sql.SQLException
+import java.util.regex.Pattern
+import java.util.concurrent.TimeUnit
+
+import org.awaitility.Awaitility
+
+// The Flight SQL JDBC driver on the classpath shades Arrow Flight; its
FlightSqlClient is the
+// one a test can drive directly (see test_session_options).
+import
org.apache.arrow.driver.jdbc.shaded.org.apache.arrow.flight.CloseSessionRequest
+import org.apache.arrow.driver.jdbc.shaded.org.apache.arrow.flight.FlightClient
+import
org.apache.arrow.driver.jdbc.shaded.org.apache.arrow.flight.FlightRuntimeException
+import
org.apache.arrow.driver.jdbc.shaded.org.apache.arrow.flight.FlightStatusCode
+import org.apache.arrow.driver.jdbc.shaded.org.apache.arrow.flight.Location
+import
org.apache.arrow.driver.jdbc.shaded.org.apache.arrow.flight.sql.FlightSqlClient
+import
org.apache.arrow.driver.jdbc.shaded.org.apache.arrow.memory.RootAllocator
+
+// An Arrow Flight SQL session is a connection of the same pool a MySQL
connection is in: the
+// user's max_user_connections counts both, whichever came first, and a
refusal reads the same
+// over either protocol - over Flight as the RESOURCE_EXHAUSTED status of the
request that would
+// have opened the session, since a session opens on its first request, not
when the token is
+// issued.
+//
+// The limit is 4 rather than 1 because the token manager still keeps at most
+// max_user_connections / 2 bearer tokens per user and evicts the oldest -
session included - when
+// one more is issued; three MySQL connections take the slots a token cannot,
so that the second
+// Flight session is the one the pool refuses. (The per-user token cache goes
with PR-3.2.)
+//
+// Not in the 'arrow_flight_sql' group on purpose: `sql` stays the MySQL
control connection, and
+// the sessions under test are raw Flight SQL clients and plain MySQL
connections of their own.
+suite("test_connection_quota") {
+ String host = context.config.otherConfigs.get("extArrowFlightSqlHost")
+ int port = context.config.otherConfigs.get("extArrowFlightSqlPort") as int
+
+ String user = "flight_quota_user"
+ String password = "Quota_12345"
+ int limit = 4
+ // What both protocols say when the user's limit is reached; only the
count at the refusal varies.
+ Pattern refusal = Pattern.compile("^Reach limit of connections\\. Total:
(\\d+), User: ${limit}, Current: \\d+")
+
+ sql "DROP USER IF EXISTS '${user}'"
+ sql "CREATE USER '${user}' IDENTIFIED BY '${password}'"
+ sql "GRANT SELECT_PRIV ON *.* TO '${user}'"
+ // In cloud mode a query runs on a compute group; a user with no
USAGE_PRIV on one is refused with
+ // an INTERNAL error when it runs a statement (the Flight SELECT 1 below),
before the pool's quota
+ // is ever reached. Grant it the way the other cloud Flight suites do (see
test_auth_remote_ip).
+ if (isCloudMode()) {
+ def computeGroups = sql "SHOW COMPUTE GROUPS"
+ assertTrue(!computeGroups.isEmpty(), "cloud mode but SHOW COMPUTE
GROUPS returned nothing")
+ sql "GRANT USAGE_PRIV ON COMPUTE GROUP '${computeGroups[0][0]}' TO
'${user}'"
+ }
+ sql "SET PROPERTY FOR '${user}' 'max_user_connections' = '${limit}'"
+
+ // The connection quota is enforced per-FE, and
information_schema.processlist is a cluster-wide
+ // view when fetch_all_fe_for_system_table is on (the default): on a
multi-FE cluster the count then
+ // depends on which FE answers and can include connections on another FE,
so a per-FE quota test
+ // cannot rely on it. Anchor everything to the one FE that serves the
configured Flight endpoint --
+ // open the MySQL connections on that FE (its Flight host, the query port
and options from jdbcUrl)
+ // and count only that FE's own connections (fetch_all_fe_for_system_table
= false). Without this the
+ // MySQL connections, the Flight sessions and the counting query need not
land on the same FE and the
+ // count never settles (build 1049514).
+ def jdbcMatcher = (context.config.jdbcUrl =~
/^(jdbc:mysql:\/\/)[^\/:@]+(:\d+.*)$/)
+ assertTrue(jdbcMatcher.matches(),
+ "cannot derive the Flight FE's MySQL url from jdbcUrl:
${context.config.jdbcUrl}")
+ def feMysqlUrl = jdbcMatcher.replaceFirst("\$1${host}\$2")
+ def allocator = new RootAllocator()
+ def client = FlightClient.builder(allocator,
Location.forGrpcInsecure(host, port)).build()
+ def flight = new FlightSqlClient(client)
+ def mysqlConnections = []
+ def openTokens = []
+ def countConn = null
+ try {
+ // A dedicated connection on the Flight FE that reports only that FE's
connections, so the count
+ // is exactly the MySQL connections and Flight sessions this suite
opened there.
+ countConn = DriverManager.getConnection(feMysqlUrl,
context.config.jdbcUser, context.config.jdbcPassword)
+ countConn.createStatement().withCloseable { it.execute("SET
fetch_all_fe_for_system_table = false") }
+ // The anchoring above assumes the FE reached at feMysqlUrl is the one
serving the configured
+ // Flight endpoint (its host, jdbcUrl's query port). Assert it here --
otherwise the suite would
+ // fail later at the quota checks with a confusing pool-pointing error
-- by comparing this FE's
+ // own arrow_flight_sql_port to the configured Flight port.
forward_to_master keeps SHOW FRONTEND
+ // CONFIG reporting this FE rather than the master.
+ countConn.createStatement().withCloseable { it.execute("SET
forward_to_master = false") }
+ countConn.createStatement().withCloseable { st ->
+ def rs = st.executeQuery("SHOW FRONTEND CONFIG LIKE
'arrow_flight_sql_port'")
+ assertTrue(rs.next(), "SHOW FRONTEND CONFIG returned no
arrow_flight_sql_port")
+ assertEquals(port as String, rs.getString("Value"),
+ "the FE reached at ${feMysqlUrl} does not serve the Flight
endpoint on port ${port}; "
+ + "this suite needs MySQL, Flight and the count
query on the same FE")
+ }
+ // The user's connections as that FE's pool sees them: MySQL
connections and Flight sessions alike.
+ def connectionsOf = {
+ def st = countConn.createStatement()
+ try {
+ def rs = st.executeQuery(
+ "SELECT COUNT(*) FROM information_schema.processlist
WHERE User = '${user}'")
+ rs.next()
+ return rs.getInt(1)
+ } finally {
+ st.close()
+ }
+ }
+ // A closed MySQL connection leaves the pool on the frontend's nio
thread after the client's
+ // COM_QUIT, and Connector/J does not wait for that; so wait here
before the next connection
+ // is opened against the count.
+ def awaitConnections = { int expected ->
+ Awaitility.await().atMost(30, TimeUnit.SECONDS).pollInterval(200,
TimeUnit.MILLISECONDS)
+ .until { connectionsOf() == expected }
+ }
+ // Closes a session the way the drivers do, once; a token whose
session is already gone is
+ // UNAUTHENTICATED and needs nothing.
+ def closeSession = { cred ->
+ try {
+ flight.closeSession(new CloseSessionRequest(), cred)
+ } catch (FlightRuntimeException e) {
+ assertEquals(FlightStatusCode.UNAUTHENTICATED,
e.status().code())
+ }
+ openTokens.remove(cred)
+ }
+ // Whether the pool refused this token's session: its
RESOURCE_EXHAUSTED refusal message, or null
+ // when the session was admitted. The quota is checked when the
session is registered, before the
+ // probe query runs (DorisFlightSqlProducer.getFlightInfoStatement
gets the ConnectContext, which
+ // registers it, and only then executes the statement). So a
RESOURCE_EXHAUSTED is the refusal --
+ // the session never entered the pool and the frontend invalidated the
token -- while any other
+ // outcome (success, or the statement failing for a reason outside
this suite's scope, e.g. an
+ // environment-specific query error) means the session was admitted
and is in the pool; the
+ // connectionsOf() checks below verify that count. The token is
tracked before the request so a
+ // session that opened is closed at the end.
+ def refusalOf = { cred ->
+ openTokens << cred
+ try {
+ flight.execute("SELECT 1", cred).getEndpoints()
+ return null
+ } catch (FlightRuntimeException e) {
+ def code = e.status().code()
+ if (code == FlightStatusCode.RESOURCE_EXHAUSTED) {
+ openTokens.remove(cred)
+ return e.status().description()
+ }
+ if (code == FlightStatusCode.INTERNAL) {
+ // Admitted: the pool check passed and the session was
registered before the statement
+ // ran, so it counts (the connectionsOf() checks confirm
it). The probe query then
+ // failed for a reason outside a connection-quota test's
scope -- an environment-
+ // specific execution error -- which is logged here so it
stays diagnosable.
+ logger.warn("test_connection_quota: an admitted Flight
session's probe query failed "
+ + "with ${code}: ${e.status().description()}")
+ return null
+ }
+ // Neither a quota refusal nor an admitted session (e.g.
UNAUTHENTICATED); let it surface.
+ throw e
+ }
+ }
+ // The pool's limit named by a refusal, asserting the refusal is
worded as MySQL's is.
+ def totalOf = { String message ->
+ def m = refusal.matcher(message)
+ assertTrue(m.find(), "expected the MySQL wording of the refusal,
got: ${message}")
+ return m.group(1)
+ }
+ // A MySQL connection of the user that could not open: the refusal, or
null when it opened.
+ def mysqlRefusal = {
+ try {
+ mysqlConnections << DriverManager.getConnection(feMysqlUrl,
user, password)
+ return null
+ } catch (SQLException e) {
+ return e.getMessage()
+ }
+ }
+
+ // 1. Three MySQL connections, then the first Flight session: the
user's four.
+ (1..limit - 1).each { assertNull(mysqlRefusal(), "MySQL connection
${it} of ${limit - 1} was refused") }
+ awaitConnections(limit - 1)
+ def first = client.authenticateBasicToken(user, password).get()
+ assertNull(refusalOf(first), "the Flight session should open as the
user's last connection")
+ assertEquals(limit, connectionsOf(), "the Flight session is a
connection of the user's like the others")
+
+ // 2. The second Flight session is refused, in MySQL's words...
+ def second = client.authenticateBasicToken(user, password).get()
+ String flightRefused = refusalOf(second)
+ assertNotNull(flightRefused, "the second Flight session opened
although the user's limit is reached")
+ String total = totalOf(flightRefused)
+
+ // 3. ...and so is a MySQL connection, in the same words.
+ String mysqlRefused = mysqlRefusal()
+ assertNotNull(mysqlRefused, "the MySQL connection opened although the
user's limit is reached")
+ assertEquals(total, totalOf(mysqlRefused))
+
+ // 4. CloseSession releases the Flight session's connection: a MySQL
connection opens now, and
+ // once it is closed again a Flight session does.
+ assertEquals("CLOSED", flight.closeSession(new CloseSessionRequest(),
first).getStatus().name())
+ openTokens.remove(first)
+ awaitConnections(limit - 1)
+ assertNull(mysqlRefusal(), "the MySQL connection should open once the
Flight session is closed")
+ mysqlConnections.remove(mysqlConnections.size() - 1).close()
+ awaitConnections(limit - 1)
+ def third = client.authenticateBasicToken(user, password).get()
+ assertNull(refusalOf(third), "the Flight session should open once the
MySQL connection is closed")
+ assertEquals(limit, connectionsOf(), "the reopened Flight session must
be the user's ${limit}th connection")
+ closeSession(third)
+ awaitConnections(limit - 1)
+ } finally {
+ // Sessions outlive the client: close the ones still open, or a rerun
on the same frontend
+ // starts against a user whose slots they hold until wait_timeout
(DROP USER does not end them).
+ // Nothing here asserts or throws: the failure that brought the suite
here, if any, is the one
+ // reported, and every step of the cleanup runs.
+ def quietly = { String what, Closure step ->
+ try {
+ step()
+ } catch (Exception e) {
+ logger.warn("cleanup of test_connection_quota: ${what} failed:
${e.message}")
+ }
+ }
+ openTokens.each { cred ->
+ quietly("closing a session left open") {
+ try {
+ flight.closeSession(new CloseSessionRequest(), cred)
+ } catch (FlightRuntimeException e) {
+ // A token whose session is already gone is
UNAUTHENTICATED and needs nothing.
+ if (e.status().code() != FlightStatusCode.UNAUTHENTICATED)
{
+ throw e
+ }
+ }
+ }
+ }
+ mysqlConnections.each { conn -> quietly("closing a MySQL connection")
{ conn.close() } }
+ quietly("closing the count connection") { if (countConn != null)
countConn.close() }
+ quietly("closing the Flight client") { client.close() }
+ quietly("closing the allocator") { allocator.close() }
+ sql "DROP USER IF EXISTS '${user}'"
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]