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-dotnet.git
The following commit(s) were added to refs/heads/main by this push:
new 862f3fc feat: Support IPC Message custom_metadata in
ArrowStreamWriter and ArrowStreamReader (#432)
862f3fc is described below
commit 862f3fc3e76dcd563ddef662758cdf2f8d6b51b0
Author: Curt Hagenlocher <[email protected]>
AuthorDate: Sun Sep 13 16:49:47 2026 -0700
feat: Support IPC Message custom_metadata in ArrowStreamWriter and
ArrowStreamReader (#432)
Adds IPC `Message.custom_metadata` support to the stream and file
readers and writers.
## Credit
The original implementation is **@cmettler's** work in #283 (closing
#282). **@rustyconover** rebased it onto a `main` that had drifted ~76
commits ahead and addressed the first round of review comments in #424.
Both of those PRs are branches on personal forks; this one moves the
work onto a branch in the base repository so that any committer can push
to it, and supersedes them. The individual commits here retain their
original authorship.
## What this adds
- `ArrowStreamReader.ReadNextRecordBatchWithCustomMetadata()` and
`…Async()` return a `RecordBatchWithMetadata` pairing the batch with its
IPC `Message.custom_metadata`, mirroring pyarrow's
`read_next_batch_with_custom_metadata()` and the equivalent Arrow C++
struct. The struct deconstructs, so callers can write `var (batch,
metadata) = …`.
- `ArrowFileReader.ReadRecordBatchWithCustomMetadataAsync(int index)`
gives the indexed read the same capability as the sequential one.
- `ArrowStreamWriter.WriteRecordBatch(batch, customMetadata)` and the
async counterpart attach per-message `custom_metadata` when writing,
matching pyarrow's `write_batch(batch, custom_metadata)`.
- Cross-language round-trip tests via pythonnet + pyarrow, skipped
unless `PYTHONNET_PYDLL` is set, consistent with the existing
`CDataSchemaPythonTest` pattern.
## Changes on top of #424
- The read side originally exposed a `LastBatchCustomMetadata` property.
A property that has to be read at exactly the right moment is easy to
get out of step with the batch in hand, and it had no sensible value at
the end of the stream, so it was replaced with the
`RecordBatchWithMetadata` return type above. The transient state on
`ArrowReaderImplementation` remains, but it is internal and consumed
immediately.
- The new write overloads went straight to `WriteRecordBatchInternal`,
while `ArrowFileWriter` relied on overriding each public
`WriteRecordBatch` to call `WriteStart()` first — so the new overload on
an `ArrowFileWriter` skipped the ARROW1 magic and silently produced a
file that `ArrowFileReader` rejects. `WriteStart()`/`WriteStartAsync()`
moved into `WriteRecordBatchInternal`, where every write path must pass
through it; both are idempotent, so byte output is unchanged for the
stream writer, the file writer and Flight.
- Removed the second virtual `WriteMessageAsync` overload. Two virtual
overloads where one forwards to the other is the trap that already
routed Flight's record batch writes past `FlightDataStream`'s override.
- Custom metadata is validated before anything is written rather than
part-way through building the message, so a rejected dictionary leaves
the writer usable.
- `Message.custom_metadata` is read the same way schema and field
metadata already are in `MessageSerializer`, rather than skipping null
keys and rewriting null values as `""`.
## Verification
- `dotnet build Apache.Arrow.sln` — succeeds, 0 warnings, 0 errors.
- `dotnet test test/Apache.Arrow.Tests` — 1893 passed / 30 skipped on
net8.0, 1849 passed / 30 skipped on net462 and net472, 0 failed. The
skips are the pre-existing Python-dependent tests.
Closes #282
Supersedes #283
Supersedes #424
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01DT86mdkGm3XseKwiUeGUcx
Co-Authored-By: Christoph Mettler
<[email protected]>
Co-Authored-By: Rusty Conover <[email protected]>
---------
Co-authored-by: Christoph Mettler <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: Rusty Conover <[email protected]>
---
.../Internal/FlightDataStream.cs | 8 +-
src/Apache.Arrow/Ipc/ArrowFileReader.cs | 11 +
src/Apache.Arrow/Ipc/ArrowFileWriter.cs | 21 --
src/Apache.Arrow/Ipc/ArrowReaderImplementation.cs | 20 ++
src/Apache.Arrow/Ipc/ArrowStreamReader.cs | 35 +++
src/Apache.Arrow/Ipc/ArrowStreamWriter.cs | 90 ++++++-
src/Apache.Arrow/Ipc/RecordBatchWithMetadata.cs | 49 ++++
test/Apache.Arrow.Tests/ArrowFileWriterTests.cs | 115 +++++++++
test/Apache.Arrow.Tests/ArrowStreamWriterTests.cs | 260 +++++++++++++++++++++
.../CustomMetadataPythonTests.cs | 132 +++++++++++
10 files changed, 707 insertions(+), 34 deletions(-)
diff --git a/src/Apache.Arrow.Flight/Internal/FlightDataStream.cs
b/src/Apache.Arrow.Flight/Internal/FlightDataStream.cs
index 50b2a40..46a7ac2 100644
--- a/src/Apache.Arrow.Flight/Internal/FlightDataStream.cs
+++ b/src/Apache.Arrow.Flight/Internal/FlightDataStream.cs
@@ -55,7 +55,7 @@ namespace Apache.Arrow.Flight.Internal
var offset = SerializeSchema(Schema);
CancellationTokenSource cancellationTokenSource = new
CancellationTokenSource();
- await WriteMessageAsync(MessageHeader.Schema, offset, 0,
cancellationTokenSource.Token).ConfigureAwait(false);
+ await WriteMessageAsync(MessageHeader.Schema, offset, 0, default,
cancellationTokenSource.Token).ConfigureAwait(false);
await
_clientStreamWriter.WriteAsync(_currentFlightData).ConfigureAwait(false);
HasWrittenSchema = true;
}
@@ -81,7 +81,7 @@ namespace Apache.Arrow.Flight.Internal
_currentFlightData.AppMetadata = applicationMetadata;
}
- await
WriteRecordBatchInternalAsync(recordBatch).ConfigureAwait(false);
+ await WriteRecordBatchInternalAsync(recordBatch, customMetadata:
null).ConfigureAwait(false);
//Reset stream position
this.BaseStream.Position = 0;
@@ -91,11 +91,11 @@ namespace Apache.Arrow.Flight.Internal
await
_clientStreamWriter.WriteAsync(_currentFlightData).ConfigureAwait(false);
}
- private protected override ValueTask<long>
WriteMessageAsync<T>(MessageHeader headerType, Offset<T> headerOffset, int
bodyLength, CancellationToken cancellationToken)
+ private protected override ValueTask<long>
WriteMessageAsync<T>(MessageHeader headerType, Offset<T> headerOffset, int
bodyLength, VectorOffset customMetadataOffset, CancellationToken
cancellationToken)
{
Offset<Flatbuf.Message> messageOffset =
Flatbuf.Message.CreateMessage(
Builder, CurrentMetadataVersion, headerType,
headerOffset.Value,
- bodyLength);
+ bodyLength, customMetadataOffset);
Builder.Finish(messageOffset.Value);
diff --git a/src/Apache.Arrow/Ipc/ArrowFileReader.cs
b/src/Apache.Arrow/Ipc/ArrowFileReader.cs
index fa3f84a..c8b63ef 100644
--- a/src/Apache.Arrow/Ipc/ArrowFileReader.cs
+++ b/src/Apache.Arrow/Ipc/ArrowFileReader.cs
@@ -85,5 +85,16 @@ namespace Apache.Arrow.Ipc
{
return Implementation.ReadRecordBatchAsync(index,
cancellationToken);
}
+
+ /// <summary>
+ /// Reads the record batch at the given index together with the custom
metadata on its
+ /// IPC Message, which is null if the message carried none.
+ /// </summary>
+ public async ValueTask<RecordBatchWithMetadata>
ReadRecordBatchWithCustomMetadataAsync(int index, CancellationToken
cancellationToken = default)
+ {
+ RecordBatch batch = await
Implementation.ReadRecordBatchAsync(index,
cancellationToken).ConfigureAwait(false);
+
+ return batch == null ? default : new
RecordBatchWithMetadata(batch, Implementation.LastBatchCustomMetadata);
+ }
}
}
diff --git a/src/Apache.Arrow/Ipc/ArrowFileWriter.cs
b/src/Apache.Arrow/Ipc/ArrowFileWriter.cs
index 91b7c29..cfdf226 100644
--- a/src/Apache.Arrow/Ipc/ArrowFileWriter.cs
+++ b/src/Apache.Arrow/Ipc/ArrowFileWriter.cs
@@ -66,27 +66,6 @@ namespace Apache.Arrow.Ipc
RecordBatchBlocks = new List<Block>();
}
- public override void WriteRecordBatch(RecordBatch recordBatch)
- {
- // TODO: Compare record batch schema
-
- WriteStart();
-
- WriteRecordBatchInternal(recordBatch);
- }
-
- public override async Task WriteRecordBatchAsync(RecordBatch
recordBatch, CancellationToken cancellationToken = default)
- {
- // TODO: Compare record batch schema
-
- await WriteStartAsync(cancellationToken).ConfigureAwait(false);
-
- cancellationToken.ThrowIfCancellationRequested();
-
- await WriteRecordBatchInternalAsync(recordBatch, cancellationToken)
- .ConfigureAwait(false);
- }
-
private protected override void StartingWritingRecordBatch()
{
_currentRecordBatchOffset = BaseStream.Position;
diff --git a/src/Apache.Arrow/Ipc/ArrowReaderImplementation.cs
b/src/Apache.Arrow/Ipc/ArrowReaderImplementation.cs
index 2c380e5..45fd792 100644
--- a/src/Apache.Arrow/Ipc/ArrowReaderImplementation.cs
+++ b/src/Apache.Arrow/Ipc/ArrowReaderImplementation.cs
@@ -81,6 +81,11 @@ namespace Apache.Arrow.Ipc
public abstract ValueTask<RecordBatch>
ReadNextRecordBatchAsync(CancellationToken cancellationToken);
public abstract RecordBatch ReadNextRecordBatch();
+ /// <summary>
+ /// Custom metadata from the most recently read RecordBatch Message,
if any.
+ /// </summary>
+ internal IReadOnlyDictionary<string, string> LastBatchCustomMetadata {
get; private protected set; }
+
internal static T ReadMessage<T>(ByteBuffer bb)
where T : struct, IFlatbufferObject
{
@@ -148,6 +153,7 @@ namespace Apache.Arrow.Ipc
}
List<IArrowArray> arrays = BuildArrays(message.Version,
Schema, bodyByteBuffer, rb);
+ LastBatchCustomMetadata =
ReadMessageCustomMetadata(message);
return new RecordBatch(Schema, memoryOwner, arrays,
(int)rb.Length);
default:
// NOTE: Skip unsupported message type
@@ -158,6 +164,20 @@ namespace Apache.Arrow.Ipc
return null;
}
+ private static IReadOnlyDictionary<string, string>
ReadMessageCustomMetadata(Flatbuf.Message message)
+ {
+ Dictionary<string, string> metadata = message.CustomMetadataLength
> 0
+ ? new Dictionary<string, string>(message.CustomMetadataLength)
: null;
+ for (int i = 0; i < message.CustomMetadataLength; i++)
+ {
+ Flatbuf.KeyValue keyValue =
message.CustomMetadata(i).GetValueOrDefault();
+
+ metadata[keyValue.Key] = keyValue.Value;
+ }
+
+ return metadata;
+ }
+
internal static ByteBuffer CreateByteBuffer(ReadOnlyMemory<byte>
buffer)
{
return new ByteBuffer(new ReadOnlyMemoryBufferAllocator(buffer),
0);
diff --git a/src/Apache.Arrow/Ipc/ArrowStreamReader.cs
b/src/Apache.Arrow/Ipc/ArrowStreamReader.cs
index afa3713..bdc1fb7 100644
--- a/src/Apache.Arrow/Ipc/ArrowStreamReader.cs
+++ b/src/Apache.Arrow/Ipc/ArrowStreamReader.cs
@@ -14,6 +14,7 @@
// limitations under the License.
using System;
+using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
@@ -151,5 +152,39 @@ namespace Apache.Arrow.Ipc
{
return _implementation.ReadNextRecordBatch();
}
+
+ /// <summary>
+ /// Reads the next record batch together with the custom metadata on
its IPC Message,
+ /// the counterpart of <see
cref="ArrowStreamWriter.WriteRecordBatch(RecordBatch,
IReadOnlyDictionary{string, string})"/>.
+ /// </summary>
+ /// <returns>
+ /// The record batch and its custom metadata. At the end of the stream
both
+ /// <see cref="RecordBatchWithMetadata.Batch"/> and
+ /// <see cref="RecordBatchWithMetadata.CustomMetadata"/> are null; the
metadata is also
+ /// null for a batch whose message carried none.
+ /// </returns>
+ public async ValueTask<RecordBatchWithMetadata>
ReadNextRecordBatchWithCustomMetadataAsync(CancellationToken cancellationToken
= default)
+ {
+ RecordBatch batch = await
_implementation.ReadNextRecordBatchAsync(cancellationToken).ConfigureAwait(false);
+
+ return batch == null ? default : new
RecordBatchWithMetadata(batch, _implementation.LastBatchCustomMetadata);
+ }
+
+ /// <summary>
+ /// Reads the next record batch together with the custom metadata on
its IPC Message,
+ /// the counterpart of <see
cref="ArrowStreamWriter.WriteRecordBatch(RecordBatch,
IReadOnlyDictionary{string, string})"/>.
+ /// </summary>
+ /// <returns>
+ /// The record batch and its custom metadata. At the end of the stream
both
+ /// <see cref="RecordBatchWithMetadata.Batch"/> and
+ /// <see cref="RecordBatchWithMetadata.CustomMetadata"/> are null; the
metadata is also
+ /// null for a batch whose message carried none.
+ /// </returns>
+ public RecordBatchWithMetadata ReadNextRecordBatchWithCustomMetadata()
+ {
+ RecordBatch batch = _implementation.ReadNextRecordBatch();
+
+ return batch == null ? default : new
RecordBatchWithMetadata(batch, _implementation.LastBatchCustomMetadata);
+ }
}
}
diff --git a/src/Apache.Arrow/Ipc/ArrowStreamWriter.cs
b/src/Apache.Arrow/Ipc/ArrowStreamWriter.cs
index a39caa6..aa63985 100644
--- a/src/Apache.Arrow/Ipc/ArrowStreamWriter.cs
+++ b/src/Apache.Arrow/Ipc/ArrowStreamWriter.cs
@@ -805,9 +805,17 @@ namespace Apache.Arrow.Ipc
Builder, compressionType,
Flatbuf.BodyCompressionMethod.BUFFER);
}
- private protected void WriteRecordBatchInternal(RecordBatch
recordBatch)
+ private protected void WriteRecordBatchInternal(RecordBatch
recordBatch, IReadOnlyDictionary<string, string> customMetadata)
{
// TODO: Truncate buffers with extraneous padding / unused capacity
+ // TODO: Compare record batch schema
+
+ ValidateCustomMetadata(customMetadata);
+
+ // Derived writers use WriteStartInternal to emit a preamble
before any message
+ // (ArrowFileWriter writes the file magic there). Doing this here
rather than in
+ // the public entry points means a new WriteRecordBatch overload
cannot skip it.
+ WriteStart();
if (!HasWrittenSchema)
{
@@ -829,6 +837,8 @@ namespace Apache.Arrow.Ipc
VectorOffset buffersVectorOffset = Builder.EndVector();
+ VectorOffset customMetadataVectorOffset =
GetCustomMetadataOffset(customMetadata);
+
// Serialize record batch
StartingWritingRecordBatch();
@@ -840,7 +850,7 @@ namespace Apache.Arrow.Ipc
variadicCountsOffset);
long metadataLength =
WriteMessage(Flatbuf.MessageHeader.RecordBatch,
- recordBatchOffset, recordBatchBuilder.TotalLength);
+ recordBatchOffset, recordBatchBuilder.TotalLength,
customMetadataVectorOffset);
long bufferLength = WriteBufferData(recordBatchBuilder.Buffers);
@@ -848,8 +858,16 @@ namespace Apache.Arrow.Ipc
}
private protected async Task WriteRecordBatchInternalAsync(RecordBatch
recordBatch,
+ IReadOnlyDictionary<string, string> customMetadata,
CancellationToken cancellationToken = default)
{
+ // TODO: Compare record batch schema
+
+ ValidateCustomMetadata(customMetadata);
+
+ // See the comment in WriteRecordBatchInternal.
+ await WriteStartAsync(cancellationToken).ConfigureAwait(false);
+
if (!HasWrittenSchema)
{
await WriteSchemaAsync(Schema,
cancellationToken).ConfigureAwait(false);
@@ -870,6 +888,8 @@ namespace Apache.Arrow.Ipc
VectorOffset buffersVectorOffset = Builder.EndVector();
+ VectorOffset customMetadataVectorOffset =
GetCustomMetadataOffset(customMetadata);
+
// Serialize record batch
StartingWritingRecordBatch();
@@ -882,6 +902,7 @@ namespace Apache.Arrow.Ipc
long metadataLength = await
WriteMessageAsync(Flatbuf.MessageHeader.RecordBatch,
recordBatchOffset, recordBatchBuilder.TotalLength,
+ customMetadataVectorOffset,
cancellationToken).ConfigureAwait(false);
long bufferLength = await
WriteBufferDataAsync(recordBatchBuilder.Buffers,
cancellationToken).ConfigureAwait(false);
@@ -1059,7 +1080,7 @@ namespace Apache.Arrow.Ipc
using var builder = recordBatchBuilder;
long metadataLength = await
WriteMessageAsync(Flatbuf.MessageHeader.DictionaryBatch,
- dictionaryBatchOffset, recordBatchBuilder.TotalLength,
cancellationToken).ConfigureAwait(false);
+ dictionaryBatchOffset, recordBatchBuilder.TotalLength,
default, cancellationToken).ConfigureAwait(false);
long bufferLength = await
WriteBufferDataAsync(recordBatchBuilder.Buffers,
cancellationToken).ConfigureAwait(false);
@@ -1129,12 +1150,22 @@ namespace Apache.Arrow.Ipc
public virtual void WriteRecordBatch(RecordBatch recordBatch)
{
- WriteRecordBatchInternal(recordBatch);
+ WriteRecordBatchInternal(recordBatch, customMetadata: null);
+ }
+
+ public virtual void WriteRecordBatch(RecordBatch recordBatch,
IReadOnlyDictionary<string, string> customMetadata)
+ {
+ WriteRecordBatchInternal(recordBatch, customMetadata);
}
public virtual Task WriteRecordBatchAsync(RecordBatch recordBatch,
CancellationToken cancellationToken = default)
{
- return WriteRecordBatchInternalAsync(recordBatch,
cancellationToken);
+ return WriteRecordBatchInternalAsync(recordBatch, customMetadata:
null, cancellationToken);
+ }
+
+ public virtual Task WriteRecordBatchAsync(RecordBatch recordBatch,
IReadOnlyDictionary<string, string> customMetadata, CancellationToken
cancellationToken = default)
+ {
+ return WriteRecordBatchInternalAsync(recordBatch, customMetadata,
cancellationToken);
}
public void WriteStart()
@@ -1291,6 +1322,45 @@ namespace Apache.Arrow.Ipc
return
Flatbuf.DictionaryEncoding.CreateDictionaryEncoding(Builder, id, indexOffset,
dicType.Ordered);
}
+ /// <summary>
+ /// Builds the Message-level custom_metadata vector, or a default
offset when there is none.
+ /// </summary>
+ private VectorOffset
GetCustomMetadataOffset(IReadOnlyDictionary<string, string> customMetadata)
+ {
+ if (customMetadata == null || customMetadata.Count == 0)
+ {
+ return default;
+ }
+
+ Offset<Flatbuf.KeyValue>[] metadataOffsets =
GetMetadataOffsets(customMetadata);
+ return Flatbuf.Message.CreateCustomMetadataVector(Builder,
metadataOffsets);
+ }
+
+ /// <summary>
+ /// Validates that a caller-supplied custom metadata dictionary
contains no null keys or values,
+ /// so that failures are reported before anything is written rather
than as an opaque exception
+ /// from the FlatBuffer builder part-way through a message.
+ /// </summary>
+ private static void ValidateCustomMetadata(IReadOnlyDictionary<string,
string> customMetadata)
+ {
+ if (customMetadata == null)
+ {
+ return;
+ }
+
+ foreach (KeyValuePair<string, string> metadatum in customMetadata)
+ {
+ if (metadatum.Key == null)
+ {
+ throw new ArgumentException("Custom metadata must not
contain null keys.", nameof(customMetadata));
+ }
+ if (metadatum.Value == null)
+ {
+ throw new ArgumentException($"Custom metadata value for
key '{metadatum.Key}' must not be null.", nameof(customMetadata));
+ }
+ }
+ }
+
private Offset<Flatbuf.KeyValue>[]
GetMetadataOffsets(IReadOnlyDictionary<string, string> metadata)
{
Debug.Assert(metadata != null);
@@ -1334,7 +1404,7 @@ namespace Apache.Arrow.Ipc
// Build message
- await WriteMessageAsync(Flatbuf.MessageHeader.Schema,
schemaOffset, 0, cancellationToken)
+ await WriteMessageAsync(Flatbuf.MessageHeader.Schema,
schemaOffset, 0, default, cancellationToken)
.ConfigureAwait(false);
return schemaOffset;
@@ -1347,12 +1417,13 @@ namespace Apache.Arrow.Ipc
/// The number of bytes written to the stream.
/// </returns>
private protected long WriteMessage<T>(
- Flatbuf.MessageHeader headerType, Offset<T> headerOffset, int
bodyLength)
+ Flatbuf.MessageHeader headerType, Offset<T> headerOffset, int
bodyLength,
+ VectorOffset customMetadataOffset = default)
where T : struct
{
Offset<Flatbuf.Message> messageOffset =
Flatbuf.Message.CreateMessage(
Builder, CurrentMetadataVersion, headerType,
headerOffset.Value,
- bodyLength);
+ bodyLength, customMetadataOffset);
Builder.Finish(messageOffset.Value);
@@ -1378,12 +1449,13 @@ namespace Apache.Arrow.Ipc
/// </returns>
private protected virtual async ValueTask<long> WriteMessageAsync<T>(
Flatbuf.MessageHeader headerType, Offset<T> headerOffset, int
bodyLength,
+ VectorOffset customMetadataOffset,
CancellationToken cancellationToken)
where T : struct
{
Offset<Flatbuf.Message> messageOffset =
Flatbuf.Message.CreateMessage(
Builder, CurrentMetadataVersion, headerType,
headerOffset.Value,
- bodyLength);
+ bodyLength, customMetadataOffset);
Builder.Finish(messageOffset.Value);
diff --git a/src/Apache.Arrow/Ipc/RecordBatchWithMetadata.cs
b/src/Apache.Arrow/Ipc/RecordBatchWithMetadata.cs
new file mode 100644
index 0000000..54e2103
--- /dev/null
+++ b/src/Apache.Arrow/Ipc/RecordBatchWithMetadata.cs
@@ -0,0 +1,49 @@
+// 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.Collections.Generic;
+
+namespace Apache.Arrow.Ipc
+{
+ /// <summary>
+ /// A record batch read from an Arrow IPC source, together with the custom
metadata
+ /// carried on the IPC Message that held it.
+ /// </summary>
+ public readonly struct RecordBatchWithMetadata
+ {
+ public RecordBatchWithMetadata(RecordBatch batch,
IReadOnlyDictionary<string, string> customMetadata)
+ {
+ Batch = batch;
+ CustomMetadata = customMetadata;
+ }
+
+ /// <summary>
+ /// The record batch that was read, or null at the end of the stream.
+ /// </summary>
+ public RecordBatch Batch { get; }
+
+ /// <summary>
+ /// The Message-level custom metadata accompanying <see
cref="Batch"/>, or null if the
+ /// message carried none.
+ /// </summary>
+ public IReadOnlyDictionary<string, string> CustomMetadata { get; }
+
+ public void Deconstruct(out RecordBatch batch, out
IReadOnlyDictionary<string, string> customMetadata)
+ {
+ batch = Batch;
+ customMetadata = CustomMetadata;
+ }
+ }
+}
diff --git a/test/Apache.Arrow.Tests/ArrowFileWriterTests.cs
b/test/Apache.Arrow.Tests/ArrowFileWriterTests.cs
index d810a53..f3b0a34 100644
--- a/test/Apache.Arrow.Tests/ArrowFileWriterTests.cs
+++ b/test/Apache.Arrow.Tests/ArrowFileWriterTests.cs
@@ -311,6 +311,121 @@ namespace Apache.Arrow.Tests
await ValidateRecordBatchFile(stream, recordBatch, strictCompare:
false);
}
+ [Fact]
+ public void WriteCustomMetadata_StillWritesFileMagic()
+ {
+ // ArrowFileWriter has to emit the file magic before any message.
Regression test for
+ // a WriteRecordBatch overload reaching WriteRecordBatchInternal
without it.
+ RecordBatch originalBatch =
TestData.CreateSampleRecordBatch(length: 100);
+ var customMetadata = new Dictionary<string, string> { ["batch"] =
"first" };
+
+ var stream = new MemoryStream();
+ using (var writer = new ArrowFileWriter(stream,
originalBatch.Schema, leaveOpen: true))
+ {
+ writer.WriteRecordBatch(originalBatch, customMetadata);
+ writer.WriteEnd();
+ }
+
+ Assert.Equal(
+ ArrowFileConstants.Magic,
+ stream.ToArray().AsSpan(0,
ArrowFileConstants.Magic.Length).ToArray());
+ }
+
+ [Fact]
+ public async Task WriteCustomMetadataAsync_StillWritesFileMagic()
+ {
+ RecordBatch originalBatch =
TestData.CreateSampleRecordBatch(length: 100);
+ var customMetadata = new Dictionary<string, string> { ["batch"] =
"first" };
+
+ var stream = new MemoryStream();
+ using (var writer = new ArrowFileWriter(stream,
originalBatch.Schema, leaveOpen: true))
+ {
+ await writer.WriteRecordBatchAsync(originalBatch,
customMetadata);
+ await writer.WriteEndAsync();
+ }
+
+ Assert.Equal(
+ ArrowFileConstants.Magic,
+ stream.ToArray().AsSpan(0,
ArrowFileConstants.Magic.Length).ToArray());
+ }
+
+ [Fact]
+ public async Task WriteCustomMetadata_RoundTrips()
+ {
+ RecordBatch originalBatch =
TestData.CreateSampleRecordBatch(length: 100);
+ var customMetadata = new Dictionary<string, string>
+ {
+ ["rpc.method"] = "add",
+ ["request_id"] = "abc-123",
+ };
+
+ var stream = new MemoryStream();
+ using (var writer = new ArrowFileWriter(stream,
originalBatch.Schema, leaveOpen: true))
+ {
+ writer.WriteRecordBatch(originalBatch, customMetadata);
+ writer.WriteEnd();
+ }
+
+ stream.Position = 0;
+
+ await ValidateRecordBatchFile(stream, originalBatch);
+
+ stream.Position = 0;
+ using var reader = new ArrowFileReader(stream);
+ RecordBatchWithMetadata read =
reader.ReadNextRecordBatchWithCustomMetadata();
+ Assert.NotNull(read.Batch);
+ Assert.Equal(customMetadata, read.CustomMetadata);
+
+ // The indexed read on ArrowFileReader reports the same metadata.
+ RecordBatchWithMetadata indexed = await
reader.ReadRecordBatchWithCustomMetadataAsync(0);
+ Assert.NotNull(indexed.Batch);
+ Assert.Equal(customMetadata, indexed.CustomMetadata);
+ }
+
+ [Fact]
+ public async Task WriteCustomMetadataAsync_RoundTrips()
+ {
+ RecordBatch originalBatch =
TestData.CreateSampleRecordBatch(length: 100);
+ var customMetadata = new Dictionary<string, string> { ["key1"] =
"value1" };
+
+ var stream = new MemoryStream();
+ using (var writer = new ArrowFileWriter(stream,
originalBatch.Schema, leaveOpen: true))
+ {
+ await writer.WriteRecordBatchAsync(originalBatch,
customMetadata);
+ await writer.WriteEndAsync();
+ }
+
+ stream.Position = 0;
+
+ await ValidateRecordBatchFile(stream, originalBatch);
+
+ stream.Position = 0;
+ using var reader = new ArrowFileReader(stream);
+ RecordBatchWithMetadata read = await
reader.ReadNextRecordBatchWithCustomMetadataAsync();
+ Assert.NotNull(read.Batch);
+ Assert.Equal(customMetadata, read.CustomMetadata);
+ }
+
+ [Fact]
+ public async Task
WriteCustomMetadata_AfterExplicitWriteStart_RoundTrips()
+ {
+ // WriteStart is idempotent, so writing it up front must not
produce a second preamble.
+ RecordBatch originalBatch =
TestData.CreateSampleRecordBatch(length: 100);
+ var customMetadata = new Dictionary<string, string> { ["key1"] =
"value1" };
+
+ var stream = new MemoryStream();
+ using (var writer = new ArrowFileWriter(stream,
originalBatch.Schema, leaveOpen: true))
+ {
+ writer.WriteStart();
+ writer.WriteRecordBatch(originalBatch, customMetadata);
+ writer.WriteEnd();
+ }
+
+ stream.Position = 0;
+
+ await ValidateRecordBatchFile(stream, originalBatch);
+ }
+
private static void Shuffle(int[] values, Random random)
{
var length = values.Length;
diff --git a/test/Apache.Arrow.Tests/ArrowStreamWriterTests.cs
b/test/Apache.Arrow.Tests/ArrowStreamWriterTests.cs
index 1a4b5a6..172f69b 100644
--- a/test/Apache.Arrow.Tests/ArrowStreamWriterTests.cs
+++ b/test/Apache.Arrow.Tests/ArrowStreamWriterTests.cs
@@ -15,6 +15,7 @@
using System;
using System.Buffers.Binary;
+using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
@@ -736,5 +737,264 @@ namespace Apache.Arrow.Tests
Assert.True(allocator.Statistics.Allocations > 0);
Assert.Equal(0, allocator.Rented);
}
+
+ [Fact]
+ public void WriteCustomMetadata_RoundTrips()
+ {
+ RecordBatch originalBatch =
TestData.CreateSampleRecordBatch(length: 10);
+ var customMetadata = new Dictionary<string, string>
+ {
+ ["rpc.method"] = "add",
+ ["rpc.version"] = "1",
+ ["request_id"] = "abc-123",
+ };
+
+ using var stream = new MemoryStream();
+ using (var writer = new ArrowStreamWriter(stream,
originalBatch.Schema, leaveOpen: true))
+ {
+ writer.WriteRecordBatch(originalBatch, customMetadata);
+ writer.WriteEnd();
+ }
+
+ stream.Position = 0;
+
+ using var reader = new ArrowStreamReader(stream);
+ RecordBatchWithMetadata read =
reader.ReadNextRecordBatchWithCustomMetadata();
+ Assert.NotNull(read.Batch);
+ ArrowReaderVerifier.CompareBatches(originalBatch, read.Batch);
+
+ Assert.NotNull(read.CustomMetadata);
+ Assert.Equal(3, read.CustomMetadata.Count);
+ Assert.Equal("add", read.CustomMetadata["rpc.method"]);
+ Assert.Equal("1", read.CustomMetadata["rpc.version"]);
+ Assert.Equal("abc-123", read.CustomMetadata["request_id"]);
+ }
+
+ [Fact]
+ public async Task WriteCustomMetadataAsync_RoundTrips()
+ {
+ RecordBatch originalBatch =
TestData.CreateSampleRecordBatch(length: 10);
+ var customMetadata = new Dictionary<string, string>
+ {
+ ["key1"] = "value1",
+ ["key2"] = "value2",
+ };
+
+ using var stream = new MemoryStream();
+ using (var writer = new ArrowStreamWriter(stream,
originalBatch.Schema, leaveOpen: true))
+ {
+ await writer.WriteRecordBatchAsync(originalBatch,
customMetadata);
+ await writer.WriteEndAsync();
+ }
+
+ stream.Position = 0;
+
+ using var reader = new ArrowStreamReader(stream);
+ (RecordBatch readBatch, IReadOnlyDictionary<string, string>
readMetadata) =
+ await reader.ReadNextRecordBatchWithCustomMetadataAsync();
+ Assert.NotNull(readBatch);
+ ArrowReaderVerifier.CompareBatches(originalBatch, readBatch);
+
+ Assert.Equal(customMetadata, readMetadata);
+ }
+
+ [Fact]
+ public void WriteCustomMetadata_MultipleBatches_EachHasOwnMetadata()
+ {
+ RecordBatch batch = TestData.CreateSampleRecordBatch(length: 5);
+ var meta1 = new Dictionary<string, string> { ["batch"] = "first" };
+ var meta2 = new Dictionary<string, string> { ["batch"] = "second",
["extra"] = "data" };
+
+ using var stream = new MemoryStream();
+ using (var writer = new ArrowStreamWriter(stream, batch.Schema,
leaveOpen: true))
+ {
+ writer.WriteRecordBatch(batch, meta1);
+ writer.WriteRecordBatch(batch, meta2);
+ writer.WriteEnd();
+ }
+
+ stream.Position = 0;
+
+ using var reader = new ArrowStreamReader(stream);
+
+ Assert.Equal(meta1,
reader.ReadNextRecordBatchWithCustomMetadata().CustomMetadata);
+ Assert.Equal(meta2,
reader.ReadNextRecordBatchWithCustomMetadata().CustomMetadata);
+ }
+
+ [Fact]
+ public void WriteWithoutCustomMetadata_CustomMetadataIsNull()
+ {
+ RecordBatch batch = TestData.CreateSampleRecordBatch(length: 5);
+
+ using var stream = new MemoryStream();
+ using (var writer = new ArrowStreamWriter(stream, batch.Schema,
leaveOpen: true))
+ {
+ writer.WriteRecordBatch(batch);
+ writer.WriteEnd();
+ }
+
+ stream.Position = 0;
+
+ using var reader = new ArrowStreamReader(stream);
+ RecordBatchWithMetadata read =
reader.ReadNextRecordBatchWithCustomMetadata();
+ Assert.NotNull(read.Batch);
+ Assert.Null(read.CustomMetadata);
+ }
+
+ [Fact]
+ public void WriteCustomMetadata_MixedBatches_WithAndWithoutMetadata()
+ {
+ RecordBatch batch = TestData.CreateSampleRecordBatch(length: 5);
+ var meta = new Dictionary<string, string> { ["key"] = "value" };
+
+ using var stream = new MemoryStream();
+ using (var writer = new ArrowStreamWriter(stream, batch.Schema,
leaveOpen: true))
+ {
+ writer.WriteRecordBatch(batch, meta);
+ writer.WriteRecordBatch(batch); // no metadata
+ writer.WriteEnd();
+ }
+
+ stream.Position = 0;
+
+ using var reader = new ArrowStreamReader(stream);
+
+ Assert.Equal(meta,
reader.ReadNextRecordBatchWithCustomMetadata().CustomMetadata);
+
+ RecordBatchWithMetadata second =
reader.ReadNextRecordBatchWithCustomMetadata();
+ Assert.NotNull(second.Batch);
+ Assert.Null(second.CustomMetadata);
+
+ // At the end of the stream both halves are null, not the previous
batch's metadata.
+ RecordBatchWithMetadata end =
reader.ReadNextRecordBatchWithCustomMetadata();
+ Assert.Null(end.Batch);
+ Assert.Null(end.CustomMetadata);
+ }
+
+ [Fact]
+ public void WriteCustomMetadata_EmptyDictionary_WritesNoMetadata()
+ {
+ RecordBatch batch = TestData.CreateSampleRecordBatch(length: 5);
+
+ using var stream = new MemoryStream();
+ using (var writer = new ArrowStreamWriter(stream, batch.Schema,
leaveOpen: true))
+ {
+ writer.WriteRecordBatch(batch, new Dictionary<string,
string>());
+ writer.WriteEnd();
+ }
+
+ stream.Position = 0;
+
+ using var reader = new ArrowStreamReader(stream);
+ RecordBatchWithMetadata read =
reader.ReadNextRecordBatchWithCustomMetadata();
+ Assert.NotNull(read.Batch);
+ Assert.Null(read.CustomMetadata);
+ }
+
+ [Fact]
+ public void WriteCustomMetadata_NullKey_Throws()
+ {
+ RecordBatch batch = TestData.CreateSampleRecordBatch(length: 5);
+ // Dictionary<string, string> rejects a null key, so go through a
map that allows one.
+ var withNullKey = new NullTolerantMetadata(new
KeyValuePair<string, string>(null, "value"));
+
+ using var stream = new MemoryStream();
+ using var writer = new ArrowStreamWriter(stream, batch.Schema,
leaveOpen: true);
+
+ Assert.Throws<ArgumentException>(() =>
writer.WriteRecordBatch(batch, withNullKey));
+ }
+
+ [Fact]
+ public void WriteCustomMetadata_NullValue_Throws()
+ {
+ RecordBatch batch = TestData.CreateSampleRecordBatch(length: 5);
+ var meta = new Dictionary<string, string> { ["key"] = null };
+
+ using var stream = new MemoryStream();
+ using var writer = new ArrowStreamWriter(stream, batch.Schema,
leaveOpen: true);
+
+ Assert.Throws<ArgumentException>(() =>
writer.WriteRecordBatch(batch, meta));
+ }
+
+ [Fact]
+ public async Task WriteCustomMetadataAsync_NullValue_Throws()
+ {
+ RecordBatch batch = TestData.CreateSampleRecordBatch(length: 5);
+ var meta = new Dictionary<string, string> { ["key"] = null };
+
+ using var stream = new MemoryStream();
+ using var writer = new ArrowStreamWriter(stream, batch.Schema,
leaveOpen: true);
+
+ await Assert.ThrowsAsync<ArgumentException>(
+ () => writer.WriteRecordBatchAsync(batch, meta));
+ }
+
+ [Fact]
+ public void WriteCustomMetadata_RejectedMetadata_LeavesWriterUsable()
+ {
+ // Validation happens before anything is written, so a rejected
dictionary must not
+ // leave the writer part-way through a message.
+ RecordBatch batch = TestData.CreateSampleRecordBatch(length: 5);
+ var good = new Dictionary<string, string> { ["key"] = "value" };
+ var bad = new Dictionary<string, string> { ["key"] = null };
+
+ using var stream = new MemoryStream();
+ using (var writer = new ArrowStreamWriter(stream, batch.Schema,
leaveOpen: true))
+ {
+ Assert.Throws<ArgumentException>(() =>
writer.WriteRecordBatch(batch, bad));
+ writer.WriteRecordBatch(batch, good);
+ writer.WriteEnd();
+ }
+
+ stream.Position = 0;
+
+ using var reader = new ArrowStreamReader(stream);
+ RecordBatchWithMetadata read =
reader.ReadNextRecordBatchWithCustomMetadata();
+ Assert.NotNull(read.Batch);
+ ArrowReaderVerifier.CompareBatches(batch, read.Batch);
+ Assert.Equal(good, read.CustomMetadata);
+ Assert.Null(reader.ReadNextRecordBatch());
+ }
+
+ [Fact]
+ public void WriteCustomMetadata_EmptyValues_RoundTrips()
+ {
+ RecordBatch batch = TestData.CreateSampleRecordBatch(length: 5);
+ var meta = new Dictionary<string, string> { ["empty"] = "" };
+
+ using var stream = new MemoryStream();
+ using (var writer = new ArrowStreamWriter(stream, batch.Schema,
leaveOpen: true))
+ {
+ writer.WriteRecordBatch(batch, meta);
+ writer.WriteEnd();
+ }
+
+ stream.Position = 0;
+
+ using var reader = new ArrowStreamReader(stream);
+ IReadOnlyDictionary<string, string> readMetadata =
+ reader.ReadNextRecordBatchWithCustomMetadata().CustomMetadata;
+ Assert.NotNull(readMetadata);
+ Assert.Equal("", readMetadata["empty"]);
+ }
+
+ /// <summary>
+ /// A metadata collection that can hold a null key, which <see
cref="Dictionary{TKey, TValue}"/> cannot.
+ /// </summary>
+ private sealed class NullTolerantMetadata :
IReadOnlyDictionary<string, string>
+ {
+ private readonly KeyValuePair<string, string>[] _entries;
+
+ public NullTolerantMetadata(params KeyValuePair<string, string>[]
entries) => _entries = entries;
+
+ public int Count => _entries.Length;
+ public IEnumerable<string> Keys => _entries.Select(e => e.Key);
+ public IEnumerable<string> Values => _entries.Select(e => e.Value);
+ public string this[string key] => throw new
NotSupportedException();
+ public bool ContainsKey(string key) => throw new
NotSupportedException();
+ public bool TryGetValue(string key, out string value) => throw new
NotSupportedException();
+ public IEnumerator<KeyValuePair<string, string>> GetEnumerator()
=> ((IEnumerable<KeyValuePair<string, string>>)_entries).GetEnumerator();
+ IEnumerator IEnumerable.GetEnumerator() =>
_entries.GetEnumerator();
+ }
}
}
diff --git a/test/Apache.Arrow.Tests/CustomMetadataPythonTests.cs
b/test/Apache.Arrow.Tests/CustomMetadataPythonTests.cs
new file mode 100644
index 0000000..58cbbd7
--- /dev/null
+++ b/test/Apache.Arrow.Tests/CustomMetadataPythonTests.cs
@@ -0,0 +1,132 @@
+// 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.IO;
+using Apache.Arrow.Ipc;
+using Python.Runtime;
+using Xunit;
+
+namespace Apache.Arrow.Tests
+{
+
+ // -------------------------------------------------------------------
+ // Cross-language Python tests for custom_metadata
+ // -------------------------------------------------------------------
+
+ [Collection("PythonNet")]
+ public class CustomMetadataPythonTests
+ {
+ public CustomMetadataPythonTests(PythonNetFixture pythonNet)
+ {
+ pythonNet.EnsureInitialized();
+ }
+
+ // -------------------------------------------------------------------
+ // C# writes IPC with custom_metadata → Python reads
+ // -------------------------------------------------------------------
+
+ [SkippableFact]
+ public void ExportCustomMetadata_PythonReads()
+ {
+ RecordBatch batch = TestData.CreateSampleRecordBatch(length: 5);
+ var batchMetadata = new Dictionary<string, string>
+ {
+ ["rpc.method"] = "greet",
+ ["request_id"] = "abc-123",
+ ["custom_key"] = "custom_value",
+ };
+
+ // Serialize to IPC stream with custom batch metadata
+ byte[] ipcBytes;
+ using (var ms = new MemoryStream())
+ {
+ using (var writer = new ArrowStreamWriter(ms, batch.Schema,
leaveOpen: true))
+ {
+ writer.WriteRecordBatch(batch, batchMetadata);
+ writer.WriteEnd();
+ }
+ ipcBytes = ms.ToArray();
+ }
+
+ // Python reads and verifies custom_metadata
+ using (Py.GIL())
+ {
+ dynamic pa = Py.Import("pyarrow");
+ dynamic reader =
pa.ipc.open_stream(pa.BufferReader(ipcBytes.ToPython()));
+
+ PyObject result =
reader.read_next_batch_with_custom_metadata();
+ dynamic pyBatch = result[0];
+ dynamic customMeta = result[1];
+
+ // Verify batch data round-tripped
+ Assert.Equal(5, (int)pyBatch.num_rows);
+
+ // Verify custom_metadata (pyarrow returns bytes — decode to
str)
+ Assert.Equal("greet",
(string)customMeta["rpc.method"].decode());
+ Assert.Equal("abc-123",
(string)customMeta["request_id"].decode());
+ Assert.Equal("custom_value",
(string)customMeta["custom_key"].decode());
+ }
+ }
+
+ // -------------------------------------------------------------------
+ // Python writes IPC with custom_metadata → C# reads
+ // -------------------------------------------------------------------
+
+ [SkippableFact]
+ public void ImportCustomMetadata_PythonWrites()
+ {
+ byte[] ipcBytes;
+
+ // Python creates a batch with custom_metadata and serializes to
IPC
+ using (Py.GIL())
+ {
+ dynamic pa = Py.Import("pyarrow");
+ dynamic io = Py.Import("io");
+
+ dynamic pyBatch = pa.record_batch(new PyList(new PyObject[]
+ {
+ pa.array(new int[] { 1, 2, 3, 4, 5 }),
+ }), new[] { "x" });
+
+ dynamic buf = io.BytesIO();
+ dynamic writer = pa.ipc.new_stream(buf, pyBatch.schema);
+ dynamic customMeta = pa.KeyValueMetadata(new PyDict
+ {
+ ["origin"] = "python".ToPython(),
+ ["version"] = "2".ToPython(),
+ });
+ writer.write_batch(pyBatch, custom_metadata: customMeta);
+ writer.close();
+
+ ipcBytes = ((PyObject)buf.getvalue()).As<byte[]>();
+ }
+
+ // C# reads and verifies custom_metadata
+ using var ms = new MemoryStream(ipcBytes);
+ using var reader = new ArrowStreamReader(ms);
+
+ (RecordBatch batch, IReadOnlyDictionary<string, string> metadata) =
+ reader.ReadNextRecordBatchWithCustomMetadata();
+ Assert.NotNull(batch);
+ Assert.Equal(5, batch.Length);
+
+ Assert.NotNull(metadata);
+ Assert.Equal("python", metadata["origin"]);
+ Assert.Equal("2", metadata["version"]);
+ }
+ }
+}