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 931db0e  fix: Concatenate extension arrays and keep concatenated 
results valid after inputs are disposed (#444)
931db0e is described below

commit 931db0e3380824d946b0c4fbcbb704afe0d8372b
Author: Curt Hagenlocher <[email protected]>
AuthorDate: Sat Sep 26 17:10:27 2026 -0700

    fix: Concatenate extension arrays and keep concatenated results valid after 
inputs are disposed (#444)
    
    ## What's Changed
    
    Fixes the three `ArrowArrayConcatenator` problems reported in #443, plus
    a related lifetime issue found while fixing them.
    
    - **Extension arrays are concatenated.** `ExtensionType.Accept` passes
    the visitor through to the storage type, so the storage visitor then
    rejected the `Extension` type ID. `ArrayDataConcatenator.Concatenate`
    now checks for an extension type first. It requires every input to have
    the same extension name and metadata, concatenates the storage arrays,
    and puts the extension type back on the result. This path also applies
    to child arrays, so e.g. a list of `GuidArray`s works too.
    - **View results own their data buffers.**
    `ConcatenateBinaryViewArrayData` now calls `Retain()` on each variadic
    data buffer it takes from an input. The result stays valid after the
    inputs (including `SliceShared` inputs) are disposed.
    - **Empty view inputs with data buffers no longer throw.** The loop that
    copies data buffers now skips zero-length inputs, just as
    `ConcatenateViewBuffer` already did when counting them. The buffer count
    and the view buffer indices now match.
    - **Other results that aliased an input now retain it.** The result used
    to share an input's memory without holding a reference in three cases: a
    single input to either `Concatenate` method, a nested child where only
    one input contributed values (for example, lists where only one has
    values), and the zero-length child or values array used when every input
    is empty. These now use `Retain()` / `SliceShared(0, 0)`.
    
    Note: a single-input `Concatenate` call now returns a new array that
    holds its own references, not the input object itself. The caller should
    dispose it like any other concatenation result.
    
    ### Tests
    
    Eight new tests in `ArrowArrayConcatenatorTests` cover extension arrays
    (plain, nested in a list, mismatched types), view results that outlive
    their inputs (plain and `SliceShared`), empty view inputs with data
    buffers, and results that outlive a single input, a list with one
    non-empty input, and a run-end encoded array with one non-empty input.
    All eight fail without the fix. The full `Apache.Arrow.Tests` suite
    passes on net8.0, net462 and net472.
    
    Closes #443.
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 src/Apache.Arrow/Arrays/ArrayDataConcatenator.cs   |  43 +++++-
 src/Apache.Arrow/Arrays/ArrowArrayConcatenator.cs  |   3 +-
 .../ArrowArrayConcatenatorTests.cs                 | 150 +++++++++++++++++++++
 3 files changed, 190 insertions(+), 6 deletions(-)

diff --git a/src/Apache.Arrow/Arrays/ArrayDataConcatenator.cs 
b/src/Apache.Arrow/Arrays/ArrayDataConcatenator.cs
index 3d84331..831fbfa 100644
--- a/src/Apache.Arrow/Arrays/ArrayDataConcatenator.cs
+++ b/src/Apache.Arrow/Arrays/ArrayDataConcatenator.cs
@@ -32,17 +32,43 @@ namespace Apache.Arrow
 
             if (arrayDataList.Count == 1)
             {
-                return arrayDataList[0];
+                // The result must stay valid after the input is disposed, so 
it takes its own references.
+                return arrayDataList[0].Retain();
+            }
+
+            IArrowType type = arrayDataList[0].DataType;
+            if (type is ExtensionType extensionType)
+            {
+                return ConcatenateExtension(extensionType, arrayDataList, 
allocator);
             }
 
             var arrowArrayConcatenationVisitor = new 
ArrayDataConcatenationVisitor(arrayDataList, allocator);
 
-            IArrowType type = arrayDataList[0].DataType;
             type.Accept(arrowArrayConcatenationVisitor);
 
             return arrowArrayConcatenationVisitor.Result;
         }
 
+        private static ArrayData ConcatenateExtension(ExtensionType type, 
IReadOnlyList<ArrayData> arrayDataList, MemoryAllocator allocator)
+        {
+            var storageDataList = new List<ArrayData>(arrayDataList.Count);
+            foreach (ArrayData arrayData in arrayDataList)
+            {
+                if (!(arrayData.DataType is ExtensionType otherType) ||
+                    otherType.Name != type.Name ||
+                    otherType.ExtensionMetadata != type.ExtensionMetadata)
+                {
+                    throw new ArgumentException(
+                        $"Cannot concatenate extension type {type.Name} with 
{arrayData.DataType.Name}");
+                }
+
+                storageDataList.Add(new ArrayData(otherType.StorageType, 
arrayData.Length, arrayData.NullCount, arrayData.Offset, arrayData.Buffers, 
arrayData.Children, arrayData.Dictionary));
+            }
+
+            ArrayData storage = Concatenate(storageDataList, allocator);
+            return new ArrayData(type, storage.Length, storage.NullCount, 
storage.Offset, storage.Buffers, storage.Children, storage.Dictionary);
+        }
+
         private class ArrayDataConcatenationVisitor :
             IArrowTypeVisitor<BooleanType>,
             IArrowTypeVisitor<FixedWidthType>,
@@ -443,7 +469,7 @@ namespace Apache.Arrow
                     // All inputs were empty. Reuse the first input's values 
child sliced to length
                     // 0 so we get a valid ArrayData with the correct 
buffer/child layout for the
                     // values type, regardless of what that type is.
-                    valuesResult = _arrayDataList[0].Children[1].Slice(0, 0);
+                    valuesResult = 
_arrayDataList[0].Children[1].SliceShared(0, 0);
                 }
                 else
                 {
@@ -704,9 +730,16 @@ namespace Apache.Arrow
                 int index = 2;
                 foreach (ArrayData arrayData in _arrayDataList)
                 {
+                    // Must skip the same inputs as ConcatenateViewBuffer so 
buffer indices line up.
+                    if (arrayData.Length == 0)
+                    {
+                        continue;
+                    }
+
+                    // The result shares the inputs' data buffers, so it needs 
its own references to them.
                     for (int i = 2; i < arrayData.Buffers.Length; i++)
                     {
-                        buffers[index++] = arrayData.Buffers[i];
+                        buffers[index++] = arrayData.Buffers[i].Retain();
                     }
                 }
 
@@ -798,7 +831,7 @@ namespace Apache.Arrow
                     {
                         // All parent arrays are empty, but the nested array 
still needs a real
                         // zero-length child to preserve Arrow's structural 
invariant.
-                        return arrayData.Children[0].Slice(0, 0);
+                        return arrayData.Children[0].SliceShared(0, 0);
                     }
                 }
 
diff --git a/src/Apache.Arrow/Arrays/ArrowArrayConcatenator.cs 
b/src/Apache.Arrow/Arrays/ArrowArrayConcatenator.cs
index 01ca243..64a9fcd 100644
--- a/src/Apache.Arrow/Arrays/ArrowArrayConcatenator.cs
+++ b/src/Apache.Arrow/Arrays/ArrowArrayConcatenator.cs
@@ -29,7 +29,8 @@ namespace Apache.Arrow
 
             if (arrowArrayList.Count == 1)
             {
-                return arrowArrayList[0];
+                // The result must stay valid after the input is disposed, so 
it takes its own references.
+                return 
ArrowArrayFactory.BuildArray(arrowArrayList[0].Data.Retain());
             }
 
             var arrayDataList = new List<ArrayData>(arrowArrayList.Count);
diff --git a/test/Apache.Arrow.Tests/ArrowArrayConcatenatorTests.cs 
b/test/Apache.Arrow.Tests/ArrowArrayConcatenatorTests.cs
index f5ed951..4e781a7 100644
--- a/test/Apache.Arrow.Tests/ArrowArrayConcatenatorTests.cs
+++ b/test/Apache.Arrow.Tests/ArrowArrayConcatenatorTests.cs
@@ -424,6 +424,156 @@ namespace Apache.Arrow.Tests
             Assert.Equal("B", values.GetString(1));
         }
 
+        [Fact]
+        public void TestExtensionArrays()
+        {
+            var guids = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() 
};
+            var a = new 
GuidArray.Builder().Append(guids[0]).AppendNull().Build();
+            var b = new 
GuidArray.Builder().Append(guids[1]).Append(guids[2]).Build();
+
+            var concatenated = 
Assert.IsType<GuidArray>(ArrowArrayConcatenator.Concatenate(new IArrowArray[] { 
a, b }));
+
+            Assert.Equal(4, concatenated.Length);
+            Assert.Equal(1, concatenated.NullCount);
+            Assert.Equal(new Guid?[] { guids[0], null, guids[1], guids[2] }, 
concatenated.ToArray());
+        }
+
+        [Fact]
+        public void TestListOfExtensionArrays()
+        {
+            var guids = new[] { Guid.NewGuid(), Guid.NewGuid() };
+            ListArray List(Guid guid)
+            {
+                var values = new GuidArray.Builder().Append(guid).Build();
+                var offsets = new 
ArrowBuffer.Builder<int>().Append(0).Append(1).Build();
+                return new ListArray(new ListType(GuidType.Default), 1, 
offsets, values, ArrowBuffer.Empty);
+            }
+
+            var concatenated = 
(ListArray)ArrowArrayConcatenator.Concatenate(new IArrowArray[] { 
List(guids[0]), List(guids[1]) });
+
+            var values = Assert.IsType<GuidArray>(concatenated.Values);
+            Assert.Equal(new Guid?[] { guids[0], guids[1] }, values.ToArray());
+        }
+
+        [Fact]
+        public void TestMismatchedExtensionTypesThrow()
+        {
+            var a = new GuidArray.Builder().Append(Guid.NewGuid()).Build();
+            var b = new FixedSizeBinaryArray(new ArrayData(new 
FixedSizeBinaryType(16), a.Length, 0, 0, a.Data.Buffers));
+
+            Assert.Throws<ArgumentException>(() => 
ArrowArrayConcatenator.Concatenate(new IArrowArray[] { a, b }));
+        }
+
+        [Fact]
+        public void TestStringViewResultOutlivesInputs()
+        {
+            var a = BuildStringView("a string longer than twelve bytes");
+            var b = BuildStringView("another string longer than twelve");
+
+            var concatenated = 
(StringViewArray)ArrowArrayConcatenator.Concatenate(new IArrowArray[] { a, b });
+            a.Dispose();
+            b.Dispose();
+
+            Assert.Equal("a string longer than twelve bytes", 
concatenated.GetString(0));
+            Assert.Equal("another string longer than twelve", 
concatenated.GetString(1));
+            concatenated.Dispose();
+        }
+
+        [Fact]
+        public void TestStringViewResultOutlivesSharedSliceInputs()
+        {
+            var source = BuildStringView("a string longer than twelve bytes", 
"another string longer than twelve");
+            var a = new StringViewArray(source.Data.SliceShared(0, 1));
+            var b = new StringViewArray(source.Data.SliceShared(1, 1));
+            source.Dispose();
+
+            var concatenated = 
(StringViewArray)ArrowArrayConcatenator.Concatenate(new IArrowArray[] { a, b });
+            a.Dispose();
+            b.Dispose();
+
+            Assert.Equal("a string longer than twelve bytes", 
concatenated.GetString(0));
+            Assert.Equal("another string longer than twelve", 
concatenated.GetString(1));
+            concatenated.Dispose();
+        }
+
+        [Fact]
+        public void TestStringViewWithEmptyInputHavingDataBuffers()
+        {
+            var empty = new 
StringViewArray(BuildStringView("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzz").Data.Slice(0, 
0));
+            var b = BuildStringView("another string longer than twelve");
+            var c = BuildStringView("short", "yet another string longer than 
twelve");
+
+            var concatenated = 
(StringViewArray)ArrowArrayConcatenator.Concatenate(new IArrowArray[] { empty, 
b, empty, c, empty });
+
+            Assert.Equal(3, concatenated.Length);
+            Assert.Equal("another string longer than twelve", 
concatenated.GetString(0));
+            Assert.Equal("short", concatenated.GetString(1));
+            Assert.Equal("yet another string longer than twelve", 
concatenated.GetString(2));
+        }
+
+        [Fact]
+        public void TestSingleElementResultOutlivesInput()
+        {
+            var array = BuildStringView("a string longer than twelve bytes");
+
+            var concatenated = 
(StringViewArray)ArrowArrayConcatenator.Concatenate(new IArrowArray[] { array 
});
+            array.Dispose();
+
+            Assert.Equal("a string longer than twelve bytes", 
concatenated.GetString(0));
+            concatenated.Dispose();
+        }
+
+        [Fact]
+        public void TestListWithSingleNonEmptyInputOutlivesInputs()
+        {
+            var builder = new ListArray.Builder(StringType.Default);
+            var valueBuilder = (StringArray.Builder)builder.ValueBuilder;
+            builder.Append();
+            valueBuilder.Append("x");
+            valueBuilder.Append("y");
+            var nonEmpty = builder.Build();
+            var empty = new ListArray.Builder(StringType.Default).Build();
+
+            var concatenated = 
(ListArray)ArrowArrayConcatenator.Concatenate(new IArrowArray[] { empty, 
nonEmpty });
+            nonEmpty.Dispose();
+            empty.Dispose();
+
+            var values = (StringArray)concatenated.Values;
+            Assert.Equal("x", values.GetString(0));
+            Assert.Equal("y", values.GetString(1));
+            concatenated.Dispose();
+        }
+
+        [Fact]
+        public void TestRunEndEncodedWithSingleNonEmptyInputOutlivesInputs()
+        {
+            var empty = new RunEndEncodedArray(
+                new Int32Array.Builder().Build(),
+                new StringArray.Builder().Build());
+            var nonEmpty = new RunEndEncodedArray(
+                new Int32Array.Builder().AppendRange(new[] { 2, 4 }).Build(),
+                new StringArray.Builder().AppendRange(new[] { "A", "B" 
}).Build());
+
+            var concatenated = 
(RunEndEncodedArray)ArrowArrayConcatenator.Concatenate(new IArrowArray[] { 
empty, nonEmpty });
+            nonEmpty.Dispose();
+            empty.Dispose();
+
+            var values = (StringArray)concatenated.Values;
+            Assert.Equal("A", values.GetString(0));
+            Assert.Equal("B", values.GetString(1));
+            concatenated.Dispose();
+        }
+
+        private static StringViewArray BuildStringView(params string[] values)
+        {
+            var builder = new StringViewArray.Builder();
+            foreach (string value in values)
+            {
+                builder.Append(value);
+            }
+            return builder.Build();
+        }
+
         private static IEnumerable<Tuple<List<IArrowArray>, IArrowArray>> 
GenerateTestData(bool slicedArrays = false)
         {
             var targetTypes = new List<IArrowType>() {

Reply via email to