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 a72dcaa17b1 [fix](fe) Keep an ADBC catalog's driver open until the 
calls still using it finish (#69874)
a72dcaa17b1 is described below

commit a72dcaa17b127265a5d713d811157d638d5f6fde
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Sat Oct 31 08:44:19 2026 +0000

    [fix](fe) Keep an ADBC catalog's driver open until the calls still using it 
finish (#69874)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #66331, #68353
    
    Problem Summary:
    
    **In short:** dropping or altering an ADBC catalog can crash the FE
    process. Closing the catalog frees the native ADBC objects at once,
    including the connections and results that other threads are still
    reading, such as the column-statistics loads that a query on the catalog
    has just started. This kills the FE in about one External regression run
    in 75, whatever the PR under test changes. With this PR, closing a
    client only refuses new calls, and the last call still in flight
    releases the driver as it leaves. DROP and ALTER CATALOG still return
    without waiting.
    
    This commit was first pushed as the last commit of #68353, which is
    still in review. It shares nothing with the rest of that PR, so it is
    carved out here to land on its own.
    
    #### Background
    
    An ADBC catalog (#66331) reads its metadata through `AdbcClient`, which
    owns one native `AdbcDatabase` and one Arrow allocator per catalog.
    Every metadata call borrows a fresh connection through
    `AdbcClient.withConnection` and closes it afterwards. FE talks to the C
    driver through adbc-driver-jni 0.24. In that bridge, closing a parent
    closes everything still open under it, on the closing thread:
    `JniDatabase.close()` closes every connection still open on the
    database, and `JniConnection.close()` closes every result (`getObjects`
    and similar readers) still open on the connection. The bridge only
    checks a handle before a call starts, and the C driver does not check at
    all, so freeing an object that another thread is still using is a
    use-after-free in native code, which takes the whole JVM down rather
    than failing one call.
    
    Column statistics reach the driver too. When a query plans against an
    ADBC table, the STATS_FETCH pool loads column statistics in the
    background. A load that finds nothing in the statistics table asks the
    table itself: `PluginDrivenExternalTable.getColumnStatistic` →
    `AdbcConnectorMetadata.getTableHandle` → `withConnection` →
    `getObjects`. With `"meta.cache.adbc.metadata.enable" = "false"`, every
    such load calls into the driver.
    
    #### The problem, and what it cost
    
    - `AdbcClient.close()` closed the database and then the allocator
    immediately, whatever was still in flight. DROP CATALOG and ALTER
    CATALOG ... SET PROPERTIES close the catalog's connector this way, on
    their own thread (since #69665, on the `ExternalResourceReclaimer` pool;
    before that, synchronously on the statement thread, with the same race).
    - A statistics load that was reading a `getObjects` result, or was
    inside the driver on its connection, then read memory that had just been
    freed. Where it dies depends on where that thread is at that moment.
    - In CI, the External regression pipeline lost its FE in 11 of the 834
    runs between 2026-10-22 and 2026-10-30. Each time, 54 to 56 suites then
    failed with `Communications link failure`, so the PR under test showed a
    red run it did not cause. All 11 died at the same moment:
    `test_adbc_sqlite_catalog_scan` drops its catalog with the metadata
    cache turned off right after querying it. In the same millisecond,
    fe.log shows the allocator closing with `Memory was leaked by query` and
    a statistics load failing with `Failed to read the ADBC getObjects
    result: ArrowArrayStream is already closed`. That is the same race,
    caught by a thread that came back only after the close had finished.
    
    | FE crash in `fe.out` | Runs |
    | --- | --- |
    | `SIGSEGV` in
    `nanoarrow::ArrayStreamFactory<VectorArrayStream>::get_next_wrapper+0x4`
    | 6 |
    | `SIGSEGV` in `PrivateArrowSchemaDeepCopy+0x46` | 2 |
    | glibc `double free or corruption (fasttop)` | 2 |
    | no message | 1 |
    | `SIGSEGV` in `sqlite3FindTable` (an earlier run, 2026-10-06) | 1 |
    
    - Users can hit this too, not only CI: DROP or ALTER an ADBC catalog
    while a statement or a statistics load is still reading it.
    
    #### How this PR fixes it
    
    - `AdbcClient` counts the calls inside `withConnection` (`enter` /
    `leave`, under the client's monitor, which also opens the database on
    first use).
    - `close()` marks the client closed, so new calls are refused with
    `AdbcClient has been closed`. It releases the database and the allocator
    itself only when no call is in flight. Otherwise it returns at once, and
    the last call to leave releases them.
    - A deferred release that fails is logged with the driver's path, as the
    catalog already logs a failed connector close. It is not thrown at that
    last caller, whose own work succeeded and which did not close anything.
    - `close()` never waits for the calls in flight, so DROP and ALTER
    CATALOG are not held up by a slow remote source.
    
    #### Results
    
    Unit tests (0 skipped). The first case runs against the real JNI bridge
    and the SQLite driver, the others against a recording database:
    
    | `AdbcClientTest` case | `close()` releasing at once, as before this PR
    | This PR |
    | --- | --- | --- |
    | a call still on its connection when the client is closed keeps using
    it | fails: `Native connection handle is closed` | passes |
    | a call still reading a `getObjects` result when the client is closed
    reads it to the end | fails: `the database was released under an open
    connection` | passes |
    | the last call to leave a closed client releases the database exactly
    once, after it leaves | fails: `the database was released under an open
    connection` | passes |
    | a release that fails as the last call leaves does not fail that call |
    fails: `the database was released under an open connection` | passes |
    | an idle client releases at once | passes | passes |
    
    Freeing only the allocator at once, and leaving the database to the last
    call, fails the `getObjects` case alone: `Attempting operation on
    allocator when allocator is closed`.
    
    A stress program (not committed) closes a client 200 times while four
    threads loop over `getObjects` and `getTableSchema` through
    `withConnection` against the SQLite driver:
    
    | | `AdbcClient` of master | This PR |
    | --- | --- | --- |
    | Runs | 5 | 5 |
    | JVM crashed | 5 of 5, 1.1 to 1.3 s after the JVM started: `SIGSEGV` in
    `PrivateArrowSchemaDeepCopy+0x40`, under
    `ArrowArrayStreamReader.readSchema` ← `AdbcObjectsReader.readNamespaces`
    ← `AdbcClient.withConnection` | 0 of 5 |
    | Closes completed per run | crashed before finishing | 200 |
    | Metadata calls per run | crashed before reporting | 6,019 to 7,238 |
    | Calls the close reached while they were using the driver | | 0 |
    | Failed closes, failed deferred releases | | 0, 0 (checked with WARN
    logging on, where the unit test's deliberate release failure shows up as
    one warning) |
    
    The stress runs used the macOS arm64 build of the same SQLite driver, so
    the offset differs from the Linux one in CI.
    
    #### Classes, and how they call each other
    
    - `AdbcClient`: `withConnection` brackets the body with `enter` /
    `leave`; `close` refuses new calls and releases only when idle;
    `release` closes the database, then the allocator; `openDatabase` is the
    one place the database is created, so a test can stand a recording
    database in for the driver.
    - `AdbcClientTest`: five new cases, one against the real driver and four
    against a recording database. The recording connection answers
    `getObjects` with a result built in memory in the client's allocator:
    reading a driver's result into Arrow loads arrow-c-data's JNI shim,
    which needs CXXABI_1.3.9 and does not load on the FE UT agent (see
    `AdbcNativeTestSupport`, #68510).
    - `AdbcObjectsReaderTest`: `response`, `catalog` and `schema` are
    package-private, so `AdbcClientTest` builds that result with them.
    
    ```
    STATS_FETCH: ColumnStatisticsCacheLoader.doLoad
      -> PluginDrivenExternalTable.getColumnStatistic
         -> AdbcConnectorMetadata.getTableHandle (namespaces, table names)
            -> AdbcClient.withConnection
                 enter()   refused once closed; callsInFlight++
                 -> AdbcConnection.getObjects -> 
AdbcObjectsReader.readNamespaces / readTableNames
                 leave()   callsInFlight--; the last one out of a closed client 
-> release()
    
    DROP CATALOG / ALTER CATALOG ... SET PROPERTIES
      -> ExternalCatalog.onClose / resetToUninitialized -> 
ExternalResourceReclaimer
         -> AdbcConnector.close -> AdbcClient.close()
              closed = true; release() now only if no call is in flight
              release(): AdbcDatabase.close (closes its connections and their 
results) -> allocator.close
    ```
    
    Not changed: `AdbcConnector`, the catalog lifecycle and
    `ExternalResourceReclaimer`, BE, and the regression suites;
    `test_adbc_sqlite_catalog_scan` stays as it is, since what it exposes is
    this bug.
---
 .../apache/doris/connector/adbc/AdbcClient.java    | 131 ++++++++----
 .../doris/connector/adbc/AdbcClientTest.java       | 229 ++++++++++++++++++++-
 .../connector/adbc/AdbcObjectsReaderTest.java      |   9 +-
 3 files changed, 325 insertions(+), 44 deletions(-)

diff --git 
a/fe/fe-connector/fe-connector-adbc/src/main/java/org/apache/doris/connector/adbc/AdbcClient.java
 
b/fe/fe-connector/fe-connector-adbc/src/main/java/org/apache/doris/connector/adbc/AdbcClient.java
index d19d18b31a4..e5c3f7e7a66 100644
--- 
a/fe/fe-connector/fe-connector-adbc/src/main/java/org/apache/doris/connector/adbc/AdbcClient.java
+++ 
b/fe/fe-connector/fe-connector-adbc/src/main/java/org/apache/doris/connector/adbc/AdbcClient.java
@@ -26,6 +26,8 @@ import org.apache.arrow.adbc.core.AdbcException;
 import org.apache.arrow.adbc.driver.jni.JniDriver;
 import org.apache.arrow.memory.BufferAllocator;
 import org.apache.arrow.memory.RootAllocator;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
 
 import java.io.Closeable;
 import java.nio.file.Path;
@@ -48,9 +50,20 @@ import java.util.Map;
  * constructor: an FE follower replaying the edit log builds every catalog, 
and its filesystem layout need
  * not match the leader's, so a missing driver file would otherwise stop FE 
from starting instead of
  * failing the one catalog that cannot work.
+ *
+ * <p><b>Closing never frees what a call is still using.</b> A catalog is 
dropped or altered on one thread
+ * while others may still be inside {@link #withConnection} for it -- a 
statistics loader, a statement being
+ * planned. Closing the {@code AdbcDatabase} closes every connection still 
open on it as well (the JNI driver
+ * closes its children first), and native driver state freed under a thread 
that is inside the driver
+ * crashes the whole FE process rather than failing that one call. So {@link 
#close} only refuses new calls,
+ * and the native objects are released by whoever leaves last: {@code close} 
itself when no call is in
+ * flight, otherwise the last call to finish. Waiting for those calls inside 
{@code close} instead would hold
+ * DROP and ALTER CATALOG for as long as a remote source takes to answer.
  */
 public class AdbcClient implements Closeable {
 
+    private static final Logger LOG = LogManager.getLogger(AdbcClient.class);
+
     private final Path driverPath;
     private final String driverUrl;
     private final String entrypoint;
@@ -59,9 +72,12 @@ public class AdbcClient implements Closeable {
     private final String password;
     private final Map<String, String> driverOptions;
 
-    private volatile BufferAllocator allocator;
-    private volatile AdbcDatabase database;
-    private volatile boolean closed;
+    // All guarded by this.
+    private BufferAllocator allocator;
+    private AdbcDatabase database;
+    private boolean closed;
+    /** Calls inside {@link #withConnection}; the last one to leave a closed 
client releases the driver. */
+    private int callsInFlight;
 
     public AdbcClient(Path driverPath, String driverUrl, String entrypoint, 
String uri,
             String user, String password, Map<String, String> driverOptions) {
@@ -83,7 +99,7 @@ public class AdbcClient implements Closeable {
      * cost does not show.
      */
     public <T> T withConnection(AdbcConnectionCall<T> body) {
-        AdbcDatabase db = getOrOpenDatabase();
+        AdbcDatabase db = enter();
         ClassLoader previous = Thread.currentThread().getContextClassLoader();
         try {
             // The plugin loads child-first and the ADBC/Arrow classes live in 
it; pin the context
@@ -101,43 +117,60 @@ public class AdbcClient implements Closeable {
             throw new DorisConnectorException("ADBC operation failed: " + 
e.getMessage(), e);
         } finally {
             Thread.currentThread().setContextClassLoader(previous);
+            leave();
         }
     }
 
-    private AdbcDatabase getOrOpenDatabase() {
+    /** Counts the calling thread in, opening the database on first use. */
+    private synchronized AdbcDatabase enter() {
         if (closed) {
             throw new DorisConnectorException("AdbcClient has been closed");
         }
-        AdbcDatabase db = database;
-        if (db != null) {
-            return db;
+        if (database == null) {
+            AdbcDriverPathResolver.checkExists(driverPath, driverUrl);
+            ClassLoader previous = 
Thread.currentThread().getContextClassLoader();
+            try {
+                
Thread.currentThread().setContextClassLoader(getClass().getClassLoader());
+                if (allocator == null) {
+                    allocator = new RootAllocator();
+                }
+                database = openDatabase(allocator, buildParameters());
+            } catch (AdbcException e) {
+                throw translate(e, "Failed to open the ADBC driver " + 
driverPath);
+            } catch (UnsatisfiedLinkError e) {
+                throw new DorisConnectorException("Failed to load the ADBC JNI 
bridge."
+                        + " FE loads it from the directory named by the JVM 
property"
+                        + " arrow.adbc.driver.jni.library.path (set in 
fe.conf, normally"
+                        + " ${DORIS_HOME}/lib): " + e.getMessage(), e);
+            } finally {
+                Thread.currentThread().setContextClassLoader(previous);
+            }
         }
+        callsInFlight++;
+        return database;
+    }
+
+    /** Counts the calling thread out; the last call to leave a closed client 
releases the driver. */
+    private void leave() {
         synchronized (this) {
-            if (closed) {
-                throw new DorisConnectorException("AdbcClient has been 
closed");
+            callsInFlight--;
+            if (!closed || callsInFlight > 0) {
+                return;
             }
-            if (database == null) {
-                AdbcDriverPathResolver.checkExists(driverPath, driverUrl);
-                ClassLoader previous = 
Thread.currentThread().getContextClassLoader();
-                try {
-                    
Thread.currentThread().setContextClassLoader(getClass().getClassLoader());
-                    if (allocator == null) {
-                        allocator = new RootAllocator();
-                    }
-                    database = new 
JniDriver(allocator).open(buildParameters());
-                } catch (AdbcException e) {
-                    throw translate(e, "Failed to open the ADBC driver " + 
driverPath);
-                } catch (UnsatisfiedLinkError e) {
-                    throw new DorisConnectorException("Failed to load the ADBC 
JNI bridge."
-                            + " FE loads it from the directory named by the 
JVM property"
-                            + " arrow.adbc.driver.jni.library.path (set in 
fe.conf, normally"
-                            + " ${DORIS_HOME}/lib): " + e.getMessage(), e);
-                } finally {
-                    Thread.currentThread().setContextClassLoader(previous);
-                }
-            }
-            return database;
         }
+        // close() found this call still inside the driver and left the 
release to it. A failure is logged, as
+        // the catalog logs a failed close 
(PluginDrivenExternalCatalog#closeResources), not thrown: the call
+        // itself is done, and its caller is not the one that closed the 
catalog.
+        try {
+            release();
+        } catch (RuntimeException e) {
+            LOG.warn("Failed to release the ADBC driver {} of a closed 
catalog", driverPath, e);
+        }
+    }
+
+    /** The one place the database is created, so a test can stand a recording 
one in for the driver. */
+    AdbcDatabase openDatabase(BufferAllocator allocator, Map<String, Object> 
parameters) throws AdbcException {
+        return new JniDriver(allocator).open(parameters);
     }
 
     private Map<String, Object> buildParameters() {
@@ -184,20 +217,40 @@ public class AdbcClient implements Closeable {
         return new DorisConnectorException(sb.toString(), e);
     }
 
+    /**
+     * Refuses new calls, and releases the driver now unless a call is still 
inside it -- then the last such
+     * call releases it as it leaves. Does not wait for those calls; see the 
class comment.
+     */
     @Override
-    public synchronized void close() {
-        closed = true;
+    public void close() {
+        synchronized (this) {
+            closed = true;
+            if (callsInFlight > 0) {
+                return;
+            }
+        }
+        release();
+    }
+
+    /** Frees the database and then its allocator; a client never used, or 
already released, holds neither. */
+    private void release() {
+        AdbcDatabase db;
+        BufferAllocator alloc;
+        synchronized (this) {
+            db = database;
+            alloc = allocator;
+            database = null;
+            allocator = null;
+        }
         try {
-            if (database != null) {
-                database.close();
+            if (db != null) {
+                db.close();
             }
         } catch (Exception e) {
             throw new DorisConnectorException("Failed to close the ADBC 
database: " + e.getMessage(), e);
         } finally {
-            database = null;
-            if (allocator != null) {
-                allocator.close();
-                allocator = null;
+            if (alloc != null) {
+                alloc.close();
             }
         }
     }
diff --git 
a/fe/fe-connector/fe-connector-adbc/src/test/java/org/apache/doris/connector/adbc/AdbcClientTest.java
 
b/fe/fe-connector/fe-connector-adbc/src/test/java/org/apache/doris/connector/adbc/AdbcClientTest.java
index 05583d94192..f87978c97c0 100644
--- 
a/fe/fe-connector/fe-connector-adbc/src/test/java/org/apache/doris/connector/adbc/AdbcClientTest.java
+++ 
b/fe/fe-connector/fe-connector-adbc/src/test/java/org/apache/doris/connector/adbc/AdbcClientTest.java
@@ -19,18 +19,35 @@ package org.apache.doris.connector.adbc;
 
 import org.apache.doris.connector.spi.DorisConnectorException;
 
+import org.apache.arrow.adbc.core.AdbcConnection;
+import org.apache.arrow.adbc.core.AdbcDatabase;
 import org.apache.arrow.adbc.core.AdbcException;
+import org.apache.arrow.adbc.core.AdbcStatement;
 import org.apache.arrow.adbc.core.AdbcStatusCode;
+import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.vector.ipc.ArrowReader;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.List;
 import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
 
 /**
  * Exercises {@link AdbcClient} against the real JNI bridge and the real 
SQLite driver from thirdparty.
- * Skips (loudly) when those libraries are absent -- see {@link 
AdbcNativeTestSupport}.
+ * Skips (loudly) when those libraries are absent -- see {@link 
AdbcNativeTestSupport}. The tests of when the
+ * driver is released stand a {@link RecordingClient} in for it instead, so 
they run everywhere.
  */
 class AdbcClientTest {
 
@@ -74,6 +91,135 @@ class AdbcClientTest {
         Assertions.assertTrue(e.getMessage().contains("closed"), 
e.getMessage());
     }
 
+    @Test
+    void closeLeavesACallThatIsStillUsingTheDriverAlone(@TempDir Path tempDir) 
throws Exception {
+        AdbcClient client = sqliteClient(tempDir.resolve("probe.db"));
+        CountDownLatch insideCall = new CountDownLatch(1);
+        CountDownLatch mayContinue = new CountDownLatch(1);
+        ExecutorService caller = Executors.newSingleThreadExecutor();
+        try {
+            Future<String> call = caller.submit(() -> 
client.withConnection(connection -> {
+                insideCall.countDown();
+                Assertions.assertTrue(mayContinue.await(30, TimeUnit.SECONDS));
+                // Back into the driver after the client was closed, as a 
statistics loader is when the catalog
+                // it reads is dropped under it. Closing the database used to 
close this connection too; here
+                // the JNI handle check turns that into an error, but a thread 
already inside the driver read
+                // freed memory and took FE down with it (SIGSEGV in 
sqlite3FindTable).
+                return connection.getCurrentCatalog();
+            }));
+            Assertions.assertTrue(insideCall.await(30, TimeUnit.SECONDS));
+            closeWithoutWaitingForCalls(client);
+            mayContinue.countDown();
+            Assertions.assertEquals("main", call.get(30, TimeUnit.SECONDS));
+        } finally {
+            caller.shutdownNow();
+        }
+    }
+
+    @Test
+    void closeLeavesAResultACallIsStillReadingAlone(@TempDir Path tempDir) 
throws Exception {
+        RecordingClient client = new 
RecordingClient(Files.createFile(tempDir.resolve("driver.so")));
+        CountDownLatch insideCall = new CountDownLatch(1);
+        CountDownLatch mayContinue = new CountDownLatch(1);
+        ExecutorService caller = Executors.newSingleThreadExecutor();
+        try {
+            Future<List<AdbcNamespace>> call = caller.submit(() -> 
client.withConnection(connection -> {
+                try (ArrowReader reader = connection.getObjects(
+                        AdbcConnection.GetObjectsDepth.DB_SCHEMAS, null, null, 
null, null, null)) {
+                    insideCall.countDown();
+                    Assertions.assertTrue(mayContinue.await(30, 
TimeUnit.SECONDS));
+                    // The getObjects result behind getTableHandle, read after 
the client was closed, as a
+                    // statistics load reads it when the catalog is dropped 
under it. Closing the client used to
+                    // release the database, and with it this connection and 
its result, and then the allocator the
+                    // result lives in; with the SQLite driver, a read caught 
in between took FE down (SIGSEGV in
+                    // get_next_wrapper or PrivateArrowSchemaDeepCopy).
+                    return AdbcObjectsReader.readNamespaces(reader);
+                }
+            }));
+            Assertions.assertTrue(insideCall.await(30, TimeUnit.SECONDS));
+            closeWithoutWaitingForCalls(client);
+            mayContinue.countDown();
+            Assertions.assertEquals(List.of(new AdbcNamespace("main", "")), 
call.get(30, TimeUnit.SECONDS));
+        } finally {
+            caller.shutdownNow();
+        }
+    }
+
+    @Test
+    void theLastCallToLeaveAClosedClientReleasesTheDatabase(@TempDir Path 
tempDir) throws Exception {
+        RecordingClient client = new 
RecordingClient(Files.createFile(tempDir.resolve("driver.so")));
+        CountDownLatch insideCall = new CountDownLatch(1);
+        CountDownLatch mayLeave = new CountDownLatch(1);
+        ExecutorService caller = Executors.newSingleThreadExecutor();
+        try {
+            Future<String> call = caller.submit(() -> 
client.withConnection(connection -> {
+                insideCall.countDown();
+                Assertions.assertTrue(mayLeave.await(30, TimeUnit.SECONDS));
+                return "done";
+            }));
+            Assertions.assertTrue(insideCall.await(30, TimeUnit.SECONDS));
+
+            closeWithoutWaitingForCalls(client);
+            // Refused from now on, yet nothing is released while a call is 
still inside.
+            DorisConnectorException e = 
Assertions.assertThrows(DorisConnectorException.class,
+                    () -> client.withConnection(connection -> "late"));
+            Assertions.assertTrue(e.getMessage().contains("closed"), 
e.getMessage());
+            Assertions.assertEquals(0, client.database.closes.get());
+
+            mayLeave.countDown();
+            Assertions.assertEquals("done", call.get(30, TimeUnit.SECONDS));
+            Assertions.assertEquals(1, client.database.closes.get());
+        } finally {
+            caller.shutdownNow();
+        }
+        client.close();
+        Assertions.assertEquals(1, client.database.closes.get());
+    }
+
+    @Test
+    void releaseFailingAsTheLastCallLeavesIsNotThatCallsFailure(@TempDir Path 
tempDir) throws Exception {
+        RecordingClient client = new 
RecordingClient(Files.createFile(tempDir.resolve("driver.so")),
+                new RecordingDatabase(true));
+        CountDownLatch insideCall = new CountDownLatch(1);
+        CountDownLatch mayLeave = new CountDownLatch(1);
+        ExecutorService caller = Executors.newSingleThreadExecutor();
+        try {
+            Future<String> call = caller.submit(() -> 
client.withConnection(connection -> {
+                insideCall.countDown();
+                Assertions.assertTrue(mayLeave.await(30, TimeUnit.SECONDS));
+                return "done";
+            }));
+            Assertions.assertTrue(insideCall.await(30, TimeUnit.SECONDS));
+            closeWithoutWaitingForCalls(client);
+
+            mayLeave.countDown();
+            // The call did its work; the driver failing to close as it leaves 
is logged, not thrown at a caller that
+            // did not close the catalog - a statistics load or a statement 
that happened to be the last one in.
+            Assertions.assertEquals("done", call.get(30, TimeUnit.SECONDS));
+            Assertions.assertEquals(1, client.database.closes.get());
+        } finally {
+            caller.shutdownNow();
+        }
+        // Released, if unsuccessfully: nothing is closed again.
+        client.close();
+        Assertions.assertEquals(1, client.database.closes.get());
+    }
+
+    @Test
+    void closingAClientNoCallIsUsingReleasesTheDatabaseAtOnce(@TempDir Path 
tempDir) throws Exception {
+        RecordingClient client = new 
RecordingClient(Files.createFile(tempDir.resolve("driver.so")));
+        Assertions.assertEquals("done", client.withConnection(connection -> 
"done"));
+        Assertions.assertEquals(0, client.database.closes.get());
+
+        client.close();
+        Assertions.assertEquals(1, client.database.closes.get());
+    }
+
+    /** Fails instead of hanging the run if close() ever starts waiting for 
the calls still in flight. */
+    private static void closeWithoutWaitingForCalls(AdbcClient client) throws 
Exception {
+        CompletableFuture.runAsync(client::close).get(30, TimeUnit.SECONDS);
+    }
+
     @Test
     void missingDriverFileFailsAtFirstUseNotAtConstruction(@TempDir Path 
tempDir) {
         // An FE follower replaying the edit log constructs every catalog; if 
construction reached the
@@ -130,4 +276,85 @@ class AdbcClientTest {
         Assertions.assertTrue(message.contains("42P01"), message);
         Assertions.assertTrue(message.contains("7"), message);
     }
+
+    /** An {@link AdbcClient} whose database records how it is used instead of 
reaching a driver. */
+    private static final class RecordingClient extends AdbcClient {
+
+        private final RecordingDatabase database;
+
+        private RecordingClient(Path driver) {
+            this(driver, new RecordingDatabase(false));
+        }
+
+        private RecordingClient(Path driver, RecordingDatabase database) {
+            super(driver, driver.toString(), null, "file:/unused.db", null, 
null, Map.of());
+            this.database = database;
+        }
+
+        @Override
+        AdbcDatabase openDatabase(BufferAllocator allocator, Map<String, 
Object> parameters) {
+            database.allocator = allocator;
+            return database;
+        }
+    }
+
+    private static final class RecordingDatabase implements AdbcDatabase {
+
+        private final AtomicInteger openConnections = new AtomicInteger();
+        private final AtomicInteger closes = new AtomicInteger();
+        private final boolean closeFails;
+        /** The client's allocator, handed over when the client opens its 
database, as the JNI driver is given it. */
+        private BufferAllocator allocator;
+
+        private RecordingDatabase(boolean closeFails) {
+            this.closeFails = closeFails;
+        }
+
+        @Override
+        public AdbcConnection connect() {
+            openConnections.incrementAndGet();
+            return new AdbcConnection() {
+                @Override
+                public AdbcStatement createStatement() {
+                    throw new AssertionError("these tests run no statement");
+                }
+
+                @Override
+                public ArrowReader getInfo(int[] infoCodes) {
+                    throw new AssertionError("these tests ask for no info");
+                }
+
+                @Override
+                public ArrowReader getObjects(AdbcConnection.GetObjectsDepth 
depth, String catalogPattern,
+                        String dbSchemaPattern, String tableNamePattern, 
String[] tableTypes,
+                        String columnNamePattern) {
+                    // SQLite's answer, in the client's allocator, which is 
where a driver's result lives. Not read
+                    // from the SQLite driver itself: reading a driver's 
result into Arrow needs arrow-c-data's JNI
+                    // shim, which FE UT cannot load on every host -- see 
AdbcNativeTestSupport.
+                    try {
+                        return AdbcObjectsReaderTest.response(allocator,
+                                AdbcObjectsReaderTest.catalog("main", 
AdbcObjectsReaderTest.schema("", null)));
+                    } catch (IOException e) {
+                        throw new UncheckedIOException(e);
+                    }
+                }
+
+                @Override
+                public void close() {
+                    openConnections.decrementAndGet();
+                }
+            };
+        }
+
+        @Override
+        public void close() throws AdbcException {
+            // The JNI driver closes every connection still open on the 
database first, from this thread --
+            // whichever thread is still using one.
+            Assertions.assertEquals(0, openConnections.get(), "the database 
was released under an open connection");
+            closes.incrementAndGet();
+            if (closeFails) {
+                throw new AdbcException("the driver failed to close the 
database", null, AdbcStatusCode.IO, null, 0);
+            }
+        }
+    }
 }
diff --git 
a/fe/fe-connector/fe-connector-adbc/src/test/java/org/apache/doris/connector/adbc/AdbcObjectsReaderTest.java
 
b/fe/fe-connector/fe-connector-adbc/src/test/java/org/apache/doris/connector/adbc/AdbcObjectsReaderTest.java
index 92da09f5f62..1275c1f597e 100644
--- 
a/fe/fe-connector/fe-connector-adbc/src/test/java/org/apache/doris/connector/adbc/AdbcObjectsReaderTest.java
+++ 
b/fe/fe-connector/fe-connector-adbc/src/test/java/org/apache/doris/connector/adbc/AdbcObjectsReaderTest.java
@@ -37,6 +37,7 @@ import org.junit.jupiter.api.Test;
 
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
+import java.io.IOException;
 import java.nio.channels.Channels;
 import java.nio.charset.StandardCharsets;
 import java.util.List;
@@ -232,12 +233,12 @@ class AdbcObjectsReaderTest {
     // ========= fixture =========
 
     /** One catalog row: the name the source reported, and its schema entries 
(null at depth CATALOGS). */
-    private static Object[] catalog(String name, Object[]... schemas) {
+    static Object[] catalog(String name, Object[]... schemas) {
         return new Object[] {name, schemas == null ? null : List.of(schemas)};
     }
 
     /** One schema entry: the name the source reported (SQLite's is the empty 
string) and its tables. */
-    private static Object[] schema(String name, Object[]... tables) {
+    static Object[] schema(String name, Object[]... tables) {
         return new Object[] {name, tables == null ? null : List.of(tables)};
     }
 
@@ -249,9 +250,9 @@ class AdbcObjectsReaderTest {
     /**
      * Builds the getObjects response these rows describe and returns it as 
the reader a driver would have
      * handed over -- written to an IPC stream and read back, so the reader 
sees real Arrow vectors rather
-     * than the fixture's own objects.
+     * than the fixture's own objects. {@link AdbcClientTest} hands it out as 
a driver's {@code getObjects} result.
      */
-    private static ArrowReader response(BufferAllocator allocator, Object[]... 
rows) throws Exception {
+    static ArrowReader response(BufferAllocator allocator, Object[]... rows) 
throws IOException {
         VectorSchemaRoot root = VectorSchemaRoot.create(GET_OBJECTS_SCHEMA, 
allocator);
         VarCharVector catalogs = (VarCharVector) root.getVector(CATALOG_NAME);
         ListVector schemas = (ListVector) root.getVector(CATALOG_DB_SCHEMAS);


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

Reply via email to