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

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 2e8cf7779e [common] Release the BTree index file on every path that 
abandons the writer (#9474)
2e8cf7779e is described below

commit 2e8cf7779e460a34ad0d3920f99a0716d18f18dc
Author: ZIHAN DAI <[email protected]>
AuthorDate: Sun Sep 20 15:10:51 2026 +1000

    [common] Release the BTree index file on every path that abandons the 
writer (#9474)
---
 .../paimon/globalindex/btree/BTreeIndexWriter.java |  34 +++-
 .../btree/BTreeIndexWriterCloseTest.java           | 204 +++++++++++++++++++++
 2 files changed, 233 insertions(+), 5 deletions(-)

diff --git 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/btree/BTreeIndexWriter.java
 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/btree/BTreeIndexWriter.java
index b8d6f62bea..fecf600f56 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/btree/BTreeIndexWriter.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/btree/BTreeIndexWriter.java
@@ -31,12 +31,14 @@ import org.apache.paimon.sst.BlockHandle;
 import org.apache.paimon.sst.BloomFilterHandle;
 import org.apache.paimon.sst.SstFileWriter;
 import org.apache.paimon.utils.BloomFilter;
+import org.apache.paimon.utils.IOUtils;
 import org.apache.paimon.utils.LazyField;
 import org.apache.paimon.utils.LongArrayList;
 import org.apache.paimon.utils.RoaringNavigableMap64;
 
 import javax.annotation.Nullable;
 
+import java.io.Closeable;
 import java.io.IOException;
 import java.util.Collections;
 import java.util.Comparator;
@@ -70,10 +72,11 @@ import java.util.zip.CRC32;
  * <p>For efficiency, we combine entries with the same keys and store a 
compact list of row ids for
  * each key.
  */
-public class BTreeIndexWriter implements GlobalIndexSingleColumnWriter {
+public class BTreeIndexWriter implements GlobalIndexSingleColumnWriter, 
Closeable {
 
     private final String fileName;
     private final PositionOutputStream out;
+    private boolean closed;
 
     private final SstFileWriter writer;
     private final KeySerializer keySerializer;
@@ -126,10 +129,15 @@ public class BTreeIndexWriter implements 
GlobalIndexSingleColumnWriter {
         }
         this.fileName = 
indexFileWriter.newFileName(BTreeGlobalIndexerFactory.IDENTIFIER);
         this.out = indexFileWriter.newOutputStream(this.fileName);
-        this.keySerializer = keySerializer;
-        this.comparator = keySerializer.createComparator();
-        this.fileVersion = fileVersion;
-        this.writer = new SstFileWriter(out, blockSize, bloomFilterBuilder, 
compressionFactory);
+        try {
+            this.keySerializer = keySerializer;
+            this.comparator = keySerializer.createComparator();
+            this.fileVersion = fileVersion;
+            this.writer = new SstFileWriter(out, blockSize, 
bloomFilterBuilder, compressionFactory);
+        } catch (RuntimeException | Error e) {
+            IOUtils.closeQuietly(out);
+            throw e;
+        }
     }
 
     @Override
@@ -205,7 +213,10 @@ public class BTreeIndexWriter implements 
GlobalIndexSingleColumnWriter {
             writer.writeSlice(footerEncoding);
 
             out.close();
+            closed = true;
         } catch (IOException e) {
+            IOUtils.closeQuietly(out);
+            closed = true;
             throw new RuntimeException("Error in closing BTree index writer", 
e);
         }
 
@@ -243,4 +254,17 @@ public class BTreeIndexWriter implements 
GlobalIndexSingleColumnWriter {
 
         return nullBitmapHandle;
     }
+
+    /**
+     * Releases the output stream for a build that is abandoned without {@link 
#finish()}. The owner
+     * cleanup paths reach a writer only through {@code instanceof 
AutoCloseable}, so without this
+     * the stream opened in the constructor stays open for the life of the 
process.
+     */
+    @Override
+    public void close() throws IOException {
+        if (!closed) {
+            closed = true;
+            out.close();
+        }
+    }
 }
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/BTreeIndexWriterCloseTest.java
 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/BTreeIndexWriterCloseTest.java
new file mode 100644
index 0000000000..b351db26b8
--- /dev/null
+++ 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/BTreeIndexWriterCloseTest.java
@@ -0,0 +1,204 @@
+/*
+ * 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.paimon.globalindex.btree;
+
+import org.apache.paimon.compression.BlockCompressionFactory;
+import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.globalindex.KeySerializer;
+import org.apache.paimon.globalindex.io.GlobalIndexFileWriter;
+import org.apache.paimon.memory.MemorySlice;
+import org.apache.paimon.types.IntType;
+import org.apache.paimon.utils.IOUtils;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.Comparator;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests that {@link BTreeIndexWriter} releases the file it opened, on every 
path that abandons it.
+ */
+public class BTreeIndexWriterCloseTest {
+
+    /**
+     * finish() writes the null bitmap, the bloom filter, the index block and 
the footer before
+     * closing. A write that fails part way through - a full disk is the 
ordinary cause - must not
+     * leave the file open.
+     */
+    @Test
+    public void testFinishReleasesTheFileWhenWritingFails() throws IOException 
{
+        AtomicInteger closed = new AtomicInteger();
+        BTreeIndexWriter writer =
+                new BTreeIndexWriter(
+                        failingWriter(closed),
+                        KeySerializer.create(new IntType()),
+                        1024,
+                        (BlockCompressionFactory) null);
+
+        for (int i = 0; i < 200; i++) {
+            writer.write(i, (long) i);
+        }
+
+        assertThatThrownBy(writer::finish)
+                .isInstanceOf(RuntimeException.class)
+                .hasMessageContaining("Error in closing BTree index writer");
+
+        assertThat(closed).hasValue(1);
+    }
+
+    /**
+     * A build can be abandoned before finish() ever runs - the row source 
fails, or a Flink or
+     * Spark index build task is cancelled. The owner cleanup paths, 
PkSortedIndexFile#build and
+     * SortedSingleColumnIndexWriter#close, release the writer only if they 
can see it as an
+     * AutoCloseable, and skip it silently otherwise. This is that exact idiom.
+     */
+    @Test
+    public void testTheOwnerCleanupPathReleasesAnAbandonedWriter() throws 
IOException {
+        AtomicInteger closed = new AtomicInteger();
+        BTreeIndexWriter writer =
+                new BTreeIndexWriter(
+                        failingWriter(closed),
+                        KeySerializer.create(new IntType()),
+                        1024,
+                        (BlockCompressionFactory) null);
+        writer.write(1, 1L);
+
+        if (writer instanceof AutoCloseable) {
+            IOUtils.closeQuietly((AutoCloseable) writer);
+        }
+
+        assertThat(closed).hasValue(1);
+    }
+
+    /**
+     * SortedGlobalIndexWriter holds the task writer in a try-with-resources, 
so close() can follow
+     * a successful finish(). It must not close the stream a second time.
+     */
+    @Test
+    public void testCloseIsIdempotent() throws IOException {
+        AtomicInteger closed = new AtomicInteger();
+        BTreeIndexWriter writer =
+                new BTreeIndexWriter(
+                        failingWriter(closed),
+                        KeySerializer.create(new IntType()),
+                        1024,
+                        (BlockCompressionFactory) null);
+        writer.write(1, 1L);
+
+        writer.close();
+        writer.close();
+
+        assertThat(closed).hasValue(1);
+    }
+
+    /**
+     * SstFileWriter allocates its block buffers after the stream is open, so 
a direct or heap
+     * allocation failure there arrives as an Error rather than an exception. 
The stream still has
+     * to be released: a task runtime can catch that and keep the JVM alive.
+     */
+    @Test
+    public void testConstructorReleasesTheFileWhenSetupThrowsAnError() {
+        AtomicInteger closed = new AtomicInteger();
+        assertThatThrownBy(
+                        () ->
+                                new BTreeIndexWriter(
+                                        failingWriter(closed),
+                                        errorThrowingSerializer(),
+                                        1024,
+                                        (BlockCompressionFactory) null))
+                .isInstanceOf(OutOfMemoryError.class);
+
+        assertThat(closed).hasValue(1);
+    }
+
+    private static KeySerializer errorThrowingSerializer() {
+        KeySerializer delegate = KeySerializer.create(new IntType());
+        return new KeySerializer() {
+
+            @Override
+            public byte[] serialize(Object key) {
+                return delegate.serialize(key);
+            }
+
+            @Override
+            public Object deserialize(MemorySlice data) {
+                return delegate.deserialize(data);
+            }
+
+            @Override
+            public Comparator<Object> createComparator() {
+                throw new OutOfMemoryError("Direct buffer memory");
+            }
+        };
+    }
+
+    private static GlobalIndexFileWriter failingWriter(AtomicInteger closed) {
+        return new GlobalIndexFileWriter() {
+
+            @Override
+            public String newFileName(String prefix) {
+                return "test-btree" + prefix;
+            }
+
+            @Override
+            public PositionOutputStream newOutputStream(String fileName) {
+                return new PositionOutputStream() {
+
+                    private long pos;
+                    private final long capacity = 1500L;
+
+                    @Override
+                    public long getPos() {
+                        return pos;
+                    }
+
+                    @Override
+                    public void write(int b) throws IOException {
+                        write(new byte[] {(byte) b}, 0, 1);
+                    }
+
+                    @Override
+                    public void write(byte[] b) throws IOException {
+                        write(b, 0, b.length);
+                    }
+
+                    @Override
+                    public void write(byte[] b, int off, int len) throws 
IOException {
+                        if (pos + len > capacity) {
+                            throw new IOException("no space left on device");
+                        }
+                        pos += len;
+                    }
+
+                    @Override
+                    public void flush() {}
+
+                    @Override
+                    public void close() {
+                        closed.incrementAndGet();
+                    }
+                };
+            }
+        };
+    }
+}

Reply via email to