Skip to content

csharp: AdbcCommand and AdbcDataReader block on every async call path #4715

Description

@RagingKore

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 awaiting 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:

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.

There is a fourth one at AdbcConnection.cs:554, in the schema-loading path. Same defect, different code path.

The deadlock

.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, which is the thread already blocked in .Result. Neither side can proceed.

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: the same code completes with no context, so the probe works. A and D differ only by ConfigureAwait(false), which pins the cause. The deadlock needs the context capture below the wrapper and the .Result inside it.

B is the one that matters. 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, plus the Databricks CloudFetch, Thrift and Telemetry code, 70 in total. That reads as an oversight on this path rather than a policy.

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.

Bug or enhancement

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.

On their own, items 2 and 3 are enhancements. DbDataReader and DbCommand ship working base implementations, and a provider that declines to override them breaks no contract.

Together they produce 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 hangs is a bug.

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 awaiting 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

Four additive changes in csharp/src/Client/, three 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 cost of the synchronous path.
  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 fix(csharp/src/Client): clear cached record batch in Read() to prevent stale data on exception #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).
  4. Where the synchronous APIs must still block, use AsTask() rather than .Result. A driver's stream is genuinely asynchronous, so reading .Result on the ValueTask it returns is unsupported. This covers Read() and the AdbcConnection schema loop.

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.

Not fixed here

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, the same role a WPF Dispatcher or a WinForms message loop plays.

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:

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.
probe.cs
#: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();
}

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.

Metadata

Metadata

Assignees

Labels

.NETPull requests that update .net codeType: bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions