This is an automated email from the ASF dual-hosted git repository.
adamreeve pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-dotnet.git
The following commit(s) were added to refs/heads/main by this push:
new c964702 fix: Dispose record batches exported through the C stream
interface (#483)
c964702 is described below
commit c96470238a99a4b8542ed582c2fd5007b78c4365
Author: ump45nose <[email protected]>
AuthorDate: Fri Oct 23 05:08:21 2026 +0800
fix: Dispose record batches exported through the C stream interface (#483)
- `ExportedAllocationOwner` gets `TakeOwnership(IDisposable)`. Owned
objects are disposed in `Dispose`, after the exported buffers have been
unpinned and their shared handles released, so memory managers that
throw when disposed while pinned are not affected. This also covers
buffers exported by pinning rather than through a shared handle.
- `CArrowArrayExporter` and `CArrowDeviceArrayExporter` get internal
`ExportRecordBatch` overloads with a `transferOwnership` flag. The
stream exporters pass `true`, so a batch is disposed when the consumer
releases the exported array (or right away if the export fails). The
public `ExportRecordBatch` methods still leave the batch to the caller.
Batches returned by an `IArrowArrayStream` are owned by the caller, so
streams that keep using a batch after handing it out would now see it
disposed once the consumer releases it.
---
src/Apache.Arrow/C/CArrowArrayExporter.cs | 15 +++
src/Apache.Arrow/C/CArrowArrayStreamExporter.cs | 4 +-
src/Apache.Arrow/C/CArrowDeviceArrayExporter.cs | 13 ++-
.../C/CArrowDeviceArrayStreamExporter.cs | 4 +-
src/Apache.Arrow/Memory/ExportedAllocationOwner.cs | 18 +++
test/Apache.Arrow.Tests/CDataInterfaceDataTests.cs | 129 +++++++++++++++++++++
.../CDeviceDataInterfaceTests.cs | 30 +++++
7 files changed, 210 insertions(+), 3 deletions(-)
diff --git a/src/Apache.Arrow/C/CArrowArrayExporter.cs
b/src/Apache.Arrow/C/CArrowArrayExporter.cs
index 882533c..12761da 100644
--- a/src/Apache.Arrow/C/CArrowArrayExporter.cs
+++ b/src/Apache.Arrow/C/CArrowArrayExporter.cs
@@ -91,6 +91,17 @@ namespace Apache.Arrow.C
/// </code>
/// </example>
public static unsafe void ExportRecordBatch(RecordBatch batch,
CArrowArray* cArray)
+ {
+ ExportRecordBatch(batch, cArray, transferOwnership: false);
+ }
+
+ /// <summary>
+ /// Export a <see cref="RecordBatch"/> to a <see cref="CArrowArray"/>.
When
+ /// <paramref name="transferOwnership"/> is true, the exported array
takes over the caller's
+ /// reference to <paramref name="batch"/> and disposes it when the
array is released (or
+ /// immediately if the export fails).
+ /// </summary>
+ internal static unsafe void ExportRecordBatch(RecordBatch batch,
CArrowArray* cArray, bool transferOwnership)
{
if (batch == null)
{
@@ -106,6 +117,10 @@ namespace Apache.Arrow.C
}
ExportedAllocationOwner allocationOwner = new
ExportedAllocationOwner();
+ if (transferOwnership)
+ {
+ allocationOwner.TakeOwnership(batch);
+ }
try
{
ConvertRecordBatch(allocationOwner, batch, cArray);
diff --git a/src/Apache.Arrow/C/CArrowArrayStreamExporter.cs
b/src/Apache.Arrow/C/CArrowArrayStreamExporter.cs
index 17b320f..aec5ddb 100644
--- a/src/Apache.Arrow/C/CArrowArrayStreamExporter.cs
+++ b/src/Apache.Arrow/C/CArrowArrayStreamExporter.cs
@@ -104,7 +104,9 @@ namespace Apache.Arrow.C
RecordBatch recordBatch =
arrayStream.ArrowArrayStream.ReadNextRecordBatchAsync().Result;
if (recordBatch != null)
{
- CArrowArrayExporter.ExportRecordBatch(recordBatch, cArray);
+ // The stream hands us ownership of each batch it returns.
The exported array keeps
+ // it alive and disposes it on release, rather than
leaving it to the finalizer.
+ CArrowArrayExporter.ExportRecordBatch(recordBatch, cArray,
transferOwnership: true);
}
return arrayStream.ClearError();
}
diff --git a/src/Apache.Arrow/C/CArrowDeviceArrayExporter.cs
b/src/Apache.Arrow/C/CArrowDeviceArrayExporter.cs
index 05dfabb..8f70b37 100644
--- a/src/Apache.Arrow/C/CArrowDeviceArrayExporter.cs
+++ b/src/Apache.Arrow/C/CArrowDeviceArrayExporter.cs
@@ -67,6 +67,17 @@ namespace Apache.Arrow.C
/// </code>
/// </example>
public static unsafe void ExportRecordBatch(RecordBatch batch,
CArrowDeviceArray* deviceArray)
+ {
+ ExportRecordBatch(batch, deviceArray, transferOwnership: false);
+ }
+
+ /// <summary>
+ /// Export a <see cref="RecordBatch"/> to a <see
cref="CArrowDeviceArray"/>. When
+ /// <paramref name="transferOwnership"/> is true, the exported array
takes over the caller's
+ /// reference to <paramref name="batch"/> and disposes it when the
array is released (or
+ /// immediately if the export fails).
+ /// </summary>
+ internal static unsafe void ExportRecordBatch(RecordBatch batch,
CArrowDeviceArray* deviceArray, bool transferOwnership)
{
if (batch == null)
{
@@ -77,7 +88,7 @@ namespace Apache.Arrow.C
throw new ArgumentNullException(nameof(deviceArray));
}
- CArrowArrayExporter.ExportRecordBatch(batch, &deviceArray->array);
+ CArrowArrayExporter.ExportRecordBatch(batch, &deviceArray->array,
transferOwnership);
deviceArray->device_type = ArrowDeviceType.Cpu;
deviceArray->device_id = -1;
deviceArray->sync_event = null;
diff --git a/src/Apache.Arrow/C/CArrowDeviceArrayStreamExporter.cs
b/src/Apache.Arrow/C/CArrowDeviceArrayStreamExporter.cs
index d695efb..5aa235c 100644
--- a/src/Apache.Arrow/C/CArrowDeviceArrayStreamExporter.cs
+++ b/src/Apache.Arrow/C/CArrowDeviceArrayStreamExporter.cs
@@ -105,7 +105,9 @@ namespace Apache.Arrow.C
RecordBatch recordBatch =
stream.ArrowArrayStream.ReadNextRecordBatchAsync().Result;
if (recordBatch != null)
{
- CArrowDeviceArrayExporter.ExportRecordBatch(recordBatch,
cDeviceArray);
+ // The stream hands us ownership of each batch it returns.
The exported array keeps
+ // it alive and disposes it on release, rather than
leaving it to the finalizer.
+ CArrowDeviceArrayExporter.ExportRecordBatch(recordBatch,
cDeviceArray, transferOwnership: true);
}
return stream.ClearError();
}
diff --git a/src/Apache.Arrow/Memory/ExportedAllocationOwner.cs
b/src/Apache.Arrow/Memory/ExportedAllocationOwner.cs
index 63d0ce1..8434a0e 100644
--- a/src/Apache.Arrow/Memory/ExportedAllocationOwner.cs
+++ b/src/Apache.Arrow/Memory/ExportedAllocationOwner.cs
@@ -26,6 +26,7 @@ namespace Apache.Arrow.Memory
private readonly List<IntPtr> _pointers = new List<IntPtr>();
private readonly List<MemoryHandle> _handles = new
List<MemoryHandle>();
private readonly List<SharedMemoryHandle> _sharedHandles = new
List<SharedMemoryHandle>();
+ private readonly List<IDisposable> _ownedObjects = new
List<IDisposable>();
private long _allocationSize;
private long _referenceCount;
private bool _disposed;
@@ -62,6 +63,15 @@ namespace Apache.Arrow.Memory
return pointer;
}
+ /// <summary>
+ /// Makes this owner responsible for disposing <paramref name="obj"/>
once the exported
+ /// memory is released.
+ /// </summary>
+ public void TakeOwnership(IDisposable obj)
+ {
+ _ownedObjects.Add(obj);
+ }
+
public void IncRef()
{
Interlocked.Increment(ref _referenceCount);
@@ -103,6 +113,14 @@ namespace Apache.Arrow.Memory
_sharedHandles[i] = default;
}
+ // Owned objects are disposed only after the pins above have been
released, because
+ // some memory managers throw when they are disposed while still
pinned.
+ for (int i = 0; i < _ownedObjects.Count; i++)
+ {
+ _ownedObjects[i]?.Dispose();
+ _ownedObjects[i] = null;
+ }
+
// Nothing is allocated when exporting an array without buffers or
children (e.g. a NullArray),
// and RemoveMemoryPressure throws for zero.
if (_allocationSize > 0)
diff --git a/test/Apache.Arrow.Tests/CDataInterfaceDataTests.cs
b/test/Apache.Arrow.Tests/CDataInterfaceDataTests.cs
index 5fcabb2..d265cad 100644
--- a/test/Apache.Arrow.Tests/CDataInterfaceDataTests.cs
+++ b/test/Apache.Arrow.Tests/CDataInterfaceDataTests.cs
@@ -14,10 +14,15 @@
// limitations under the License.
using System;
+using System.Buffers;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
using Apache.Arrow.C;
+using Apache.Arrow.Ipc;
+using Apache.Arrow.Memory;
using Apache.Arrow.Types;
using Xunit;
@@ -298,5 +303,129 @@ namespace Apache.Arrow.Tests
CArrowArray.Free(cArray);
}
+ [Fact]
+ public async Task ExportedArrayStreamDisposesBatchesOnRelease()
+ {
+ var allocator = new TestMemoryAllocator();
+ RecordBatch batch = GetAllocatorBackedRecordBatch(allocator);
+ Assert.True(allocator.Rented > 0);
+
+ IArrowArrayStream imported;
+ unsafe
+ {
+ CArrowArrayStream* cArrayStream = CArrowArrayStream.Create();
+ CArrowArrayStreamExporter.ExportArrayStream(new
SingleBatchStream(batch), cArrayStream);
+ imported =
CArrowArrayStreamImporter.ImportArrayStream(cArrayStream);
+ CArrowArrayStream.Free(cArrayStream);
+ }
+
+ using (imported)
+ {
+ RecordBatch importedBatch = await
imported.ReadNextRecordBatchAsync();
+ Assert.Equal(new int?[] { 1, 2, null, 4 },
(Int32Array)importedBatch.Column(0));
+ Assert.True(allocator.Rented > 0);
+
+ // Releasing the exported array should release the batch the
stream handed over.
+ importedBatch.Dispose();
+ Assert.Equal(0, allocator.Rented);
+
+ Assert.Null(await imported.ReadNextRecordBatchAsync());
+ }
+ }
+
+ [Fact]
+ public async Task ExportedArrayStreamUnpinsMemoryBeforeDisposingBatch()
+ {
+ // The values buffer wraps a ReadOnlyMemory<byte>, so exporting it
pins the memory.
+ // The native memory manager throws if it is disposed while still
pinned.
+ var owner = new DisposeTrackingMemoryOwner(new
NativeMemoryAllocator().Allocate(4 * sizeof(int)));
+ new int[] { 1, 2, 3, 4 }.CopyTo(MemoryMarshal.Cast<byte,
int>(owner.Memory.Span));
+ var values = new
ArrowBuffer((ReadOnlyMemory<byte>)owner.Memory.Slice(0, 4 * sizeof(int)));
+ var array = new Int32Array(values, ArrowBuffer.Empty, 4, 0, 0);
+ Schema schema = new Schema.Builder()
+ .Field(f =>
f.Name("ints").DataType(Int32Type.Default).Nullable(false))
+ .Build();
+ var batch = new RecordBatch(schema, owner, new List<IArrowArray> {
array }, array.Length);
+
+ IArrowArrayStream imported;
+ unsafe
+ {
+ CArrowArrayStream* cArrayStream = CArrowArrayStream.Create();
+ CArrowArrayStreamExporter.ExportArrayStream(new
SingleBatchStream(batch), cArrayStream);
+ imported =
CArrowArrayStreamImporter.ImportArrayStream(cArrayStream);
+ CArrowArrayStream.Free(cArrayStream);
+ }
+
+ using (imported)
+ {
+ RecordBatch importedBatch = await
imported.ReadNextRecordBatchAsync();
+ Assert.Equal(new int?[] { 1, 2, 3, 4 },
(Int32Array)importedBatch.Column(0));
+ Assert.False(owner.Disposed);
+
+ importedBatch.Dispose();
+ Assert.True(owner.Disposed);
+ }
+ }
+
+ internal static RecordBatch
GetAllocatorBackedRecordBatch(MemoryAllocator allocator)
+ {
+ Int32Array array = new Int32Array.Builder()
+ .Append(1)
+ .Append(2)
+ .AppendNull()
+ .Append(4)
+ .Build(allocator);
+ Schema schema = new Schema.Builder()
+ .Field(f =>
f.Name("ints").DataType(Int32Type.Default).Nullable(true))
+ .Build();
+ return new RecordBatch(schema, new IArrowArray[] { array },
array.Length);
+ }
+
+ private sealed class DisposeTrackingMemoryOwner : IMemoryOwner<byte>
+ {
+ private readonly IMemoryOwner<byte> _inner;
+
+ public DisposeTrackingMemoryOwner(IMemoryOwner<byte> inner)
+ {
+ _inner = inner;
+ }
+
+ public Memory<byte> Memory => _inner.Memory;
+
+ public bool Disposed { get; private set; }
+
+ public void Dispose()
+ {
+ _inner.Dispose();
+ Disposed = true;
+ }
+ }
+
+ private sealed class SingleBatchStream : IArrowArrayStream
+ {
+ private RecordBatch _batch;
+
+ public SingleBatchStream(RecordBatch batch)
+ {
+ Schema = batch.Schema;
+ _batch = batch;
+ }
+
+ public Schema Schema { get; }
+
+ public ValueTask<RecordBatch>
ReadNextRecordBatchAsync(CancellationToken cancellationToken = default)
+ {
+ // Ownership of the returned batch passes to the caller.
+ RecordBatch result = _batch;
+ _batch = null;
+ return new ValueTask<RecordBatch>(result);
+ }
+
+ public void Dispose()
+ {
+ _batch?.Dispose();
+ _batch = null;
+ }
+ }
}
}
diff --git a/test/Apache.Arrow.Tests/CDeviceDataInterfaceTests.cs
b/test/Apache.Arrow.Tests/CDeviceDataInterfaceTests.cs
index 3954d9c..a197a42 100644
--- a/test/Apache.Arrow.Tests/CDeviceDataInterfaceTests.cs
+++ b/test/Apache.Arrow.Tests/CDeviceDataInterfaceTests.cs
@@ -317,6 +317,36 @@ namespace Apache.Arrow.Tests
}
}
+ [Fact]
+ public async Task ExportedDeviceStreamDisposesBatchesOnRelease()
+ {
+ var allocator = new TestMemoryAllocator();
+ RecordBatch batch =
CDataInterfaceDataTests.GetAllocatorBackedRecordBatch(allocator);
+ Assert.True(allocator.Rented > 0);
+
+ IArrowArrayStream imported;
+ unsafe
+ {
+ CArrowDeviceArrayStream* ptr =
CArrowDeviceArrayStream.Create();
+ CArrowDeviceArrayStreamExporter.ExportArrayStream(new
TestArrayStream(batch.Schema, batch), ptr);
+ imported =
CArrowDeviceArrayStreamImporter.ImportDeviceArrayStream(ptr);
+ CArrowDeviceArrayStream.Free(ptr);
+ }
+
+ using (imported)
+ {
+ RecordBatch importedBatch = await
imported.ReadNextRecordBatchAsync();
+ Assert.Equal(new int?[] { 1, 2, null, 4 },
(Int32Array)importedBatch.Column(0));
+ Assert.True(allocator.Rented > 0);
+
+ // Releasing the exported array should release the batch the
stream handed over.
+ importedBatch.Dispose();
+ Assert.Equal(0, allocator.Rented);
+
+ Assert.Null(await imported.ReadNextRecordBatchAsync());
+ }
+ }
+
[Fact]
public unsafe void ImportNonCpuDeviceStreamThrows()
{