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

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


The following commit(s) were added to refs/heads/main by this push:
     new 3e6f730bd0d GH-50623: [C++][IPC] Fix extension-wrapped union IPC 
roundtrip (#50927)
3e6f730bd0d is described below

commit 3e6f730bd0d04d972d60d4efee12b6be9c53cf25
Author: alb3e3 <[email protected]>
AuthorDate: Tue Aug 25 19:30:25 2026 +0400

    GH-50623: [C++][IPC] Fix extension-wrapped union IPC roundtrip (#50927)
    
    ### Rationale for this change
    
    IPC schema serialization already treats extension arrays as their storage 
type. Record batch serialization still handled extension arrays according to 
the logical `extension` type when deciding whether to emit a validity buffer. 
That mismatches storage types whose IPC layout differs from the default, 
including unions.
    
    For extension-wrapped dense and sparse unions, the writer emitted a 
spurious top-level validity-buffer slot. The resulting IPC payload dropped the 
union buffers from the positions the reader expects, producing invalid data on 
read.
    
    ### What changes are included in this PR?
    
    - teach `RecordBatchSerializer::VisitArray()` to use the physical storage 
array when deciding common IPC buffer handling for extension arrays
    - keep field-node accounting on the logical extension array while 
delegating layout-sensitive buffer decisions to the storage array
    - add dense and sparse union extension roundtrip regression tests for both 
file and stream IPC writers
    
    This follows the same storage-type rule already used by IPC schema 
serialization.
    
    AI-assisted contribution note: this patch was prepared with AI assistance 
under Arrow's AI-generated code guidance. I reproduced the bug locally, 
validated the fix, and reviewed the final diff myself.
    
    ### Are these changes tested?
    
    Yes.
    
    Locally verified with:
    
    - `arrow-ipc-read-write-test 
--gtest_filter='TestFileFormat.DenseUnionExtensionRoundTrip:TestFileFormat.SparseUnionExtensionRoundTrip:TestStreamFormat.DenseUnionExtensionRoundTrip:TestStreamFormat.SparseUnionExtensionRoundTrip'`
    - `arrow-ipc-read-write-test 
--gtest_filter='-TestSchemaMetadata.MetadataVersionForwardCompatibility'`
    - `arrow-extension-type-test`
    
    The excluded `TestSchemaMetadata.MetadataVersionForwardCompatibility` 
requires external test data and is unrelated to this patch.
    
    ### Are there any user-facing changes?
    
    No API changes.
    
    This PR contains a "Critical Fix" in Arrow's template sense: it fixes IPC 
output that could otherwise contain invalid union payloads when an extension 
type wraps union storage, which can fail validation and crash on readback.
    
    * GitHub Issue: #50623
    
    Authored-by: Alberto Maschietto <[email protected]>
    Signed-off-by: Antoine Pitrou <[email protected]>
---
 cpp/src/arrow/ipc/read_write_test.cc   |  8 +++++--
 cpp/src/arrow/ipc/test_common.cc       | 29 ++++++++++++++++++++++++++
 cpp/src/arrow/ipc/test_common.h        |  6 ++++++
 cpp/src/arrow/ipc/writer.cc            | 28 ++++++++++++++++++-------
 cpp/src/arrow/testing/extension_type.h | 38 ++++++++++++++++++++++++++++++++++
 cpp/src/arrow/testing/gtest_util.cc    | 35 +++++++++++++++++++++++++++++++
 6 files changed, 134 insertions(+), 10 deletions(-)

diff --git a/cpp/src/arrow/ipc/read_write_test.cc 
b/cpp/src/arrow/ipc/read_write_test.cc
index aa3f9fd77f2..cfa6a541d5c 100644
--- a/cpp/src/arrow/ipc/read_write_test.cc
+++ b/cpp/src/arrow/ipc/read_write_test.cc
@@ -397,14 +397,18 @@ const std::vector<test::MakeRecordBatch*> kBatchCases = {
     &MakeIntervals,
     &MakeUuid,
     &MakeComplex128,
-    &MakeDictExtension};
+    &MakeDictExtension,
+    &MakeDenseUnionExtension,
+    &MakeSparseUnionExtension};
 
 static int g_file_number = 0;
 
 class ExtensionTypesMixin {
  public:
   // Register the extension types required to ensure roundtripping
-  ExtensionTypesMixin() : ext_guard_({uuid(), dict_extension_type(), 
complex128()}) {}
+  ExtensionTypesMixin()
+      : ext_guard_({uuid(), dict_extension_type(), complex128(),
+                    dense_union_extension_type(), 
sparse_union_extension_type()}) {}
 
  protected:
   ExtensionTypeGuard ext_guard_;
diff --git a/cpp/src/arrow/ipc/test_common.cc b/cpp/src/arrow/ipc/test_common.cc
index ceca6d9e434..2b9cf115421 100644
--- a/cpp/src/arrow/ipc/test_common.cc
+++ b/cpp/src/arrow/ipc/test_common.cc
@@ -1164,6 +1164,35 @@ Status MakeDictExtension(std::shared_ptr<RecordBatch>* 
out) {
 
 namespace {
 
+Status MakeUnionExtension(const std::shared_ptr<DataType>& type,
+                          std::shared_ptr<RecordBatch>* out) {
+  auto storage_type = checked_cast<const ExtensionType&>(*type).storage_type();
+
+  auto f0 = field("f0", type);
+  auto f1 = field("f1", type, /*nullable=*/false);
+  auto schema = ::arrow::schema({f0, f1});
+
+  auto a0 = ExtensionType::WrapArray(
+      type, ArrayFromJSON(storage_type, R"([[0, 1.5], [1, null]])"));
+  auto a1 = ExtensionType::WrapArray(
+      type, ArrayFromJSON(storage_type, R"([[0, 1.5], [1, "abc"]])"));
+
+  *out = RecordBatch::Make(schema, a1->length(), {a0, a1});
+  return Status::OK();
+}
+
+}  // namespace
+
+Status MakeDenseUnionExtension(std::shared_ptr<RecordBatch>* out) {
+  return MakeUnionExtension(dense_union_extension_type(), out);
+}
+
+Status MakeSparseUnionExtension(std::shared_ptr<RecordBatch>* out) {
+  return MakeUnionExtension(sparse_union_extension_type(), out);
+}
+
+namespace {
+
 template <typename CValueType, typename SeedType, typename DistributionType>
 void FillRandomData(CValueType* data, size_t n, CValueType min, CValueType max,
                     SeedType seed) {
diff --git a/cpp/src/arrow/ipc/test_common.h b/cpp/src/arrow/ipc/test_common.h
index 6044ef207bc..dd0abc1ff49 100644
--- a/cpp/src/arrow/ipc/test_common.h
+++ b/cpp/src/arrow/ipc/test_common.h
@@ -185,6 +185,12 @@ Status MakeComplex128(std::shared_ptr<RecordBatch>* out);
 ARROW_TESTING_EXPORT
 Status MakeDictExtension(std::shared_ptr<RecordBatch>* out);
 
+ARROW_TESTING_EXPORT
+Status MakeDenseUnionExtension(std::shared_ptr<RecordBatch>* out);
+
+ARROW_TESTING_EXPORT
+Status MakeSparseUnionExtension(std::shared_ptr<RecordBatch>* out);
+
 ARROW_TESTING_EXPORT
 Status MakeRandomTensor(const std::shared_ptr<DataType>& type,
                         const std::vector<int64_t>& shape, bool row_major_p,
diff --git a/cpp/src/arrow/ipc/writer.cc b/cpp/src/arrow/ipc/writer.cc
index 263689a648d..855d1a2f211 100644
--- a/cpp/src/arrow/ipc/writer.cc
+++ b/cpp/src/arrow/ipc/writer.cc
@@ -150,32 +150,44 @@ class RecordBatchSerializer {
       return Status::Invalid("Max recursion depth reached");
     }
 
-    if (!options_.allow_64bit && arr.length() > 
std::numeric_limits<int32_t>::max()) {
+    // An extension array is serialized as its storage array: the extension 
type
+    // itself is carried in the schema, not in the record batch body.  The 
storage
+    // array shares the extension array's ArrayData, so length, offset and null
+    // count are unchanged; only the type id differs, and it is the storage 
type id
+    // that decides the buffer layout below.
+    const Array& physical_arr = arr.type_id() == Type::EXTENSION
+                                    ? *checked_cast<const 
ExtensionArray&>(arr).storage()
+                                    : arr;
+
+    if (!options_.allow_64bit &&
+        physical_arr.length() > std::numeric_limits<int32_t>::max()) {
       return Status::CapacityError("Cannot write arrays larger than 2^31 - 1 
in length");
     }
 
-    if (arr.offset() != 0 && arr.device_type() != DeviceAllocationType::kCPU) {
+    if (physical_arr.offset() != 0 &&
+        physical_arr.device_type() != DeviceAllocationType::kCPU) {
       // https://github.com/apache/arrow/issues/43029
       return Status::NotImplemented("Cannot compute null count for non-cpu 
sliced array");
     }
 
     // push back all common elements
-    field_nodes_.push_back({arr.length(), arr.null_count(), 0});
+    field_nodes_.push_back({physical_arr.length(), physical_arr.null_count(), 
0});
 
     // In V4, null types have no validity bitmap
     // In V5 and later, null and union types have no validity bitmap
-    if (internal::HasValidityBitmap(arr.type_id(), options_.metadata_version)) 
{
-      if (arr.null_count() > 0) {
+    if (internal::HasValidityBitmap(physical_arr.type_id(), 
options_.metadata_version)) {
+      if (physical_arr.null_count() > 0) {
         std::shared_ptr<Buffer> bitmap;
-        RETURN_NOT_OK(GetTruncatedBitmap(arr.offset(), arr.length(), 
arr.null_bitmap(),
-                                         options_.memory_pool, &bitmap));
+        RETURN_NOT_OK(GetTruncatedBitmap(physical_arr.offset(), 
physical_arr.length(),
+                                         physical_arr.null_bitmap(), 
options_.memory_pool,
+                                         &bitmap));
         out_->body_buffers.emplace_back(std::move(bitmap));
       } else {
         // Push a dummy zero-length buffer, not to be copied
         out_->body_buffers.emplace_back(kNullBuffer);
       }
     }
-    return VisitType(arr);
+    return VisitType(physical_arr);
   }
 
   // Override this for writing dictionary metadata
diff --git a/cpp/src/arrow/testing/extension_type.h 
b/cpp/src/arrow/testing/extension_type.h
index 9b4492a543a..e5d6b597c5b 100644
--- a/cpp/src/arrow/testing/extension_type.h
+++ b/cpp/src/arrow/testing/extension_type.h
@@ -168,6 +168,38 @@ class ARROW_TESTING_EXPORT MetadataOptionalExtensionType : 
public ExtensionType
   }
 };
 
+class ARROW_TESTING_EXPORT UnionExtensionArray : public ExtensionArray {
+ public:
+  using ExtensionArray::ExtensionArray;
+};
+
+/// \brief An extension type over an arbitrary union storage type.
+///
+/// The storage type and extension name are given at construction, so that a
+/// dense-union-backed and a sparse-union-backed extension type can coexist in
+/// the same extension type registry.
+class ARROW_TESTING_EXPORT UnionExtensionType : public ExtensionType {
+ public:
+  UnionExtensionType(std::shared_ptr<DataType> storage_type, std::string 
extension_name)
+      : ExtensionType(std::move(storage_type)),
+        extension_name_(std::move(extension_name)) {}
+
+  std::string extension_name() const override { return extension_name_; }
+
+  bool ExtensionEquals(const ExtensionType& other) const override;
+
+  std::shared_ptr<Array> MakeArray(std::shared_ptr<ArrayData> data) const 
override;
+
+  Result<std::shared_ptr<DataType>> Deserialize(
+      std::shared_ptr<DataType> storage_type,
+      const std::string& serialized) const override;
+
+  std::string Serialize() const override { return extension_name_; }
+
+ private:
+  std::string extension_name_;
+};
+
 class ARROW_TESTING_EXPORT Complex128Array : public ExtensionArray {
  public:
   using ExtensionArray::ExtensionArray;
@@ -213,6 +245,12 @@ std::shared_ptr<DataType> binary_view_extension_type();
 ARROW_TESTING_EXPORT
 std::shared_ptr<DataType> complex128();
 
+ARROW_TESTING_EXPORT
+std::shared_ptr<DataType> dense_union_extension_type();
+
+ARROW_TESTING_EXPORT
+std::shared_ptr<DataType> sparse_union_extension_type();
+
 ARROW_TESTING_EXPORT
 std::shared_ptr<Array> ExampleUuid();
 
diff --git a/cpp/src/arrow/testing/gtest_util.cc 
b/cpp/src/arrow/testing/gtest_util.cc
index 3ea7d9bb22e..daadfe9c2cd 100644
--- a/cpp/src/arrow/testing/gtest_util.cc
+++ b/cpp/src/arrow/testing/gtest_util.cc
@@ -977,6 +977,29 @@ Result<std::shared_ptr<DataType>> 
BinaryViewExtensionType::Deserialize(
   return std::make_shared<BinaryViewExtensionType>();
 }
 
+bool UnionExtensionType::ExtensionEquals(const ExtensionType& other) const {
+  return (other.extension_name() == this->extension_name());
+}
+
+std::shared_ptr<Array> UnionExtensionType::MakeArray(
+    std::shared_ptr<ArrayData> data) const {
+  DCHECK_EQ(data->type->id(), Type::EXTENSION);
+  DCHECK(ExtensionEquals(checked_cast<const ExtensionType&>(*data->type)));
+  return std::make_shared<UnionExtensionArray>(data);
+}
+
+Result<std::shared_ptr<DataType>> UnionExtensionType::Deserialize(
+    std::shared_ptr<DataType> storage_type, const std::string& serialized) 
const {
+  if (serialized != extension_name_) {
+    return Status::Invalid("Type identifier did not match: '", serialized, 
"'");
+  }
+  if (!storage_type->Equals(*storage_type_)) {
+    return Status::Invalid("Invalid storage type for ", extension_name_, ": ",
+                           storage_type->ToString());
+  }
+  return std::make_shared<UnionExtensionType>(std::move(storage_type), 
extension_name_);
+}
+
 bool Complex128Type::ExtensionEquals(const ExtensionType& other) const {
   return (other.extension_name() == this->extension_name());
 }
@@ -1019,6 +1042,18 @@ std::shared_ptr<DataType> dict_extension_type() {
 
 std::shared_ptr<DataType> complex128() { return 
std::make_shared<Complex128Type>(); }
 
+std::shared_ptr<DataType> dense_union_extension_type() {
+  return std::make_shared<UnionExtensionType>(
+      dense_union({field("floats", float64()), field("strings", 
large_utf8())}, {0, 1}),
+      "dense-union-extension");
+}
+
+std::shared_ptr<DataType> sparse_union_extension_type() {
+  return std::make_shared<UnionExtensionType>(
+      sparse_union({field("floats", float64()), field("strings", 
large_utf8())}, {0, 1}),
+      "sparse-union-extension");
+}
+
 std::shared_ptr<Array> MakeComplex128(const std::shared_ptr<Array>& real,
                                       const std::shared_ptr<Array>& imag) {
   auto type = complex128();

Reply via email to