This is an automated email from the ASF dual-hosted git repository.

paleolimbot pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-nanoarrow.git


The following commit(s) were added to refs/heads/main by this push:
     new 3f824063 feat(ipc): Support custom_metadata on Message for IPC (#924)
3f824063 is described below

commit 3f824063f59848e05692ab520de8ab4d9ebb1880
Author: Rusty Conover <[email protected]>
AuthorDate: Sat Aug 22 21:52:09 2026 -0400

    feat(ipc): Support custom_metadata on Message for IPC (#924)
    
    Closes #923.
    
    Adds encoder and decoder support for `Message.custom_metadata` — the
    per-message
    key/value metadata that is separate from the Schema/Field metadata
    carried in the schema.
    
    ### Encoder
    
    ```c
    ArrowErrorCode ArrowIpcEncoderSetMessageMetadata(struct ArrowIpcEncoder* 
encoder,
                                                     struct ArrowBuffer* 
metadata,
                                                     struct ArrowError* error);
    ```
    
    `metadata` holds nanoarrow's packed representation (the same as
    `ArrowSchema.metadata`, so
    `ArrowMetadataBuilder*` can produce it and no new representation is
    introduced). The
    encoder takes ownership by moving the buffer, so the common case doesn't
    copy. It applies
    to the *next* message encoded by `ArrowIpcEncoderEncodeSchema()` or
    `ArrowIpcEncoderEncodeSimpleRecordBatch()` and is cleared once that
    message is encoded.
    `NULL` (or metadata with no keys) clears it, and no `custom_metadata`
    field is written at
    all in that case, so the encoded bytes are unchanged for callers that
    don't use this.
    
    `ArrowIpcEncodeMetadata()` now takes the packed `const char* metadata`
    instead of a
    `const struct ArrowSchema*` so it is reusable from the Message path;
    behaviour at the
    Schema/Field call sites is unchanged.
    
    ### Decoder
    
    ```c
    // Copy, using the same packed representation
    ArrowErrorCode ArrowIpcDecoderGetMessageMetadata(struct ArrowIpcDecoder* 
decoder,
                                                     struct ArrowBuffer* out,
                                                     struct ArrowError* error);
    
    // Zero copy: both borrow from the message header until the next header is 
decoded
    ArrowErrorCode ArrowIpcDecoderGetMessageMetadataValue(struct 
ArrowIpcDecoder* decoder,
                                                          struct 
ArrowStringView key,
                                                          struct 
ArrowStringView* value_out,
                                                          struct ArrowError* 
error);
    
    ArrowErrorCode ArrowIpcDecoderVisitMessageMetadata(struct ArrowIpcDecoder* 
decoder,
                                                       
ArrowIpcMetadataVisitFunction visit,
                                                       void* private_data,
                                                       struct ArrowError* 
error);
    ```
    
    Per @paleolimbot's review comment on the issue, the copying accessor
    writes to a
    `struct ArrowBuffer*` (what `ArrowMetadataBuilder*` builds), and the
    get-by-name and
    visit-in-place variants avoid the copy for large metadata. All three are
    implemented on
    top of one internal KeyValue-vector visitor, which also backs the
    packing used for Schema
    and Field metadata. If the message has no
    `custom_metadata`, `out` is left empty so `(const char*)out->data` is
    `NULL` and can be
    passed straight to `ArrowSchemaSetMetadata()` /
    `ArrowMetadataReaderInit()`. A missing key
    leaves `value_out` unmodified, matching `ArrowMetadataGetValue()`.
    
    The metadata is recorded by both `ArrowIpcDecoderVerifyHeader()` and
    `ArrowIpcDecoderDecodeHeader()` and applies to any message type, since
    `Message.custom_metadata` is a field of `Message` rather than of
    `RecordBatch`.
    
    ### Tests
    
    - `encoder_test.cc`: round trip of message metadata through encoder →
    decoder for both
    RecordBatch and Schema messages, that Schema-message metadata stays
    distinct from the
    contained schema's own metadata, that the metadata applies to exactly
    one message, that
    empty/`NULL`/cleared metadata produces byte-identical output to not
    calling the setter,
      and that an erroring visitor stops the visit and propagates its error.
    - `decoder_test.cc`: interop with Arrow C++ in both directions —
    `arrow::ipc::ReadMessage()`
    reads back what nanoarrow writes, and nanoarrow reads the metadata
    written by
      `RecordBatchWriter::WriteRecordBatch(batch, custom_metadata)`.
    
    Verified locally against Arrow C++ 25.0.1
    (`-DNANOARROW_BUILD_TESTS_WITH_ARROW=ON`).
    
    ### Also fixed here: a crash decoding metadata with an absent key
    
    `KeyValue.key` and `KeyValue.value` are optional fields in `Schema.fbs`
    (the generated
    verifier agrees: `flatcc_verify_string_field(td, 0, 0)`), so a
    flatbuffer-verified message
    may contain a `KeyValue` with no key at all. The decoder used `strlen()`
    on it, so
    `ArrowIpcDecoderDecodeSchema()` segfaulted (SIGSEGV, confirmed on
    `main`) on input that had
    passed verification — reachable from any untrusted stream via
    `ArrowIpcArrayStreamReader`.
    
    Consolidating the iteration replaced `strlen()` with
    `flatbuffers_string_len()`, which is
    NULL-safe, so an absent key or value now decodes as an empty string. The
    same change stops
    metadata values containing embedded nulls from being silently truncated,
    which the packed
    representation round-trips fine. `NanoarrowIpcDecodeMetadataWithoutKey`
    covers it and
    crashes without the fix.
    
    Happy to split this into its own PR if you'd rather keep this one purely
    additive — it
    landed here because it's the same lines the consolidation rewrites.
    
    ### Out of scope
    
    `ArrowIpcArrayStreamReader` / `ArrowIpcWriter` are unchanged:
    `ArrowArrayStream` has
    nowhere to put per-batch metadata, so as discussed in the issue this
    first pass exposes the
    feature only at the encoder/decoder layer.
    
    ---------
    
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
 src/nanoarrow/ipc/decoder.c       | 172 ++++++++++++++++----
 src/nanoarrow/ipc/decoder_test.cc | 194 ++++++++++++++++++++++
 src/nanoarrow/ipc/encoder.c       |  78 ++++++++-
 src/nanoarrow/ipc/encoder_test.cc | 329 ++++++++++++++++++++++++++++++++++++++
 src/nanoarrow/nanoarrow_ipc.h     |  86 ++++++++++
 5 files changed, 824 insertions(+), 35 deletions(-)

diff --git a/src/nanoarrow/ipc/decoder.c b/src/nanoarrow/ipc/decoder.c
index c9687f0c..b13d0bd6 100644
--- a/src/nanoarrow/ipc/decoder.c
+++ b/src/nanoarrow/ipc/decoder.c
@@ -81,6 +81,10 @@ struct ArrowIpcDecoderPrivate {
   int64_t n_union_fields;
   // A pointer to the last flatbuffers message.
   const void* last_message;
+  // The custom_metadata of the last flatbuffers Message (i.e., 
Message.custom_metadata,
+  // which is distinct from the metadata of the Schema or Field it may 
contain). This is
+  // NULL if the last message had no custom_metadata.
+  ns(KeyValue_vec_t) last_message_metadata;
   // Storage for a DictionaryBatch
   struct ArrowIpcDictionaryBatch dictionary;
   // Storage for a Footer
@@ -640,50 +644,90 @@ static inline int32_t ArrowIpcReadInt32LE(struct 
ArrowBufferView* data, int swap
   return value;
 }
 
-static int ArrowIpcDecoderSetMetadata(struct ArrowSchema* schema,
-                                      ns(KeyValue_vec_t) kv_vec,
-                                      struct ArrowError* error) {
+// Returned by an internal ArrowIpcMetadataVisitFunction to stop iterating 
early.
+// This is never returned to a caller of the public API.
+#define _NANOARROW_IPC_VISIT_STOP (-1)
+
+// Visits each key/value pair in a flatbuffers vector of KeyValue.
+//
+// Keys and values point into the message and are passed with an explicit size 
because
+// both are optional fields whose content may contain embedded nulls; a 
KeyValue with no
+// key or no value is visited with an empty string view.
+static ArrowErrorCode ArrowIpcDecoderVisitMetadata(ns(KeyValue_vec_t) kv_vec,
+                                                   
ArrowIpcMetadataVisitFunction visit,
+                                                   void* private_data,
+                                                   struct ArrowError* error) {
   int64_t n_pairs = ns(KeyValue_vec_len(kv_vec));
-  if (n_pairs == 0) {
-    return NANOARROW_OK;
+
+  for (int64_t i = 0; i < n_pairs; i++) {
+    ns(KeyValue_table_t) kv = ns(KeyValue_vec_at(kv_vec, i));
+    flatbuffers_string_t key = ns(KeyValue_key(kv));
+    flatbuffers_string_t value = ns(KeyValue_value(kv));
+
+    struct ArrowStringView key_view = {key ? key : "",
+                                       (int64_t)flatbuffers_string_len(key)};
+    struct ArrowStringView value_view = {value ? value : "",
+                                         
(int64_t)flatbuffers_string_len(value)};
+    NANOARROW_RETURN_NOT_OK(visit(key_view, value_view, private_data, error));
+  }
+
+  return NANOARROW_OK;
+}
+
+static ArrowErrorCode ArrowIpcDecoderAppendMetadata(struct ArrowStringView key,
+                                                    struct ArrowStringView 
value,
+                                                    void* private_data,
+                                                    struct ArrowError* error) {
+  int result = ArrowMetadataBuilderAppend((struct ArrowBuffer*)private_data, 
key, value);
+  if (result != NANOARROW_OK) {
+    ArrowErrorSet(error, "ArrowMetadataBuilderAppend() failed");
+  }
+
+  return result;
+}
+
+// Packs a flatbuffers vector of KeyValue into nanoarrow's metadata 
representation.
+// out is initialized by this function and will be empty if kv_vec contains no 
pairs.
+static int ArrowIpcDecoderBuildMetadata(ns(KeyValue_vec_t) kv_vec,
+                                        struct ArrowBuffer* out,
+                                        struct ArrowError* error) {
+  int result = ArrowMetadataBuilderInit(out, NULL);
+  if (result != NANOARROW_OK) {
+    ArrowBufferReset(out);
+    ArrowErrorSet(error, "ArrowMetadataBuilderInit() failed");
+    return result;
   }
 
+  int64_t n_pairs = ns(KeyValue_vec_len(kv_vec));
   if (n_pairs > 2147483647) {
+    ArrowBufferReset(out);
     ArrowErrorSet(error,
                   "Expected between 0 and 2147483647 key/value pairs but found 
%" PRId64,
                   n_pairs);
     return EINVAL;
   }
 
-  struct ArrowBuffer buf;
-  struct ArrowStringView key;
-  struct ArrowStringView value;
-  ns(KeyValue_table_t) kv;
-
-  int result = ArrowMetadataBuilderInit(&buf, NULL);
+  result =
+      ArrowIpcDecoderVisitMetadata(kv_vec, &ArrowIpcDecoderAppendMetadata, 
out, error);
   if (result != NANOARROW_OK) {
-    ArrowBufferReset(&buf);
-    ArrowErrorSet(error, "ArrowMetadataBuilderInit() failed");
+    ArrowBufferReset(out);
     return result;
   }
 
-  for (int64_t i = 0; i < n_pairs; i++) {
-    kv = ns(KeyValue_vec_at(kv_vec, i));
-
-    key.data = ns(KeyValue_key(kv));
-    key.size_bytes = strlen(key.data);
-    value.data = ns(KeyValue_value(kv));
-    value.size_bytes = strlen(value.data);
+  return NANOARROW_OK;
+}
 
-    result = ArrowMetadataBuilderAppend(&buf, key, value);
-    if (result != NANOARROW_OK) {
-      ArrowBufferReset(&buf);
-      ArrowErrorSet(error, "ArrowMetadataBuilderAppend() failed");
-      return result;
-    }
+static int ArrowIpcDecoderSetMetadata(struct ArrowSchema* schema,
+                                      ns(KeyValue_vec_t) kv_vec,
+                                      struct ArrowError* error) {
+  if (ns(KeyValue_vec_len(kv_vec)) == 0) {
+    return NANOARROW_OK;
   }
 
-  result = ArrowSchemaSetMetadata(schema, (const char*)buf.data);
+  struct ArrowBuffer buf;
+  NANOARROW_RETURN_NOT_OK(ArrowIpcDecoderBuildMetadata(kv_vec, &buf, error));
+
+  int result = ArrowSchemaSetMetadata(schema, (const char*)buf.data);
   ArrowBufferReset(&buf);
   if (result != NANOARROW_OK) {
     ArrowErrorSet(error, "ArrowSchemaSetMetadata() failed");
@@ -1434,6 +1478,7 @@ static inline void ArrowIpcDecoderResetHeaderInfo(struct 
ArrowIpcDecoder* decode
   decoder->footer = NULL;
   ArrowIpcFooterReset(&private_data->footer);
   private_data->last_message = NULL;
+  private_data->last_message_metadata = NULL;
 }
 
 // Returns NANOARROW_OK if data is large enough to read the first 8 bytes
@@ -1536,6 +1581,7 @@ ArrowErrorCode ArrowIpcDecoderVerifyHeader(struct 
ArrowIpcDecoder* decoder,
   decoder->body_size_bytes = ns(Message_bodyLength(message));
 
   private_data->last_message = ns(Message_header_get(message));
+  private_data->last_message_metadata = ns(Message_custom_metadata(message));
   return NANOARROW_OK;
 }
 
@@ -1692,9 +1738,81 @@ ArrowErrorCode ArrowIpcDecoderDecodeHeader(struct 
ArrowIpcDecoder* decoder,
   }
 
   private_data->last_message = message_header;
+  private_data->last_message_metadata = ns(Message_custom_metadata(message));
   return NANOARROW_OK;
 }
 
+ArrowErrorCode ArrowIpcDecoderGetMessageMetadata(struct ArrowIpcDecoder* 
decoder,
+                                                 struct ArrowBuffer* out,
+                                                 struct ArrowError* error) {
+  NANOARROW_DCHECK(decoder != NULL && decoder->private_data != NULL && out != 
NULL);
+  struct ArrowIpcDecoderPrivate* private_data =
+      (struct ArrowIpcDecoderPrivate*)decoder->private_data;
+
+  return ArrowIpcDecoderBuildMetadata(private_data->last_message_metadata, 
out, error);
+}
+
+struct ArrowIpcMetadataValueLookup {
+  struct ArrowStringView key;
+  struct ArrowStringView* value_out;
+};
+
+static ArrowErrorCode ArrowIpcDecoderMatchMetadataKey(struct ArrowStringView 
key,
+                                                      struct ArrowStringView 
value,
+                                                      void* private_data,
+                                                      struct ArrowError* 
error) {
+  NANOARROW_UNUSED(error);
+  struct ArrowIpcMetadataValueLookup* lookup =
+      (struct ArrowIpcMetadataValueLookup*)private_data;
+
+  if (key.size_bytes != lookup->key.size_bytes) {
+    return NANOARROW_OK;
+  }
+
+  if (key.size_bytes > 0 &&
+      memcmp(key.data, lookup->key.data, (size_t)key.size_bytes) != 0) {
+    return NANOARROW_OK;
+  }
+
+  *lookup->value_out = value;
+  return _NANOARROW_IPC_VISIT_STOP;
+}
+
+ArrowErrorCode ArrowIpcDecoderGetMessageMetadataValue(struct ArrowIpcDecoder* 
decoder,
+                                                      struct ArrowStringView 
key,
+                                                      struct ArrowStringView* 
value_out,
+                                                      struct ArrowError* 
error) {
+  NANOARROW_DCHECK(decoder != NULL && decoder->private_data != NULL && 
value_out != NULL);
+  struct ArrowIpcDecoderPrivate* private_data =
+      (struct ArrowIpcDecoderPrivate*)decoder->private_data;
+
+  struct ArrowIpcMetadataValueLookup lookup;
+  lookup.key = key;
+  lookup.value_out = value_out;
+
+  int result =
+      ArrowIpcDecoderVisitMetadata(private_data->last_message_metadata,
+                                   &ArrowIpcDecoderMatchMetadataKey, &lookup, 
error);
+  if (result == _NANOARROW_IPC_VISIT_STOP) {
+    // key was found and value_out was set
+    return NANOARROW_OK;
+  }
+
+  return result;
+}
+
+ArrowErrorCode ArrowIpcDecoderVisitMessageMetadata(struct ArrowIpcDecoder* 
decoder,
+                                                   
ArrowIpcMetadataVisitFunction visit,
+                                                   void* private_data,
+                                                   struct ArrowError* error) {
+  NANOARROW_DCHECK(decoder != NULL && decoder->private_data != NULL && visit 
!= NULL);
+  struct ArrowIpcDecoderPrivate* decoder_private =
+      (struct ArrowIpcDecoderPrivate*)decoder->private_data;
+
+  return ArrowIpcDecoderVisitMetadata(decoder_private->last_message_metadata, 
visit,
+                                      private_data, error);
+}
+
 static ArrowErrorCode ArrowIpcDecoderDecodeSchemaImpl(
     ns(Schema_table_t) schema, struct ArrowSchema* out,
     struct ArrowIpcDictionaryEncodings* dictionary_encodings_out,
diff --git a/src/nanoarrow/ipc/decoder_test.cc 
b/src/nanoarrow/ipc/decoder_test.cc
index 900bc32f..6adf1e36 100644
--- a/src/nanoarrow/ipc/decoder_test.cc
+++ b/src/nanoarrow/ipc/decoder_test.cc
@@ -22,6 +22,7 @@
 #include <arrow/array.h>
 #include <arrow/c/bridge.h>
 #include <arrow/extension/uuid.h>
+#include <arrow/io/memory.h>
 #include <arrow/ipc/api.h>
 #include <arrow/util/key_value_metadata.h>
 #endif
@@ -1381,6 +1382,62 @@ void AssertArrayViewIdentical(const struct 
ArrowArrayView* actual,
   }
 }
 
+// A Message whose Message.custom_metadata and Schema.custom_metadata each 
contain a
+// KeyValue with no key. Both `key` and `value` are optional fields of 
KeyValue in the
+// IPC format, so this passes flatbuffer verification and must not crash the 
decoder.
+// Generated with flatcc: a Schema message with no fields, whose two metadata 
vectors
+// each contain KeyValue{value: "message_value" / "schema_value"} and no key.
+alignas(8) static uint8_t kKeylessMetadataSchema[] = {
+    0xff, 0xff, 0xff, 0xff, 0x90, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 
0x86, 0xff,
+    0xff, 0xff, 0x04, 0x00, 0x01, 0x00, 0x2c, 0x00, 0x00, 0x00, 0x04, 0x00, 
0x00, 0x00,
+    0x01, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0xb0, 0xff, 0xff, 0xff, 
0x04, 0x00,
+    0x00, 0x00, 0x0d, 0x00, 0x00, 0x00, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 
0x65, 0x5f,
+    0x76, 0x61, 0x6c, 0x75, 0x65, 0x00, 0x00, 0x00, 0xc4, 0xff, 0xff, 0xff, 
0x2c, 0x00,
+    0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04, 0x00, 
0x00, 0x00,
+    0xe0, 0xff, 0xff, 0xff, 0x04, 0x00, 0x00, 0x00, 0x0c, 0x00, 0x00, 0x00, 
0x73, 0x63,
+    0x68, 0x65, 0x6d, 0x61, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x00, 0x00, 
0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x08, 0x00, 0x00, 0x00, 0x04, 0x00, 
0x0a, 0x00,
+    0x0c, 0x00, 0x00, 0x00, 0x04, 0x00, 0x08, 0x00, 0x0e, 0x00, 0x10, 0x00, 
0x04, 0x00,
+    0x06, 0x00, 0x08, 0x00, 0x00, 0x00, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00};
+
+TEST(NanoarrowIpcTest, NanoarrowIpcDecodeMetadataWithoutKey) {
+  struct ArrowBufferView data;
+  data.data.as_uint8 = kKeylessMetadataSchema;
+  data.size_bytes = sizeof(kKeylessMetadataSchema);
+
+  struct ArrowError error;
+  nanoarrow::ipc::UniqueDecoder decoder;
+  ASSERT_EQ(ArrowIpcDecoderInit(decoder.get()), NANOARROW_OK);
+  ASSERT_EQ(ArrowIpcDecoderVerifyHeader(decoder.get(), data, &error), 
NANOARROW_OK)
+      << error.message;
+  ASSERT_EQ(ArrowIpcDecoderDecodeHeader(decoder.get(), data, &error), 
NANOARROW_OK)
+      << error.message;
+
+  // A missing key is decoded as an empty key
+  nanoarrow::UniqueBuffer message_metadata;
+  ASSERT_EQ(
+      ArrowIpcDecoderGetMessageMetadata(decoder.get(), message_metadata.get(), 
&error),
+      NANOARROW_OK)
+      << error.message;
+  EXPECT_EQ(
+      ArrowSchemaMetadataToString(reinterpret_cast<const 
char*>(message_metadata->data)),
+      "=message_value");
+
+  struct ArrowStringView value = ArrowCharView(nullptr);
+  ASSERT_EQ(ArrowIpcDecoderGetMessageMetadataValue(decoder.get(), 
ArrowCharView(""),
+                                                   &value, &error),
+            NANOARROW_OK)
+      << error.message;
+  EXPECT_EQ(std::string(value.data, value.size_bytes), "message_value");
+
+  // ...including the metadata of the schema the message contains
+  nanoarrow::UniqueSchema schema;
+  ASSERT_EQ(ArrowIpcDecoderDecodeSchema(decoder.get(), schema.get(), &error),
+            NANOARROW_OK)
+      << error.message;
+  EXPECT_EQ(ArrowSchemaMetadataToString(schema->metadata), "=schema_value");
+}
+
 #if defined(NANOARROW_BUILD_TESTS_WITH_ARROW)
 TEST_P(ArrowTypeParameterizedTestFixture, NanoarrowIpcNanoarrowArrayRoundtrip) 
{
   if (GetParam()->id() == arrow::Type::DICTIONARY) {
@@ -1706,6 +1763,143 @@ INSTANTIATE_TEST_SUITE_P(
         //     arrow::KeyValueMetadata::Make({"key1", "key2"}, {"value1", 
"value2"}))})
         ));
 
+// Advance data past the message whose header was just decoded
+static void AdvancePastMessage(struct ArrowIpcDecoder* decoder,
+                               struct ArrowBufferView* data) {
+  int64_t message_size_bytes = 
_ArrowRoundUpToMultipleOf8(decoder->header_size_bytes) +
+                               
_ArrowRoundUpToMultipleOf8(decoder->body_size_bytes);
+  ASSERT_LE(message_size_bytes, data->size_bytes);
+  data->data.as_uint8 += message_size_bytes;
+  data->size_bytes -= message_size_bytes;
+}
+
+TEST(NanoarrowIpcTest, NanoarrowIpcMessageMetadataArrowInterop) {
+  auto arrow_schema = arrow::schema({arrow::field("some_name", 
arrow::int32())});
+  auto custom_metadata =
+      arrow::KeyValueMetadata::Make({"key1", "key2"}, {"value1", "value2"});
+
+  auto maybe_batch = arrow::RecordBatch::MakeEmpty(arrow_schema);
+  ASSERT_TRUE(maybe_batch.ok()) << maybe_batch.status();
+  auto batch = maybe_batch.ValueUnsafe();
+
+  struct ArrowError error;
+  nanoarrow::UniqueSchema schema;
+  nanoarrow::UniqueArray array;
+  nanoarrow::UniqueArrayView array_view;
+  ASSERT_TRUE(arrow::ExportSchema(*arrow_schema, schema.get()).ok());
+  ASSERT_TRUE(arrow::ExportRecordBatch(*batch, array.get()).ok());
+  ASSERT_EQ(ArrowArrayViewInitFromSchema(array_view.get(), schema.get(), 
&error),
+            NANOARROW_OK)
+      << error.message;
+  ASSERT_EQ(ArrowArrayViewSetArray(array_view.get(), array.get(), &error), 
NANOARROW_OK)
+      << error.message;
+
+  // What nanoarrow writes, Arrow C++ can read
+  nanoarrow::UniqueBuffer metadata;
+  ASSERT_EQ(ArrowMetadataBuilderInit(metadata.get(), nullptr), NANOARROW_OK);
+  for (int64_t i = 0; i < custom_metadata->size(); i++) {
+    ASSERT_EQ(ArrowMetadataBuilderAppend(
+                  metadata.get(), 
ArrowCharView(custom_metadata->key(i).c_str()),
+                  ArrowCharView(custom_metadata->value(i).c_str())),
+              NANOARROW_OK);
+  }
+
+  nanoarrow::ipc::UniqueEncoder encoder;
+  ASSERT_EQ(ArrowIpcEncoderInit(encoder.get()), NANOARROW_OK);
+  ASSERT_EQ(ArrowIpcEncoderSetMessageMetadata(encoder.get(), metadata.get(), 
&error),
+            NANOARROW_OK)
+      << error.message;
+
+  nanoarrow::UniqueBuffer nanoarrow_message;
+  ASSERT_EQ(ArrowIpcEncoderEncodeSimpleRecordBatch(encoder.get(), 
array_view.get(),
+                                                   nanoarrow_message.get(), 
&error),
+            NANOARROW_OK)
+      << error.message;
+  {
+    // Prepend the encapsulated header to the body written above
+    nanoarrow::UniqueBuffer body;
+    ArrowBufferMove(nanoarrow_message.get(), body.get());
+    ASSERT_EQ(ArrowIpcEncoderFinalizeBuffer(encoder.get(), 
/*encapsulate=*/true,
+                                            nanoarrow_message.get()),
+              NANOARROW_OK);
+    ASSERT_EQ(
+        ArrowBufferAppendBufferView(nanoarrow_message.get(),
+                                    ArrowBufferView{{body->data}, 
body->size_bytes}),
+        NANOARROW_OK);
+  }
+
+  arrow::io::BufferReader message_reader(
+      arrow::Buffer::Wrap(nanoarrow_message->data, 
nanoarrow_message->size_bytes));
+  auto maybe_message = arrow::ipc::ReadMessage(&message_reader);
+  ASSERT_TRUE(maybe_message.ok()) << maybe_message.status();
+  ASSERT_NE(maybe_message.ValueUnsafe()->custom_metadata(), nullptr);
+  
EXPECT_TRUE(maybe_message.ValueUnsafe()->custom_metadata()->Equals(*custom_metadata));
+
+  // What Arrow C++ writes, nanoarrow can read
+  auto maybe_out = arrow::io::BufferOutputStream::Create();
+  ASSERT_TRUE(maybe_out.ok()) << maybe_out.status();
+  auto out = maybe_out.ValueUnsafe();
+
+  auto maybe_writer = arrow::ipc::MakeStreamWriter(out, arrow_schema);
+  ASSERT_TRUE(maybe_writer.ok()) << maybe_writer.status();
+  auto writer = maybe_writer.ValueUnsafe();
+  ASSERT_TRUE(writer->WriteRecordBatch(*batch, custom_metadata).ok());
+  ASSERT_TRUE(writer->Close().ok());
+
+  auto maybe_stream = out->Finish();
+  ASSERT_TRUE(maybe_stream.ok()) << maybe_stream.status();
+  auto stream = maybe_stream.ValueUnsafe();
+
+  struct ArrowBufferView data;
+  data.data.data = stream->data();
+  data.size_bytes = stream->size();
+
+  nanoarrow::ipc::UniqueDecoder decoder;
+  ASSERT_EQ(ArrowIpcDecoderInit(decoder.get()), NANOARROW_OK);
+
+  // The Schema message has no metadata of its own
+  ASSERT_EQ(ArrowIpcDecoderVerifyHeader(decoder.get(), data, &error), 
NANOARROW_OK)
+      << error.message;
+  ASSERT_EQ(ArrowIpcDecoderDecodeHeader(decoder.get(), data, &error), 
NANOARROW_OK)
+      << error.message;
+  ASSERT_EQ(decoder->message_type, NANOARROW_IPC_MESSAGE_TYPE_SCHEMA);
+  nanoarrow::UniqueBuffer schema_message_metadata;
+  ASSERT_EQ(ArrowIpcDecoderGetMessageMetadata(decoder.get(),
+                                              schema_message_metadata.get(), 
&error),
+            NANOARROW_OK)
+      << error.message;
+  EXPECT_EQ(schema_message_metadata->size_bytes, 0);
+  ASSERT_EQ(ArrowIpcDecoderSetSchema(decoder.get(), schema.get(), &error), 
NANOARROW_OK)
+      << error.message;
+
+  // ...but the RecordBatch message does
+  ASSERT_NO_FATAL_FAILURE(AdvancePastMessage(decoder.get(), &data));
+  ASSERT_EQ(ArrowIpcDecoderVerifyHeader(decoder.get(), data, &error), 
NANOARROW_OK)
+      << error.message;
+  ASSERT_EQ(ArrowIpcDecoderDecodeHeader(decoder.get(), data, &error), 
NANOARROW_OK)
+      << error.message;
+  ASSERT_EQ(decoder->message_type, NANOARROW_IPC_MESSAGE_TYPE_RECORD_BATCH);
+
+  nanoarrow::UniqueBuffer batch_message_metadata;
+  ASSERT_EQ(ArrowIpcDecoderGetMessageMetadata(decoder.get(), 
batch_message_metadata.get(),
+                                              &error),
+            NANOARROW_OK)
+      << error.message;
+  EXPECT_EQ(ArrowSchemaMetadataToString(
+                reinterpret_cast<const char*>(batch_message_metadata->data)),
+            "key1=value1, key2=value2");
+
+  for (int64_t i = 0; i < custom_metadata->size(); i++) {
+    struct ArrowStringView value = ArrowCharView(nullptr);
+    ASSERT_EQ(ArrowIpcDecoderGetMessageMetadataValue(
+                  decoder.get(), 
ArrowCharView(custom_metadata->key(i).c_str()), &value,
+                  &error),
+              NANOARROW_OK)
+        << error.message;
+    EXPECT_EQ(std::string(value.data, value.size_bytes), 
custom_metadata->value(i));
+  }
+}
+
 class ArrowTypeIdParameterizedTestFixture
     : public ::testing::TestWithParam<enum ArrowType> {
  protected:
diff --git a/src/nanoarrow/ipc/encoder.c b/src/nanoarrow/ipc/encoder.c
index d959bfa9..2c12a1b5 100644
--- a/src/nanoarrow/ipc/encoder.c
+++ b/src/nanoarrow/ipc/encoder.c
@@ -46,6 +46,9 @@ struct ArrowIpcEncoderPrivate {
   struct ArrowBuffer nodes;
   int encoding_footer;
   struct ArrowIpcDictionaryEncodings dictionary_encodings;
+  // Metadata to attach to the next encoded Message (in nanoarrow's packed
+  // representation), or an empty buffer if the next Message has no metadata.
+  struct ArrowBuffer message_metadata;
 };
 
 ArrowErrorCode ArrowIpcEncoderInit(struct ArrowIpcEncoder* encoder) {
@@ -65,6 +68,7 @@ ArrowErrorCode ArrowIpcEncoderInit(struct ArrowIpcEncoder* 
encoder) {
   ArrowBufferInit(&private->buffers);
   ArrowBufferInit(&private->nodes);
   ArrowIpcDictionaryEncodingsInit(&private->dictionary_encodings);
+  ArrowBufferInit(&private->message_metadata);
   return NANOARROW_OK;
 }
 
@@ -77,11 +81,44 @@ void ArrowIpcEncoderReset(struct ArrowIpcEncoder* encoder) {
     ArrowBufferReset(&private->nodes);
     ArrowBufferReset(&private->buffers);
     ArrowIpcDictionaryEncodingsReset(&private->dictionary_encodings);
+    ArrowBufferReset(&private->message_metadata);
     ArrowFree(private);
   }
   memset(encoder, 0, sizeof(struct ArrowIpcEncoder));
 }
 
+ArrowErrorCode ArrowIpcEncoderSetMessageMetadata(struct ArrowIpcEncoder* 
encoder,
+                                                 struct ArrowBuffer* metadata,
+                                                 struct ArrowError* error) {
+  NANOARROW_DCHECK(encoder != NULL && encoder->private_data != NULL);
+  struct ArrowIpcEncoderPrivate* private =
+      (struct ArrowIpcEncoderPrivate*)encoder->private_data;
+
+  // Any previously set metadata that was not yet encoded is discarded
+  ArrowBufferReset(&private->message_metadata);
+
+  if (metadata != NULL) {
+    ArrowBufferMove(metadata, &private->message_metadata);
+  }
+
+  // Metadata that can't contain a key count is empty; metadata with no keys is
+  // equivalent to no metadata at all. In both cases no custom_metadata is 
encoded.
+  if (private->message_metadata.size_bytes < (int64_t)sizeof(int32_t)) {
+    ArrowBufferReset(&private->message_metadata);
+    return NANOARROW_OK;
+  }
+
+  struct ArrowMetadataReader reader;
+  NANOARROW_RETURN_NOT_OK_WITH_ERROR(
+      ArrowMetadataReaderInit(&reader, (const 
char*)private->message_metadata.data),
+      error);
+  if (reader.remaining_keys <= 0) {
+    ArrowBufferReset(&private->message_metadata);
+  }
+
+  return NANOARROW_OK;
+}
+
 static ArrowErrorCode ArrowIpcEncoderWriteContinuationAndSize(struct 
ArrowBuffer* out,
                                                               size_t size) {
   _NANOARROW_CHECK_UPPER_LIMIT(size, INT32_MAX);
@@ -346,13 +383,13 @@ static ArrowErrorCode ArrowIpcEncodeField(
     struct ArrowError* error);
 
 static ArrowErrorCode ArrowIpcEncodeMetadata(flatcc_builder_t* builder,
-                                             const struct ArrowSchema* schema,
+                                             const char* packed_metadata,
                                              int 
(*push_start)(flatcc_builder_t*),
                                              ns(KeyValue_ref_t) *
                                                  
(*push_end)(flatcc_builder_t*),
                                              struct ArrowError* error) {
   struct ArrowMetadataReader metadata;
-  NANOARROW_RETURN_NOT_OK_WITH_ERROR(ArrowMetadataReaderInit(&metadata, 
schema->metadata),
+  NANOARROW_RETURN_NOT_OK_WITH_ERROR(ArrowMetadataReaderInit(&metadata, 
packed_metadata),
                                      error);
   while (metadata.remaining_keys > 0) {
     struct ArrowStringView key, value;
@@ -368,6 +405,27 @@ static ArrowErrorCode 
ArrowIpcEncodeMetadata(flatcc_builder_t* builder,
   return NANOARROW_OK;
 }
 
+// Encodes any metadata set by ArrowIpcEncoderSetMessageMetadata() into the 
Message
+// currently under construction and clears it, such that it applies to exactly 
one
+// Message.
+static ArrowErrorCode ArrowIpcEncodeMessageMetadata(
+    struct ArrowIpcEncoderPrivate* private, struct ArrowError* error) {
+  if (private->message_metadata.size_bytes == 0) {
+    return NANOARROW_OK;
+  }
+
+  flatcc_builder_t* builder = &private->builder;
+  FLATCC_RETURN_UNLESS_0(Message_custom_metadata_start(builder), error);
+  NANOARROW_RETURN_NOT_OK(
+      ArrowIpcEncodeMetadata(builder, (const 
char*)private->message_metadata.data,
+                             &ns(Message_custom_metadata_push_start),
+                             &ns(Message_custom_metadata_push_end), error));
+  FLATCC_RETURN_UNLESS_0(Message_custom_metadata_end(builder), error);
+
+  ArrowBufferReset(&private->message_metadata);
+  return NANOARROW_OK;
+}
+
 static ArrowErrorCode ArrowIpcEncodeFields(
     flatcc_builder_t* builder, const struct ArrowSchema* schema,
     int (*push_start)(flatcc_builder_t*),
@@ -476,9 +534,9 @@ static ArrowErrorCode ArrowIpcEncodeField(
 
   if (schema->metadata) {
     FLATCC_RETURN_UNLESS_0(Field_custom_metadata_start(builder), error);
-    NANOARROW_RETURN_NOT_OK(
-        ArrowIpcEncodeMetadata(builder, schema, 
&ns(Field_custom_metadata_push_start),
-                               &ns(Field_custom_metadata_push_end), error));
+    NANOARROW_RETURN_NOT_OK(ArrowIpcEncodeMetadata(
+        builder, schema->metadata, &ns(Field_custom_metadata_push_start),
+        &ns(Field_custom_metadata_push_end), error));
     FLATCC_RETURN_UNLESS_0(Field_custom_metadata_end(builder), error);
   }
   return NANOARROW_OK;
@@ -512,9 +570,9 @@ static ArrowErrorCode ArrowIpcEncodeSchema(
 
   FLATCC_RETURN_UNLESS_0(Schema_custom_metadata_start(builder), error);
   if (schema->metadata) {
-    NANOARROW_RETURN_NOT_OK(
-        ArrowIpcEncodeMetadata(builder, schema, 
&ns(Schema_custom_metadata_push_start),
-                               &ns(Schema_custom_metadata_push_end), error));
+    NANOARROW_RETURN_NOT_OK(ArrowIpcEncodeMetadata(
+        builder, schema->metadata, &ns(Schema_custom_metadata_push_start),
+        &ns(Schema_custom_metadata_push_end), error));
   }
   FLATCC_RETURN_UNLESS_0(Schema_custom_metadata_end(builder), error);
 
@@ -554,6 +612,8 @@ ArrowErrorCode ArrowIpcEncoderEncodeSchema(struct 
ArrowIpcEncoder* encoder,
 
   FLATCC_RETURN_UNLESS_0(Message_header_Schema_end(builder), error);
 
+  NANOARROW_RETURN_NOT_OK(ArrowIpcEncodeMessageMetadata(private, error));
+
   FLATCC_RETURN_UNLESS_0(Message_bodyLength_add(builder, 0), error);
 
   FLATCC_RETURN_IF_NULL(ns(Message_end_as_root(builder)), error);
@@ -700,6 +760,8 @@ static ArrowErrorCode ArrowIpcEncoderEncodeRecordBatch(
 
   FLATCC_RETURN_UNLESS_0(Message_header_RecordBatch_end(builder), error);
 
+  NANOARROW_RETURN_NOT_OK(ArrowIpcEncodeMessageMetadata(private, error));
+
   FLATCC_RETURN_UNLESS_0(Message_bodyLength_add(builder, 
buffer_encoder->body_length),
                          error);
   FLATCC_RETURN_IF_NULL(ns(Message_end_as_root(builder)), error);
diff --git a/src/nanoarrow/ipc/encoder_test.cc 
b/src/nanoarrow/ipc/encoder_test.cc
index 2e4bcca9..3eeff1ca 100644
--- a/src/nanoarrow/ipc/encoder_test.cc
+++ b/src/nanoarrow/ipc/encoder_test.cc
@@ -17,6 +17,10 @@
 
 #include <gtest/gtest.h>
 
+#include <string>
+#include <utility>
+#include <vector>
+
 #include "flatcc/flatcc_builder.h"
 #include "nanoarrow/nanoarrow.hpp"
 #include "nanoarrow/nanoarrow_ipc.hpp"
@@ -101,3 +105,328 @@ TEST(NanoarrowIpcTest, NanoarrowIpcFooterEncoding) {
 
   EXPECT_GT(footer_buffer->size_bytes, raw_schema_buffer->size_bytes);
 }
+
+using KeyValues = std::vector<std::pair<std::string, std::string>>;
+
+// Unpack nanoarrow's metadata representation into something comparable
+static KeyValues UnpackMetadata(const char* metadata) {
+  struct ArrowMetadataReader reader;
+  NANOARROW_THROW_NOT_OK(ArrowMetadataReaderInit(&reader, metadata));
+
+  KeyValues out;
+  while (reader.remaining_keys > 0) {
+    struct ArrowStringView key, value;
+    NANOARROW_THROW_NOT_OK(ArrowMetadataReaderRead(&reader, &key, &value));
+    out.emplace_back(std::string(key.data, key.size_bytes),
+                     std::string(value.data, value.size_bytes));
+  }
+  return out;
+}
+
+static nanoarrow::UniqueBuffer PackMetadata(const KeyValues& key_values) {
+  nanoarrow::UniqueBuffer metadata;
+  NANOARROW_THROW_NOT_OK(ArrowMetadataBuilderInit(metadata.get(), nullptr));
+  for (const auto& kv : key_values) {
+    NANOARROW_THROW_NOT_OK(ArrowMetadataBuilderAppend(metadata.get(),
+                                                      
ArrowCharView(kv.first.c_str()),
+                                                      
ArrowCharView(kv.second.c_str())));
+  }
+  return metadata;
+}
+
+static ArrowErrorCode CollectKeyValue(struct ArrowStringView key,
+                                      struct ArrowStringView value, void* 
private_data,
+                                      struct ArrowError* error) {
+  NANOARROW_UNUSED(error);
+  static_cast<KeyValues*>(private_data)
+      ->emplace_back(std::string(key.data, key.size_bytes),
+                     std::string(value.data, value.size_bytes));
+  return NANOARROW_OK;
+}
+
+// Decodes the header of an encapsulated message and returns its 
Message.custom_metadata
+static KeyValues DecodeMessageMetadata(struct ArrowBuffer* message,
+                                       struct ArrowIpcDecoder* decoder) {
+  struct ArrowBufferView view;
+  view.data.data = message->data;
+  view.size_bytes = message->size_bytes;
+
+  struct ArrowError error;
+  NANOARROW_THROW_NOT_OK(ArrowIpcDecoderVerifyHeader(decoder, view, &error));
+  NANOARROW_THROW_NOT_OK(ArrowIpcDecoderDecodeHeader(decoder, view, &error));
+
+  nanoarrow::UniqueBuffer metadata;
+  NANOARROW_THROW_NOT_OK(
+      ArrowIpcDecoderGetMessageMetadata(decoder, metadata.get(), &error));
+  KeyValues out = UnpackMetadata(reinterpret_cast<const 
char*>(metadata->data));
+
+  // The visitor should see exactly the same pairs, in the same order
+  KeyValues visited;
+  NANOARROW_THROW_NOT_OK(
+      ArrowIpcDecoderVisitMessageMetadata(decoder, &CollectKeyValue, &visited, 
&error));
+  EXPECT_EQ(visited, out);
+
+  return out;
+}
+
+// A struct array view with no columns and no rows: the smallest valid 
RecordBatch
+class SimpleRecordBatch {
+ public:
+  SimpleRecordBatch() {
+    NANOARROW_THROW_NOT_OK(ArrowSchemaInitFromType(schema_.get(), 
NANOARROW_TYPE_STRUCT));
+    NANOARROW_THROW_NOT_OK(
+        ArrowArrayInitFromSchema(array_.get(), schema_.get(), nullptr));
+    NANOARROW_THROW_NOT_OK(
+        ArrowArrayViewInitFromSchema(array_view_.get(), schema_.get(), 
nullptr));
+    NANOARROW_THROW_NOT_OK(
+        ArrowArrayViewSetArray(array_view_.get(), array_.get(), nullptr));
+  }
+
+  struct ArrowSchema* schema() { return schema_.get(); }
+  const struct ArrowArrayView* array_view() { return array_view_.get(); }
+
+ private:
+  nanoarrow::UniqueSchema schema_;
+  nanoarrow::UniqueArray array_;
+  nanoarrow::UniqueArrayView array_view_;
+};
+
+TEST(NanoarrowIpcTest, NanoarrowIpcEncoderMessageMetadataRoundtrip) {
+  nanoarrow::ipc::UniqueEncoder encoder;
+  ASSERT_EQ(ArrowIpcEncoderInit(encoder.get()), NANOARROW_OK);
+  nanoarrow::ipc::UniqueDecoder decoder;
+  ASSERT_EQ(ArrowIpcDecoderInit(decoder.get()), NANOARROW_OK);
+
+  SimpleRecordBatch batch;
+  struct ArrowError error;
+  ASSERT_EQ(ArrowIpcDecoderSetSchema(decoder.get(), batch.schema(), &error), 
NANOARROW_OK)
+      << error.message;
+
+  KeyValues key_values{{"message_type", "data"}, {"cache-control", 
"no-store"}};
+  auto metadata = PackMetadata(key_values);
+  ASSERT_EQ(ArrowIpcEncoderSetMessageMetadata(encoder.get(), metadata.get(), 
&error),
+            NANOARROW_OK)
+      << error.message;
+
+  // The encoder took ownership of the metadata
+  EXPECT_EQ(metadata->data, nullptr);
+  EXPECT_EQ(metadata->size_bytes, 0);
+
+  nanoarrow::UniqueBuffer message, body;
+  ASSERT_EQ(ArrowIpcEncoderEncodeSimpleRecordBatch(encoder.get(), 
batch.array_view(),
+                                                   body.get(), &error),
+            NANOARROW_OK)
+      << error.message;
+  ASSERT_EQ(
+      ArrowIpcEncoderFinalizeBuffer(encoder.get(), /*encapsulate=*/true, 
message.get()),
+      NANOARROW_OK);
+
+  EXPECT_EQ(DecodeMessageMetadata(message.get(), decoder.get()), key_values);
+
+  // Values can also be read in place, without copying
+  struct ArrowStringView value = ArrowCharView(nullptr);
+  ASSERT_EQ(ArrowIpcDecoderGetMessageMetadataValue(
+                decoder.get(), ArrowCharView("cache-control"), &value, &error),
+            NANOARROW_OK)
+      << error.message;
+  EXPECT_EQ(std::string(value.data, value.size_bytes), "no-store");
+
+  // A key that isn't present leaves value_out untouched
+  value = ArrowCharView(nullptr);
+  ASSERT_EQ(ArrowIpcDecoderGetMessageMetadataValue(
+                decoder.get(), ArrowCharView("not-a-key"), &value, &error),
+            NANOARROW_OK)
+      << error.message;
+  EXPECT_EQ(value.data, nullptr);
+
+  // A key which is a prefix of a present key is not a match
+  value = ArrowCharView(nullptr);
+  ASSERT_EQ(ArrowIpcDecoderGetMessageMetadataValue(decoder.get(), 
ArrowCharView("cache"),
+                                                   &value, &error),
+            NANOARROW_OK)
+      << error.message;
+  EXPECT_EQ(value.data, nullptr);
+
+  // The metadata applied to exactly one message: the next one has none
+  message->size_bytes = 0;
+  body->size_bytes = 0;
+  ASSERT_EQ(ArrowIpcEncoderEncodeSimpleRecordBatch(encoder.get(), 
batch.array_view(),
+                                                   body.get(), &error),
+            NANOARROW_OK)
+      << error.message;
+  ASSERT_EQ(
+      ArrowIpcEncoderFinalizeBuffer(encoder.get(), /*encapsulate=*/true, 
message.get()),
+      NANOARROW_OK);
+  EXPECT_EQ(DecodeMessageMetadata(message.get(), decoder.get()), KeyValues{});
+}
+
+TEST(NanoarrowIpcTest, NanoarrowIpcEncoderSchemaMessageMetadata) {
+  nanoarrow::ipc::UniqueEncoder encoder;
+  ASSERT_EQ(ArrowIpcEncoderInit(encoder.get()), NANOARROW_OK);
+  nanoarrow::ipc::UniqueDecoder decoder;
+  ASSERT_EQ(ArrowIpcDecoderInit(decoder.get()), NANOARROW_OK);
+
+  SimpleRecordBatch batch;
+  struct ArrowError error;
+
+  // Message metadata is distinct from the metadata of the Schema it contains
+  auto schema_metadata = PackMetadata({{"schema_key", "schema_value"}});
+  ASSERT_EQ(ArrowSchemaSetMetadata(batch.schema(),
+                                   reinterpret_cast<const 
char*>(schema_metadata->data)),
+            NANOARROW_OK);
+
+  KeyValues message_key_values{{"message_key", "message_value"}};
+  auto message_metadata = PackMetadata(message_key_values);
+  ASSERT_EQ(
+      ArrowIpcEncoderSetMessageMetadata(encoder.get(), message_metadata.get(), 
&error),
+      NANOARROW_OK)
+      << error.message;
+
+  nanoarrow::UniqueBuffer message;
+  ASSERT_EQ(ArrowIpcEncoderEncodeSchema(encoder.get(), batch.schema(), &error),
+            NANOARROW_OK)
+      << error.message;
+  ASSERT_EQ(
+      ArrowIpcEncoderFinalizeBuffer(encoder.get(), /*encapsulate=*/true, 
message.get()),
+      NANOARROW_OK);
+
+  EXPECT_EQ(DecodeMessageMetadata(message.get(), decoder.get()), 
message_key_values);
+
+  nanoarrow::UniqueSchema roundtripped;
+  ASSERT_EQ(ArrowIpcDecoderDecodeSchema(decoder.get(), roundtripped.get(), 
&error),
+            NANOARROW_OK)
+      << error.message;
+  EXPECT_EQ(UnpackMetadata(roundtripped->metadata),
+            (KeyValues{{"schema_key", "schema_value"}}));
+}
+
+TEST(NanoarrowIpcTest, NanoarrowIpcEncoderMessageMetadataEmpty) {
+  nanoarrow::ipc::UniqueEncoder encoder;
+  ASSERT_EQ(ArrowIpcEncoderInit(encoder.get()), NANOARROW_OK);
+  nanoarrow::ipc::UniqueDecoder decoder;
+  ASSERT_EQ(ArrowIpcDecoderInit(decoder.get()), NANOARROW_OK);
+
+  SimpleRecordBatch batch;
+  struct ArrowError error;
+  ASSERT_EQ(ArrowIpcDecoderSetSchema(decoder.get(), batch.schema(), &error), 
NANOARROW_OK)
+      << error.message;
+
+  // Metadata with no keys, NULL metadata, and no call at all are all 
equivalent
+  auto empty_metadata = PackMetadata({});
+  auto keyless_metadata = PackMetadata({{"key", "value"}});
+  ASSERT_EQ(ArrowMetadataBuilderRemove(keyless_metadata.get(), 
ArrowCharView("key")),
+            NANOARROW_OK);
+
+  nanoarrow::UniqueBuffer baseline, body;
+  ASSERT_EQ(ArrowIpcEncoderEncodeSimpleRecordBatch(encoder.get(), 
batch.array_view(),
+                                                   body.get(), &error),
+            NANOARROW_OK)
+      << error.message;
+  ASSERT_EQ(
+      ArrowIpcEncoderFinalizeBuffer(encoder.get(), /*encapsulate=*/true, 
baseline.get()),
+      NANOARROW_OK);
+
+  for (struct ArrowBuffer* metadata :
+       {empty_metadata.get(), keyless_metadata.get(), (struct 
ArrowBuffer*)nullptr}) {
+    ASSERT_EQ(ArrowIpcEncoderSetMessageMetadata(encoder.get(), metadata, 
&error),
+              NANOARROW_OK)
+        << error.message;
+
+    nanoarrow::UniqueBuffer message;
+    body->size_bytes = 0;
+    ASSERT_EQ(ArrowIpcEncoderEncodeSimpleRecordBatch(encoder.get(), 
batch.array_view(),
+                                                     body.get(), &error),
+              NANOARROW_OK)
+        << error.message;
+    ASSERT_EQ(
+        ArrowIpcEncoderFinalizeBuffer(encoder.get(), /*encapsulate=*/true, 
message.get()),
+        NANOARROW_OK);
+
+    // No custom_metadata field is written at all
+    ASSERT_EQ(message->size_bytes, baseline->size_bytes);
+    EXPECT_EQ(memcmp(message->data, baseline->data, message->size_bytes), 0);
+
+    EXPECT_EQ(DecodeMessageMetadata(message.get(), decoder.get()), 
KeyValues{});
+  }
+
+  // Setting metadata and then clearing it encodes nothing
+  auto metadata = PackMetadata({{"key", "value"}});
+  ASSERT_EQ(ArrowIpcEncoderSetMessageMetadata(encoder.get(), metadata.get(), 
&error),
+            NANOARROW_OK)
+      << error.message;
+  ASSERT_EQ(ArrowIpcEncoderSetMessageMetadata(encoder.get(), nullptr, &error),
+            NANOARROW_OK)
+      << error.message;
+
+  nanoarrow::UniqueBuffer message;
+  body->size_bytes = 0;
+  ASSERT_EQ(ArrowIpcEncoderEncodeSimpleRecordBatch(encoder.get(), 
batch.array_view(),
+                                                   body.get(), &error),
+            NANOARROW_OK)
+      << error.message;
+  ASSERT_EQ(
+      ArrowIpcEncoderFinalizeBuffer(encoder.get(), /*encapsulate=*/true, 
message.get()),
+      NANOARROW_OK);
+  ASSERT_EQ(message->size_bytes, baseline->size_bytes);
+  EXPECT_EQ(memcmp(message->data, baseline->data, message->size_bytes), 0);
+
+  // Reading in place from a message without metadata finds nothing
+  struct ArrowStringView value = ArrowCharView(nullptr);
+  ASSERT_EQ(ArrowIpcDecoderGetMessageMetadataValue(decoder.get(), 
ArrowCharView("key"),
+                                                   &value, &error),
+            NANOARROW_OK)
+      << error.message;
+  EXPECT_EQ(value.data, nullptr);
+}
+
+TEST(NanoarrowIpcTest, NanoarrowIpcVisitMessageMetadataError) {
+  nanoarrow::ipc::UniqueEncoder encoder;
+  ASSERT_EQ(ArrowIpcEncoderInit(encoder.get()), NANOARROW_OK);
+  nanoarrow::ipc::UniqueDecoder decoder;
+  ASSERT_EQ(ArrowIpcDecoderInit(decoder.get()), NANOARROW_OK);
+
+  SimpleRecordBatch batch;
+  struct ArrowError error;
+  ASSERT_EQ(ArrowIpcDecoderSetSchema(decoder.get(), batch.schema(), &error), 
NANOARROW_OK)
+      << error.message;
+
+  auto metadata = PackMetadata({{"key1", "value1"}, {"key2", "value2"}});
+  ASSERT_EQ(ArrowIpcEncoderSetMessageMetadata(encoder.get(), metadata.get(), 
&error),
+            NANOARROW_OK)
+      << error.message;
+
+  nanoarrow::UniqueBuffer message, body;
+  ASSERT_EQ(ArrowIpcEncoderEncodeSimpleRecordBatch(encoder.get(), 
batch.array_view(),
+                                                   body.get(), &error),
+            NANOARROW_OK)
+      << error.message;
+  ASSERT_EQ(
+      ArrowIpcEncoderFinalizeBuffer(encoder.get(), /*encapsulate=*/true, 
message.get()),
+      NANOARROW_OK);
+
+  struct ArrowBufferView view;
+  view.data.data = message->data;
+  view.size_bytes = message->size_bytes;
+  ASSERT_EQ(ArrowIpcDecoderDecodeHeader(decoder.get(), view, &error), 
NANOARROW_OK)
+      << error.message;
+
+  // A visitor which errors stops the visit and its error code is returned
+  struct Visitor {
+    static ArrowErrorCode Visit(struct ArrowStringView key, struct 
ArrowStringView value,
+                                void* private_data, struct ArrowError* error) {
+      NANOARROW_UNUSED(value);
+      auto* visited = static_cast<KeyValues*>(private_data);
+      ArrowErrorSet(error, "visitor stopped at %.*s", 
static_cast<int>(key.size_bytes),
+                    key.data);
+      NANOARROW_RETURN_NOT_OK(CollectKeyValue(key, value, private_data, 
nullptr));
+      return visited->size() == 1 ? ENOTSUP : NANOARROW_OK;
+    }
+  };
+
+  KeyValues visited;
+  EXPECT_EQ(ArrowIpcDecoderVisitMessageMetadata(decoder.get(), 
&Visitor::Visit, &visited,
+                                                &error),
+            ENOTSUP);
+  EXPECT_EQ(visited, (KeyValues{{"key1", "value1"}}));
+  EXPECT_STREQ(error.message, "visitor stopped at key1");
+}
diff --git a/src/nanoarrow/nanoarrow_ipc.h b/src/nanoarrow/nanoarrow_ipc.h
index 72327730..fb38750c 100644
--- a/src/nanoarrow/nanoarrow_ipc.h
+++ b/src/nanoarrow/nanoarrow_ipc.h
@@ -66,6 +66,12 @@
   NANOARROW_SYMBOL(NANOARROW_NAMESPACE, 
ArrowIpcDecoderSetSchemaWithDictionaries)
 #define ArrowIpcDecoderSetEndianness \
   NANOARROW_SYMBOL(NANOARROW_NAMESPACE, ArrowIpcDecoderSetEndianness)
+#define ArrowIpcDecoderGetMessageMetadata \
+  NANOARROW_SYMBOL(NANOARROW_NAMESPACE, ArrowIpcDecoderGetMessageMetadata)
+#define ArrowIpcDecoderGetMessageMetadataValue \
+  NANOARROW_SYMBOL(NANOARROW_NAMESPACE, ArrowIpcDecoderGetMessageMetadataValue)
+#define ArrowIpcDecoderVisitMessageMetadata \
+  NANOARROW_SYMBOL(NANOARROW_NAMESPACE, ArrowIpcDecoderVisitMessageMetadata)
 #define ArrowIpcDecoderPeekFooter \
   NANOARROW_SYMBOL(NANOARROW_NAMESPACE, ArrowIpcDecoderPeekFooter)
 #define ArrowIpcDecoderVerifyFooter \
@@ -84,6 +90,8 @@
 #define ArrowIpcEncoderReset NANOARROW_SYMBOL(NANOARROW_NAMESPACE, 
ArrowIpcEncoderReset)
 #define ArrowIpcEncoderFinalizeBuffer \
   NANOARROW_SYMBOL(NANOARROW_NAMESPACE, ArrowIpcEncoderFinalizeBuffer)
+#define ArrowIpcEncoderSetMessageMetadata \
+  NANOARROW_SYMBOL(NANOARROW_NAMESPACE, ArrowIpcEncoderSetMessageMetadata)
 #define ArrowIpcEncoderEncodeSchema \
   NANOARROW_SYMBOL(NANOARROW_NAMESPACE, ArrowIpcEncoderEncodeSchema)
 #define ArrowIpcEncoderEncodeSimpleRecordBatch \
@@ -461,6 +469,20 @@ NANOARROW_DLL void ArrowIpcDecoderReset(struct 
ArrowIpcDecoder* decoder);
 NANOARROW_DLL ArrowErrorCode ArrowIpcDecoderSetDecompressor(
     struct ArrowIpcDecoder* decoder, struct ArrowIpcDecompressor* 
decompressor);
 
+/// \brief Callback invoked for each key/value pair by
+/// ArrowIpcDecoderVisitMessageMetadata()
+///
+/// Both key and value are borrowed and are passed with an explicit size 
because
+/// neither is required to be present nor free of embedded nulls: a key or 
value that
+/// the message omits is passed as an empty string view.
+///
+/// Returning any value other than NANOARROW_OK will stop the visit and cause 
that
+/// value to be returned by ArrowIpcDecoderVisitMessageMetadata().
+typedef ArrowErrorCode (*ArrowIpcMetadataVisitFunction)(struct ArrowStringView 
key,
+                                                        struct ArrowStringView 
value,
+                                                        void* private_data,
+                                                        struct ArrowError* 
error);
+
 /// \brief Peek at a message header
 ///
 /// The first 8 bytes of an Arrow IPC message are 0xFFFFFFFF followed by the 
size
@@ -510,6 +532,52 @@ NANOARROW_DLL ArrowErrorCode 
ArrowIpcDecoderDecodeHeader(struct ArrowIpcDecoder*
                                                          struct 
ArrowBufferView data,
                                                          struct ArrowError* 
error);
 
+/// \brief Get the custom metadata of the most recently decoded message
+///
+/// After a successful call to ArrowIpcDecoderVerifyHeader() or
+/// ArrowIpcDecoderDecodeHeader(), copy the message's custom_metadata into out 
using
+/// the same representation as ArrowSchema::metadata. Note that this is the 
metadata
+/// attached to the message itself and is distinct from the metadata of a 
Schema or
+/// Field that the message may contain.
+///
+/// out is initialized by this call (i.e., it must not contain data) and the 
caller
+/// is responsible for calling ArrowBufferReset(). If the message had no custom
+/// metadata, out will be empty (i.e., out->data will be NULL) such that
+/// (const char*)out->data can be passed to ArrowSchemaSetMetadata() or
+/// ArrowMetadataReaderInit().
+///
+/// Returns ENOMEM if allocation fails, EINVAL if the metadata cannot be 
decoded, or
+/// NANOARROW_OK otherwise.
+NANOARROW_DLL ArrowErrorCode ArrowIpcDecoderGetMessageMetadata(
+    struct ArrowIpcDecoder* decoder, struct ArrowBuffer* out, struct 
ArrowError* error);
+
+/// \brief Get one value from the custom metadata of the most recently decoded 
message
+///
+/// Unlike ArrowIpcDecoderGetMessageMetadata(), this does not copy: the value 
returned
+/// points into the message header passed to ArrowIpcDecoderVerifyHeader() or
+/// ArrowIpcDecoderDecodeHeader() and is only valid until that data is 
invalidated or
+/// another message header is decoded.
+///
+/// If key occurs more than once, the first value is returned. If key does not 
occur,
+/// value_out is left unmodified: initialize it with ArrowCharView(NULL) and 
check
+/// value_out->data for NULL to detect a missing key.
+NANOARROW_DLL ArrowErrorCode ArrowIpcDecoderGetMessageMetadataValue(
+    struct ArrowIpcDecoder* decoder, struct ArrowStringView key,
+    struct ArrowStringView* value_out, struct ArrowError* error);
+
+/// \brief Visit each key/value pair in the most recently decoded message's 
metadata
+///
+/// Like ArrowIpcDecoderGetMessageMetadataValue(), the keys and values passed 
to visit
+/// point into the message header passed to ArrowIpcDecoderVerifyHeader() or
+/// ArrowIpcDecoderDecodeHeader() and must not be retained beyond the lifetime 
of that
+/// data. private_data and error are passed to each invocation of visit.
+///
+/// Returns the first non-NANOARROW_OK value returned by visit, or 
NANOARROW_OK if all
+/// pairs were visited.
+NANOARROW_DLL ArrowErrorCode ArrowIpcDecoderVisitMessageMetadata(
+    struct ArrowIpcDecoder* decoder, ArrowIpcMetadataVisitFunction visit,
+    void* private_data, struct ArrowError* error);
+
 /// \brief Decode an ArrowSchema
 ///
 /// After a successful call to ArrowIpcDecoderDecodeHeader(), retrieve an 
ArrowSchema.
@@ -778,6 +846,24 @@ NANOARROW_DLL void ArrowIpcEncoderReset(struct 
ArrowIpcEncoder* encoder);
 NANOARROW_DLL ArrowErrorCode ArrowIpcEncoderFinalizeBuffer(
     struct ArrowIpcEncoder* encoder, char encapsulate, struct ArrowBuffer* 
out);
 
+/// \brief Set the custom metadata of the next encoded message
+///
+/// Attaches metadata to the next message encoded by 
ArrowIpcEncoderEncodeSchema() or
+/// ArrowIpcEncoderEncodeSimpleRecordBatch() (i.e., Message::custom_metadata, 
which is
+/// distinct from the metadata of the Schema or Field that the message may 
contain).
+/// The metadata applies to exactly one message: after a message is encoded the
+/// encoder's message metadata is cleared. Any metadata that was set but not 
yet
+/// encoded is replaced by this call; pass NULL to clear it.
+///
+/// metadata contains the same representation as ArrowSchema::metadata, as 
built by
+/// ArrowMetadataBuilderInit()/ArrowMetadataBuilderAppend(). The encoder takes 
ownership
+/// of it: unless metadata is NULL it is moved into the encoder and left 
empty. Metadata
+/// containing no keys is equivalent to no metadata at all and no 
custom_metadata is
+/// encoded for it.
+NANOARROW_DLL ArrowErrorCode
+ArrowIpcEncoderSetMessageMetadata(struct ArrowIpcEncoder* encoder,
+                                  struct ArrowBuffer* metadata, struct 
ArrowError* error);
+
 /// \brief Encode an ArrowSchema
 ///
 /// Returns ENOMEM if allocation fails, NANOARROW_OK otherwise.

Reply via email to