From d2cdde3887ccd416b053c905aa453ccc4d8b0353 Mon Sep 17 00:00:00 2001 From: Alex Stephen Date: Mon, 21 Sep 2026 18:01:44 +0000 Subject: [PATCH] Avoid duplicating the full value offsets buffer for zero-length array slices in Arrow IPC writer. --- cpp/src/arrow/ipc/read_write_test.cc | 47 ++++++++++++++++++++++++++++ cpp/src/arrow/ipc/writer.cc | 12 ++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/cpp/src/arrow/ipc/read_write_test.cc b/cpp/src/arrow/ipc/read_write_test.cc index 15cf0258b2ee..b177dde7ed82 100644 --- a/cpp/src/arrow/ipc/read_write_test.cc +++ b/cpp/src/arrow/ipc/read_write_test.cc @@ -3330,6 +3330,53 @@ TEST(PreBuffering, MixedAccess) { ASSERT_EQ(2, stats.num_record_batches); } +TEST(TestRecordBatchPayload, ZeroLengthArrayValueOffsets) { + auto schema = arrow::schema({field("str", utf8())}); + const auto options = IpcWriteOptions::Defaults(); + + // Zero-length array with null offsets buffer + { + auto array_data = ArrayData::Make(utf8(), /*length=*/0, + /*buffers=*/{nullptr, nullptr, nullptr}); + auto batch = + RecordBatch::Make(schema, /*num_rows=*/0, {MakeArray(std::move(array_data))}); + IpcPayload payload; + ASSERT_OK(GetRecordBatchPayload(*batch, options, &payload)); + ASSERT_EQ(payload.body_buffers.size(), 3); + ASSERT_EQ(payload.body_buffers[1], nullptr); + } + + // Zero-length array with empty offsets buffer + { + auto empty_buf = Buffer::FromString(""); + auto array_data = ArrayData::Make(utf8(), /*length=*/0, + /*buffers=*/{nullptr, empty_buf, empty_buf}); + auto batch = + RecordBatch::Make(schema, /*num_rows=*/0, {MakeArray(std::move(array_data))}); + IpcPayload payload; + ASSERT_OK(GetRecordBatchPayload(*batch, options, &payload)); + ASSERT_EQ(payload.body_buffers.size(), 3); + ASSERT_NE(payload.body_buffers[1], nullptr); + ASSERT_EQ(payload.body_buffers[1]->size(), 0); + } + + // Zero-length slice of a non-empty array should not retain the parent offsets + { + auto full_array = ArrayFromJSON(utf8(), R"(["ab", "cd", "ef"])"); + ASSERT_GT(full_array->data()->buffers[1]->size(), + static_cast(sizeof(int32_t))); + + auto empty_slice = full_array->Slice(3, 0); + auto batch = RecordBatch::Make(schema, /*num_rows=*/0, {empty_slice}); + IpcPayload payload; + ASSERT_OK(GetRecordBatchPayload(*batch, options, &payload)); + ASSERT_EQ(payload.body_buffers.size(), 3); + ASSERT_NE(payload.body_buffers[1], nullptr); + ASSERT_EQ(payload.body_buffers[1]->size(), sizeof(int32_t)); + ASSERT_EQ(payload.body_buffers[1]->data_as()[0], 0); + } +} + } // namespace test } // namespace ipc } // namespace arrow diff --git a/cpp/src/arrow/ipc/writer.cc b/cpp/src/arrow/ipc/writer.cc index 09a9aef89752..8b57f7442e43 100644 --- a/cpp/src/arrow/ipc/writer.cc +++ b/cpp/src/arrow/ipc/writer.cc @@ -325,7 +325,17 @@ class RecordBatchSerializer { using offset_type = typename ArrayType::offset_type; if (array.length() == 0) { - *value_offsets = array.value_offsets(); + if (array.value_offsets() == nullptr || array.value_offsets()->size() == 0) { + *value_offsets = array.value_offsets(); + return Status::OK(); + } + // An offsets buffer for an array of length N contains N + 1 elements, so a + // 0-length slice of a non-empty array only needs a single zero offset. Use a + // non-owning view over static zero bytes to avoid both a heap allocation and + // retaining the full unsliced parent buffer. + static constexpr uint64_t kZeroOffset = 0; + *value_offsets = std::make_shared( + reinterpret_cast(&kZeroOffset), sizeof(offset_type)); return Status::OK(); }