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 a735b9f1 fix: Export a zero offset for empty arrays with an empty 
offsets buffer (#966)
a735b9f1 is described below

commit a735b9f180e668e3f31c433e40f60a94ef070c88
Author: Rusty Conover <[email protected]>
AuthorDate: Sat Oct 17 00:31:58 2026 -0400

    fix: Export a zero offset for empty arrays with an empty offsets buffer 
(#966)
    
    An empty variable-size array (string, binary, their large variants,
    list, large list, map) needs one offset, `offsets[0]`, even though it
    has no elements. The C data interface doesn't carry buffer sizes, so
    consumers read `offsets[0]` without checking. Arrow C++'s
    `ImportArray()` does this, for example.
    
    IPC allows a zero-length offsets buffer for an empty array, and
    nanoarrow's own encoder writes one, since `ArrowArrayViewSetArray()`
    sizes the offsets buffer of a length-0 array as 0 bytes. When such an
    array is decoded, `ArrowArrayFinishBuilding()` leaves the empty buffer
    as it is. If the buffer is NULL, it reserves one byte but never
    initializes it. Either way, the exported `ArrowArray` has an offsets
    pointer with no valid offset behind it. With shared buffers, that
    pointer points into whatever follows in the message body.
    
    I found this moving a DuckDB extension's IPC from Arrow C++ to
    nanoarrow. A `MAP` column holding a single `NULL` round-trips through a
    worker as an empty key/value child. After nanoarrow decoded it and Arrow
    C++ imported it, about one run in four failed `ValidateFull()` with
    `array starts at negative offset -2099303496`. The value depends on
    whatever bytes happen to sit in the message body after the empty buffer.
    
    With this PR, `ArrowArrayFinalizeBuffers()` gives a `DATA_OFFSET` buffer
    of an empty array, if it holds no bytes, a single zero offset of the
    right width (4 or 8 bytes). This works for both owned and shared
    buffers: the shared reference is released, and the buffer is replaced
    with an owned one. Arrays with elements are left alone, so validation
    still reports a truncated offsets buffer as before
    (`ArrayTestExplicitValidationLevel` checks this).
    
    Things worth a look in review:
    
    - `ArrowArrayFinalizeBuffers()` only runs at
    `NANOARROW_VALIDATION_LEVEL_DEFAULT` or higher, because it assumes CPU
    data access. Decoding at `MINIMAL` or `NONE` still exports the empty
    buffer unchanged. I've left that gate alone. Putting a host buffer
    behind a device array would be worse.
    - An alternative would be for the encoder to always write `offsets[0]`,
    as Arrow C++ does. That only covers streams nanoarrow wrote itself,
    though. Other writers can emit zero-length offsets buffers too, so the
    fix belongs on the decode side.
    
    Tests: `NanoarrowIpcWriter.RoundtripEmptyArraysHaveZeroOffset` writes a
    zero-row batch with `string`, `large_binary` and `list<int32>` columns.
    It reads the batch back with and without shared buffers, then checks
    that each exported offsets buffer has a size of 4 or 8 bytes and holds
    0. The test fails on `main`, where the buffers are 0 bytes. ctest passes
    locally (macOS arm64, Debug, 389 tests). The touched suites (array, IPC
    writer/reader/decoder) pass under ASan/UBSan. `array_test` needs
    `allocator_may_return_null=1` there, as it does on `main`.
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
 src/nanoarrow/common/array.c       | 15 +++++++
 src/nanoarrow/common/array_test.cc | 53 ++++++++++++++++++++++++
 src/nanoarrow/ipc/decoder_test.cc  | 83 ++++++++++++++++++++++++++++++++++++++
 3 files changed, 151 insertions(+)

diff --git a/src/nanoarrow/common/array.c b/src/nanoarrow/common/array.c
index c6fb1cd9..e4538955 100644
--- a/src/nanoarrow/common/array.c
+++ b/src/nanoarrow/common/array.c
@@ -1033,6 +1033,21 @@ static ArrowErrorCode ArrowArrayFinalizeBuffers(struct 
ArrowArray* array) {
     }
 
     struct ArrowBuffer* buffer = ArrowArrayBuffer(array, i);
+
+    // An offsets buffer holds length + 1 offsets even for an empty array, and 
the C
+    // data interface carries no buffer sizes, so consumers read offsets[0]
+    // unconditionally. IPC permits a zero-length offsets buffer for an empty 
array
+    // (nanoarrow's encoder writes one), which would otherwise be exported as 
a pointer
+    // to no valid bytes (or into a neighbouring part of a shared message 
body).
+    if (private_data->layout.buffer_type[i] == 
NANOARROW_BUFFER_TYPE_DATA_OFFSET &&
+        array->length == 0 && buffer->size_bytes == 0) {
+      ArrowBufferReset(buffer);
+      int64_t zero = 0;
+      NANOARROW_RETURN_NOT_OK(ArrowBufferAppend(
+          buffer, &zero, private_data->layout.element_size_bits[i] / 8));
+      continue;
+    }
+
     if (buffer->data == NULL) {
       NANOARROW_RETURN_NOT_OK((ArrowBufferReserve(buffer, 1)));
     }
diff --git a/src/nanoarrow/common/array_test.cc 
b/src/nanoarrow/common/array_test.cc
index cd9ac88e..9324cdde 100644
--- a/src/nanoarrow/common/array_test.cc
+++ b/src/nanoarrow/common/array_test.cc
@@ -383,6 +383,59 @@ TEST(ArrayTest, ArrayTestBuildByBuffer) {
   ArrowArrayRelease(&array);
 }
 
+TEST(ArrayTest, ArrayTestFinishBuildingEmptyArrayOffsets) {
+  // An empty variable-size array still has one offset, and the C data 
interface carries
+  // no buffer sizes, so consumers read offsets[0] unconditionally. Finishing 
an empty
+  // array whose offsets buffer holds no bytes must leave exactly one zero 
offset there,
+  // whether that buffer was never allocated or points at bytes that aren't 
offsets.
+  struct ArrowError error;
+  struct TestCase {
+    enum ArrowType type;
+    int64_t offset_size;
+    bool garbage_storage;
+  };
+
+  for (const auto& test_case : {TestCase{NANOARROW_TYPE_STRING, 4, false},
+                                TestCase{NANOARROW_TYPE_STRING, 4, true},
+                                TestCase{NANOARROW_TYPE_LARGE_BINARY, 8, 
false},
+                                TestCase{NANOARROW_TYPE_LARGE_BINARY, 8, true},
+                                TestCase{NANOARROW_TYPE_LIST, 4, false},
+                                TestCase{NANOARROW_TYPE_LIST, 4, true}}) {
+    SCOPED_TRACE(std::string(ArrowTypeString(test_case.type)) +
+                 (test_case.garbage_storage ? " with garbage storage" : ""));
+
+    nanoarrow::UniqueSchema schema;
+    ASSERT_EQ(ArrowSchemaInitFromType(schema.get(), test_case.type), 
NANOARROW_OK);
+    if (test_case.type == NANOARROW_TYPE_LIST) {
+      ASSERT_EQ(ArrowSchemaSetType(schema->children[0], NANOARROW_TYPE_INT32),
+                NANOARROW_OK);
+    }
+
+    nanoarrow::UniqueArray array;
+    ASSERT_EQ(ArrowArrayInitFromSchema(array.get(), schema.get(), &error), 
NANOARROW_OK)
+        << error.message;
+    struct ArrowBuffer* offsets = ArrowArrayBuffer(array.get(), 1);
+    ASSERT_EQ(offsets->size_bytes, 0);
+    if (test_case.garbage_storage) {
+      // Storage behind a zero-length buffer, as when an empty array's offsets 
buffer
+      // points into some other part of a larger allocation
+      ASSERT_EQ(ArrowBufferReserve(offsets, 16), NANOARROW_OK);
+      memset(offsets->data, 0xff, 16);
+    }
+
+    ASSERT_EQ(ArrowArrayFinishBuildingDefault(array.get(), &error), 
NANOARROW_OK)
+        << error.message;
+    ASSERT_NE(array->buffers[1], nullptr);
+    EXPECT_EQ(ArrowArrayBuffer(array.get(), 1)->size_bytes, 
test_case.offset_size);
+    int64_t first_offset = -1;
+    memcpy(&first_offset, array->buffers[1], test_case.offset_size);
+    if (test_case.offset_size == 4) {
+      first_offset = static_cast<int32_t>(first_offset);
+    }
+    EXPECT_EQ(first_offset, 0);
+  }
+}
+
 TEST(ArrayTest, ArrayTestExplicitValidationLevel) {
   struct ArrowArray array;
   struct ArrowError error;
diff --git a/src/nanoarrow/ipc/decoder_test.cc 
b/src/nanoarrow/ipc/decoder_test.cc
index c7ac78ce..24acafe6 100644
--- a/src/nanoarrow/ipc/decoder_test.cc
+++ b/src/nanoarrow/ipc/decoder_test.cc
@@ -122,6 +122,31 @@ alignas(8) static uint8_t kSimpleRecordBatch[] = {
     0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 
0x03, 0x00,
     0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
 
+// One row of struct<l: list<string>, i: int32> (l = [[]], i = 0x55555555) as 
written by
+// nanoarrow's encoder. The empty string array's offsets buffer has zero bytes 
and the
+// same body offset as i's data buffer.
+alignas(8) static uint8_t kEmptyChildRecordBatch[] = {
+    0xff, 0xff, 0xff, 0xff, 0x00, 0x01, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 
0x1a, 0xff,
+    0xff, 0xff, 0x04, 0x00, 0x03, 0x00, 0x10, 0x00, 0x00, 0x00, 0x10, 0x00, 
0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xff, 0xff, 0xff, 
0x01, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x84, 0x00, 0x00, 0x00, 0x08, 0x00, 
0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 
0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 
0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 
0x08, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 
0x00, 0x00,
+    0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 
0x00, 0x00,
+    0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 
0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 
0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 
0x00, 0x00,
+    0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 
0x03, 0x00,
+    0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 
0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 
0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 
0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x14, 0x00, 
0x04, 0x00,
+    0x0c, 0x00, 0x10, 0x00, 0x0c, 0x00, 0x14, 0x00, 0x04, 0x00, 0x06, 0x00, 
0x08, 0x00,
+    0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 
0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x55, 0x55, 0x55, 0x55, 0x00, 0x00, 
0x00, 0x00};
+
 alignas(8) static uint8_t kSimpleRecordBatchCompressedZstd[] = {
     0xff, 0xff, 0xff, 0xff, 0xa0, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 
0x00, 0x00,
     0x00, 0x00, 0x0c, 0x00, 0x18, 0x00, 0x06, 0x00, 0x05, 0x00, 0x08, 0x00, 
0x0c, 0x00,
@@ -2691,3 +2716,61 @@ INSTANTIATE_TEST_SUITE_P(NanoarrowIpcDecoder, 
DeltaDictionaryTypeTest,
                                            NANOARROW_TYPE_STRUCT,
                                            NANOARROW_TYPE_FIXED_SIZE_LIST));
 #endif
+
+TEST(NanoarrowIpcTest, NanoarrowIpcDecodeEmptyArrayOffsets) {
+  // An empty array whose offsets buffer holds no bytes must still be decoded 
with a
+  // readable zero offset, whether or not the decoded array shares the message 
body
+  struct ArrowError error;
+
+  nanoarrow::UniqueSchema schema;
+  ArrowSchemaInit(schema.get());
+  ASSERT_EQ(ArrowSchemaSetTypeStruct(schema.get(), 2), NANOARROW_OK);
+  ASSERT_EQ(ArrowSchemaSetType(schema->children[0], NANOARROW_TYPE_LIST), 
NANOARROW_OK);
+  ASSERT_EQ(ArrowSchemaSetType(schema->children[0]->children[0], 
NANOARROW_TYPE_STRING),
+            NANOARROW_OK);
+  ASSERT_EQ(ArrowSchemaSetType(schema->children[1], NANOARROW_TYPE_INT32), 
NANOARROW_OK);
+
+  nanoarrow::ipc::UniqueDecoder decoder;
+  ASSERT_EQ(ArrowIpcDecoderInit(decoder.get()), NANOARROW_OK);
+  ASSERT_EQ(ArrowIpcDecoderSetSchema(decoder.get(), schema.get(), &error), 
NANOARROW_OK)
+      << error.message;
+
+  struct ArrowBufferView data;
+  data.data.as_uint8 = kEmptyChildRecordBatch;
+  data.size_bytes = sizeof(kEmptyChildRecordBatch);
+  ASSERT_EQ(ArrowIpcDecoderDecodeHeader(decoder.get(), data, &error), 
NANOARROW_OK)
+      << error.message;
+  struct ArrowBufferView body;
+  body.data.as_uint8 = kEmptyChildRecordBatch + decoder->header_size_bytes;
+  body.size_bytes = decoder->body_size_bytes;
+
+  for (bool shared : {false, true}) {
+    SCOPED_TRACE(shared ? "shared body" : "borrowed body");
+    nanoarrow::UniqueArray array;
+    if (shared) {
+      nanoarrow::UniqueBuffer body_copy;
+      ASSERT_EQ(ArrowBufferAppend(body_copy.get(), body.data.data, 
body.size_bytes),
+                NANOARROW_OK);
+      nanoarrow::UniqueBuffer shared_body;
+      ASSERT_EQ(ArrowSharedBufferInit(shared_body.get(), body_copy.get()), 
NANOARROW_OK);
+      ASSERT_EQ(ArrowIpcDecoderDecodeArrayFromShared(
+                    decoder.get(), shared_body.get(), -1, array.get(),
+                    NANOARROW_VALIDATION_LEVEL_FULL, &error),
+                NANOARROW_OK)
+          << error.message;
+    } else {
+      ASSERT_EQ(ArrowIpcDecoderDecodeArray(decoder.get(), body, -1, 
array.get(),
+                                           NANOARROW_VALIDATION_LEVEL_FULL, 
&error),
+                NANOARROW_OK)
+          << error.message;
+    }
+
+    ASSERT_EQ(array->length, 1);
+    struct ArrowArray* strings = array->children[0]->children[0];
+    ASSERT_EQ(strings->length, 0);
+    EXPECT_EQ(ArrowArrayBuffer(strings, 1)->size_bytes, 4);
+    int32_t first_offset = -1;
+    memcpy(&first_offset, strings->buffers[1], sizeof(first_offset));
+    EXPECT_EQ(first_offset, 0);
+  }
+}

Reply via email to