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

CurtHagenlocher pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-adbc.git


The following commit(s) were added to refs/heads/main by this push:
     new 2be84b1a5 feat(csharp): add async and cancellation support to the base 
classes (#4895)
2be84b1a5 is described below

commit 2be84b1a5cd0319af107081a6e0233484a38588c
Author: Curt Hagenlocher <[email protected]>
AuthorDate: Thu Oct 15 12:23:23 2026 -0700

    feat(csharp): add async and cancellation support to the base classes (#4895)
    
    ## What's changed
    
    This adds asynchronous methods that take a `CancellationToken` to the C#
    base classes (`AdbcDriver`, `AdbcDatabase`, `AdbcConnection` and
    `AdbcStatement`). The base classes also now implement
    `IAsyncDisposable`.
    
    This is the third of several PRs implementing ADBC 1.1. #4884 fixed bugs
    in the C importer and exporter, and #4885 added the 1.1 APIs to the base
    classes. The Client changes and wider DuckDB tests come next.
    
    ### Backwards compatibility
    
    Every new member is virtual and has a default implementation, so
    existing drivers compile and behave as before:
    
    - **Default async methods** run the synchronous method on the thread
      pool. When the token is cancelled, they call the object's `Cancel`
      method, so a driver that supports cancellation stops work without
      overriding anything.
    - **Cancellation is reported only when it took effect.** An
      `OperationCanceledException` is thrown when the token was cancelled
      and either `Cancel` succeeded or the driver reported
      `AdbcStatusCode.Cancelled`. Otherwise the driver's own error is
      rethrown, so a driver that can't cancel doesn't have its failures
      hidden.
    - **Existing async overrides are honored.** The existing
      `ExecuteQueryAsync()` and `ExecuteUpdateAsync()` keep their
      signatures. The new `ExecuteQueryAsync(CancellationToken)` and
      `ExecuteUpdateAsync(CancellationToken)` call them by default, adding
      cancellation through `Cancel`. That way a driver that already
      overrides the parameterless versions keeps its asynchronous
      implementation.
    - **Existing calls bind the same way.** No new overload changes which
      method an existing call resolves to.
    
    ### New APIs
    
    - **`AdbcDriver`:** `OpenAsync`.
    - **`AdbcDatabase`:** `ConnectAsync` and `ConnectWithTypedOptionsAsync`.
    - **`AdbcConnection`:**
      - `GetInfoAsync`, `GetObjectsAsync`, `GetTableSchemaAsync` and
        `GetTableTypesAsync`
      - `GetStatisticsAsync` and `GetStatisticsNamesAsync`
      - `ReadPartitionAsync`
      - `CommitAsync` and `RollbackAsync`
      - `SetOptionAsync` and `GetOptionAsync`
    - **`AdbcStatement`:**
      - `ExecuteQueryAsync(CancellationToken)` and
        `ExecuteUpdateAsync(CancellationToken)`
      - `ExecuteSchemaAsync` and `ExecutePartitionedAsync`
      - `GetParameterSchemaAsync` and `PrepareAsync`
    - **`DisposeAsync`** on the database, connection and statement.
    
    There are no C interop changes. Imported drivers get the default async
    methods, which call the synchronous ones. Those already include the 1.0
    fallbacks from #4885.
    
    ## Testing
    
    - **`BaseClassDefaultsTests`:** covers the defaults with drivers that
      implement only the synchronous members:
      - tokens cancelled before and during a call
      - drivers whose `Cancel` succeeds or fails
      - existing `ExecuteQueryAsync()`/`ExecuteUpdateAsync()` overrides
      - `DisposeAsync`
    - **`ExportedDriverRoundTrip11Tests`:**
      - a `CancellationToken` cancels a statement running in a driver that
        was exported and imported again
      - async option round trips
      - async calls on an imported 1.0 driver report the same statuses as
        the synchronous calls
    - **AoT interop tests:** `ExecuteQueryAsync` with a token round trips
      through the native AoT fixture.
    - **Results:**
      - The full solution builds without warnings, and
        `dotnet format --verify-no-changes` is clean.
      - The core test project passes on net10.0, net8.0 and net472.
      - The AoT interop tests pass against a fixture published from this
        branch.
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 csharp/src/Apache.Arrow.Adbc/AdbcAsync.cs          | 156 +++++
 csharp/src/Apache.Arrow.Adbc/AdbcConnection.cs     | 175 +++++-
 csharp/src/Apache.Arrow.Adbc/AdbcDatabase.cs       |  53 +-
 csharp/src/Apache.Arrow.Adbc/AdbcDriver.cs         |  17 +
 csharp/src/Apache.Arrow.Adbc/AdbcStatement.cs      | 187 +++++-
 .../AotFixtureTests.cs                             |  15 +
 .../BaseClassDefaultsTests.cs                      | 697 ++++++++++++++++++++-
 .../Apache.Arrow.Adbc.Tests/Client/ClientTests.cs  |   2 +-
 .../ExportedDriverRoundTrip11Tests.cs              |  77 ++-
 .../Apache.Arrow.Adbc.Tests/ImportedDuckDbTests.cs |  28 +-
 10 files changed, 1369 insertions(+), 38 deletions(-)

diff --git a/csharp/src/Apache.Arrow.Adbc/AdbcAsync.cs 
b/csharp/src/Apache.Arrow.Adbc/AdbcAsync.cs
new file mode 100644
index 000000000..8457a5f42
--- /dev/null
+++ b/csharp/src/Apache.Arrow.Adbc/AdbcAsync.cs
@@ -0,0 +1,156 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using System;
+using System.Runtime.ExceptionServices;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Apache.Arrow.Adbc
+{
+    /// <summary>
+    /// Helpers for the default implementations of the asynchronous methods on
+    /// the ADBC base classes. Those defaults run the synchronous method on the
+    /// thread pool and translate a <see cref="CancellationToken"/> into a call
+    /// to the object's <c>Cancel</c> method.
+    /// </summary>
+    internal static class AdbcAsync
+    {
+        private static readonly Action<object?> s_cancel = TryCancel;
+
+        public static Task<T> Run<T>(Func<T> operation, Action? cancel, 
CancellationToken cancellationToken)
+        {
+            if (cancellationToken.IsCancellationRequested)
+            {
+                return Task.FromCanceled<T>(cancellationToken);
+            }
+
+            return Task.Run(() =>
+            {
+                CancelState state = new CancelState(cancel);
+                Exception failure;
+                using (Register(state, cancellationToken))
+                {
+                    try
+                    {
+                        return operation();
+                    }
+                    catch (Exception e) when (!(e is 
OperationCanceledException))
+                    {
+                        failure = e;
+                    }
+                }
+
+                // Disposing the registration waits for an in-progress Cancel 
to finish
+                throw Translate(failure, state, cancellationToken);
+            }, cancellationToken);
+        }
+
+        public static Task Run(Action operation, Action? cancel, 
CancellationToken cancellationToken)
+        {
+            return Run<bool>(() => { operation(); return true; }, cancel, 
cancellationToken);
+        }
+
+        /// <summary>
+        /// Attaches cancellation to a task produced by a driver override that
+        /// doesn't itself accept a <see cref="CancellationToken"/>. The token 
is
+        /// registered before <paramref name="start"/> is called, so an 
override
+        /// that blocks before returning its task can still be cancelled.
+        /// </summary>
+        public static async Task<T> WithCancellation<T>(Func<Task<T>> start, 
Action? cancel, CancellationToken cancellationToken)
+        {
+            CancelState state = new CancelState(cancel);
+            Exception failure;
+            using (Register(state, cancellationToken))
+            {
+                try
+                {
+                    Task<T> task = start();
+                    if (!task.IsCompleted)
+                    {
+                        // Cancel may complete the task, which would otherwise 
resume this method
+                        // inline on the cancelling thread. Disposing the 
registration from inside
+                        // its own callback doesn't wait for Cancel to return, 
so Translate could
+                        // misjudge whether Cancel succeeded. Resuming on the 
thread pool instead
+                        // makes the disposal wait for the callback to finish.
+                        await task.ContinueWith(_ => { }, 
CancellationToken.None, TaskContinuationOptions.None, 
TaskScheduler.Default).ConfigureAwait(false);
+                    }
+                    return await task.ConfigureAwait(false);
+                }
+                catch (Exception e) when (!(e is OperationCanceledException))
+                {
+                    failure = e;
+                }
+            }
+
+            throw Translate(failure, state, cancellationToken);
+        }
+
+        private static CancellationTokenRegistration Register(CancelState 
state, CancellationToken cancellationToken)
+        {
+            if (state.Cancel == null || !cancellationToken.CanBeCanceled)
+            {
+                return default;
+            }
+            return cancellationToken.Register(s_cancel, state);
+        }
+
+        /// <summary>
+        /// Reports a failure as cancellation only if cancellation plausibly 
caused it: the
+        /// token was cancelled and either the driver accepted the call to 
Cancel or the
+        /// failure itself says the operation was cancelled. Otherwise, a 
driver that can't
+        /// cancel would have its own errors hidden behind <see 
cref="OperationCanceledException"/>.
+        /// </summary>
+        private static Exception Translate(Exception failure, CancelState 
state, CancellationToken cancellationToken)
+        {
+            bool cancelled = cancellationToken.IsCancellationRequested &&
+                (state.Delivered || (failure is AdbcException adbc && 
adbc.Status == AdbcStatusCode.Cancelled));
+            if (cancelled)
+            {
+                return new OperationCanceledException(failure.Message, 
failure, cancellationToken);
+            }
+
+            ExceptionDispatchInfo.Capture(failure).Throw();
+            return failure; // not reached
+        }
+
+        private static void TryCancel(object? state)
+        {
+            CancelState cancelState = (CancelState)state!;
+            try
+            {
+                cancelState.Cancel!();
+                cancelState.Delivered = true;
+            }
+            catch (Exception)
+            {
+                // Cancellation is best-effort. A driver that doesn't support
+                // cancellation simply runs the operation to completion.
+            }
+        }
+
+        private sealed class CancelState
+        {
+            public CancelState(Action? cancel) { Cancel = cancel; }
+
+            public Action? Cancel { get; }
+
+            // Set when Cancel returned without throwing
+            public volatile bool Delivered;
+        }
+    }
+}
diff --git a/csharp/src/Apache.Arrow.Adbc/AdbcConnection.cs 
b/csharp/src/Apache.Arrow.Adbc/AdbcConnection.cs
index 785d508b0..2ef04b5e4 100644
--- a/csharp/src/Apache.Arrow.Adbc/AdbcConnection.cs
+++ b/csharp/src/Apache.Arrow.Adbc/AdbcConnection.cs
@@ -17,6 +17,8 @@
 
 using System;
 using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
 using Apache.Arrow.Ipc;
 
 namespace Apache.Arrow.Adbc
@@ -25,7 +27,12 @@ namespace Apache.Arrow.Adbc
     /// Provides methods for query execution, managing prepared statements,
     /// using transactions, and so on.
     /// </summary>
-    public abstract class AdbcConnection : IDisposable
+    /// <remarks>
+    /// Each asynchronous method has a default implementation that runs the
+    /// corresponding synchronous method on the thread pool and responds to its
+    /// <see cref="CancellationToken"/> by calling <see cref="Cancel"/>.
+    /// </remarks>
+    public abstract class AdbcConnection : IDisposable, IAsyncDisposable
     {
         private bool _autoCommit = true;
         private bool _readOnly = false;
@@ -39,6 +46,15 @@ namespace Apache.Arrow.Adbc
             throw AdbcException.NotImplemented("Connection does not support 
transactions");
         }
 
+        /// <summary>
+        /// Asynchronously commit the pending transaction.
+        /// </summary>
+        /// <param name="cancellationToken">A token used to cancel the 
operation.</param>
+        public virtual Task CommitAsync(CancellationToken cancellationToken = 
default)
+        {
+            return AdbcAsync.Run(Commit, Cancel, cancellationToken);
+        }
+
         /// <summary>
         /// Create a new statement that can be executed.
         /// </summary>
@@ -71,6 +87,18 @@ namespace Apache.Arrow.Adbc
         {
         }
 
+        /// <summary>
+        /// Asynchronously releases the resources used by the connection.
+        /// </summary>
+        /// <remarks>
+        /// The default implementation calls <see cref="Dispose"/>.
+        /// </remarks>
+        public virtual ValueTask DisposeAsync()
+        {
+            Dispose();
+            return default;
+        }
+
         /// <summary>
         /// Get metadata about the driver/database.
         /// </summary>
@@ -90,6 +118,22 @@ namespace Apache.Arrow.Adbc
             throw AdbcException.NotImplemented("Connection does not support 
GetInfo");
         }
 
+        /// <summary>
+        /// Asynchronously get metadata about the driver/database.
+        /// </summary>
+        /// <param name="codes">
+        /// The metadata items to fetch. An empty list requests all of the 
items the
+        /// driver supports.
+        /// </param>
+        /// <param name="cancellationToken">A token used to cancel the 
operation.</param>
+        /// <returns>
+        /// Metadata about the driver and/or database
+        /// </returns>
+        public virtual Task<IArrowArrayStream> 
GetInfoAsync(IReadOnlyList<AdbcInfoCode> codes, CancellationToken 
cancellationToken = default)
+        {
+            return AdbcAsync.Run(() => GetInfo(codes), Cancel, 
cancellationToken);
+        }
+
         /// <summary>
         /// Get a hierarchical view of all catalogs, database schemas, tables,
         /// and columns.
@@ -131,6 +175,29 @@ namespace Apache.Arrow.Adbc
             IReadOnlyList<string>? tableTypes,
             string? columnNamePattern);
 
+        /// <summary>
+        /// Asynchronously get a hierarchical view of all catalogs, database 
schemas,
+        /// tables, and columns.
+        /// </summary>
+        /// <remarks>
+        /// See <see cref="GetObjects"/> for a description of the parameters.
+        /// </remarks>
+        /// <param name="cancellationToken">A token used to cancel the 
operation.</param>
+        public virtual Task<IArrowArrayStream> GetObjectsAsync(
+            GetObjectsDepth depth,
+            string? catalogPattern,
+            string? dbSchemaPattern,
+            string? tableNamePattern,
+            IReadOnlyList<string>? tableTypes,
+            string? columnNamePattern,
+            CancellationToken cancellationToken = default)
+        {
+            return AdbcAsync.Run(
+                () => GetObjects(depth, catalogPattern, dbSchemaPattern, 
tableNamePattern, tableTypes, columnNamePattern),
+                Cancel,
+                cancellationToken);
+        }
+
         public enum GetObjectsDepth
         {
             /// <summary>
@@ -169,11 +236,38 @@ namespace Apache.Arrow.Adbc
         /// </param>
         public abstract Schema GetTableSchema(string? catalog, string? 
dbSchema, string tableName);
 
+        /// <summary>
+        /// Asynchronously get the Arrow schema of a database table.
+        /// </summary>
+        /// <param name="catalog">
+        /// The catalog of the table (or null).
+        /// </param>
+        /// <param name="dbSchema">
+        /// The database schema of the table (or null).
+        /// </param>
+        /// <param name="tableName">
+        /// The table name.
+        /// </param>
+        /// <param name="cancellationToken">A token used to cancel the 
operation.</param>
+        public virtual Task<Schema> GetTableSchemaAsync(string? catalog, 
string? dbSchema, string tableName, CancellationToken cancellationToken = 
default)
+        {
+            return AdbcAsync.Run(() => GetTableSchema(catalog, dbSchema, 
tableName), Cancel, cancellationToken);
+        }
+
         /// <summary>
         /// Get a list of table types supported by the database.
         /// </summary>
         public abstract IArrowArrayStream GetTableTypes();
 
+        /// <summary>
+        /// Asynchronously get a list of table types supported by the database.
+        /// </summary>
+        /// <param name="cancellationToken">A token used to cancel the 
operation.</param>
+        public virtual Task<IArrowArrayStream> 
GetTableTypesAsync(CancellationToken cancellationToken = default)
+        {
+            return AdbcAsync.Run(GetTableTypes, Cancel, cancellationToken);
+        }
+
         /// <summary>
         /// Options may be set before AdbcConnectionInit.  Some drivers may
         /// support setting options after initialization as well.
@@ -185,6 +279,24 @@ namespace Apache.Arrow.Adbc
             throw AdbcException.NotImplemented("Connection does not support 
setting options");
         }
 
+        /// <summary>
+        /// Asynchronously sets a string-valued option.
+        /// </summary>
+        /// <param name="key">Option name</param>
+        /// <param name="value">Option value</param>
+        /// <param name="cancellationToken">A token used to cancel the 
operation.</param>
+        /// <remarks>
+        /// Some options, such as <see 
cref="AdbcOptions.Connection.Autocommit"/> or
+        /// <see cref="AdbcOptions.Connection.CurrentCatalog"/>, may require a 
round
+        /// trip to the server. Drivers for which that's the case should 
override this
+        /// method. The default implementation runs <see 
cref="SetOption(string, string)"/>
+        /// on the thread pool.
+        /// </remarks>
+        public virtual Task SetOptionAsync(string key, string value, 
CancellationToken cancellationToken = default)
+        {
+            return AdbcAsync.Run(() => SetOption(key, value), Cancel, 
cancellationToken);
+        }
+
         /// <summary>
         /// Sets an integer-valued option.
         /// </summary>
@@ -275,6 +387,23 @@ namespace Apache.Arrow.Adbc
             return value ?? throw AdbcOptionValue.NotFound(key);
         }
 
+        /// <summary>
+        /// Asynchronously gets the value of a string-valued option.
+        /// </summary>
+        /// <param name="key">Option name</param>
+        /// <param name="cancellationToken">A token used to cancel the 
operation.</param>
+        /// <returns>The option value.</returns>
+        /// <remarks>
+        /// Some options, such as <see 
cref="AdbcOptions.Connection.CurrentDbSchema"/>,
+        /// may require a round trip to the server. Drivers for which that's 
the case
+        /// should override this method. The default implementation runs
+        /// <see cref="GetOption(string)"/> on the thread pool.
+        /// </remarks>
+        public virtual Task<string> GetOptionAsync(string key, 
CancellationToken cancellationToken = default)
+        {
+            return AdbcAsync.Run(() => GetOption(key), Cancel, 
cancellationToken);
+        }
+
         /// <summary>
         /// Gets the value of an integer-valued option.
         /// </summary>
@@ -325,6 +454,18 @@ namespace Apache.Arrow.Adbc
             throw AdbcException.NotImplemented("Connection does not support 
partitions");
         }
 
+        /// <summary>
+        /// Asynchronously create a result set from a serialized 
PartitionDescriptor.
+        /// </summary>
+        /// <param name="partition">
+        /// The partition descriptor.
+        /// </param>
+        /// <param name="cancellationToken">A token used to cancel the 
operation.</param>
+        public virtual Task<IArrowArrayStream> 
ReadPartitionAsync(PartitionDescriptor partition, CancellationToken 
cancellationToken = default)
+        {
+            return AdbcAsync.Run(() => ReadPartition(partition), Cancel, 
cancellationToken);
+        }
+
         /// <summary>
         /// Rollback the pending transaction.
         /// </summary>
@@ -333,6 +474,15 @@ namespace Apache.Arrow.Adbc
             throw AdbcException.NotImplemented("Connection does not support 
transactions");
         }
 
+        /// <summary>
+        /// Asynchronously rollback the pending transaction.
+        /// </summary>
+        /// <param name="cancellationToken">A token used to cancel the 
operation.</param>
+        public virtual Task RollbackAsync(CancellationToken cancellationToken 
= default)
+        {
+            return AdbcAsync.Run(Rollback, Cancel, cancellationToken);
+        }
+
         /// <summary>
         /// Gets or sets the autocommit state.
         /// </summary>
@@ -409,6 +559,15 @@ namespace Apache.Arrow.Adbc
             throw AdbcException.NotImplemented("Connection does not support 
statistics");
         }
 
+        /// <summary>
+        /// Asynchronously gets the names of statistics specific to this 
driver.
+        /// </summary>
+        /// <param name="cancellationToken">A token used to cancel the 
operation.</param>
+        public virtual Task<IArrowArrayStream> 
GetStatisticsNamesAsync(CancellationToken cancellationToken = default)
+        {
+            return AdbcAsync.Run(GetStatisticsNames, Cancel, 
cancellationToken);
+        }
+
         /// <summary>
         /// Gets statistics about the data distribution of table(s)
         /// </summary>
@@ -421,5 +580,19 @@ namespace Apache.Arrow.Adbc
         {
             throw AdbcException.NotImplemented("Connection does not support 
statistics");
         }
+
+        /// <summary>
+        /// Asynchronously gets statistics about the data distribution of 
table(s)
+        /// </summary>
+        /// <param name="catalogPattern">The catalog or null. May be a search 
pattern.</param>
+        /// <param name="schemaPattern">The schema or null. May be a search 
pattern.</param>
+        /// <param name="tableNamePattern">The table name or null. May be a 
search pattern.</param>
+        /// <param name="approximate">If false, consumer desires exact 
statistics regardless of cost</param>
+        /// <param name="cancellationToken">A token used to cancel the 
operation.</param>
+        /// <returns>A result describing the statistics for the 
table(s)</returns>
+        public virtual Task<IArrowArrayStream> GetStatisticsAsync(string? 
catalogPattern, string? schemaPattern, string tableNamePattern, bool 
approximate, CancellationToken cancellationToken = default)
+        {
+            return AdbcAsync.Run(() => GetStatistics(catalogPattern, 
schemaPattern, tableNamePattern, approximate), Cancel, cancellationToken);
+        }
     }
 }
diff --git a/csharp/src/Apache.Arrow.Adbc/AdbcDatabase.cs 
b/csharp/src/Apache.Arrow.Adbc/AdbcDatabase.cs
index d5f11b0a7..46b71df42 100644
--- a/csharp/src/Apache.Arrow.Adbc/AdbcDatabase.cs
+++ b/csharp/src/Apache.Arrow.Adbc/AdbcDatabase.cs
@@ -17,6 +17,8 @@
 
 using System;
 using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
 
 namespace Apache.Arrow.Adbc
 {
@@ -27,7 +29,7 @@ namespace Apache.Arrow.Adbc
     /// For example, in-memory databases can place ownership of the actual
     /// database in this object.
     /// </summary>
-    public abstract class AdbcDatabase : IDisposable
+    public abstract class AdbcDatabase : IDisposable, IAsyncDisposable
     {
         /// <summary>
         /// Options are generally set before opening a database.  Some drivers 
may
@@ -227,6 +229,55 @@ namespace Apache.Arrow.Adbc
             return connection;
         }
 
+        /// <summary>
+        /// Asynchronously create a new connection to the database using 
options that
+        /// aren't all strings.
+        /// </summary>
+        /// <param name="options">
+        /// Additional options to use when connecting. See
+        /// <see cref="ConnectWithTypedOptions(IReadOnlyDictionary{string, 
object})"/>.
+        /// </param>
+        /// <param name="cancellationToken">
+        /// A token used to cancel the operation before it starts. Once 
started, the operation
+        /// runs to completion, because databases and drivers have no way to 
interrupt it.
+        /// </param>
+        /// <remarks>
+        /// Added in ADBC 1.1.0. The default implementation runs
+        /// <see cref="ConnectWithTypedOptions(IReadOnlyDictionary{string, 
object})"/>
+        /// on the thread pool.
+        /// </remarks>
+        public virtual Task<AdbcConnection> 
ConnectWithTypedOptionsAsync(IReadOnlyDictionary<string, object>? options, 
CancellationToken cancellationToken = default)
+        {
+            return AdbcAsync.Run(() => ConnectWithTypedOptions(options), null, 
cancellationToken);
+        }
+
+        /// <summary>
+        /// Asynchronously create a new connection to the database.
+        /// </summary>
+        /// <param name="options">
+        /// Additional options to use when connecting.
+        /// </param>
+        /// <param name="cancellationToken">
+        /// A token used to cancel the operation before it starts. Once 
started, the operation
+        /// runs to completion, because databases and drivers have no way to 
interrupt it.
+        /// </param>
+        public virtual Task<AdbcConnection> 
ConnectAsync(IReadOnlyDictionary<string, string>? options, CancellationToken 
cancellationToken = default)
+        {
+            return AdbcAsync.Run(() => Connect(options), null, 
cancellationToken);
+        }
+
         public virtual void Dispose() { }
+
+        /// <summary>
+        /// Asynchronously releases the resources used by the database.
+        /// </summary>
+        /// <remarks>
+        /// The default implementation calls <see cref="Dispose"/>.
+        /// </remarks>
+        public virtual ValueTask DisposeAsync()
+        {
+            Dispose();
+            return default;
+        }
     }
 }
diff --git a/csharp/src/Apache.Arrow.Adbc/AdbcDriver.cs 
b/csharp/src/Apache.Arrow.Adbc/AdbcDriver.cs
index f07314787..188b64e08 100644
--- a/csharp/src/Apache.Arrow.Adbc/AdbcDriver.cs
+++ b/csharp/src/Apache.Arrow.Adbc/AdbcDriver.cs
@@ -17,6 +17,8 @@
 
 using System;
 using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
 
 namespace Apache.Arrow.Adbc
 {
@@ -88,6 +90,21 @@ namespace Apache.Arrow.Adbc
             return database;
         }
 
+        /// <summary>
+        /// Asynchronously open a database via this driver.
+        /// </summary>
+        /// <param name="parameters">
+        /// Driver-specific parameters.
+        /// </param>
+        /// <param name="cancellationToken">
+        /// A token used to cancel the operation before it starts. Once 
started, the operation
+        /// runs to completion, because databases and drivers have no way to 
interrupt it.
+        /// </param>
+        public virtual Task<AdbcDatabase> 
OpenAsync(IReadOnlyDictionary<string, string> parameters, CancellationToken 
cancellationToken = default)
+        {
+            return AdbcAsync.Run(() => Open(parameters), null, 
cancellationToken);
+        }
+
         public virtual void Dispose()
         {
         }
diff --git a/csharp/src/Apache.Arrow.Adbc/AdbcStatement.cs 
b/csharp/src/Apache.Arrow.Adbc/AdbcStatement.cs
index 09866e3fb..0b8fac04e 100644
--- a/csharp/src/Apache.Arrow.Adbc/AdbcStatement.cs
+++ b/csharp/src/Apache.Arrow.Adbc/AdbcStatement.cs
@@ -16,6 +16,7 @@
  */
 
 using System;
+using System.Threading;
 using System.Threading.Tasks;
 using Apache.Arrow.Ipc;
 
@@ -26,7 +27,12 @@ namespace Apache.Arrow.Adbc
     /// may be used multiple times and can be reconfigured (e.g. they can
     /// be reused to execute multiple different queries).
     /// </summary>
-    public abstract class AdbcStatement : IDisposable
+    /// <remarks>
+    /// Each asynchronous method has a default implementation that runs the
+    /// corresponding synchronous method on the thread pool and responds to its
+    /// <see cref="CancellationToken"/> by calling <see cref="Cancel"/>.
+    /// </remarks>
+    public abstract class AdbcStatement : IDisposable, IAsyncDisposable
     {
         public AdbcStatement()
         {
@@ -83,9 +89,52 @@ namespace Apache.Arrow.Adbc
         /// A <see cref="ValueTuple"/> where the first item is the number
         /// of records and the second is the <see cref="IArrowArrayStream"/>.
         /// </returns>
-        public virtual async ValueTask<QueryResult> ExecuteQueryAsync()
+        /// <remarks>
+        /// <para>New code should call <see 
cref="ExecuteQueryAsync(CancellationToken)"/>.</para>
+        /// <para>The default implementation runs <see cref="ExecuteQuery"/> 
on the thread pool.
+        /// It doesn't call <see 
cref="ExecuteQueryAsync(CancellationToken)"/>, because that
+        /// overload's default implementation calls this one. A driver that 
overrides
+        /// <see cref="ExecuteQueryAsync(CancellationToken)"/> should also 
override this
+        /// overload to call it with <see cref="CancellationToken.None"/>, so 
that callers of
+        /// either overload get the driver's asynchronous 
implementation.</para>
+        /// </remarks>
+        public virtual ValueTask<QueryResult> ExecuteQueryAsync()
         {
-            return await Task.Run(() => ExecuteQuery());
+            // When called from ExecuteQueryAsync(CancellationToken), that 
overload registers Cancel
+            return new ValueTask<QueryResult>(AdbcAsync.Run(ExecuteQuery, 
null, TakeForwardedToken()));
+        }
+
+        /// <summary>
+        /// Executes the statement and returns a tuple containing the number
+        /// of records and the <see cref="IArrowArrayStream"/>.
+        /// </summary>
+        /// <param name="cancellationToken">
+        /// A token used to cancel the operation. Cancellation calls <see 
cref="Cancel"/>.
+        /// </param>
+        /// <returns>
+        /// A <see cref="ValueTuple"/> where the first item is the number
+        /// of records and the second is the <see cref="IArrowArrayStream"/>.
+        /// </returns>
+        /// <remarks>
+        /// The default implementation calls <see 
cref="ExecuteQueryAsync()"/>, so that drivers
+        /// written before this overload existed keep their asynchronous 
behavior, and calls
+        /// <see cref="Cancel"/> if the token is cancelled.
+        /// </remarks>
+        public virtual ValueTask<QueryResult> 
ExecuteQueryAsync(CancellationToken cancellationToken)
+        {
+            if (cancellationToken.IsCancellationRequested)
+            {
+                return new 
ValueTask<QueryResult>(Task.FromCanceled<QueryResult>(cancellationToken));
+            }
+
+            if (!cancellationToken.CanBeCanceled)
+            {
+                return ExecuteQueryAsync();
+            }
+            return new ValueTask<QueryResult>(AdbcAsync.WithCancellation(
+                () => Forward(cancellationToken, () => 
ExecuteQueryAsync()).AsTask(),
+                Cancel,
+                cancellationToken));
         }
 
         /// <summary>
@@ -98,6 +147,17 @@ namespace Apache.Arrow.Adbc
             throw AdbcException.NotImplemented("Statement does not support 
ExecuteSchema");
         }
 
+        /// <summary>
+        /// Asynchronously analyzes the statement and returns the schema of 
the result set
+        /// that would be expected if the statement were to be executed.
+        /// </summary>
+        /// <param name="cancellationToken">A token used to cancel the 
operation.</param>
+        /// <returns>An Arrow <see cref="Schema"/> describing the result 
set.</returns>
+        public virtual Task<Schema> ExecuteSchemaAsync(CancellationToken 
cancellationToken = default)
+        {
+            return AdbcAsync.Run(ExecuteSchema, Cancel, cancellationToken);
+        }
+
         /// <summary>
         /// Executes an update command and returns the number of
         /// records effected.
@@ -109,10 +169,84 @@ namespace Apache.Arrow.Adbc
         /// Executes an update command and returns the number of
         /// records effected.
         /// </summary>
-        /// <exception cref="NotImplementedException"></exception>
-        public virtual async Task<UpdateResult> ExecuteUpdateAsync()
+        /// <remarks>
+        /// <para>New code should call <see 
cref="ExecuteUpdateAsync(CancellationToken)"/>.</para>
+        /// <para>The default implementation runs <see cref="ExecuteUpdate"/> 
on the thread pool.
+        /// It doesn't call <see 
cref="ExecuteUpdateAsync(CancellationToken)"/>, because that
+        /// overload's default implementation calls this one. A driver that 
overrides
+        /// <see cref="ExecuteUpdateAsync(CancellationToken)"/> should also 
override this
+        /// overload to call it with <see cref="CancellationToken.None"/>, so 
that callers of
+        /// either overload get the driver's asynchronous 
implementation.</para>
+        /// </remarks>
+        public virtual Task<UpdateResult> ExecuteUpdateAsync()
         {
-            return await Task.Run(() => ExecuteUpdate());
+            // When called from ExecuteUpdateAsync(CancellationToken), that 
overload registers Cancel
+            return AdbcAsync.Run(ExecuteUpdate, null, TakeForwardedToken());
+        }
+
+        /// <summary>
+        /// Executes an update command and returns the number of
+        /// records effected.
+        /// </summary>
+        /// <param name="cancellationToken">
+        /// A token used to cancel the operation. Cancellation calls <see 
cref="Cancel"/>.
+        /// </param>
+        /// <remarks>
+        /// The default implementation calls <see 
cref="ExecuteUpdateAsync()"/>, so that drivers
+        /// written before this overload existed keep their asynchronous 
behavior, and calls
+        /// <see cref="Cancel"/> if the token is cancelled.
+        /// </remarks>
+        public virtual Task<UpdateResult> ExecuteUpdateAsync(CancellationToken 
cancellationToken)
+        {
+            if (cancellationToken.IsCancellationRequested)
+            {
+                return Task.FromCanceled<UpdateResult>(cancellationToken);
+            }
+
+            if (!cancellationToken.CanBeCanceled)
+            {
+                return ExecuteUpdateAsync();
+            }
+            return AdbcAsync.WithCancellation(
+                () => Forward(cancellationToken, () => ExecuteUpdateAsync()),
+                Cancel,
+                cancellationToken);
+        }
+
+        // The token overloads forward to the parameterless ones so that 
legacy overrides of those
+        // still run. When the parameterless overload isn't overridden, its 
default implementation
+        // picks up the token here, on the same thread, so that queued work 
which is cancelled
+        // before it starts never runs. The token can't be passed as an 
argument without changing
+        // the parameterless overload's signature.
+        [ThreadStatic] private static AdbcStatement? t_forwardingStatement;
+        [ThreadStatic] private static CancellationToken t_forwardedToken;
+
+        private T Forward<T>(CancellationToken cancellationToken, Func<T> call)
+        {
+            t_forwardingStatement = this;
+            t_forwardedToken = cancellationToken;
+            try
+            {
+                return call();
+            }
+            finally
+            {
+                t_forwardingStatement = null;
+                t_forwardedToken = default;
+            }
+        }
+
+        private CancellationToken TakeForwardedToken()
+        {
+            if (!ReferenceEquals(t_forwardingStatement, this))
+            {
+                return CancellationToken.None;
+            }
+
+            CancellationToken token = t_forwardedToken;
+            t_forwardingStatement = null;
+            t_forwardedToken = default;
+            return token;
         }
 
         /// <summary>
@@ -124,6 +258,16 @@ namespace Apache.Arrow.Adbc
             throw AdbcException.NotImplemented("Statement does not support 
ExecutePartitioned");
         }
 
+        /// <summary>
+        /// Asynchronously execute a result set-generating query and get a 
list of
+        /// partitions of the result set.
+        /// </summary>
+        /// <param name="cancellationToken">A token used to cancel the 
operation.</param>
+        public virtual Task<PartitionedResult> 
ExecutePartitionedAsync(CancellationToken cancellationToken = default)
+        {
+            return AdbcAsync.Run(ExecutePartitioned, Cancel, 
cancellationToken);
+        }
+
         /// <summary>
         /// Get the schema for bound parameters.
         /// </summary>
@@ -132,6 +276,15 @@ namespace Apache.Arrow.Adbc
             throw AdbcException.NotImplemented("Statement does not support 
GetParameterSchema");
         }
 
+        /// <summary>
+        /// Asynchronously get the schema for bound parameters.
+        /// </summary>
+        /// <param name="cancellationToken">A token used to cancel the 
operation.</param>
+        public virtual Task<Schema> GetParameterSchemaAsync(CancellationToken 
cancellationToken = default)
+        {
+            return AdbcAsync.Run(GetParameterSchema, Cancel, 
cancellationToken);
+        }
+
         /// <summary>
         /// Turn this statement into a prepared statement to be
         /// executed multiple times.
@@ -141,6 +294,16 @@ namespace Apache.Arrow.Adbc
             throw AdbcException.NotImplemented("Statement does not support 
Prepare");
         }
 
+        /// <summary>
+        /// Asynchronously turn this statement into a prepared statement to be
+        /// executed multiple times.
+        /// </summary>
+        /// <param name="cancellationToken">A token used to cancel the 
operation.</param>
+        public virtual Task PrepareAsync(CancellationToken cancellationToken = 
default)
+        {
+            return AdbcAsync.Run(Prepare, Cancel, cancellationToken);
+        }
+
         /// <summary>
         /// Set a string option on a statement.
         /// </summary>
@@ -249,6 +412,18 @@ namespace Apache.Arrow.Adbc
         {
         }
 
+        /// <summary>
+        /// Asynchronously releases the resources used by the statement.
+        /// </summary>
+        /// <remarks>
+        /// The default implementation calls <see cref="Dispose"/>.
+        /// </remarks>
+        public virtual ValueTask DisposeAsync()
+        {
+            Dispose();
+            return default;
+        }
+
         /// <summary>
         /// Attempts to cancel an in-progress operation on a connection.
         /// </summary>
diff --git 
a/csharp/test/AotInterop/Apache.Arrow.Adbc.TestFixture.Tests/AotFixtureTests.cs 
b/csharp/test/AotInterop/Apache.Arrow.Adbc.TestFixture.Tests/AotFixtureTests.cs
index 3a98bc5f6..5afc90d17 100644
--- 
a/csharp/test/AotInterop/Apache.Arrow.Adbc.TestFixture.Tests/AotFixtureTests.cs
+++ 
b/csharp/test/AotInterop/Apache.Arrow.Adbc.TestFixture.Tests/AotFixtureTests.cs
@@ -275,5 +275,20 @@ namespace Apache.Arrow.Adbc.TestFixture.Tests
             Assert.Equal("fixture.detail", detail.Key);
             Assert.Equal(new byte[] { 1, 2, 3 }, detail.Value);
         }
+
+        [SkippableFact]
+        public async Task ExecuteQueryAsyncWithTokenRoundTrips()
+        {
+            using AdbcDriver driver = LoadFixture();
+            using AdbcDatabase db = driver.Open(new Dictionary<string, 
string>());
+            using AdbcConnection conn = await db.ConnectAsync(null);
+            using AdbcStatement stmt = conn.CreateStatement();
+            stmt.SqlQuery = "SELECT 1";
+
+            using var cts = new System.Threading.CancellationTokenSource();
+            QueryResult result = await stmt.ExecuteQueryAsync(cts.Token);
+            using IArrowArrayStream stream = result.Stream!;
+            Assert.Equal("key", stream.Schema.FieldsList[0].Name);
+        }
     }
 }
diff --git a/csharp/test/Apache.Arrow.Adbc.Tests/BaseClassDefaultsTests.cs 
b/csharp/test/Apache.Arrow.Adbc.Tests/BaseClassDefaultsTests.cs
index dcf3e88a9..d944751ce 100644
--- a/csharp/test/Apache.Arrow.Adbc.Tests/BaseClassDefaultsTests.cs
+++ b/csharp/test/Apache.Arrow.Adbc.Tests/BaseClassDefaultsTests.cs
@@ -17,23 +17,237 @@
 
 using System;
 using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
 using Apache.Arrow.Ipc;
 using Xunit;
 
 namespace Apache.Arrow.Adbc.Tests
 {
     /// <summary>
-    /// Tests the default implementations of the ADBC 1.1.0 members of the ADBC
-    /// base classes.
+    /// Tests the default implementations of the ADBC 1.1.0 and asynchronous
+    /// members of the ADBC base classes.
     /// </summary>
     public class BaseClassDefaultsTests
     {
+        [Fact]
+        public async Task ExecuteQueryAsyncDefaultsToExecuteQuery()
+        {
+            var stmt = new SyncOnlyStatement();
+
+            await stmt.ExecuteQueryAsync();
+            await stmt.ExecuteQueryAsync(CancellationToken.None);
+            await stmt.ExecuteUpdateAsync();
+            await stmt.ExecuteUpdateAsync(CancellationToken.None);
+
+            Assert.Equal(2, stmt.QueryCount);
+            Assert.Equal(2, stmt.UpdateCount);
+        }
+
+        [Fact]
+        public async Task TokenOverloadUsesLegacyAsyncOverride()
+        {
+            var stmt = new LegacyAsyncStatement();
+            using var cts = new CancellationTokenSource();
+
+            QueryResult result = await stmt.ExecuteQueryAsync(cts.Token);
+            UpdateResult update = await stmt.ExecuteUpdateAsync(cts.Token);
+
+            Assert.Equal(LegacyAsyncStatement.QueryRows, result.RowCount);
+            Assert.Equal(LegacyAsyncStatement.UpdateRows, update.AffectedRows);
+            Assert.Equal(0, stmt.SyncCount);
+        }
+
+        [Fact]
+        public async Task LegacyOverrideCallingBaseAfterAwaitDoesNotRecurse()
+        {
+            var stmt = new LegacyCallsBaseStatement();
+
+            Task<QueryResult> task = 
stmt.ExecuteQueryAsync(CancellationToken.None).AsTask();
+            Assert.Same(task, await Task.WhenAny(task, 
Task.Delay(TimeSpan.FromSeconds(30))));
+            QueryResult result = await task;
+
+            Assert.Equal(1, stmt.OverrideCount);
+            Assert.Equal(1, stmt.SyncCount);
+            Assert.Equal(7, result.RowCount);
+        }
+
+        [Fact]
+        public async Task TokenOverloadUsesNewAsyncOverride()
+        {
+            var stmt = new NewAsyncStatement();
+
+            QueryResult result = await 
stmt.ExecuteQueryAsync(CancellationToken.None);
+            UpdateResult update = await 
stmt.ExecuteUpdateAsync(CancellationToken.None);
+
+            Assert.Equal(NewAsyncStatement.QueryRows, result.RowCount);
+            Assert.Equal(NewAsyncStatement.UpdateRows, update.AffectedRows);
+            Assert.Equal(0, stmt.SyncCount);
+        }
+
+        [Fact]
+        public async Task 
ParameterlessOverloadDoesNotForwardToTheTokenOverload()
+        {
+            // Forwarding goes only from the token overload to the 
parameterless one, so a
+            // driver that overrides only the token overload isn't reached 
this way.
+            var stmt = new NewAsyncStatement();
+
+            await stmt.ExecuteQueryAsync();
+            await stmt.ExecuteUpdateAsync();
+
+            Assert.Equal(2, stmt.SyncCount);
+        }
+
+        [Fact]
+        public async Task RecommendedNewDriverIsReachedThroughBothOverloads()
+        {
+            var stmt = new RecommendedAsyncStatement();
+
+            Assert.Equal(NewAsyncStatement.QueryRows, (await 
stmt.ExecuteQueryAsync()).RowCount);
+            Assert.Equal(NewAsyncStatement.QueryRows, (await 
stmt.ExecuteQueryAsync(CancellationToken.None)).RowCount);
+            Assert.Equal(NewAsyncStatement.UpdateRows, (await 
stmt.ExecuteUpdateAsync()).AffectedRows);
+            Assert.Equal(NewAsyncStatement.UpdateRows, (await 
stmt.ExecuteUpdateAsync(CancellationToken.None)).AffectedRows);
+            Assert.Equal(0, stmt.SyncCount);
+        }
+
+        [Fact]
+        public async Task LegacyOverrideCallingBaseRunsOnceWhenCalledDirectly()
+        {
+            var stmt = new LegacyCallsBaseStatement();
+
+            QueryResult result = await stmt.ExecuteQueryAsync();
+
+            Assert.Equal(1, stmt.OverrideCount);
+            Assert.Equal(1, stmt.SyncCount);
+            Assert.Equal(7, result.RowCount);
+        }
+
+        [Fact]
+        public async Task NewOverrideCallingParameterlessBaseDoesNotRecurse()
+        {
+            var stmt = new NewCallsParameterlessBaseStatement();
+
+            Task<QueryResult> task = 
stmt.ExecuteQueryAsync(CancellationToken.None).AsTask();
+            Assert.Same(task, await Task.WhenAny(task, 
Task.Delay(TimeSpan.FromSeconds(30))));
+
+            Assert.Equal(1, stmt.OverrideCount);
+            Assert.Equal(1, stmt.SyncCount);
+        }
+
+        [Fact]
+        public async Task CancellationCallsCancelAndThrowsOperationCanceled()
+        {
+            var stmt = new BlockingStatement();
+            using var cts = new CancellationTokenSource();
+
+            ValueTask<QueryResult> pending = stmt.ExecuteQueryAsync(cts.Token);
+            await WaitForStartAsync(stmt.Started.Task);
+            cts.Cancel();
+
+            OperationCanceledException ex = await 
Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await pending);
+            Assert.Equal(cts.Token, ex.CancellationToken);
+            Assert.IsType<AdbcException>(ex.InnerException);
+            Assert.True(stmt.CancelCalled);
+        }
+
+        [Fact]
+        public async Task 
UpdateCancellationCallsCancelAndThrowsOperationCanceled()
+        {
+            var stmt = new BlockingStatement();
+            using var cts = new CancellationTokenSource();
+
+            Task<UpdateResult> pending = stmt.ExecuteUpdateAsync(cts.Token);
+            await WaitForStartAsync(stmt.Started.Task);
+            cts.Cancel();
+
+            OperationCanceledException ex = await 
Assert.ThrowsAnyAsync<OperationCanceledException>(() => pending);
+            Assert.Equal(cts.Token, ex.CancellationToken);
+            Assert.True(stmt.CancelCalled);
+        }
+
+        [Fact]
+        public async Task CancellationReachesALegacyAsyncOverride()
+        {
+            var stmt = new LegacyBlockingStatement();
+            using var cts = new CancellationTokenSource();
+
+            // The token overload forwards to the legacy override and calls 
Cancel on cancellation
+            ValueTask<QueryResult> pending = stmt.ExecuteQueryAsync(cts.Token);
+            await WaitForStartAsync(stmt.Started.Task);
+            cts.Cancel();
+
+            OperationCanceledException ex = await 
Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await pending);
+            Assert.Equal(cts.Token, ex.CancellationToken);
+            Assert.IsType<AdbcException>(ex.InnerException);
+            Assert.True(stmt.CancelCalled);
+        }
+
+        [Fact]
+        public async Task PreCancelledTokenDoesNotExecute()
+        {
+            var stmt = new SyncOnlyStatement();
+            using var cts = new CancellationTokenSource();
+            cts.Cancel();
+
+            await Assert.ThrowsAnyAsync<OperationCanceledException>(async () 
=> await stmt.ExecuteQueryAsync(cts.Token));
+            await Assert.ThrowsAnyAsync<OperationCanceledException>(() => 
stmt.ExecuteSchemaAsync(cts.Token));
+
+            Assert.Equal(0, stmt.QueryCount);
+        }
+
+        [Fact]
+        public async Task ConnectionAsyncCancellationCallsConnectionCancel()
+        {
+            var conn = new BlockingConnection();
+            using var cts = new CancellationTokenSource();
+
+            Task<IArrowArrayStream> pending = 
conn.GetTableTypesAsync(cts.Token);
+            await WaitForStartAsync(conn.Started.Task);
+            cts.Cancel();
+
+            await Assert.ThrowsAnyAsync<OperationCanceledException>(() => 
pending);
+            Assert.True(conn.CancelCalled);
+        }
+
+        [Fact]
+        public async Task CancellationIsBestEffortWhenCancelIsNotImplemented()
+        {
+            // SyncOnlyStatement doesn't override Cancel, so once started the 
operation runs to completion
+            var stmt = new SyncOnlyStatement { Delay = 
TimeSpan.FromMilliseconds(200) };
+            using var cts = new CancellationTokenSource();
+
+            ValueTask<QueryResult> pending = stmt.ExecuteQueryAsync(cts.Token);
+            await WaitForStartAsync(stmt.Started.Task);
+            cts.Cancel();
+            QueryResult result = await pending;
+
+            Assert.Equal(1, stmt.QueryCount);
+            Assert.Equal(0, result.RowCount);
+        }
+
+        [Fact]
+        public async Task 
CancellationDoesNotHideErrorsFromDriversThatCannotCancel()
+        {
+            // FailingStatement doesn't override Cancel, so the default throws 
NotImplemented.
+            // The failure is the driver's own and must not be reported as 
cancellation.
+            var stmt = new FailingStatement();
+            using var cts = new CancellationTokenSource();
+
+            ValueTask<QueryResult> pending = stmt.ExecuteQueryAsync(cts.Token);
+            await WaitForStartAsync(stmt.Started.Task);
+            cts.Cancel();
+            stmt.Fail.Set();
+
+            AdbcException ex = await Assert.ThrowsAsync<AdbcException>(async 
() => await pending);
+            Assert.Equal(AdbcStatusCode.InvalidArgument, ex.Status);
+        }
+
         [Fact]
         public void DoubleOptionsRoundTripThroughStrings()
         {
             // "R" formatting doesn't round-trip this value on .NET Framework
             const double value = 0.84551240822557006;
-            var stmt = new OptionsStatement();
+            var stmt = new SyncOnlyStatement();
 
             stmt.SetOptionDouble("d", value);
             Assert.Equal(value, stmt.GetOptionDouble("d"));
@@ -42,7 +256,7 @@ namespace Apache.Arrow.Adbc.Tests
         [Fact]
         public void TypedOptionsDefaultToStringOptions()
         {
-            var stmt = new OptionsStatement();
+            var stmt = new SyncOnlyStatement();
 
             stmt.SetOptionInt64("i", -12);
             stmt.SetOptionDouble("d", 0.1);
@@ -61,7 +275,7 @@ namespace Apache.Arrow.Adbc.Tests
         [Fact]
         public void ConnectionGetOptionAnswersStandardOptions()
         {
-            var conn = new RecordingConnection();
+            var conn = new BlockingConnection();
 
             Assert.Equal(AdbcOptions.Enabled, 
conn.GetOption(AdbcOptions.Connection.Autocommit));
             Assert.Equal(AdbcOptions.Disabled, 
conn.GetOption(AdbcOptions.Connection.ReadOnly));
@@ -70,10 +284,49 @@ namespace Apache.Arrow.Adbc.Tests
             Assert.Equal(AdbcStatusCode.NotFound, 
Assert.Throws<AdbcException>(() => conn.GetOption("unknown")).Status);
         }
 
+        [Fact]
+        public async Task ConnectionOptionAsyncDefaultsToSyncOptions()
+        {
+            var conn = new BlockingConnection();
+
+            await conn.SetOptionAsync("key", "value");
+            Assert.Equal("value", conn.Options["key"]);
+
+            Assert.Equal(AdbcOptions.Enabled, await 
conn.GetOptionAsync(AdbcOptions.Connection.Autocommit));
+            AdbcException e = await Assert.ThrowsAsync<AdbcException>(() => 
conn.GetOptionAsync("unknown"));
+            Assert.Equal(AdbcStatusCode.NotFound, e.Status);
+        }
+
+        [Fact]
+        public async Task ConnectionOptionAsyncHonorsAPreCancelledToken()
+        {
+            var conn = new BlockingConnection();
+            using var cts = new CancellationTokenSource();
+            cts.Cancel();
+
+            await Assert.ThrowsAnyAsync<OperationCanceledException>(() => 
conn.SetOptionAsync("key", "value", cts.Token));
+            await Assert.ThrowsAnyAsync<OperationCanceledException>(() => 
conn.GetOptionAsync(AdbcOptions.Connection.Autocommit, cts.Token));
+            Assert.Empty(conn.Options);
+        }
+
+        [Fact]
+        public async Task 
ConnectionSetOptionAsyncCancellationCallsConnectionCancel()
+        {
+            var conn = new BlockingConnection();
+            using var cts = new CancellationTokenSource();
+
+            Task pending = conn.SetOptionAsync(BlockingConnection.BlockingKey, 
"value", cts.Token);
+            await WaitForStartAsync(conn.Started.Task);
+            cts.Cancel();
+
+            await Assert.ThrowsAnyAsync<OperationCanceledException>(() => 
pending);
+            Assert.True(conn.CancelCalled);
+        }
+
         [Fact]
         public void CurrentCatalogSetterDefaultsToSetOption()
         {
-            var conn = new RecordingConnection();
+            var conn = new BlockingConnection();
 
             conn.CurrentCatalog = "cat";
             conn.CurrentDbSchema = "schema";
@@ -98,7 +351,7 @@ namespace Apache.Arrow.Adbc.Tests
             });
 
             Assert.Equal(new Dictionary<string, string> { { "s", "text" }, { 
"b", "false" }, { "l", "4" }, { "d", "2.5" } }, db.LastStringOptions);
-            var binary = ((RecordingConnection)conn).BinaryOptions;
+            var binary = ((BlockingConnection)conn).BinaryOptions;
             Assert.Equal(new byte[] { 1, 2 }, binary["bytes"]);
             Assert.Equal(new byte[] { 3 }, binary["memory"]);
 
@@ -106,6 +359,25 @@ namespace Apache.Arrow.Adbc.Tests
             Assert.Null(db.LastStringOptions);
         }
 
+        [Fact]
+        public async Task 
ConnectWithTypedOptionsAsyncDefaultsToConnectWithTypedOptions()
+        {
+            var db = new ConnectingDatabase();
+
+            using AdbcConnection conn = await 
db.ConnectWithTypedOptionsAsync(new Dictionary<string, object>
+            {
+                { "l", 4L },
+                { "bytes", new byte[] { 1 } },
+            });
+
+            Assert.Equal(new Dictionary<string, string> { { "l", "4" } }, 
db.LastStringOptions);
+            Assert.Equal(new byte[] { 1 }, 
((BlockingConnection)conn).BinaryOptions["bytes"]);
+
+            using var cts = new CancellationTokenSource();
+            cts.Cancel();
+            await Assert.ThrowsAnyAsync<OperationCanceledException>(() => 
db.ConnectWithTypedOptionsAsync(null, cts.Token));
+        }
+
         [Fact]
         public void 
ConnectWithTypedOptionsDisposesTheConnectionIfABinaryOptionFails()
         {
@@ -113,7 +385,7 @@ namespace Apache.Arrow.Adbc.Tests
 
             Assert.Throws<AdbcException>(() => db.ConnectWithTypedOptions(new 
Dictionary<string, object>
             {
-                { RecordingConnection.RejectedKey, new byte[] { 1 } },
+                { BlockingConnection.RejectedKey, new byte[] { 1 } },
             }));
             Assert.True(db.LastConnection!.Disposed);
         }
@@ -155,6 +427,75 @@ namespace Apache.Arrow.Adbc.Tests
             Assert.Equal("false", driver.Parameters["b"]);
         }
 
+        [Fact]
+        public async Task 
CancelThatCompletesALegacyTaskInlineIsReportedAsCancellation()
+        {
+            // Cancel faults the legacy task synchronously, which used to 
resume the
+            // cancellation helper inline before Cancel had returned
+            var stmt = new InlineFaultingLegacyStatement();
+            using var cts = new CancellationTokenSource();
+
+            ValueTask<QueryResult> pending = stmt.ExecuteQueryAsync(cts.Token);
+            // Cancel from a thread without a SynchronizationContext, where 
the legacy task's
+            // continuations may run inline (xUnit's own context would prevent 
that)
+            await Task.Run(() => cts.Cancel());
+
+            OperationCanceledException ex = await 
Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await pending);
+            Assert.IsType<TimeoutException>(ex.InnerException);
+        }
+
+        [Fact]
+        public async Task 
SynchronouslyBlockingLegacyQueryOverrideCanBeCancelled()
+        {
+            var stmt = new SynchronousLegacyStatement();
+            using var cts = new CancellationTokenSource();
+
+            // The override blocks before returning its task, so the call has 
to run elsewhere
+            Task<QueryResult> pending = Task.Run(() => 
stmt.ExecuteQueryAsync(cts.Token).AsTask());
+            await WaitForStartAsync(stmt.Started.Task);
+            cts.Cancel();
+
+            await Assert.ThrowsAnyAsync<OperationCanceledException>(() => 
pending);
+            Assert.True(stmt.CancelCalled);
+        }
+
+        [Fact]
+        public async Task 
SynchronouslyBlockingLegacyUpdateOverrideCanBeCancelled()
+        {
+            var stmt = new SynchronousLegacyStatement();
+            using var cts = new CancellationTokenSource();
+
+            Task<UpdateResult> pending = Task.Run(() => 
stmt.ExecuteUpdateAsync(cts.Token));
+            await WaitForStartAsync(stmt.Started.Task);
+            cts.Cancel();
+
+            await Assert.ThrowsAnyAsync<OperationCanceledException>(() => 
pending);
+            Assert.True(stmt.CancelCalled);
+        }
+
+        [Fact]
+        public async Task QueryCancelledBeforeTheDefaultWorkStartsDoesNotRun()
+        {
+            using var queryCts = new CancellationTokenSource();
+            var queryStmt = new CancelThenCallBaseStatement(queryCts);
+            await Assert.ThrowsAnyAsync<OperationCanceledException>(async () 
=> await queryStmt.ExecuteQueryAsync(queryCts.Token));
+            Assert.Equal(0, queryStmt.QueryCount);
+
+            using var updateCts = new CancellationTokenSource();
+            var updateStmt = new CancelThenCallBaseStatement(updateCts);
+            await Assert.ThrowsAnyAsync<OperationCanceledException>(() => 
updateStmt.ExecuteUpdateAsync(updateCts.Token));
+            Assert.Equal(0, updateStmt.UpdateCount);
+        }
+
+        [Fact]
+        public async Task DisposeAsyncCallsDispose()
+        {
+            var stmt = new SyncOnlyStatement();
+            await stmt.DisposeAsync();
+            Assert.True(stmt.Disposed);
+        }
+
+
         [Fact]
         public void DriverOpenWithObjectsAppliesBinaryValuesAfterOpening()
         {
@@ -180,21 +521,325 @@ namespace Apache.Arrow.Adbc.Tests
             Assert.True(driver.LastDatabase!.Disposed);
         }
 
-        private sealed class OptionsStatement : AdbcStatement
+        // Waits without blocking a thread-pool thread, which the operation 
under test needs
+        private static async Task WaitForStartAsync(Task started)
         {
+            Assert.Same(started, await Task.WhenAny(started, 
Task.Delay(TimeSpan.FromSeconds(30))));
+        }
+
+        private sealed class SyncOnlyStatement : AdbcStatement
+        {
+            public int QueryCount;
+            public int UpdateCount;
+            public bool Disposed;
+            public TimeSpan Delay;
+            public TaskCompletionSource<bool> Started { get; } = new 
TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
             public Dictionary<string, string> Options { get; } = new 
Dictionary<string, string>();
 
             public override void SetOption(string key, string value) => 
Options[key] = value;
             public override string GetOption(string key) => 
Options.TryGetValue(key, out string? v) ? v : base.GetOption(key);
 
-            public override QueryResult ExecuteQuery() => throw new 
NotImplementedException();
-            public override UpdateResult ExecuteUpdate() => throw new 
NotImplementedException();
+            public override QueryResult ExecuteQuery()
+            {
+                Started.TrySetResult(true);
+                Thread.Sleep(Delay);
+                Interlocked.Increment(ref QueryCount);
+                return new QueryResult(0, null!);
+            }
+
+            public override UpdateResult ExecuteUpdate()
+            {
+                Interlocked.Increment(ref UpdateCount);
+                return new UpdateResult(0);
+            }
+
+            public override Schema ExecuteSchema() => throw new 
InvalidOperationException("should not be called");
+
+            public override void Dispose() => Disposed = true;
+        }
+
+        private sealed class FailingStatement : AdbcStatement
+        {
+            public TaskCompletionSource<bool> Started { get; } = new 
TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
+            public ManualResetEventSlim Fail { get; } = new 
ManualResetEventSlim();
+
+            public override QueryResult ExecuteQuery()
+            {
+                Started.TrySetResult(true);
+                Fail.Wait(TimeSpan.FromSeconds(30));
+                throw new AdbcException("bad query", 
AdbcStatusCode.InvalidArgument);
+            }
+
+            public override UpdateResult ExecuteUpdate() => throw new 
NotSupportedException();
+        }
+
+        /// <summary>
+        /// A driver written before the CancellationToken overloads existed.
+        /// </summary>
+        private sealed class LegacyAsyncStatement : AdbcStatement
+        {
+            public const long QueryRows = 11;
+            public const long UpdateRows = 12;
+            public int SyncCount;
+
+            public override async ValueTask<QueryResult> ExecuteQueryAsync()
+            {
+                await Task.Yield();
+                return new QueryResult(QueryRows, null!);
+            }
+
+            public override async Task<UpdateResult> ExecuteUpdateAsync()
+            {
+                await Task.Yield();
+                return new UpdateResult(UpdateRows);
+            }
+
+            public override QueryResult ExecuteQuery() { SyncCount++; return 
new QueryResult(0, null!); }
+            public override UpdateResult ExecuteUpdate() { SyncCount++; return 
new UpdateResult(0); }
+        }
+
+        /// <summary>
+        /// A legacy override that does asynchronous work before delegating to 
the base class.
+        /// </summary>
+        private sealed class LegacyCallsBaseStatement : AdbcStatement
+        {
+            public int OverrideCount;
+            public int SyncCount;
+
+            public override async ValueTask<QueryResult> ExecuteQueryAsync()
+            {
+                Interlocked.Increment(ref OverrideCount);
+                await Task.Delay(1);
+                return await base.ExecuteQueryAsync();
+            }
+
+            public override QueryResult ExecuteQuery()
+            {
+                Interlocked.Increment(ref SyncCount);
+                return new QueryResult(7, null!);
+            }
+
+            public override UpdateResult ExecuteUpdate() => new 
UpdateResult(0);
+        }
+
+        /// <summary>
+        /// A driver written against the CancellationToken overloads.
+        /// </summary>
+        private sealed class NewAsyncStatement : AdbcStatement
+        {
+            public const long QueryRows = 21;
+            public const long UpdateRows = 22;
+            public int SyncCount;
+
+            public override async ValueTask<QueryResult> 
ExecuteQueryAsync(CancellationToken cancellationToken)
+            {
+                await Task.Yield();
+                return new QueryResult(QueryRows, null!);
+            }
+
+            public override async Task<UpdateResult> 
ExecuteUpdateAsync(CancellationToken cancellationToken)
+            {
+                await Task.Yield();
+                return new UpdateResult(UpdateRows);
+            }
+
+            public override QueryResult ExecuteQuery() { SyncCount++; return 
new QueryResult(0, null!); }
+            public override UpdateResult ExecuteUpdate() { SyncCount++; return 
new UpdateResult(0); }
+        }
+
+        /// <summary>
+        /// A driver following the recommended pattern: it implements the 
token overloads
+        /// and forwards the parameterless ones to them.
+        /// </summary>
+        private sealed class RecommendedAsyncStatement : AdbcStatement
+        {
+            public int SyncCount;
+
+            public override ValueTask<QueryResult> ExecuteQueryAsync() => 
ExecuteQueryAsync(CancellationToken.None);
+
+            public override async ValueTask<QueryResult> 
ExecuteQueryAsync(CancellationToken cancellationToken)
+            {
+                await Task.Yield();
+                return new QueryResult(NewAsyncStatement.QueryRows, null!);
+            }
+
+            public override Task<UpdateResult> ExecuteUpdateAsync() => 
ExecuteUpdateAsync(CancellationToken.None);
+
+            public override async Task<UpdateResult> 
ExecuteUpdateAsync(CancellationToken cancellationToken)
+            {
+                await Task.Yield();
+                return new UpdateResult(NewAsyncStatement.UpdateRows);
+            }
+
+            public override QueryResult ExecuteQuery() { SyncCount++; return 
new QueryResult(0, null!); }
+            public override UpdateResult ExecuteUpdate() { SyncCount++; return 
new UpdateResult(0); }
+        }
+
+        /// <summary>
+        /// A token-overload override that falls back to the parameterless 
base implementation.
+        /// </summary>
+        private sealed class NewCallsParameterlessBaseStatement : AdbcStatement
+        {
+            public int OverrideCount;
+            public int SyncCount;
+
+            public override async ValueTask<QueryResult> 
ExecuteQueryAsync(CancellationToken cancellationToken)
+            {
+                Interlocked.Increment(ref OverrideCount);
+                return await base.ExecuteQueryAsync();
+            }
+
+            public override QueryResult ExecuteQuery()
+            {
+                Interlocked.Increment(ref SyncCount);
+                return new QueryResult(0, null!);
+            }
+
+            public override UpdateResult ExecuteUpdate() => new 
UpdateResult(0);
+        }
+
+        /// <summary>
+        /// A legacy driver whose Cancel faults its pending task 
synchronously, so any
+        /// continuation that isn't forced onto another thread runs inside 
Cancel.
+        /// </summary>
+        private sealed class InlineFaultingLegacyStatement : AdbcStatement
+        {
+            private readonly TaskCompletionSource<QueryResult> _result = new 
TaskCompletionSource<QueryResult>();
+
+            public override void Cancel() => _result.TrySetException(new 
TimeoutException("interrupted"));
+
+            public override ValueTask<QueryResult> ExecuteQueryAsync() => new 
ValueTask<QueryResult>(_result.Task);
+
+            public override QueryResult ExecuteQuery() => throw new 
NotSupportedException();
+            public override UpdateResult ExecuteUpdate() => throw new 
NotSupportedException();
         }
 
-        private sealed class RecordingConnection : AdbcConnection
+        /// <summary>
+        /// A legacy driver whose async overrides run the blocking operation 
before
+        /// returning a task.
+        /// </summary>
+        private sealed class SynchronousLegacyStatement : AdbcStatement
         {
+            private readonly ManualResetEventSlim _cancel = new 
ManualResetEventSlim();
+            public TaskCompletionSource<bool> Started { get; } = new 
TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
+            public bool CancelCalled => _cancel.IsSet;
+
+            public override void Cancel() => _cancel.Set();
+
+            public override ValueTask<QueryResult> ExecuteQueryAsync() => new 
ValueTask<QueryResult>(ExecuteQuery());
+            public override Task<UpdateResult> ExecuteUpdateAsync() => 
Task.FromResult(ExecuteUpdate());
+
+            public override QueryResult ExecuteQuery()
+            {
+                Block();
+                return new QueryResult(0, null!);
+            }
+
+            public override UpdateResult ExecuteUpdate()
+            {
+                Block();
+                return new UpdateResult(0);
+            }
+
+            private void Block()
+            {
+                Started.TrySetResult(true);
+                _cancel.Wait(TimeSpan.FromSeconds(30));
+                throw new AdbcException("cancelled", AdbcStatusCode.Cancelled);
+            }
+        }
+
+        /// <summary>
+        /// A legacy override that calls the base implementation after the 
token has been
+        /// cancelled, standing in for cancellation that arrives while the 
work is queued.
+        /// </summary>
+        private sealed class CancelThenCallBaseStatement : AdbcStatement
+        {
+            private readonly CancellationTokenSource _cts;
+            public int QueryCount;
+            public int UpdateCount;
+
+            public CancelThenCallBaseStatement(CancellationTokenSource cts) { 
_cts = cts; }
+
+            public override ValueTask<QueryResult> ExecuteQueryAsync()
+            {
+                _cts.Cancel();
+                return base.ExecuteQueryAsync();
+            }
+
+            public override Task<UpdateResult> ExecuteUpdateAsync()
+            {
+                _cts.Cancel();
+                return base.ExecuteUpdateAsync();
+            }
+
+            public override QueryResult ExecuteQuery()
+            {
+                Interlocked.Increment(ref QueryCount);
+                return new QueryResult(0, null!);
+            }
+
+            public override UpdateResult ExecuteUpdate()
+            {
+                Interlocked.Increment(ref UpdateCount);
+                return new UpdateResult(0);
+            }
+        }
+
+        private sealed class BlockingStatement : AdbcStatement
+        {
+            private readonly ManualResetEventSlim _cancel = new 
ManualResetEventSlim();
+            public TaskCompletionSource<bool> Started { get; } = new 
TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
+            public bool CancelCalled => _cancel.IsSet;
+
+            public override void Cancel() => _cancel.Set();
+
+            public override QueryResult ExecuteQuery()
+            {
+                Started.TrySetResult(true);
+                _cancel.Wait(TimeSpan.FromSeconds(30));
+                throw new AdbcException("cancelled", AdbcStatusCode.Cancelled);
+            }
+
+            public override UpdateResult ExecuteUpdate()
+            {
+                Started.TrySetResult(true);
+                _cancel.Wait(TimeSpan.FromSeconds(30));
+                throw new AdbcException("cancelled", AdbcStatusCode.Cancelled);
+            }
+        }
+
+        /// <summary>
+        /// A driver written before the token overloads existed, with a 
genuinely
+        /// asynchronous query that completes when Cancel is called.
+        /// </summary>
+        private sealed class LegacyBlockingStatement : AdbcStatement
+        {
+            private readonly TaskCompletionSource<bool> _cancel = new 
TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
+            public TaskCompletionSource<bool> Started { get; } = new 
TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
+            public bool CancelCalled => _cancel.Task.IsCompleted;
+
+            public override void Cancel() => _cancel.TrySetResult(true);
+
+            public override async ValueTask<QueryResult> ExecuteQueryAsync()
+            {
+                Started.TrySetResult(true);
+                await _cancel.Task.ConfigureAwait(false);
+                throw new AdbcException("cancelled", AdbcStatusCode.Cancelled);
+            }
+
+            public override QueryResult ExecuteQuery() => throw new 
NotSupportedException();
+            public override UpdateResult ExecuteUpdate() => throw new 
NotSupportedException();
+        }
+
+        private sealed class BlockingConnection : AdbcConnection
+        {
+            private readonly ManualResetEventSlim _cancel = new 
ManualResetEventSlim();
+            public TaskCompletionSource<bool> Started { get; } = new 
TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
+            public bool CancelCalled => _cancel.IsSet;
             public Dictionary<string, string> Options { get; } = new 
Dictionary<string, string>();
 
+            public const string BlockingKey = "test.blocking";
             public const string RejectedKey = "test.rejected";
 
             public Dictionary<string, byte[]> BinaryOptions { get; } = new 
Dictionary<string, byte[]>();
@@ -211,9 +856,29 @@ namespace Apache.Arrow.Adbc.Tests
 
             public override void Dispose() => Disposed = true;
 
-            public override void SetOption(string key, string value) => 
Options[key] = value;
+            public override void Cancel() => _cancel.Set();
 
-            public override IArrowArrayStream GetTableTypes() => throw new 
NotImplementedException();
+            public override void SetOption(string key, string value)
+            {
+                if (key == BlockingKey)
+                {
+                    Block();
+                }
+                Options[key] = value;
+            }
+
+            public override IArrowArrayStream GetTableTypes()
+            {
+                Block();
+                return null!;
+            }
+
+            private void Block()
+            {
+                Started.TrySetResult(true);
+                _cancel.Wait(TimeSpan.FromSeconds(30));
+                throw new AdbcException("cancelled", AdbcStatusCode.Cancelled);
+            }
 
             public override AdbcStatement CreateStatement() => throw new 
NotImplementedException();
 
@@ -229,12 +894,12 @@ namespace Apache.Arrow.Adbc.Tests
         private sealed class ConnectingDatabase : AdbcDatabase
         {
             public IReadOnlyDictionary<string, string>? LastStringOptions { 
get; private set; }
-            public RecordingConnection? LastConnection { get; private set; }
+            public BlockingConnection? LastConnection { get; private set; }
 
             public override AdbcConnection Connect(IReadOnlyDictionary<string, 
string>? options)
             {
                 LastStringOptions = options;
-                return LastConnection = new RecordingConnection();
+                return LastConnection = new BlockingConnection();
             }
         }
 
diff --git a/csharp/test/Apache.Arrow.Adbc.Tests/Client/ClientTests.cs 
b/csharp/test/Apache.Arrow.Adbc.Tests/Client/ClientTests.cs
index 5a49fa764..7c96dd02e 100644
--- a/csharp/test/Apache.Arrow.Adbc.Tests/Client/ClientTests.cs
+++ b/csharp/test/Apache.Arrow.Adbc.Tests/Client/ClientTests.cs
@@ -235,7 +235,7 @@ namespace Apache.Arrow.Adbc.Tests.Client
         [Fact]
         public void ReadAsyncDoesNotDeadlockOnASynchronizationContext()
         {
-            const int timeoutMilliseconds = 5_000;
+            const int timeoutMilliseconds = 30_000; // generous for loaded CI 
runners; a real deadlock never completes
             const int expectedRows = 4;
 
             using ManualResetEventSlim finished = new 
ManualResetEventSlim(false);
diff --git 
a/csharp/test/Apache.Arrow.Adbc.Tests/ExportedDriverRoundTrip11Tests.cs 
b/csharp/test/Apache.Arrow.Adbc.Tests/ExportedDriverRoundTrip11Tests.cs
index 6d8877e2a..62ed7af2c 100644
--- a/csharp/test/Apache.Arrow.Adbc.Tests/ExportedDriverRoundTrip11Tests.cs
+++ b/csharp/test/Apache.Arrow.Adbc.Tests/ExportedDriverRoundTrip11Tests.cs
@@ -216,6 +216,22 @@ namespace Apache.Arrow.Adbc.Tests
             Assert.Equal(42, stmt.ExecuteUpdate().AffectedRows);
         }
 
+        [Fact]
+        public async Task ConnectionOptionsAsyncRoundTrip()
+        {
+            var fixture = new Driver11();
+            using AdbcDriver imported = Load(fixture);
+            using AdbcDatabase db = imported.Open(new Dictionary<string, 
string>());
+            using AdbcConnection conn = db.Connect(null);
+
+            await conn.SetOptionAsync(AdbcOptions.Connection.CurrentCatalog, 
"main");
+            Assert.Equal("main", fixture.LastConnection!.CurrentCatalog);
+            Assert.Equal("main", await 
conn.GetOptionAsync(AdbcOptions.Connection.CurrentCatalog));
+
+            AdbcException e = await Assert.ThrowsAnyAsync<AdbcException>(() => 
conn.GetOptionAsync("unknown"));
+            Assert.Equal(AdbcStatusCode.NotFound, e.Status);
+        }
+
         [Fact]
         public void StatisticsAreMarshaled()
         {
@@ -346,13 +362,33 @@ namespace Apache.Arrow.Adbc.Tests
             stmt.SqlQuery = "BLOCK";
 
             Task<QueryResult> pending = Task.Run(() => stmt.ExecuteQuery());
-            
Assert.True(fixture.LastStatement!.Started.Wait(TimeSpan.FromSeconds(10)));
+            await WaitForStartAsync(fixture.LastStatement!.Started.Task);
             stmt.Cancel();
 
             AdbcException ex = await Assert.ThrowsAnyAsync<AdbcException>(() 
=> pending);
             Assert.Equal(AdbcStatusCode.Cancelled, ex.Status);
         }
 
+        [Fact]
+        public async Task CancellationTokenCancelsImportedStatement()
+        {
+            var fixture = new Driver11();
+            using AdbcDriver imported = Load(fixture);
+            using AdbcDatabase db = imported.Open(new Dictionary<string, 
string>());
+            using AdbcConnection conn = db.Connect(null);
+            using AdbcStatement stmt = conn.CreateStatement();
+            stmt.SqlQuery = "BLOCK";
+
+            using CancellationTokenSource cts = new CancellationTokenSource();
+            ValueTask<QueryResult> pending = stmt.ExecuteQueryAsync(cts.Token);
+            await WaitForStartAsync(fixture.LastStatement!.Started.Task);
+            cts.Cancel();
+
+            OperationCanceledException ex = await 
Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await pending);
+            AdbcException inner = 
Assert.IsAssignableFrom<AdbcException>(ex.InnerException);
+            Assert.Equal(AdbcStatusCode.Cancelled, inner.Status);
+        }
+
         [Fact]
         public void Version1_0DriverFallsBackForTypedSetters()
         {
@@ -415,6 +451,35 @@ namespace Apache.Arrow.Adbc.Tests
             Assert.True(fixture.LastDatabase!.Disposed);
         }
 
+        [Fact]
+        public async Task Version1_0DriverAsyncCallsMatchSyncFallbacks()
+        {
+            var fixture = new Driver11();
+            AdbcDriverInit adapter = (int version, ref CAdbcDriver 
nativeDriver, ref CAdbcError error) =>
+            {
+                if (version != AdbcVersion.Version_1_0_0) { return 
AdbcStatusCode.NotImplemented; }
+                return CallExporter(version, ref nativeDriver, ref error, 
fixture);
+            };
+
+            using AdbcDriver imported = CAdbcDriverImporter.Load(adapter);
+            using AdbcDatabase db = imported.Open(new Dictionary<string, 
string>());
+            using AdbcConnection conn = await 
db.ConnectWithTypedOptionsAsync(new Dictionary<string, object> { { "timeout", 
5L } });
+            Assert.Equal("5", fixture.LastConnection!.Options["timeout"]);
+
+            await conn.SetOptionAsync("other", "x");
+            Assert.Equal("x", fixture.LastConnection.Options["other"]);
+
+            AdbcException ex = await Assert.ThrowsAnyAsync<AdbcException>(() 
=> conn.GetOptionAsync("timeout"));
+            Assert.Equal(AdbcStatusCode.NotFound, ex.Status);
+
+            ex = await Assert.ThrowsAnyAsync<AdbcException>(() => 
conn.GetStatisticsNamesAsync());
+            Assert.Equal(AdbcStatusCode.NotImplemented, ex.Status);
+
+            using AdbcStatement stmt = conn.CreateStatement();
+            ex = await Assert.ThrowsAnyAsync<AdbcException>(() => 
stmt.ExecuteSchemaAsync());
+            Assert.Equal(AdbcStatusCode.NotImplemented, ex.Status);
+        }
+
         private static AdbcDriver Load(AdbcDriver driver)
         {
             AdbcDriver imported = CAdbcDriverImporter.Load(
@@ -438,6 +503,12 @@ namespace Apache.Arrow.Adbc.Tests
         /// <summary>
         /// Stores options with their types so tests can see which typed 
setter was used.
         /// </summary>
+        // Waits without blocking a thread-pool thread, which the operation 
under test needs
+        private static async Task WaitForStartAsync(Task started)
+        {
+            Assert.Same(started, await Task.WhenAny(started, 
Task.Delay(TimeSpan.FromSeconds(30))));
+        }
+
         private sealed class OptionBag
         {
             public Dictionary<string, object> Values { get; } = new 
Dictionary<string, object>();
@@ -593,7 +664,7 @@ namespace Apache.Arrow.Adbc.Tests
             private readonly OptionBag _options = new OptionBag();
             private readonly ManualResetEventSlim _cancelled = new 
ManualResetEventSlim();
 
-            public ManualResetEventSlim Started { get; } = new 
ManualResetEventSlim();
+            public TaskCompletionSource<bool> Started { get; } = new 
TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
 
             public override void SetOption(string key, string value) => 
_options.Values[key] = value;
             public override void SetOptionBytes(string key, ReadOnlySpan<byte> 
value) => _options.Values[key] = value.ToArray();
@@ -613,7 +684,7 @@ namespace Apache.Arrow.Adbc.Tests
                     case "FAIL_CANCELED":
                         throw new OperationCanceledException();
                     case "BLOCK":
-                        Started.Set();
+                        Started.TrySetResult(true);
                         if (_cancelled.Wait(TimeSpan.FromSeconds(30)))
                         {
                             throw new AdbcException("cancelled", 
AdbcStatusCode.Cancelled);
diff --git a/csharp/test/Apache.Arrow.Adbc.Tests/ImportedDuckDbTests.cs 
b/csharp/test/Apache.Arrow.Adbc.Tests/ImportedDuckDbTests.cs
index ea8a9562f..2595b88b6 100644
--- a/csharp/test/Apache.Arrow.Adbc.Tests/ImportedDuckDbTests.cs
+++ b/csharp/test/Apache.Arrow.Adbc.Tests/ImportedDuckDbTests.cs
@@ -362,11 +362,7 @@ namespace Apache.Arrow.Adbc.Tests
             statement.SqlQuery = "SELECT count(*) FROM range(1000000000000)";
             Task<long> query = Task.Run(() => 
GetResultCount(statement.ExecuteQuery()));
 
-            await Task.Delay(500);
-            statement.Cancel();
-
-            Task completed = await Task.WhenAny(query, 
Task.Delay(TimeSpan.FromSeconds(30)));
-            Assert.Same(query, completed);
+            await CancelUntilCompleteAsync(statement.Cancel, query);
             AdbcException exception = await 
Assert.ThrowsAnyAsync<AdbcException>(() => query);
             // DuckDB reports interruption as InvalidArgument rather than 
Cancelled
             Assert.Contains("Interrupted", exception.Message);
@@ -382,16 +378,28 @@ namespace Apache.Arrow.Adbc.Tests
             statement.SqlQuery = "SELECT count(*) FROM range(1000000000000)";
             Task<long> query = Task.Run(() => 
GetResultCount(statement.ExecuteQuery()));
 
-            await Task.Delay(500);
-            connection.Cancel();
-
-            Task completed = await Task.WhenAny(query, 
Task.Delay(TimeSpan.FromSeconds(30)));
-            Assert.Same(query, completed);
+            await CancelUntilCompleteAsync(connection.Cancel, query);
             AdbcException exception = await 
Assert.ThrowsAnyAsync<AdbcException>(() => query);
             // DuckDB reports interruption as InvalidArgument rather than 
Cancelled
             Assert.Contains("Interrupted", exception.Message);
         }
 
+        /// <summary>
+        /// Calls <paramref name="cancel"/> until <paramref name="query"/> 
finishes. DuckDB clears
+        /// an interrupt when a query begins, so a single Cancel that arrives 
before the query
+        /// has started (as it can on a busy CI runner) would leave the query 
running indefinitely.
+        /// </summary>
+        private static async Task CancelUntilCompleteAsync(Action cancel, Task 
query)
+        {
+            DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30);
+            while (!query.IsCompleted && DateTime.UtcNow < deadline)
+            {
+                cancel();
+                await Task.WhenAny(query, Task.Delay(250));
+            }
+            Assert.True(query.IsCompleted, "The query was not cancelled");
+        }
+
         private static long GetResultCount(AdbcStatement statement, string 
query)
         {
             statement.SqlQuery = query;

Reply via email to