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 d9cb6a2115 feat(csharp): implement AdbcErrorFromArrayStream for
exported and imported streams (#4983)
d9cb6a2115 is described below
commit d9cb6a2115c57f9c708cf6c394e91254647bafc6
Author: Curt Hagenlocher <[email protected]>
AuthorDate: Sat Oct 31 06:51:35 2026 -0700
feat(csharp): implement AdbcErrorFromArrayStream for exported and imported
streams (#4983)
When a C# driver exported through `CAdbcDriverExporter` fails partway
through reading a result stream, the error's status, vendor code,
SQLSTATE and details are now preserved across the C ABI.
Exported streams no longer go through Arrow's
`CArrowArrayStreamExporter`. They keep the exception from a failed
`get_next`/`get_schema`, return an errno matching the status (e.g.
`ECANCELED` for `Cancelled`), and report the error through
`AdbcErrorFromArrayStream`. On the consumer side, `CAdbcDriverImporter`
calls the driver's `ErrorFromArrayStream` when a read fails and throws
an `AdbcException` with the status and details, falling back to the
previous message-only exception for 1.0 drivers or when the driver
returns null.
Because Apache.Arrow keeps the `ArrowArrayStream` callback fields
internal, this adds internal structs with the same layout.
This PR was prepared with AI assistance (Claude Code).
Closes #4982
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---
.../C/CAdbcDriverExporter.Streams.cs | 367 ++++++++++++++++++++
.../src/Apache.Arrow.Adbc/C/CAdbcDriverExporter.cs | 31 +-
.../C/CAdbcDriverImporter.Streams.cs | 231 +++++++++++++
.../src/Apache.Arrow.Adbc/C/CAdbcDriverImporter.cs | 4 +-
csharp/src/Apache.Arrow.Adbc/C/Delegates.cs | 5 +
.../src/Apache.Arrow.Adbc/C/NativeArrowStructs.cs | 114 +++++++
.../FixtureDriver.cs | 26 ++
.../AotFixtureTests.cs | 18 +
.../ExportedDriverRoundTrip11Tests.cs | 371 +++++++++++++++++++++
9 files changed, 1140 insertions(+), 27 deletions(-)
diff --git a/csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverExporter.Streams.cs
b/csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverExporter.Streams.cs
new file mode 100644
index 0000000000..e7a5b83f6b
--- /dev/null
+++ b/csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverExporter.Streams.cs
@@ -0,0 +1,367 @@
+/*
+ * 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.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Apache.Arrow.C;
+using Apache.Arrow.Ipc;
+
+namespace Apache.Arrow.Adbc.C
+{
+ public partial class CAdbcDriverExporter
+ {
+#if NET5_0_OR_GREATER
+ private static unsafe delegate* unmanaged<CArrowArrayStream*,
CArrowSchema*, int> StreamGetSchemaPtr => &GetStreamSchema;
+ private static unsafe delegate* unmanaged<CArrowArrayStream*,
CArrowArray*, int> StreamGetNextPtr => &GetStreamNext;
+ private static unsafe delegate* unmanaged<CArrowArrayStream*, byte*>
StreamGetLastErrorPtr => &GetStreamLastError;
+ private static unsafe delegate* unmanaged<CArrowArrayStream*, void>
StreamReleasePtr => &ReleaseStream;
+ private static unsafe delegate* unmanaged<CArrowArrayStream*,
AdbcStatusCode*, CAdbcError*> ErrorFromArrayStreamPtr =>
&GetErrorFromArrayStream;
+#else
+ private static unsafe IntPtr StreamGetSchemaPtr =
ArrayStreamCallbacks.Export<ArrayStreamGetSchema>(GetStreamSchema,
ArrayStreamCallbacks.GetSchemaType);
+ private static unsafe IntPtr StreamGetNextPtr =
ArrayStreamCallbacks.Export<ArrayStreamGetNext>(GetStreamNext,
ArrayStreamCallbacks.GetNextType);
+ private static unsafe IntPtr StreamGetLastErrorPtr =
ArrayStreamCallbacks.Export<ArrayStreamGetLastError>(GetStreamLastError,
ArrayStreamCallbacks.GetLastErrorType);
+ private static unsafe IntPtr StreamReleasePtr =
ArrayStreamCallbacks.Export<ArrayStreamRelease>(ReleaseStream,
ArrayStreamCallbacks.ReleaseType);
+ private static unsafe IntPtr ErrorFromArrayStreamPtr =
NativeDelegate<ErrorFromArrayStream>.AsNativePointer(GetErrorFromArrayStream);
+#endif
+
+ /// <summary>
+ /// Exports a stream produced by the managed driver. The exported
stream takes
+ /// ownership on success; if exporting fails, nobody else will dispose
it.
+ /// </summary>
+ /// <remarks>
+ /// When reading the stream fails, the exported stream keeps the
exception so that
+ /// <c>AdbcErrorFromArrayStream</c> can report its status, SQLSTATE,
vendor code and
+ /// details, and returns an errno that corresponds to the status.
+ /// </remarks>
+ internal unsafe static void ExportStream(IArrowArrayStream? stream,
CArrowArrayStream* cStream)
+ {
+ try
+ {
+ if (stream == null)
+ {
+ throw new ArgumentNullException(nameof(stream));
+ }
+ if (cStream == null)
+ {
+ throw new ArgumentNullException(nameof(cStream));
+ }
+
+ NativeArrayStream* nativeStream =
NativeArrayStream.From(cStream);
+ nativeStream->private_data =
(void*)GCHandle.ToIntPtr(GCHandle.Alloc(new ExportedStream(stream)));
+ nativeStream->get_schema = StreamGetSchemaPtr;
+ nativeStream->get_next = StreamGetNextPtr;
+ nativeStream->get_last_error = StreamGetLastErrorPtr;
+ nativeStream->release = StreamReleasePtr;
+ }
+ catch
+ {
+ stream?.Dispose();
+ throw;
+ }
+ }
+
+ private unsafe static ExportedStream?
GetExportedStream(CArrowArrayStream* cStream)
+ {
+ NativeArrayStream* nativeStream = NativeArrayStream.From(cStream);
+ if (cStream == null ||
+ (IntPtr)nativeStream->release != (IntPtr)StreamReleasePtr ||
+ nativeStream->private_data == null)
+ {
+ return null;
+ }
+ return
GCHandle.FromIntPtr((IntPtr)nativeStream->private_data).Target as
ExportedStream;
+ }
+
+#if NET5_0_OR_GREATER
+ [UnmanagedCallersOnly]
+#endif
+ private unsafe static int GetStreamSchema(CArrowArrayStream* cStream,
CArrowSchema* cSchema)
+ {
+ ExportedStream? stream = null;
+ try
+ {
+ stream = GetExportedStream(cStream) ?? throw new
ArgumentException("not a stream exported by this driver", nameof(cStream));
+ stream.ClearError();
+ CArrowSchemaExporter.ExportSchema(stream.Stream.Schema,
cSchema);
+ return 0;
+ }
+ catch (Exception e)
+ {
+ return stream?.SetError(e) ?? Errno.EINVAL;
+ }
+ }
+
+#if NET5_0_OR_GREATER
+ [UnmanagedCallersOnly]
+#endif
+ private unsafe static int GetStreamNext(CArrowArrayStream* cStream,
CArrowArray* cArray)
+ {
+ ExportedStream? stream = null;
+ try
+ {
+ NativeArray.From(cArray)->release = default;
+ stream = GetExportedStream(cStream) ?? throw new
ArgumentException("not a stream exported by this driver", nameof(cStream));
+ stream.ClearError();
+
+ RecordBatch? batch = ReadNextBatch(stream.Stream);
+ if (batch != null)
+ {
+ CArrowArrayExporter.ExportRecordBatch(batch, cArray);
+ }
+ return 0;
+ }
+ catch (Exception e)
+ {
+ return stream?.SetError(e) ?? Errno.EINVAL;
+ }
+ }
+
+ private static RecordBatch? ReadNextBatch(IArrowArrayStream stream)
+ {
+ // A managed caller in the same process may call get_next on a
thread with a
+ // synchronization context. Blocking that thread deadlocks a read
that resumes on
+ // the context, so start the read without it. A native caller has
no context.
+ if (SynchronizationContext.Current != null ||
TaskScheduler.Current != TaskScheduler.Default)
+ {
+ return Task.Run(() =>
stream.ReadNextRecordBatchAsync().AsTask()).GetAwaiter().GetResult();
+ }
+
+ ValueTask<RecordBatch?> pending =
stream.ReadNextRecordBatchAsync();
+ return pending.IsCompletedSuccessfully ? pending.Result :
pending.AsTask().GetAwaiter().GetResult();
+ }
+
+#if NET5_0_OR_GREATER
+ [UnmanagedCallersOnly]
+#endif
+ private unsafe static byte* GetStreamLastError(CArrowArrayStream*
cStream)
+ {
+ try
+ {
+ return GetExportedStream(cStream)?.LastErrorMessage;
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+
+#if NET5_0_OR_GREATER
+ [UnmanagedCallersOnly]
+#endif
+ private unsafe static void ReleaseStream(CArrowArrayStream* cStream)
+ {
+ NativeArrayStream* nativeStream = NativeArrayStream.From(cStream);
+ try
+ {
+ if (nativeStream->private_data != null)
+ {
+ GCHandle gch =
GCHandle.FromIntPtr((IntPtr)nativeStream->private_data);
+ try
+ {
+ ((ExportedStream)gch.Target!).Dispose();
+ }
+ finally
+ {
+ gch.Free();
+ nativeStream->private_data = null;
+ }
+ }
+ }
+ catch (Exception)
+ {
+ // An exception would end the process, and the consumer has no
way to learn of it.
+ }
+ nativeStream->release = default;
+ }
+
+#if NET5_0_OR_GREATER
+ [UnmanagedCallersOnly]
+#endif
+ private unsafe static CAdbcError*
GetErrorFromArrayStream(CArrowArrayStream* cStream, AdbcStatusCode* status)
+ {
+ try
+ {
+ return GetExportedStream(cStream)?.GetError(status);
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+
+ /// <summary>
+ /// The state behind an exported <see cref="CArrowArrayStream"/>: the
managed stream
+ /// and the error from the last call that failed, if any.
+ /// </summary>
+ private sealed unsafe class ExportedStream : IDisposable
+ {
+ private IArrowArrayStream? _stream;
+ private CAdbcError* _error;
+ private AdbcStatusCode _status;
+
+ public ExportedStream(IArrowArrayStream stream)
+ {
+ _stream = stream;
+ }
+
+ public IArrowArrayStream Stream => _stream ?? throw new
ObjectDisposedException(nameof(ExportedStream));
+
+ public byte* LastErrorMessage => _error != null && _error->release
!= default ? _error->message : null;
+
+ public CAdbcError* GetError(AdbcStatusCode* status)
+ {
+ if (_error == null || _error->release == default)
+ {
+ return null;
+ }
+ if (status != null)
+ {
+ *status = _status;
+ }
+ return _error;
+ }
+
+ /// <summary>
+ /// Records <paramref name="exception"/> as the stream's error and
returns the
+ /// errno that corresponds to its status.
+ /// </summary>
+ public int SetError(Exception exception)
+ {
+ ClearError();
+ _status = CAdbcDriverExporter.SetError(null, exception);
+ if (_status == AdbcStatusCode.Success)
+ {
+ // An errno of 0 would read as the end of the stream
+ _status = AdbcStatusCode.UnknownError;
+ }
+
+ try
+ {
+ if (_error == null)
+ {
+ _error =
(CAdbcError*)Marshal.AllocHGlobal(sizeof(CAdbcError));
+ *_error = default;
+ }
+
+ // AdbcErrorFromArrayStream is part of ADBC 1.1.0, so the
consumer
+ // understands the 1.1.0 error layout.
+ _error->vendor_code = ErrorVendorCodePrivateData;
+ CAdbcDriverExporter.SetError(_error, exception);
+ }
+ catch (Exception)
+ {
+ // Report the errno without a message.
+ }
+ return Errno.FromStatus(_status);
+ }
+
+ public void ClearError()
+ {
+ if (_error != null)
+ {
+ if (_error->release != default)
+ {
+ ReleaseError(_error);
+ }
+ *_error = default;
+ }
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ _stream?.Dispose();
+ }
+ finally
+ {
+ _stream = null;
+ if (_error != null)
+ {
+ ClearError();
+ Marshal.FreeHGlobal((IntPtr)_error);
+ _error = null;
+ }
+ }
+ }
+ }
+
+ /// <summary>
+ /// The errno values that an <see cref="CArrowArrayStream"/> reports,
which depend on the
+ /// C runtime of the platform.
+ /// </summary>
+ internal static class Errno
+ {
+ public const int ENOENT = 2;
+ public const int EIO = 5;
+ public const int EACCES = 13;
+ public const int EEXIST = 17;
+ public const int EINVAL = 22;
+
+ public static readonly int ENOTSUP;
+ public static readonly int ECANCELED;
+ public static readonly int ETIMEDOUT;
+
+ static Errno()
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ ENOTSUP = 129;
+ ECANCELED = 105;
+ ETIMEDOUT = 138;
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ {
+ ENOTSUP = 45;
+ ECANCELED = 89;
+ ETIMEDOUT = 60;
+ }
+ else if
(RuntimeInformation.IsOSPlatform(OSPlatform.Create("FREEBSD")))
+ {
+ ENOTSUP = 45;
+ ECANCELED = 85;
+ ETIMEDOUT = 60;
+ }
+ else
+ {
+ // Linux
+ ENOTSUP = 95;
+ ECANCELED = 125;
+ ETIMEDOUT = 110;
+ }
+ }
+
+ /// <summary>
+ /// Maps a status to an errno the same way the C and Go drivers do.
+ /// </summary>
+ public static int FromStatus(AdbcStatusCode status) => status
switch
+ {
+ AdbcStatusCode.Success => 0,
+ AdbcStatusCode.NotImplemented => ENOTSUP,
+ AdbcStatusCode.NotFound => ENOENT,
+ AdbcStatusCode.AlreadyExists => EEXIST,
+ AdbcStatusCode.InvalidArgument or AdbcStatusCode.InvalidState
=> EINVAL,
+ AdbcStatusCode.Cancelled => ECANCELED,
+ AdbcStatusCode.Timeout => ETIMEDOUT,
+ AdbcStatusCode.Unauthenticated or AdbcStatusCode.Unauthorized
=> EACCES,
+ _ => EIO,
+ };
+ }
+ }
+}
diff --git a/csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverExporter.cs
b/csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverExporter.cs
index 90c12346a1..0db85d4719 100644
--- a/csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverExporter.cs
+++ b/csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverExporter.cs
@@ -30,13 +30,12 @@ namespace Apache.Arrow.Adbc.C
/// Exposes a managed <see cref="AdbcDriver"/> through the ADBC C API.
/// </summary>
/// <remarks>
- /// When a caller requests ADBC 1.1.0, every 1.1.0 entry point is populated
- /// except <c>ErrorFromArrayStream</c>; exported streams report errors
through
- /// <c>get_last_error</c> instead. Functionality that the managed driver
doesn't
- /// override surfaces as <see cref="AdbcStatusCode.NotImplemented"/>,
which is
- /// the same status a driver manager reports for a missing entry point.
+ /// When a caller requests ADBC 1.1.0, every 1.1.0 entry point is
populated.
+ /// Functionality that the managed driver doesn't override surfaces as
+ /// <see cref="AdbcStatusCode.NotImplemented"/>, which is the same status
a driver
+ /// manager reports for a missing entry point.
/// </remarks>
- public class CAdbcDriverExporter
+ public partial class CAdbcDriverExporter
{
/// <summary>
/// The value of <c>ADBC_ERROR_VENDOR_CODE_PRIVATE_DATA</c>. A caller
sets an error's
@@ -279,8 +278,7 @@ namespace Apache.Arrow.Adbc.C
{
nativeDriver->ErrorGetDetailCount = ErrorGetDetailCountPtr;
nativeDriver->ErrorGetDetail = ErrorGetDetailPtr;
- // ErrorFromArrayStream is left null: exported streams report
errors
- // through ArrowArrayStream.get_last_error only.
+ nativeDriver->ErrorFromArrayStream = ErrorFromArrayStreamPtr;
nativeDriver->DatabaseGetOption = DatabaseGetOptionPtr;
nativeDriver->DatabaseGetOptionBytes =
DatabaseGetOptionBytesPtr;
@@ -430,23 +428,6 @@ namespace Apache.Arrow.Adbc.C
}
}
- /// <summary>
- /// Exports a stream produced by the managed driver. The exported
stream takes
- /// ownership on success; if exporting fails, nobody else will dispose
it.
- /// </summary>
- private unsafe static void ExportStream(IArrowArrayStream? stream,
CArrowArrayStream* cStream)
- {
- try
- {
- CArrowArrayStreamExporter.ExportArrayStream(stream!, cStream);
- }
- catch
- {
- stream?.Dispose();
- throw;
- }
- }
-
private static IntPtr FromDisposable(IDisposable d)
{
GCHandle gch = GCHandle.Alloc(d);
diff --git a/csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverImporter.Streams.cs
b/csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverImporter.Streams.cs
new file mode 100644
index 0000000000..19dbec6192
--- /dev/null
+++ b/csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverImporter.Streams.cs
@@ -0,0 +1,231 @@
+/*
+ * 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.Collections.Generic;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Apache.Arrow.Adbc.Extensions;
+using Apache.Arrow.C;
+using Apache.Arrow.Ipc;
+
+namespace Apache.Arrow.Adbc.C
+{
+ public static partial class CAdbcDriverImporter
+ {
+ /// <summary>
+ /// A result stream imported from a driver. When the stream fails, it
asks the driver
+ /// for the ADBC error behind the failure (see
<c>AdbcErrorFromArrayStream</c>) and
+ /// throws it as an <see cref="AdbcException"/>.
+ /// </summary>
+ private sealed unsafe class ImportedArrayStream : IArrowArrayStream
+ {
+ private readonly ImportedAdbcDriver? _driver;
+ private readonly Schema _schema;
+ private CArrowArrayStream* _cStream;
+
+ /// <summary>
+ /// Imports <paramref name="cStream"/>, which must have been
allocated with
+ /// <see cref="CArrowArrayStream.Create"/>. On success, the
imported stream owns
+ /// it and holds a reference to <paramref name="driver"/>, whose
callbacks it calls
+ /// until it's disposed; on failure, the caller still owns the
stream.
+ /// </summary>
+ public ImportedArrayStream(ImportedAdbcDriver? driver,
CArrowArrayStream* cStream)
+ {
+ if (NativeArrayStream.From(cStream)->release == default)
+ {
+ throw new ArgumentException("Tried to import an array
stream that has already been released.", nameof(cStream));
+ }
+
+ _driver = driver;
+
+ CArrowSchema* cSchema = CArrowSchema.Create();
+ try
+ {
+ int errno = GetSchema(cStream, cSchema);
+ if (errno != 0)
+ {
+ throw StreamError(cStream, errno);
+ }
+ _schema = CArrowSchemaImporter.ImportSchema(cSchema);
+ }
+ finally
+ {
+ CArrowSchema.Free(cSchema);
+ }
+
+ _driver?.AddReference();
+ _cStream = cStream;
+ }
+
+ ~ImportedArrayStream()
+ {
+ Dispose();
+ }
+
+ public Schema Schema => _schema;
+
+ public ValueTask<RecordBatch?>
ReadNextRecordBatchAsync(CancellationToken cancellationToken = default)
+ {
+ if (_cStream == null)
+ {
+ throw new
ObjectDisposedException(nameof(ImportedArrayStream));
+ }
+
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return new
ValueTask<RecordBatch?>(Task.FromCanceled<RecordBatch?>(cancellationToken));
+ }
+
+ try
+ {
+ return new ValueTask<RecordBatch?>(ReadNext());
+ }
+ catch (Exception e)
+ {
+ return new
ValueTask<RecordBatch?>(Task.FromException<RecordBatch?>(e));
+ }
+ }
+
+ private RecordBatch? ReadNext()
+ {
+ CArrowArray* cArray = CArrowArray.Create();
+ try
+ {
+ int errno = GetNext(_cStream, cArray);
+ if (errno != 0)
+ {
+ throw StreamError(_cStream, errno);
+ }
+
+ // A released array marks the end of the stream
+ if (NativeArray.From(cArray)->release == IntPtr.Zero)
+ {
+ return null;
+ }
+
+ return CArrowArrayImporter.ImportRecordBatch(cArray,
_schema);
+ }
+ finally
+ {
+ CArrowArray.Free(cArray);
+ }
+ }
+
+ public void Dispose()
+ {
+ CArrowArrayStream* cStream = _cStream;
+ if (cStream != null)
+ {
+ _cStream = null;
+ try
+ {
+ FreeStream(cStream);
+ }
+ finally
+ {
+ _driver?.RemoveReference();
+ }
+ }
+ GC.SuppressFinalize(this);
+ }
+
+ /// <summary>
+ /// Releases and frees a stream allocated with <see
cref="CArrowArrayStream.Create"/>.
+ /// </summary>
+ /// <remarks>
+ /// On .NET Framework, this releases the stream through
<c>ArrayStreamCallbacks</c>, so
+ /// it can release a stream exported in the same process with any
delegate type.
+ /// </remarks>
+ public static void FreeStream(CArrowArrayStream* cStream)
+ {
+ NativeArrayStream* nativeStream =
NativeArrayStream.From(cStream);
+ if (nativeStream->release != default)
+ {
+#if NET5_0_OR_GREATER
+ nativeStream->release(cStream);
+#else
+
ArrayStreamCallbacks.Import<ArrayStreamRelease>(nativeStream->release)(cStream);
+#endif
+ nativeStream->release = default;
+ }
+ CArrowArrayStream.Free(cStream);
+ }
+
+ /// <summary>
+ /// Builds the exception for a stream call that returned <paramref
name="errno"/>:
+ /// the driver's ADBC error if it reports one, and otherwise the
stream's last error.
+ /// </summary>
+ private Exception StreamError(CArrowArrayStream* cStream, int
errno)
+ {
+ if (_driver != null && _driver.DriverVersion >=
AdbcVersion.Version_1_1_0)
+ {
+ ref CAdbcDriver nativeDriver = ref _driver.DriverUnsafe;
+ if (nativeDriver.ErrorFromArrayStream != default)
+ {
+ AdbcStatusCode status = AdbcStatusCode.UnknownError;
+#if NET5_0_OR_GREATER
+ CAdbcError* error =
nativeDriver.ErrorFromArrayStream(cStream, &status);
+#else
+ CAdbcError* error =
Marshal.GetDelegateForFunctionPointer<ErrorFromArrayStream>(nativeDriver.ErrorFromArrayStream)(cStream,
&status);
+#endif
+ // The stream owns the error, so it must not be
released here
+ if (error != null)
+ {
+ IReadOnlyList<KeyValuePair<string, byte[]>>?
details = ReadErrorDetails(_driver, error);
+ return new ImportedAdbcException(
+ ref *error,
+ status == AdbcStatusCode.Success ?
AdbcStatusCode.UnknownError : status,
+ details);
+ }
+ }
+ }
+
+ NativeArrayStream* nativeStream =
NativeArrayStream.From(cStream);
+#if NET5_0_OR_GREATER
+ byte* message = nativeStream->get_last_error(cStream);
+#else
+ byte* message =
ArrayStreamCallbacks.Import<ArrayStreamGetLastError>(nativeStream->get_last_error)(cStream);
+#endif
+ return new Exception(message == null
+ ? $"Array stream operation failed with no message. Error
code: {errno}"
+ : MarshalExtensions.PtrToStringUTF8(message));
+ }
+
+ private static int GetSchema(CArrowArrayStream* cStream,
CArrowSchema* cSchema)
+ {
+ NativeArrayStream* nativeStream =
NativeArrayStream.From(cStream);
+#if NET5_0_OR_GREATER
+ return nativeStream->get_schema(cStream, cSchema);
+#else
+ return
ArrayStreamCallbacks.Import<ArrayStreamGetSchema>(nativeStream->get_schema)(cStream,
cSchema);
+#endif
+ }
+
+ private static int GetNext(CArrowArrayStream* cStream,
CArrowArray* cArray)
+ {
+ NativeArrayStream* nativeStream =
NativeArrayStream.From(cStream);
+#if NET5_0_OR_GREATER
+ return nativeStream->get_next(cStream, cArray);
+#else
+ return
ArrayStreamCallbacks.Import<ArrayStreamGetNext>(nativeStream->get_next)(cStream,
cArray);
+#endif
+ }
+ }
+ }
+}
diff --git a/csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverImporter.cs
b/csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverImporter.cs
index 6d4fc59e7a..1d28f96993 100644
--- a/csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverImporter.cs
+++ b/csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverImporter.cs
@@ -1776,7 +1776,7 @@ namespace Apache.Arrow.Adbc.C
public IArrowArrayStream ImportStream()
{
Debug.Assert(_arrayStream != null);
- IArrowArrayStream arrayStream =
CArrowArrayStreamImporter.ImportArrayStream(_arrayStream);
+ IArrowArrayStream arrayStream = new
ImportedArrayStream(_driver, _arrayStream);
_arrayStream = null;
return arrayStream;
}
@@ -1992,7 +1992,7 @@ namespace Apache.Arrow.Adbc.C
}
if (_arrayStream != null)
{
- CArrowArrayStream.Free(_arrayStream);
+ ImportedArrayStream.FreeStream(_arrayStream);
_arrayStream = null;
}
}
diff --git a/csharp/src/Apache.Arrow.Adbc/C/Delegates.cs
b/csharp/src/Apache.Arrow.Adbc/C/Delegates.cs
index e2838129b9..2873f7b8c5 100644
--- a/csharp/src/Apache.Arrow.Adbc/C/Delegates.cs
+++ b/csharp/src/Apache.Arrow.Adbc/C/Delegates.cs
@@ -31,6 +31,11 @@ namespace Apache.Arrow.Adbc.C
internal unsafe delegate CAdbcErrorDetail ErrorGetDetail(CAdbcError*
error, int index);
internal unsafe delegate CAdbcError*
ErrorFromArrayStream(CArrowArrayStream* stream, AdbcStatusCode* status);
+ internal unsafe delegate int ArrayStreamGetSchema(CArrowArrayStream*
stream, CArrowSchema* schema);
+ internal unsafe delegate int ArrayStreamGetNext(CArrowArrayStream* stream,
CArrowArray* array);
+ internal unsafe delegate byte* ArrayStreamGetLastError(CArrowArrayStream*
stream);
+ internal unsafe delegate void ArrayStreamRelease(CArrowArrayStream*
stream);
+
internal unsafe delegate AdbcStatusCode DatabaseGetOption(CAdbcDatabase*
database, byte* name, byte* value, nint* length, CAdbcError* error);
internal unsafe delegate AdbcStatusCode
DatabaseGetOptionBytes(CAdbcDatabase* database, byte* name, byte* value, nint*
length, CAdbcError* error);
internal unsafe delegate AdbcStatusCode
DatabaseGetOptionDouble(CAdbcDatabase* database, byte* name, double* value,
CAdbcError* error);
diff --git a/csharp/src/Apache.Arrow.Adbc/C/NativeArrowStructs.cs
b/csharp/src/Apache.Arrow.Adbc/C/NativeArrowStructs.cs
new file mode 100644
index 0000000000..02d800a6ac
--- /dev/null
+++ b/csharp/src/Apache.Arrow.Adbc/C/NativeArrowStructs.cs
@@ -0,0 +1,114 @@
+/*
+ * 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.Reflection;
+using System.Runtime.InteropServices;
+using Apache.Arrow.C;
+
+namespace Apache.Arrow.Adbc.C
+{
+ /// <summary>
+ /// The layout of <see cref="CArrowArrayStream"/>, whose callbacks
Apache.Arrow doesn't
+ /// expose. ADBC exports and imports result streams itself so that it can
attach an
+ /// ADBC error to a stream that fails (see
<c>AdbcErrorFromArrayStream</c>).
+ /// </summary>
+ [StructLayout(LayoutKind.Sequential)]
+ internal unsafe struct NativeArrayStream
+ {
+#if NET5_0_OR_GREATER
+ public delegate* unmanaged<CArrowArrayStream*, CArrowSchema*, int>
get_schema;
+ public delegate* unmanaged<CArrowArrayStream*, CArrowArray*, int>
get_next;
+ public delegate* unmanaged<CArrowArrayStream*, byte*> get_last_error;
+ public delegate* unmanaged<CArrowArrayStream*, void> release;
+#else
+ public IntPtr get_schema;
+ public IntPtr get_next;
+ public IntPtr get_last_error;
+ public IntPtr release;
+#endif
+ public void* private_data;
+
+ public static NativeArrayStream* From(CArrowArrayStream* stream) =>
(NativeArrayStream*)stream;
+ }
+
+ /// <summary>
+ /// The layout of <see cref="CArrowArray"/>, whose release callback
Apache.Arrow doesn't expose.
+ /// </summary>
+ [StructLayout(LayoutKind.Sequential)]
+ internal unsafe struct NativeArray
+ {
+ public long length;
+ public long null_count;
+ public long offset;
+ public long n_buffers;
+ public long n_children;
+ public byte** buffers;
+ public CArrowArray** children;
+ public CArrowArray* dictionary;
+ public IntPtr release;
+ public void* private_data;
+
+ public static NativeArray* From(CArrowArray* array) =>
(NativeArray*)array;
+ }
+
+#if !NET5_0_OR_GREATER
+ /// <summary>
+ /// Creates and calls <see cref="CArrowArrayStream"/> callbacks on .NET
Framework.
+ /// </summary>
+ /// <remarks>
+ /// On .NET Framework, <see
cref="Marshal.GetDelegateForFunctionPointer{TDelegate}(IntPtr)"/>
+ /// returns the original delegate for a function pointer created from a
managed delegate, and
+ /// fails if that delegate has a different type. ADBC calls callbacks
through
+ /// <see cref="Import{T}"/>, which accepts a delegate of any type.
Apache.Arrow calls them
+ /// through its own internal delegate types, so ADBC exports its callbacks
with those types,
+ /// found by reflection, to stay compatible with consumers that import
streams through
+ /// Apache.Arrow. If they can't be found, ADBC exports its own types. Once
Apache.Arrow
+ /// accepts callbacks of any delegate type (apache/arrow-dotnet#491), the
reflection can go.
+ /// </remarks>
+ internal static class ArrayStreamCallbacks
+ {
+ public static readonly Type? GetSchemaType =
ArrowDelegateType("GetSchemaArrayStream");
+ public static readonly Type? GetNextType =
ArrowDelegateType("GetNextArrayStream");
+ public static readonly Type? GetLastErrorType =
ArrowDelegateType("GetLastErrorArrayStream");
+ public static readonly Type? ReleaseType =
ArrowDelegateType("ReleaseArrayStream");
+
+ /// <summary>
+ /// Returns a function pointer for the static method behind <paramref
name="callback"/>.
+ /// </summary>
+ public static IntPtr Export<T>(T callback, Type? arrowType) where T :
Delegate
+ {
+ Delegate exported = arrowType == null ? callback :
Delegate.CreateDelegate(arrowType, callback.Method);
+ return NativeDelegate<Delegate>.AsNativePointer(exported);
+ }
+
+ /// <summary>
+ /// Returns a delegate that calls <paramref name="pointer"/>,
whichever delegate type
+ /// it was created from.
+ /// </summary>
+ public static T Import<T>(IntPtr pointer) where T : Delegate
+ {
+ // Unlike the generic overload, this doesn't cast the delegate it
returns to T
+ Delegate imported = Marshal.GetDelegateForFunctionPointer(pointer,
typeof(T));
+ return imported as T ?? (T)Delegate.CreateDelegate(typeof(T),
imported, "Invoke");
+ }
+
+ private static Type? ArrowDelegateType(string name) =>
+ typeof(CArrowArrayStreamExporter).GetNestedType(name,
BindingFlags.NonPublic);
+ }
+#endif
+}
diff --git
a/csharp/test/AotInterop/Apache.Arrow.Adbc.TestFixture.Native/FixtureDriver.cs
b/csharp/test/AotInterop/Apache.Arrow.Adbc.TestFixture.Native/FixtureDriver.cs
index dcfba33643..362dfb1734 100644
---
a/csharp/test/AotInterop/Apache.Arrow.Adbc.TestFixture.Native/FixtureDriver.cs
+++
b/csharp/test/AotInterop/Apache.Arrow.Adbc.TestFixture.Native/FixtureDriver.cs
@@ -157,6 +157,10 @@ namespace Apache.Arrow.Adbc.TestFixture.Native
{
throw new DetailedException();
}
+ if (_sqlQuery == "FAIL_STREAM")
+ {
+ return new QueryResult(-1, new FailingStream(ResultSchema(),
new DetailedException()));
+ }
_executed = true;
@@ -254,4 +258,26 @@ namespace Apache.Arrow.Adbc.TestFixture.Native
public void Dispose() { _batch?.Dispose(); _batch = null; }
}
+
+ /// <summary>
+ /// IArrowArrayStream that fails on its first read, after the result was
handed out.
+ /// </summary>
+ internal sealed class FailingStream : IArrowArrayStream
+ {
+ private readonly Schema _schema;
+ private readonly Exception _exception;
+
+ public FailingStream(Schema schema, Exception exception)
+ {
+ _schema = schema;
+ _exception = exception;
+ }
+
+ public Schema Schema => _schema;
+
+ public ValueTask<RecordBatch?>
ReadNextRecordBatchAsync(CancellationToken cancellationToken = default)
+ => throw _exception;
+
+ public void Dispose() { }
+ }
}
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 5afc90d17c..5f07ccfd6d 100644
---
a/csharp/test/AotInterop/Apache.Arrow.Adbc.TestFixture.Tests/AotFixtureTests.cs
+++
b/csharp/test/AotInterop/Apache.Arrow.Adbc.TestFixture.Tests/AotFixtureTests.cs
@@ -276,6 +276,24 @@ namespace Apache.Arrow.Adbc.TestFixture.Tests
Assert.Equal(new byte[] { 1, 2, 3 }, detail.Value);
}
+ [SkippableFact]
+ public async Task StreamErrorDetailsCrossAotBoundary()
+ {
+ using AdbcDriver driver = LoadFixture();
+ using AdbcDatabase db = driver.Open(new Dictionary<string,
string>());
+ using AdbcConnection conn = db.Connect(null);
+ using AdbcStatement stmt = conn.CreateStatement();
+ stmt.SqlQuery = "FAIL_STREAM";
+
+ using IArrowArrayStream stream = stmt.ExecuteQuery().Stream!;
+ AdbcException ex = await
Assert.ThrowsAnyAsync<AdbcException>(async () => await
stream.ReadNextRecordBatchAsync());
+ Assert.Equal(AdbcStatusCode.InvalidArgument, ex.Status);
+ Assert.Equal("42000", ex.SqlState);
+ KeyValuePair<string, byte[]> detail = Assert.Single(ex.Details);
+ Assert.Equal("fixture.detail", detail.Key);
+ Assert.Equal(new byte[] { 1, 2, 3 }, detail.Value);
+ }
+
[SkippableFact]
public async Task ExecuteQueryAsyncWithTokenRoundTrips()
{
diff --git
a/csharp/test/Apache.Arrow.Adbc.Tests/ExportedDriverRoundTrip11Tests.cs
b/csharp/test/Apache.Arrow.Adbc.Tests/ExportedDriverRoundTrip11Tests.cs
index 62ed7af2c1..62e84997c4 100644
--- a/csharp/test/Apache.Arrow.Adbc.Tests/ExportedDriverRoundTrip11Tests.cs
+++ b/csharp/test/Apache.Arrow.Adbc.Tests/ExportedDriverRoundTrip11Tests.cs
@@ -17,10 +17,12 @@
using System;
using System.Collections.Generic;
+using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Apache.Arrow.Adbc.C;
+using Apache.Arrow.C;
using Apache.Arrow.Ipc;
using Apache.Arrow.Types;
using Xunit;
@@ -325,6 +327,288 @@ namespace Apache.Arrow.Adbc.Tests
Assert.Empty(ex.Details);
}
+ [Fact]
+ public async Task StreamErrorKeepsStatusAndDetails()
+ {
+ using AdbcDriver imported = Load(new Driver11());
+ using AdbcDatabase db = imported.Open(new Dictionary<string,
string>());
+ using AdbcConnection conn = db.Connect(null);
+ using AdbcStatement stmt = conn.CreateStatement();
+ stmt.SqlQuery = "STREAM_FAIL_DETAILED";
+
+ QueryResult result = stmt.ExecuteQuery();
+ using IArrowArrayStream stream = result.Stream!;
+ using (RecordBatch? batch = await
stream.ReadNextRecordBatchAsync())
+ {
+ Assert.Equal(2, batch!.Length);
+ }
+
+ AdbcException ex = await
Assert.ThrowsAnyAsync<AdbcException>(async () => await
stream.ReadNextRecordBatchAsync());
+ Assert.Equal(AdbcStatusCode.InvalidData, ex.Status);
+ Assert.Equal("42000", ex.SqlState);
+ Assert.Contains("query failed", ex.Message);
+ Assert.Equal(2, ex.Details.Count);
+ Assert.Equal("query_id", ex.Details[0].Key);
+ Assert.Equal("q-123",
Encoding.UTF8.GetString(ex.Details[0].Value));
+ }
+
+ [Theory]
+ [InlineData("STREAM_FAIL_CANCELLED", "read cancelled")]
+ [InlineData("STREAM_FAIL_CANCELED", "token cancelled")]
+ public async Task StreamCancellationIsReportedAsCancelled(string
query, string message)
+ {
+ using AdbcDriver imported = Load(new Driver11());
+ using AdbcDatabase db = imported.Open(new Dictionary<string,
string>());
+ using AdbcConnection conn = db.Connect(null);
+ using AdbcStatement stmt = conn.CreateStatement();
+ stmt.SqlQuery = query;
+
+ using IArrowArrayStream stream = stmt.ExecuteQuery().Stream!;
+ (await stream.ReadNextRecordBatchAsync())?.Dispose();
+
+ AdbcException ex = await
Assert.ThrowsAnyAsync<AdbcException>(async () => await
stream.ReadNextRecordBatchAsync());
+ Assert.Equal(AdbcStatusCode.Cancelled, ex.Status);
+ Assert.Equal(message, ex.Message);
+ }
+
+ [Fact]
+ public async Task StreamKeepsDriverLoaded()
+ {
+ var fixture = new Driver11();
+ IArrowArrayStream stream;
+ 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 = "STREAM_FAIL_CANCELLED";
+ stream = stmt.ExecuteQuery().Stream!;
+ }
+
+ using (stream)
+ {
+ Assert.False(fixture.Disposed);
+ (await stream.ReadNextRecordBatchAsync())?.Dispose();
+
+ AdbcException ex = await
Assert.ThrowsAnyAsync<AdbcException>(async () => await
stream.ReadNextRecordBatchAsync());
+ Assert.Equal(AdbcStatusCode.Cancelled, ex.Status);
+ Assert.False(fixture.Disposed);
+ }
+
+ Assert.True(fixture.Disposed);
+ }
+
+ [Fact]
+ public void StreamSchemaErrorKeepsStatus()
+ {
+ using AdbcDriver imported = Load(new Driver11());
+ using AdbcDatabase db = imported.Open(new Dictionary<string,
string>());
+ using AdbcConnection conn = db.Connect(null);
+ using AdbcStatement stmt = conn.CreateStatement();
+ stmt.SqlQuery = "STREAM_FAIL_SCHEMA";
+
+ AdbcException ex = Assert.ThrowsAny<AdbcException>(() =>
stmt.ExecuteQuery());
+ Assert.Equal(AdbcStatusCode.Unauthorized, ex.Status);
+ Assert.Equal("no access", ex.Message);
+ }
+
+ [Fact]
+ public async Task Version1_0DriverStreamErrorHasMessageOnly()
+ {
+ 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 = db.Connect(null);
+ using AdbcStatement stmt = conn.CreateStatement();
+ stmt.SqlQuery = "STREAM_FAIL_CANCELLED";
+
+ using IArrowArrayStream stream = stmt.ExecuteQuery().Stream!;
+ (await stream.ReadNextRecordBatchAsync())?.Dispose();
+
+ // Without AdbcErrorFromArrayStream, only the message crosses the
C ABI
+ Exception ex = await Assert.ThrowsAnyAsync<Exception>(async () =>
await stream.ReadNextRecordBatchAsync());
+ Assert.False(ex is AdbcException);
+ Assert.Equal("read cancelled", ex.Message);
+ }
+
+#if NET5_0_OR_GREATER
+ [Fact]
+ public unsafe void ExportedStreamReportsErrnoAndError()
+ {
+ CAdbcDriver driver = default;
+ CAdbcError error = default;
+ CAdbcDatabase database = default;
+ CAdbcConnection connection = default;
+ CAdbcStatement statement = default;
+ Assert.Equal(AdbcStatusCode.Success,
CAdbcDriverExporter.AdbcDriverInit(AdbcVersion.Version_1_1_0, &driver, &error,
new Driver11()));
+ Assert.Equal(AdbcStatusCode.Success, driver.DatabaseNew(&database,
&error));
+ Assert.Equal(AdbcStatusCode.Success,
driver.DatabaseInit(&database, &error));
+ Assert.Equal(AdbcStatusCode.Success,
driver.ConnectionNew(&connection, &error));
+ Assert.Equal(AdbcStatusCode.Success,
driver.ConnectionInit(&connection, &database, &error));
+ Assert.Equal(AdbcStatusCode.Success,
driver.StatementNew(&connection, &statement, &error));
+ fixed (byte* query =
Encoding.UTF8.GetBytes("STREAM_FAIL_CANCELLED\0"))
+ {
+ Assert.Equal(AdbcStatusCode.Success,
driver.StatementSetSqlQuery(&statement, query, &error));
+ }
+
+ CArrowArrayStream* stream = CArrowArrayStream.Create();
+ CArrowArray* array = CArrowArray.Create();
+ try
+ {
+ long rows = 0;
+ Assert.Equal(AdbcStatusCode.Success,
driver.StatementExecuteQuery(&statement, stream, &rows, &error));
+ NativeArrayStream* nativeStream =
NativeArrayStream.From(stream);
+
+ Assert.Equal(0, nativeStream->get_next(stream, array));
+ CArrowArray.CallReleaseFunc(array);
+
+ AdbcStatusCode status = AdbcStatusCode.Success;
+ Assert.True(driver.ErrorFromArrayStream(stream, &status) ==
null);
+
+ Assert.Equal(CAdbcDriverExporter.Errno.ECANCELED,
nativeStream->get_next(stream, array));
+ Assert.Equal("read cancelled",
Marshal.PtrToStringUTF8((IntPtr)nativeStream->get_last_error(stream)));
+
+ CAdbcError* streamError = driver.ErrorFromArrayStream(stream,
&status);
+ Assert.True(streamError != null);
+ Assert.Equal(AdbcStatusCode.Cancelled, status);
+ Assert.Equal("read cancelled",
Marshal.PtrToStringUTF8((IntPtr)streamError->message));
+ }
+ finally
+ {
+ CArrowArray.Free(array);
+ CArrowArrayStream.Free(stream);
+ driver.StatementRelease(&statement, &error);
+ driver.ConnectionRelease(&connection, &error);
+ driver.DatabaseRelease(&database, &error);
+ driver.release(&driver, &error);
+ }
+ }
+
+#endif
+
+ [Fact]
+ public async Task ArrowImporterReadsExportedStream()
+ {
+ IArrowArrayStream imported;
+ unsafe
+ {
+ CArrowArrayStream* cStream = CArrowArrayStream.Create();
+ try
+ {
+ CAdbcDriverExporter.ExportStream(new FailingStream(new
AdbcException("read cancelled", AdbcStatusCode.Cancelled)), cStream);
+ imported =
CArrowArrayStreamImporter.ImportArrayStream(cStream);
+ }
+ finally
+ {
+ CArrowArrayStream.Free(cStream);
+ }
+ }
+
+ using (imported)
+ {
+ Assert.Equal("x",
Assert.Single(imported.Schema.FieldsList).Name);
+ using (RecordBatch? batch = await
imported.ReadNextRecordBatchAsync())
+ {
+ Assert.Equal(2, batch!.Length);
+ }
+ Exception ex = await Assert.ThrowsAnyAsync<Exception>(async ()
=> await imported.ReadNextRecordBatchAsync());
+ Assert.Equal("read cancelled", ex.Message);
+ }
+ }
+
+ [Fact]
+ public void ExportedStreamDoesNotBlockSynchronizationContext()
+ {
+ int rows = -1;
+ Exception? failure = null;
+ Thread thread = new Thread(() =>
+ {
+ SynchronizationContext.SetSynchronizationContext(new
BlockedContext());
+ try
+ {
+ unsafe
+ {
+ CArrowArrayStream* cStream =
CArrowArrayStream.Create();
+ try
+ {
+ CAdbcDriverExporter.ExportStream(new
ContextResumingStream(), cStream);
+ using IArrowArrayStream imported =
CArrowArrayStreamImporter.ImportArrayStream(cStream);
+ using RecordBatch? batch =
imported.ReadNextRecordBatchAsync().AsTask().GetAwaiter().GetResult();
+ rows = batch!.Length;
+ }
+ finally
+ {
+ CArrowArrayStream.Free(cStream);
+ }
+ }
+ }
+ catch (Exception e)
+ {
+ failure = e;
+ }
+ });
+ thread.IsBackground = true;
+ thread.Start();
+
+ Assert.True(thread.Join(TimeSpan.FromSeconds(30)), "reading the
exported stream deadlocked");
+ Assert.Null(failure);
+ Assert.Equal(2, rows);
+ }
+
+ [Fact]
+ public void RejectedBindStreamIsReleased()
+ {
+ AdbcDriverInit adapter = (int version, ref CAdbcDriver
nativeDriver, ref CAdbcError error) =>
+ {
+ AdbcStatusCode status = CallExporter(version, ref
nativeDriver, ref error, new Driver11());
+ unsafe
+ {
+#if NET5_0_OR_GREATER
+ nativeDriver.StatementBindStream =
+ (delegate* unmanaged<CAdbcStatement*,
CArrowArrayStream*, CAdbcError*,
AdbcStatusCode>)Marshal.GetFunctionPointerForDelegate(s_rejectBindStream);
+#else
+ nativeDriver.StatementBindStream =
Marshal.GetFunctionPointerForDelegate(s_rejectBindStream);
+#endif
+ }
+ return status;
+ };
+
+ using AdbcDriver imported = CAdbcDriverImporter.Load(adapter);
+ using AdbcDatabase db = imported.Open(new Dictionary<string,
string>());
+ using AdbcConnection conn = db.Connect(null);
+ using AdbcStatement stmt = conn.CreateStatement();
+
+ // The driver fails without taking the stream, so the importer
releases it
+ var stream = new FailingStream(new InvalidOperationException());
+ AdbcException ex = Assert.ThrowsAny<AdbcException>(() =>
stmt.BindStream(stream));
+ Assert.Equal(AdbcStatusCode.InvalidState, ex.Status);
+ Assert.True(stream.Disposed);
+ }
+
+#if NET5_0_OR_GREATER
+ private unsafe delegate AdbcStatusCode
StatementBindStream(CAdbcStatement* statement, CArrowArrayStream* stream,
CAdbcError* error);
+#endif
+
+ // On .NET Framework, the importer can only call a managed callback
through the library's own delegate type
+ private static readonly unsafe StatementBindStream s_rejectBindStream
= (statement, stream, error) => AdbcStatusCode.InvalidState;
+
+ [Fact]
+ public void StatusesMapToErrno()
+ {
+ Assert.Equal(CAdbcDriverExporter.Errno.ECANCELED,
CAdbcDriverExporter.Errno.FromStatus(AdbcStatusCode.Cancelled));
+ Assert.Equal(CAdbcDriverExporter.Errno.ETIMEDOUT,
CAdbcDriverExporter.Errno.FromStatus(AdbcStatusCode.Timeout));
+ Assert.Equal(CAdbcDriverExporter.Errno.EINVAL,
CAdbcDriverExporter.Errno.FromStatus(AdbcStatusCode.InvalidArgument));
+ Assert.Equal(CAdbcDriverExporter.Errno.ENOTSUP,
CAdbcDriverExporter.Errno.FromStatus(AdbcStatusCode.NotImplemented));
+ Assert.Equal(CAdbcDriverExporter.Errno.EIO,
CAdbcDriverExporter.Errno.FromStatus(AdbcStatusCode.IOError));
+ Assert.Equal(CAdbcDriverExporter.Errno.EIO,
CAdbcDriverExporter.Errno.FromStatus(AdbcStatusCode.UnknownError));
+ }
+
[Fact]
public void UnknownOptionIsNotFound()
{
@@ -528,6 +812,10 @@ namespace Apache.Arrow.Adbc.Tests
public override int DriverVersion => AdbcVersion.Version_1_1_0;
+ public bool Disposed { get; private set; }
+
+ public override void Dispose() => Disposed = true;
+
public override AdbcDatabase Open(IReadOnlyDictionary<string,
string> parameters)
{
var options = new Dictionary<string, object>();
@@ -683,6 +971,14 @@ namespace Apache.Arrow.Adbc.Tests
throw new PlainException();
case "FAIL_CANCELED":
throw new OperationCanceledException();
+ case "STREAM_FAIL_DETAILED":
+ return new QueryResult(-1, new FailingStream(new
DetailedException()));
+ case "STREAM_FAIL_CANCELLED":
+ return new QueryResult(-1, new FailingStream(new
AdbcException("read cancelled", AdbcStatusCode.Cancelled)));
+ case "STREAM_FAIL_CANCELED":
+ return new QueryResult(-1, new FailingStream(new
OperationCanceledException("token cancelled")));
+ case "STREAM_FAIL_SCHEMA":
+ return new QueryResult(-1, new FailingStream(new
AdbcException("no access", AdbcStatusCode.Unauthorized), failSchema: true));
case "BLOCK":
Started.TrySetResult(true);
if (_cancelled.Wait(TimeSpan.FromSeconds(30)))
@@ -758,5 +1054,80 @@ namespace Apache.Arrow.Adbc.Tests
public void Dispose() { _batch?.Dispose(); _batch = null; }
}
+
+ /// <summary>
+ /// Returns one batch and then fails with the given exception, or
fails to report
+ /// its schema if <c>failSchema</c> is set.
+ /// </summary>
+ private sealed class FailingStream : IArrowArrayStream
+ {
+ private static readonly Schema s_schema = new Schema.Builder()
+ .Field(f =>
f.Name("x").DataType(Int32Type.Default).Nullable(false))
+ .Build();
+
+ private readonly Exception _exception;
+ private readonly bool _failSchema;
+ private bool _returnedBatch;
+
+ public FailingStream(Exception exception, bool failSchema = false)
+ {
+ _exception = exception;
+ _failSchema = failSchema;
+ }
+
+ public Schema Schema => _failSchema ? throw _exception : s_schema;
+
+ public bool Disposed { get; private set; }
+
+ public ValueTask<RecordBatch?>
ReadNextRecordBatchAsync(CancellationToken cancellationToken = default)
+ {
+ if (_returnedBatch)
+ {
+ throw _exception;
+ }
+
+ _returnedBatch = true;
+ return new ValueTask<RecordBatch?>(Batch());
+ }
+
+ public static RecordBatch Batch()
+ {
+ Int32Array values = new
Int32Array.Builder().Append(1).Append(2).Build();
+ return new RecordBatch(s_schema, new IArrowArray[] { values },
2);
+ }
+
+ public void Dispose() => Disposed = true;
+ }
+
+ /// <summary>
+ /// Returns one batch after resuming on the caller's synchronization
context.
+ /// </summary>
+ private sealed class ContextResumingStream : IArrowArrayStream
+ {
+ private bool _returnedBatch;
+
+ public Schema Schema => FailingStream.Batch().Schema;
+
+ public async ValueTask<RecordBatch?>
ReadNextRecordBatchAsync(CancellationToken cancellationToken = default)
+ {
+ await Task.Delay(1);
+ if (_returnedBatch)
+ {
+ return null;
+ }
+ _returnedBatch = true;
+ return FailingStream.Batch();
+ }
+
+ public void Dispose() { }
+ }
+
+ /// <summary>
+ /// A synchronization context whose thread is always busy, like a
blocked UI thread.
+ /// </summary>
+ private sealed class BlockedContext : SynchronizationContext
+ {
+ public override void Post(SendOrPostCallback d, object? state) { }
+ }
}
}