RagingKore opened a new issue, #4715:
URL: https://github.com/apache/arrow-adbc/issues/4715

   ## What happened?
   
   `Apache.Arrow.Adbc.Client` is the ADO.NET wrapper over ADBC. It never calls 
the
   asynchronous methods that the layers above and below it already provide. An 
`await`ing
   caller reaches the wire through blocking calls, two of them `.Result`.
   
   Three defects, all in `csharp/src/Client/`, verified against `main` at 
`b6fcd135`.
   
   **1. `AdbcDataReader.ReadNextRecordBatchAsync` blocks on an already-async 
stream.**
   
   `AdbcDataReader.cs:392` declares a private method returning 
`ValueTask<RecordBatch?>`
   whose name ends in `Async`. Line 396 is its body:
   
   ```csharp
   RecordBatch? recordBatch = 
this.adbcQueryResult.Stream?.ReadNextRecordBatchAsync(cancellationToken).Result;
   ```
   
   `QueryResult.Stream` is an `IArrowArrayStream` (`Results.cs:51`). Its
   `ReadNextRecordBatchAsync(CancellationToken)` is asynchronous by contract 
and takes a
   token. The reader blocks on it, and discards the token, once per record 
batch.
   
   **2. `AdbcDataReader` does not override `ReadAsync`.**
   
   `AdbcDataReader.cs:325` overrides `Read()` only. `DbDataReader.ReadAsync` 
falls back to
   the base implementation, which runs the synchronous body and reaches the 
`.Result` at
   line 339.
   
   **3. `AdbcCommand` does not override `ExecuteDbDataReaderAsync`.**
   
   `AdbcCommand.cs:207` overrides `ExecuteDbDataReader` only. 
`ExecuteReaderAsync` falls
   back to the synchronous body, which calls `AdbcStatement.ExecuteQuery()`.
   `AdbcStatement.ExecuteQueryAsync()` already exists at `AdbcStatement.cs:86` 
and is
   `virtual`. Nothing in the Client calls it.
   
   A fourth occurrence of the same pattern sits at `AdbcConnection.cs:554`, in 
the
   schema-loading path. It is a separate code path and I have left it out of 
the fix
   below, but it is the same defect.
   
   ### It deadlocks; it does not only block
   
   `.Result` blocks the calling thread until the task completes. The task 
completes on a
   continuation. Without `ConfigureAwait(false)` that continuation is posted 
back to the
   captured `SynchronizationContext` — the thread already blocked in `.Result`. 
Neither
   side can proceed.
   
   I probed this rather than assuming it. The probe drives the real 
`AdbcConnection`,
   `AdbcCommand`, and `AdbcDataReader` from the shipped 
`Apache.Arrow.Adbc.Client` through
   their public API. Only the `IArrowArrayStream` is a fake, and its one 
distinguishing
   feature is an `await` without `ConfigureAwait(false)`, matching 
`FlightSqlResult.cs:54`.
   Each scenario has a five-second timeout. Its source is in the reproduction 
section.
   
   | | Scenario | Expected | Observed |
   |---|---|---|---|
   | A | `reader.Read()` on a UI-style `SynchronizationContext` | hang | 
**HUNG** |
   | B | `await reader.ReadAsync(ct)` on the same | hang | **HUNG** |
   | C | `reader.Read()` with no `SynchronizationContext` | complete | 
completed |
   | D | as A, but the stream uses `ConfigureAwait(false)` | complete | 
completed |
   
   C is the control, and shows the probe is not simply broken. D isolates the 
cause: A and
   D differ only by `ConfigureAwait(false)`, so the deadlock needs both the 
context capture
   below the wrapper and the `.Result` inside it.
   
   B is the case worth emphasising. The caller wrote `await`. 
`DbDataReader.ReadAsync` has
   no override, so the base implementation runs `Read()` inline on the calling 
thread and
   deadlocks anyway. Writing asynchronous code does not avoid this.
   
   Nothing on this path calls `ConfigureAwait(false)`:
   
   | Directory | `await` | `ConfigureAwait(false)` |
   |---|---:|---:|
   | `csharp/src/Client` | 0 | 0 |
   | `csharp/src/Drivers/FlightSql` | 4 | 0 |
   | `csharp/src/Apache.Arrow.Adbc` | 6 | 0 |
   
   Other parts of the tree use it heavily — 16 calls in `BigQueryStatement.cs`, 
and more
   throughout the Databricks CloudFetch, Thrift, and Telemetry code, 70 in 
total. The
   omission on this path looks inconsistent rather than deliberate.
   
   `Apache.Arrow.Adbc.Client` targets `netstandard2.0`, so it ships to .NET 
Framework
   hosts. WinForms, WPF, and classic ASP.NET each install a single-threaded
   `SynchronizationContext`. Any of them reaches scenario A or B.
   
   On ASP.NET Core and console hosts no `SynchronizationContext` exists, so the 
call
   returns, as scenario C shows. It still parks a thread-pool thread for every 
record
   batch, which starves the pool under concurrency.
   
   ### Why this is a defect, not a feature request
   
   Item 1 stands on its own terms. A private method named 
`ReadNextRecordBatchAsync`,
   returning `ValueTask<RecordBatch?>`, taking a `CancellationToken`, whose 
body calls
   `.Result` on an asynchronous method, is wrong whatever the caller wants. It 
blocks a
   thread and drops the token.
   
   Taken alone, items 2 and 3 are additive improvements rather than defects. 
`DbDataReader`
   and `DbCommand` ship working base implementations, and a provider that 
declines to
   override them breaks no contract.
   
   The three compose into a defect. Items 2 and 3 route an awaiting caller into 
item 1, and
   the result is the deterministic hang shown above. A fallback path that 
deadlocks is not a
   missing feature.
   
   ### Drivers that are already asynchronous
   
   Two in-tree drivers implement `ExecuteQueryAsync` for real: Flight SQL at
   `FlightSqlStatement.cs:37`, and HiveServer2/Databricks at 
`HiveServer2Statement.cs:166`.
   
   Flight SQL then inverts itself to satisfy the synchronous abstract member —
   `FlightSqlStatement.cs:47-50` is `ExecuteQuery() => 
ExecuteQueryAsync().Result`.
   Composed through the wrapper, an `await`ing caller runs: BCL synchronous 
fallback,
   `ExecuteQuery()`, `.Result`, then the genuinely asynchronous method. A 
thread blocks for
   a gRPC round trip that was already asynchronous.
   
   Item 1 affects every driver, because `IArrowArrayStream` is an Apache Arrow 
contract
   that every driver's stream satisfies asynchronously.
   
   ### Relationship to #1843 and #1865
   
   This is not #1843. That issue asked for a broader asynchronous surface; 
#1865 answered
   it with the `AdbcStatement11` family, which is asynchronous-primitive and 
carries a
   `CancellationToken` throughout. That design is sound and this report does 
not question it.
   
   This defect sits below it. `AdbcStatement.ExecuteQueryAsync()` and
   `AdbcStatement11.ExecuteQueryAsync(CancellationToken)` both return the same
   `QueryResult`, holding the same `IArrowArrayStream`. The `.Result` at
   `AdbcDataReader.cs:396` is downstream of that join, so moving a driver to 
the 1.1 family
   does not remove it. Meanwhile every driver shipping today reaches users 
through
   `AdbcStatement` and this wrapper.
   
   ### Proposed fix
   
   Three additive changes in `csharp/src/Client/`, two files. No public 
contract change and
   no driver change.
   
   1. Make `ReadNextRecordBatchAsync` genuinely `async` and await the stream. 
The method is
      private, so this changes no contract. `Read()` keeps its `.Result` on it, 
which is the
      synchronous path's honest cost.
   2. Add an `AdbcDataReader.ReadAsync(CancellationToken)` override that awaits 
the batch
      fetch. Preserve the intra-batch fast path and the dispose-before-fetch 
ordering added
      by #4133, so a caller retrying after a mid-stream error sees the 
exception again
      instead of stale rows.
   3. Add an `AdbcCommand.ExecuteDbDataReaderAsync` override that awaits
      `AdbcStatement.ExecuteQueryAsync()`, sharing the body of
      `ExecuteReader(CommandBehavior)`.
   
   Drivers that override only `ExecuteQuery` keep the existing base behaviour,
   `Task.Run(() => ExecuteQuery())`, and are unaffected.
   
   Adding `ConfigureAwait(false)` to the driver and core `await` sites would 
also help, and
   is a separate change.
   
   ### Known limitation, left alone deliberately
   
   `AdbcStatement.ExecuteQueryAsync()` takes no `CancellationToken`, so change 
3 can only
   observe the token at the command boundary. The initial query call stays 
uncancellable,
   exactly as today. Per-batch fetches become cancellable, and that is where 
the repeated
   round trips happen.
   
   Adding a token overload to `AdbcStatement` would be new public API on the 
core package
   and would re-open the design settled in #1865. This report does not propose 
it.
   
   ---
   
   ## Stack Trace
   
   No exception is thrown. This is the managed stack of the deadlocked thread, 
captured
   from a live process with `dotnet-stack report -p <pid>` while the probe in 
the next
   field was hung. The thread shown is the one pumping the message loop, which 
is what a
   WPF Dispatcher or a WinForms message loop is.
   
   ```
   Thread (0x145D401B):
     [Native Frames]
     
System.Private.CoreLib!System.Threading.WaitSubsystem+ThreadWaitInfo.Wait(int32,bool,bool,value
 class LockHolder&)
     System.Private.CoreLib!System.Threading.WaitSubsystem.Wait(class 
IWaitableObject,class ThreadWaitInfo,int32,bool)
     
System.Private.CoreLib!System.Threading.WaitHandle.WaitOneNoCheck(int32,bool,class
 System.Object,value class WaitHandleWaitSourceMap)
     System.Private.CoreLib!System.Threading.Condition.Wait(int32,class 
System.Object)
     
System.Private.CoreLib!System.Threading.ManualResetEventSlim.Wait(int32,value 
class System.Threading.CancellationToken)
     
System.Private.CoreLib!System.Threading.Tasks.Task.SpinThenBlockingWait(int32,value
 class System.Threading.CancellationToken)
     
System.Private.CoreLib!System.Threading.Tasks.Task.InternalWaitCore(int32,value 
class System.Threading.CancellationToken)
     
System.Private.CoreLib!System.Threading.Tasks.Task.InternalWait(int32,value 
class System.Threading.CancellationToken)
     
System.Private.CoreLib!System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(class
 System.Threading.Tasks.Task,value class 
System.Threading.Tasks.ConfigureAwaitOptions)
     
System.Private.CoreLib!System.Threading.Tasks.ValueTask`1[System.__Canon].get_Result()
     
Apache.Arrow.Adbc.Client!Apache.Arrow.Adbc.Client.AdbcDataReader.ReadNextRecordBatchAsync(value
 class System.Threading.CancellationToken)
     Apache.Arrow.Adbc.Client!Apache.Arrow.Adbc.Client.AdbcDataReader.Read()
     hang!PumpSyncContext.Pump()
     System.Private.CoreLib!System.Threading.Thread.StartCallback(class 
System.Threading.Thread*)
   ```
   
   Read it bottom-up. The pumping thread is inside `AdbcDataReader.Read()`, 
inside
   `AdbcDataReader.ReadNextRecordBatchAsync`, inside 
`ValueTask<T>.get_Result()`, blocked in
   `Task.InternalWait`. Two frames of `Apache.Arrow.Adbc.Client` sit directly 
above
   `get_Result`.
   
   The cycle it is stuck in:
   
   ```
   ┌───────────────────────┐                ┌────────────────────────┐
   │                       │                │  continuation of the   │
   │  UI thread parked in  │   waits for    │        await at        │
   │      .Result at       ├───────────────►│ FlightSqlResult.cs:54, │
   │ AdbcDataReader.cs:396 │                │     queued on the      │
   │                       │                │        context         │
   └───────────────────────┘                └────────────┬───────────┘
               ▲can only run on                          │
               │                                         │
               ╰─────────────────────────────────────────╯
   ```
   
   For reference, the same path traced by line:
   
   ```
   caller: await command.ExecuteReaderAsync(ct)
     -> DbCommand.ExecuteDbDataReaderAsync(...)          BCL default -> calls 
the SYNC method
         -> AdbcCommand.ExecuteDbDataReader(...)         
Client/AdbcCommand.cs:207  (only override)
             -> AdbcCommand.ExecuteReader(behavior)      
Client/AdbcCommand.cs:228
                 -> AdbcStatement.ExecuteQuery()         abstract, synchronous
                     -> FlightSqlStatement.ExecuteQuery()    
Drivers/FlightSql/FlightSqlStatement.cs:47
                         -> ExecuteQueryAsync().Result       
Drivers/FlightSql/FlightSqlStatement.cs:49   BLOCKS
   
   caller: while (await reader.ReadAsync(ct))
     -> DbDataReader.ReadAsync(...)                      BCL default -> calls 
the SYNC method
         -> AdbcDataReader.Read()                        
Client/AdbcDataReader.cs:325
             -> ReadNextRecordBatchAsync().Result        
Client/AdbcDataReader.cs:339                     BLOCKS
                 -> Stream.ReadNextRecordBatchAsync(ct).Result
                                                         
Client/AdbcDataReader.cs:396                     BLOCKS
                     -> FlightSqlResult.ReadNextRecordBatchAsync(ct)
                         -> await ...MoveNext(ct)        
Drivers/FlightSql/FlightSqlResult.cs:54
                                                         no 
ConfigureAwait(false) -> continuation posted
                                                         back to the blocked 
SynchronizationContext
   ```
   
   ---
   
   ## How can we reproduce the bug?
   
   What a user writes, in a WPF or WinForms event handler:
   
   ```csharp
   private async void OnLoadClick(object sender, RoutedEventArgs e)
   {
       using var command = connection.CreateCommand();
       command.CommandText = "SELECT * FROM some_large_table";
   
       using var reader = await command.ExecuteReaderAsync(ct);
   
       while (await reader.ReadAsync(ct))   // never returns
       {
           ...
       }
   }
   ```
   
   The UI freezes. Nothing throws and no timeout fires, because the token never 
reaches an
   overridden `ReadAsync`.
   
   ### Runnable probe
   
   Save as `probe.cs` outside the repository, adjust the `#:project` path, then 
run with
   .NET SDK 10 or later:
   
   ```
   dotnet run probe.cs
   ```
   
   Observed on macOS, .NET SDK 10.0.301, `main` at `b6fcd135`:
   
   ```
   [A] reader.Read() on a UI-style SynchronizationContext
        expected: HUNG       observed: HUNG       OK
   [B] await reader.ReadAsync(ct) on a UI-style SynchronizationContext
        expected: HUNG       observed: HUNG       OK
   [C] reader.Read() with no SynchronizationContext (console / ASP.NET Core)
        expected: completed  observed: completed  OK
   [D] reader.Read() on a UI-style SynchronizationContext, stream uses 
ConfigureAwait(false)
        expected: completed  observed: completed  OK
   
   RESULT: all scenarios matched expectations.
   ```
   
   <details>
   <summary>probe.cs</summary>
   
   ```csharp
   #:property PublishAot=false
   #:property Nullable=disable
   #:property TreatWarningsAsErrors=false
   #:project ../arrow-adbc/csharp/src/Client/Apache.Arrow.Adbc.Client.csproj
   
   // Everything below the stream fake is the real shipped code, reached 
through its
   // fully public surface. No internals, no reflection, no patched build.
   
   using System;
   using System.Collections.Concurrent;
   using System.Collections.Generic;
   using System.Threading;
   using System.Threading.Tasks;
   using Apache.Arrow;
   using Apache.Arrow.Ipc;
   using Apache.Arrow.Types;
   using Adbc = Apache.Arrow.Adbc;
   using Client = Apache.Arrow.Adbc.Client;
   
   const int TimeoutMs = 5000;
   
   bool useConfigureAwait = false;
   StreamMode.UseConfigureAwait = () => useConfigureAwait;
   
   int failures = 0;
   
   failures += Report("A", "reader.Read() on a UI-style SynchronizationContext",
       expectHang: true, RunWithSyncContext(reader => reader.Read()));
   
   failures += Report("B", "await reader.ReadAsync(ct) on a UI-style 
SynchronizationContext",
       expectHang: true,
       RunWithSyncContext(reader => 
reader.ReadAsync(CancellationToken.None).GetAwaiter().GetResult()));
   
   failures += Report("C", "reader.Read() with no SynchronizationContext 
(console / ASP.NET Core)",
       expectHang: false, RunWithoutSyncContext(reader => reader.Read()));
   
   useConfigureAwait = true;
   failures += Report("D", "reader.Read() on a UI-style SynchronizationContext, 
stream uses ConfigureAwait(false)",
       expectHang: false, RunWithSyncContext(reader => reader.Read()));
   useConfigureAwait = false;
   
   Console.WriteLine();
   Console.WriteLine(failures == 0
       ? "RESULT: all scenarios matched expectations."
       : $"RESULT: {failures} scenario(s) did NOT match expectations.");
   return failures == 0 ? 0 : 1;
   
   static int Report(string id, string what, bool expectHang, bool hung)
   {
       string got = hung ? "HUNG" : "completed";
       string want = expectHang ? "HUNG" : "completed";
       Console.WriteLine($"[{id}] {what}");
       Console.WriteLine($"     expected: {want,-9}  observed: {got,-9}  {(hung 
== expectHang ? "OK" : "MISMATCH")}");
       return hung == expectHang ? 0 : 1;
   }
   
   // Runs body(reader) on a thread that installs a single-threaded 
SynchronizationContext
   // and pumps it, as a WPF Dispatcher or a WinForms message loop does.
   static bool RunWithSyncContext(Func<Client.AdbcDataReader, bool> body)
   {
       var finished = new ManualResetEventSlim(false);
       var t = new Thread(() =>
       {
           var ctx = new PumpSyncContext();
           SynchronizationContext.SetSynchronizationContext(ctx);
           ctx.Post(_ =>
           {
               try { body(NewReader()); } catch { }
               finished.Set();
               ctx.Complete();
           }, null);
           ctx.Pump();
       });
       t.IsBackground = true;
       t.Start();
       return !finished.Wait(TimeoutMs);
   }
   
   static bool RunWithoutSyncContext(Func<Client.AdbcDataReader, bool> body)
   {
       var finished = new ManualResetEventSlim(false);
       var t = new Thread(() =>
       {
           SynchronizationContext.SetSynchronizationContext(null);
           try { body(NewReader()); } catch { }
           finished.Set();
       });
       t.IsBackground = true;
       t.Start();
       return !finished.Wait(TimeoutMs);
   }
   
   static Client.AdbcDataReader NewReader()
   {
       var connection = new Client.AdbcConnection(
           new ProbeDriver(),
           new Dictionary<string, string> { { "probe", "1" } },
           new Dictionary<string, string>());
       connection.Open();
   
       var command = connection.CreateCommand();
       command.CommandText = "SELECT 1";
       return command.ExecuteReader();
   }
   
   sealed class PumpSyncContext : SynchronizationContext
   {
       readonly BlockingCollection<KeyValuePair<SendOrPostCallback, object>> 
queue = new();
   
       public override void Post(SendOrPostCallback d, object state)
       {
           try { queue.Add(new KeyValuePair<SendOrPostCallback, object>(d, 
state)); }
           catch (InvalidOperationException) { }
       }
   
       public override void Send(SendOrPostCallback d, object state) => 
d(state);
   
       public void Pump()
       {
           foreach (var item in queue.GetConsumingEnumerable()) 
item.Key(item.Value);
       }
   
       public void Complete() => queue.CompleteAdding();
   }
   
   // The only fake. Its one distinguishing feature is the await below:
   // no ConfigureAwait(false), matching 
Drivers/FlightSql/FlightSqlResult.cs:54.
   sealed class AsyncCapturingStream : IArrowArrayStream
   {
       readonly Schema schema;
       readonly RecordBatch[] batches;
       int index = -1;
   
       public AsyncCapturingStream()
       {
           schema = new Schema(new List<Field> { new Field("n", 
Int32Type.Default, true) }, null);
           var builder = new Int32Array.Builder();
           builder.AppendRange(new List<int> { 1, 2, 3 });
           Int32Array array = builder.Build();
           batches = new[] { new RecordBatch(schema, new List<IArrowArray> { 
array }, array.Length) };
       }
   
       public Schema Schema => schema;
   
       public async ValueTask<RecordBatch> 
ReadNextRecordBatchAsync(CancellationToken cancellationToken = default)
       {
           if (StreamMode.UseConfigureAwait())
               await Task.Delay(25, cancellationToken).ConfigureAwait(false);
           else
               await Task.Delay(25, cancellationToken);
   
           index++;
           return index < batches.Length ? batches[index] : null;
       }
   
       public void Dispose() { }
   }
   
   static class StreamMode
   {
       public static Func<bool> UseConfigureAwait = () => false;
   }
   
   sealed class ProbeStatement : Adbc.AdbcStatement
   {
       public override Adbc.QueryResult ExecuteQuery() => new 
Adbc.QueryResult(3, new AsyncCapturingStream());
       public override Adbc.UpdateResult ExecuteUpdate() => throw new 
NotImplementedException();
   }
   
   sealed class ProbeConnection : Adbc.AdbcConnection
   {
       public override Adbc.AdbcStatement CreateStatement() => new 
ProbeStatement();
   
       public override IArrowArrayStream GetObjects(GetObjectsDepth depth, 
string catalogPattern,
           string dbSchemaPattern, string tableNamePattern, 
IReadOnlyList<string> tableTypes,
           string columnNamePattern) => throw new NotImplementedException();
   
       public override Schema GetTableSchema(string catalog, string dbSchema, 
string tableName) =>
           throw new NotImplementedException();
   
       public override IArrowArrayStream GetTableTypes() => throw new 
NotImplementedException();
   }
   
   sealed class ProbeDatabase : Adbc.AdbcDatabase
   {
       public override Adbc.AdbcConnection Connect(IReadOnlyDictionary<string, 
string> options) => new ProbeConnection();
   }
   
   sealed class ProbeDriver : Adbc.AdbcDriver
   {
       public override Adbc.AdbcDatabase Open(IReadOnlyDictionary<string, 
string> parameters) => new ProbeDatabase();
   }
   ```
   
   </details>
   
   ---
   
   ## Environment/Setup
   
   - Repository: `apache/arrow-adbc`, `main` at `b6fcd135`
   - Package: `Apache.Arrow.Adbc.Client`, version prefix `0.25.0-SNAPSHOT`
     (`csharp/Directory.Build.props:32`)
   - Target frameworks: `netstandard2.0;net8.0`
   - Driver used to trace the chain: `Apache.Arrow.Adbc.Drivers.FlightSql`
   - OS: macOS. The defect is a property of the source and is platform 
independent, but the
     deadlock requires a host that installs a `SynchronizationContext`.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to