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

nzw921rx pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git


The following commit(s) were added to refs/heads/dev by this push:
     new 4a6f0fe829 [Fix][Connector-V2] Fix Arrow memory leak by correcting 
close order in ArrowToSeatunnelRowReader (#11929)
4a6f0fe829 is described below

commit 4a6f0fe82964f430877bc8b689e2ed75883d9136
Author: David Zollo <[email protected]>
AuthorDate: Sat Oct 17 03:32:26 2026 -0400

    [Fix][Connector-V2] Fix Arrow memory leak by correcting close order in 
ArrowToSeatunnelRowReader (#11929)
    
    Co-authored-by: Daniel <[email protected]>
    Co-authored-by: DanielLeens <[email protected]>
---
 .github/workflows/backend.yml                      |  14 +-
 .../arrow/reader/ArrowToSeatunnelRowReader.java    |  58 ++++-
 .../reader/ArrowToSeatunnelRowReaderCloseTest.java | 272 +++++++++++++++++++++
 3 files changed, 330 insertions(+), 14 deletions(-)

diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml
index 6a26ce2f29..0a64afd075 100644
--- a/.github/workflows/backend.yml
+++ b/.github/workflows/backend.yml
@@ -771,7 +771,7 @@ jobs:
       matrix:
         java: [ '11', '17' ]
         os: [ 'ubuntu-latest' ]
-    timeout-minutes: 180
+    timeout-minutes: 300
     steps:
       - uses: actions/checkout@v4
       - name: Set up JDK ${{ matrix.java }}
@@ -811,7 +811,7 @@ jobs:
       matrix:
         java: [ '11', '17' ]
         os: [ 'ubuntu-latest' ]
-    timeout-minutes: 180
+    timeout-minutes: 300
     steps:
       - uses: actions/checkout@v4
       - name: Set up JDK ${{ matrix.java }}
@@ -891,7 +891,7 @@ jobs:
       matrix:
         java: [ '11', '17' ]
         os: [ 'ubuntu-latest' ]
-    timeout-minutes: 200
+    timeout-minutes: 300
     steps:
       - uses: actions/checkout@v4
       - name: Set up JDK ${{ matrix.java }}
@@ -930,7 +930,7 @@ jobs:
       matrix:
         java: [ '11', '17' ]
         os: [ 'ubuntu-latest' ]
-    timeout-minutes: 180
+    timeout-minutes: 300
     steps:
       - uses: actions/checkout@v4
       - name: Set up JDK ${{ matrix.java }}
@@ -969,7 +969,7 @@ jobs:
       matrix:
         java: [ '11', '17' ]
         os: [ 'ubuntu-latest' ]
-    timeout-minutes: 210
+    timeout-minutes: 300
     steps:
       - uses: actions/checkout@v4
       - name: Set up JDK ${{ matrix.java }}
@@ -1008,7 +1008,7 @@ jobs:
       matrix:
         java: [ '11', '17' ]
         os: [ 'ubuntu-latest' ]
-    timeout-minutes: 210
+    timeout-minutes: 300
     steps:
       - uses: actions/checkout@v4
       - name: Set up JDK ${{ matrix.java }}
@@ -1048,7 +1048,7 @@ jobs:
       matrix:
         java: [ '11', '17' ]
         os: [ 'ubuntu-latest' ]
-    timeout-minutes: 210
+    timeout-minutes: 300
     steps:
       - uses: actions/checkout@v4
       - name: Set up JDK ${{ matrix.java }}
diff --git 
a/seatunnel-connectors-v2/connector-common/src/main/java/org/apache/seatunnel/connectors/seatunnel/common/source/arrow/reader/ArrowToSeatunnelRowReader.java
 
b/seatunnel-connectors-v2/connector-common/src/main/java/org/apache/seatunnel/connectors/seatunnel/common/source/arrow/reader/ArrowToSeatunnelRowReader.java
index f7821ed5fd..327fe3da33 100644
--- 
a/seatunnel-connectors-v2/connector-common/src/main/java/org/apache/seatunnel/connectors/seatunnel/common/source/arrow/reader/ArrowToSeatunnelRowReader.java
+++ 
b/seatunnel-connectors-v2/connector-common/src/main/java/org/apache/seatunnel/connectors/seatunnel/common/source/arrow/reader/ArrowToSeatunnelRowReader.java
@@ -17,6 +17,7 @@
 
 package org.apache.seatunnel.connectors.seatunnel.common.source.arrow.reader;
 
+import 
org.apache.seatunnel.shade.com.google.common.annotations.VisibleForTesting;
 import org.apache.seatunnel.shade.org.apache.arrow.memory.RootAllocator;
 import org.apache.seatunnel.shade.org.apache.arrow.vector.FieldVector;
 import org.apache.seatunnel.shade.org.apache.arrow.vector.VectorSchemaRoot;
@@ -82,6 +83,27 @@ public class ArrowToSeatunnelRowReader implements 
AutoCloseable {
         initArrowReader(byteArray);
     }
 
+    /**
+     * Creates a reader with prebuilt Arrow resources.
+     *
+     * <p>The constructor keeps close-order regression tests focused on 
resource ownership while
+     * preserving the production byte-array constructor as the normal entry 
point. It skips
+     * initArrowReader(byte[]) on purpose, so it must never be promoted to a 
production entry point:
+     * hasNext()/next() are only safe after readArrow() ran against real 
stream state.
+     */
+    @VisibleForTesting
+    ArrowToSeatunnelRowReader(
+            ArrowStreamReader arrowStreamReader,
+            RootAllocator rootAllocator,
+            SeaTunnelRowType seaTunnelRowType) {
+        // Take ownership of the caller-supplied resources before any schema 
processing that
+        // can throw, so close() stays able to release them even if 
initialization fails.
+        this.arrowStreamReader = arrowStreamReader;
+        this.rootAllocator = rootAllocator;
+        this.seaTunnelDataTypes = seaTunnelRowType.getFieldTypes();
+        initFieldIndexMap(seaTunnelRowType);
+    }
+
     private void initFieldIndexMap(SeaTunnelRowType seaTunnelRowType) {
         for (int i = 0; i < seaTunnelRowType.getFieldNames().length; i++) {
             fieldIndexMap.put(seaTunnelRowType.getFieldNames()[i], i);
@@ -319,18 +341,40 @@ public class ArrowToSeatunnelRowReader implements 
AutoCloseable {
 
     @Override
     public void close() {
+        RuntimeException closeException = null;
         try {
-            if (root != null) {
-                root.close();
-            }
-            if (rootAllocator != null) {
-                rootAllocator.close();
-            }
+            // ArrowStreamReader must be closed before RootAllocator.
+            // ArrowStreamReader internally closes VectorSchemaRoot and 
releases all Arrow
+            // buffer allocations back to the allocator. If the allocator is 
closed first,
+            // it detects unreleased memory and throws IllegalStateException 
("Memory was
+            // leaked by query"), which is the root cause of issue #9863.
             if (arrowStreamReader != null) {
                 arrowStreamReader.close();
             }
         } catch (IOException e) {
-            throw new RuntimeException("failed to close arrow stream reader.", 
e);
+            closeException = new RuntimeException("failed to close arrow 
stream reader.", e);
+        } catch (RuntimeException e) {
+            // Arrow's ArrowReader.close() can also fail with unchecked 
exceptions (for example
+            // from VectorSchemaRoot or dictionary vector cleanup). Route them 
through the same
+            // aggregation as IOException; otherwise they would propagate 
directly and any
+            // allocator close failure below would be dropped without a 
suppressed link, losing
+            // the leak diagnostic this close path exists to preserve.
+            closeException = e;
+        } finally {
+            try {
+                if (rootAllocator != null) {
+                    rootAllocator.close();
+                }
+            } catch (RuntimeException e) {
+                if (closeException == null) {
+                    closeException = e;
+                } else {
+                    closeException.addSuppressed(e);
+                }
+            }
+        }
+        if (closeException != null) {
+            throw closeException;
         }
     }
 }
diff --git 
a/seatunnel-connectors-v2/connector-common/src/test/java/org/apache/seatunnel/connectors/seatunnel/common/source/arrow/reader/ArrowToSeatunnelRowReaderCloseTest.java
 
b/seatunnel-connectors-v2/connector-common/src/test/java/org/apache/seatunnel/connectors/seatunnel/common/source/arrow/reader/ArrowToSeatunnelRowReaderCloseTest.java
new file mode 100644
index 0000000000..6202ac5691
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-common/src/test/java/org/apache/seatunnel/connectors/seatunnel/common/source/arrow/reader/ArrowToSeatunnelRowReaderCloseTest.java
@@ -0,0 +1,272 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.seatunnel.connectors.seatunnel.common.source.arrow.reader;
+
+import org.apache.seatunnel.shade.org.apache.arrow.memory.BufferAllocator;
+import org.apache.seatunnel.shade.org.apache.arrow.memory.RootAllocator;
+import org.apache.seatunnel.shade.org.apache.arrow.vector.FieldVector;
+import org.apache.seatunnel.shade.org.apache.arrow.vector.IntVector;
+import org.apache.seatunnel.shade.org.apache.arrow.vector.VarCharVector;
+import org.apache.seatunnel.shade.org.apache.arrow.vector.VectorSchemaRoot;
+import 
org.apache.seatunnel.shade.org.apache.arrow.vector.dictionary.Dictionary;
+import 
org.apache.seatunnel.shade.org.apache.arrow.vector.dictionary.DictionaryEncoder;
+import 
org.apache.seatunnel.shade.org.apache.arrow.vector.dictionary.DictionaryProvider;
+import 
org.apache.seatunnel.shade.org.apache.arrow.vector.ipc.ArrowStreamReader;
+import 
org.apache.seatunnel.shade.org.apache.arrow.vector.ipc.ArrowStreamWriter;
+import org.apache.seatunnel.shade.org.apache.arrow.vector.types.pojo.ArrowType;
+import 
org.apache.seatunnel.shade.org.apache.arrow.vector.types.pojo.DictionaryEncoding;
+
+import org.apache.seatunnel.api.table.type.BasicType;
+import org.apache.seatunnel.api.table.type.SeaTunnelDataType;
+import org.apache.seatunnel.api.table.type.SeaTunnelRowType;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.InOrder;
+import org.mockito.Mockito;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.channels.Channels;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Verifies that Arrow reader cleanup releases the allocator even when stream 
reader close fails,
+ * and that the production read path can drain real Arrow streams without 
tripping the allocator
+ * leak check from issue #9863.
+ */
+public class ArrowToSeatunnelRowReaderCloseTest {
+
+    /**
+     * Covers the normal close path and preserves the required 
reader-before-allocator order.
+     *
+     * <p>This protects the Arrow allocator from being closed before the 
stream reader has released
+     * vector buffers.
+     */
+    @Test
+    public void testCloseReleasesAllocatorWhenStreamReaderCloseSucceeds() 
throws Exception {
+        ArrowStreamReader arrowStreamReader = 
Mockito.mock(ArrowStreamReader.class);
+        RootAllocator rootAllocator = Mockito.mock(RootAllocator.class);
+        ArrowToSeatunnelRowReader reader =
+                new ArrowToSeatunnelRowReader(
+                        arrowStreamReader, rootAllocator, 
createSeatunnelRowType());
+
+        reader.close();
+
+        InOrder inOrder = Mockito.inOrder(arrowStreamReader, rootAllocator);
+        inOrder.verify(arrowStreamReader).close();
+        inOrder.verify(rootAllocator).close();
+    }
+
+    /**
+     * Covers the regression where an ArrowStreamReader close failure skipped 
allocator cleanup.
+     *
+     * <p>The reader close failure is still propagated, but the allocator must 
always receive its
+     * close call.
+     */
+    @Test
+    public void testCloseReleasesAllocatorWhenStreamReaderCloseFails() throws 
Exception {
+        IOException closeFailure = new IOException("close failure");
+        ArrowStreamReader arrowStreamReader = 
Mockito.mock(ArrowStreamReader.class);
+        Mockito.doThrow(closeFailure).when(arrowStreamReader).close();
+        RootAllocator rootAllocator = Mockito.mock(RootAllocator.class);
+        ArrowToSeatunnelRowReader reader =
+                new ArrowToSeatunnelRowReader(
+                        arrowStreamReader, rootAllocator, 
createSeatunnelRowType());
+
+        RuntimeException exception = 
Assertions.assertThrows(RuntimeException.class, reader::close);
+
+        Assertions.assertSame(closeFailure, exception.getCause());
+        InOrder inOrder = Mockito.inOrder(arrowStreamReader, rootAllocator);
+        inOrder.verify(arrowStreamReader).close();
+        inOrder.verify(rootAllocator).close();
+    }
+
+    /**
+     * Covers unchecked reader close failures so the allocator failure is 
never silently dropped.
+     *
+     * <p>An unchecked exception from ArrowStreamReader.close() must become 
the primary close
+     * failure and any allocator close failure must be attached as a 
suppressed exception, otherwise
+     * the allocator diagnostic for a real leak is lost during error handling.
+     */
+    @Test
+    public void 
testCloseAggregatesUncheckedReaderFailureWithAllocatorFailure() throws 
Exception {
+        RuntimeException readerFailure = new IllegalStateException("reader 
close failure");
+        IllegalStateException allocatorFailure = new 
IllegalStateException("Memory was leaked");
+        ArrowStreamReader arrowStreamReader = 
Mockito.mock(ArrowStreamReader.class);
+        Mockito.doThrow(readerFailure).when(arrowStreamReader).close();
+        RootAllocator rootAllocator = Mockito.mock(RootAllocator.class);
+        Mockito.doThrow(allocatorFailure).when(rootAllocator).close();
+        ArrowToSeatunnelRowReader reader =
+                new ArrowToSeatunnelRowReader(
+                        arrowStreamReader, rootAllocator, 
createSeatunnelRowType());
+
+        RuntimeException exception = 
Assertions.assertThrows(RuntimeException.class, reader::close);
+
+        Assertions.assertSame(readerFailure, exception);
+        Assertions.assertArrayEquals(new Throwable[] {allocatorFailure}, 
exception.getSuppressed());
+        InOrder inOrder = Mockito.inOrder(arrowStreamReader, rootAllocator);
+        inOrder.verify(arrowStreamReader).close();
+        inOrder.verify(rootAllocator).close();
+    }
+
+    /**
+     * Round-trip regression for the plain read path with a real allocator.
+     *
+     * <p>The reader is built through the production byte-array constructor, 
so readArrow() runs the
+     * same load, convert, and close sequence Doris and StarRocks sources use. 
Any close-order
+     * regression that leaves allocations behind surfaces here as an 
IllegalStateException from
+     * RootAllocator.close().
+     */
+    @Test
+    public void testReadArrowDrainsRealStreamAndClosesWithoutLeak() throws 
Exception {
+        byte[] payload = writePlainIntStream();
+
+        ArrowToSeatunnelRowReader reader =
+                new ArrowToSeatunnelRowReader(payload, 
createSeatunnelRowType());
+
+        Assertions.assertDoesNotThrow(reader::readArrow);
+        Assertions.assertEquals(3, reader.getReadRowCount());
+        Assertions.assertEquals(Arrays.asList(1, 2, 3), drainIds(reader));
+    }
+
+    /**
+     * Round-trip regression for the exact issue #9863 failure signature.
+     *
+     * <p>Dictionary batches are allocated by ArrowStreamReader outside the 
VectorSchemaRoot, so
+     * closing the allocator before the stream reader (the pre-fix order) 
fails with "Memory was
+     * leaked by query" even for a fully drained stream. This test writes a 
dictionary-encoded
+     * column and must fail if the reader-before-allocator close order is ever 
reverted. The encoded
+     * column is intentionally absent from the SeaTunnel schema; the reader 
skips unknown fields,
+     * keeping the assertion focused on resource release.
+     */
+    @Test
+    public void testReadArrowReleasesDictionaryAllocationsOnClose() throws 
Exception {
+        byte[] payload = writeDictionaryEncodedStream();
+
+        ArrowToSeatunnelRowReader reader =
+                new ArrowToSeatunnelRowReader(payload, 
createSeatunnelRowType());
+
+        Assertions.assertDoesNotThrow(reader::readArrow);
+        Assertions.assertEquals(3, reader.getReadRowCount());
+        Assertions.assertEquals(Arrays.asList(1, 2, 3), drainIds(reader));
+    }
+
+    /**
+     * Writes a single-batch Arrow stream holding one int column named "id" 
with values 1, 2, 3.
+     *
+     * <p>The writer side uses its own allocator; leaked writer allocations 
would fail the test when
+     * the try-with-resources closes that allocator.
+     */
+    private byte[] writePlainIntStream() throws IOException {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        try (BufferAllocator writerAllocator = new 
RootAllocator(Integer.MAX_VALUE);
+                IntVector idVector = new IntVector("id", writerAllocator)) {
+            fillIds(idVector);
+            try (VectorSchemaRoot writerRoot = VectorSchemaRoot.of(idVector)) {
+                writerRoot.setRowCount(3);
+                try (ArrowStreamWriter writer =
+                        new ArrowStreamWriter(writerRoot, null, 
Channels.newChannel(out))) {
+                    writer.start();
+                    writer.writeBatch();
+                    writer.end();
+                }
+            }
+        }
+        return out.toByteArray();
+    }
+
+    /**
+     * Writes a single-batch Arrow stream with a plain "id" column plus a 
dictionary-encoded "tag"
+     * column, forcing the reader to hold dictionary allocations outside the 
root.
+     */
+    private byte[] writeDictionaryEncodedStream() throws IOException {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        try (BufferAllocator writerAllocator = new 
RootAllocator(Integer.MAX_VALUE);
+                VarCharVector dictionaryValues =
+                        new VarCharVector("tag_dictionary", writerAllocator);
+                VarCharVector rawTags = new VarCharVector("tag", 
writerAllocator);
+                IntVector idVector = new IntVector("id", writerAllocator)) {
+            dictionaryValues.allocateNew(2);
+            dictionaryValues.set(0, "apple".getBytes(StandardCharsets.UTF_8));
+            dictionaryValues.set(1, "banana".getBytes(StandardCharsets.UTF_8));
+            dictionaryValues.setValueCount(2);
+            Dictionary dictionary =
+                    new Dictionary(
+                            dictionaryValues,
+                            new DictionaryEncoding(1L, false, new 
ArrowType.Int(32, true)));
+
+            rawTags.allocateNew(3);
+            rawTags.set(0, "apple".getBytes(StandardCharsets.UTF_8));
+            rawTags.set(1, "banana".getBytes(StandardCharsets.UTF_8));
+            rawTags.set(2, "apple".getBytes(StandardCharsets.UTF_8));
+            rawTags.setValueCount(3);
+
+            fillIds(idVector);
+
+            try (FieldVector encodedTags =
+                    (FieldVector) DictionaryEncoder.encode(rawTags, 
dictionary)) {
+                DictionaryProvider.MapDictionaryProvider provider =
+                        new 
DictionaryProvider.MapDictionaryProvider(dictionary);
+                try (VectorSchemaRoot writerRoot = 
VectorSchemaRoot.of(idVector, encodedTags)) {
+                    writerRoot.setRowCount(3);
+                    try (ArrowStreamWriter writer =
+                            new ArrowStreamWriter(writerRoot, provider, 
Channels.newChannel(out))) {
+                        writer.start();
+                        writer.writeBatch();
+                        writer.end();
+                    }
+                }
+            }
+        }
+        return out.toByteArray();
+    }
+
+    /** Populates the shared "id" test column with values 1, 2, 3. */
+    private void fillIds(IntVector idVector) {
+        idVector.allocateNew(3);
+        idVector.set(0, 1);
+        idVector.set(1, 2);
+        idVector.set(2, 3);
+        idVector.setValueCount(3);
+    }
+
+    /** Drains all rows from the reader and returns the "id" column values in 
read order. */
+    private List<Integer> drainIds(ArrowToSeatunnelRowReader reader) {
+        List<Integer> ids = new ArrayList<>();
+        while (reader.hasNext()) {
+            ids.add((Integer) reader.next().getField(0));
+        }
+        return ids;
+    }
+
+    /**
+     * Builds the minimal SeaTunnel schema needed to initialize the reader 
resource owner.
+     *
+     * <p>Only the "id" column is declared on purpose: the dictionary-encoded 
"tag" column must stay
+     * out of the schema so the round-trip test exercises the unknown-field 
skip path while still
+     * forcing dictionary allocations on the reader side.
+     */
+    private SeaTunnelRowType createSeatunnelRowType() {
+        return new SeaTunnelRowType(
+                new String[] {"id"}, new SeaTunnelDataType[] 
{BasicType.INT_TYPE});
+    }
+}

Reply via email to