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 a606089b72 [common] Release the BTree index input when construction or
close fails (#9407)
a606089b72 is described below
commit a606089b721cb6283e2fe0384e7c4f6eb1c2673e
Author: ZIHAN DAI <[email protected]>
AuthorDate: Thu Aug 27 18:21:02 2026 +1000
[common] Release the BTree index input when construction or close fails
(#9407)
---
.../paimon/globalindex/btree/BTreeIndexReader.java | 55 ++++--
.../btree/BTreeIndexReaderCloseTest.java | 217 +++++++++++++++++++++
2 files changed, 251 insertions(+), 21 deletions(-)
diff --git
a/paimon-common/src/main/java/org/apache/paimon/globalindex/btree/BTreeIndexReader.java
b/paimon-common/src/main/java/org/apache/paimon/globalindex/btree/BTreeIndexReader.java
index d8183337bd..6203d6d4a0 100644
---
a/paimon-common/src/main/java/org/apache/paimon/globalindex/btree/BTreeIndexReader.java
+++
b/paimon-common/src/main/java/org/apache/paimon/globalindex/btree/BTreeIndexReader.java
@@ -39,6 +39,7 @@ import org.apache.paimon.sst.BlockIterator;
import org.apache.paimon.sst.ReverseBlockIterator;
import org.apache.paimon.sst.SstFileReader;
import org.apache.paimon.utils.FileBasedBloomFilter;
+import org.apache.paimon.utils.IOUtils;
import org.apache.paimon.utils.LazyField;
import org.apache.paimon.utils.Preconditions;
import org.apache.paimon.utils.RoaringNavigableMap64;
@@ -154,25 +155,30 @@ public class BTreeIndexReader implements Closeable {
this.maxKey = null;
}
this.input = fileReader.getInputStream(globalIndexIOMeta);
-
- // prepare file footer
- long fileSize = globalIndexIOMeta.fileSize();
- Path filePath = globalIndexIOMeta.filePath();
- BlockCache blockCache = new BlockCache(filePath, input, cacheManager);
- BTreeFileFooter footer = readFooter(blockCache, fileSize);
-
- // prepare nullBitmap and SstFileReader
- this.nullBitmap =
- new LazyField<>(() -> readNullBitmap(blockCache,
footer.getNullBitmapHandle()));
- FileBasedBloomFilter bloomFilter =
- FileBasedBloomFilter.create(
- input, filePath, cacheManager,
footer.getBloomFilterHandle());
- this.reader =
- new SstFileReader(
- createSliceComparator(keySerializer),
- blockCache,
- footer.getIndexBlockHandle(),
- bloomFilter);
+ try {
+ // prepare file footer
+ long fileSize = globalIndexIOMeta.fileSize();
+ Path filePath = globalIndexIOMeta.filePath();
+ BlockCache blockCache = new BlockCache(filePath, input,
cacheManager);
+ BTreeFileFooter footer = readFooter(blockCache, fileSize);
+
+ // prepare nullBitmap and SstFileReader
+ this.nullBitmap =
+ new LazyField<>(() -> readNullBitmap(blockCache,
footer.getNullBitmapHandle()));
+ FileBasedBloomFilter bloomFilter =
+ FileBasedBloomFilter.create(
+ input, filePath, cacheManager,
footer.getBloomFilterHandle());
+ this.reader =
+ new SstFileReader(
+ createSliceComparator(keySerializer),
+ blockCache,
+ footer.getIndexBlockHandle(),
+ bloomFilter);
+ } catch (RuntimeException e) {
+ // nothing else holds a reference to input yet, so this is the
only chance to release it
+ IOUtils.closeQuietly(input);
+ throw e;
+ }
}
private BTreeFileFooter readFooter(BlockCache blockCache, long fileSize) {
@@ -231,8 +237,15 @@ public class BTreeIndexReader implements Closeable {
@Override
public void close() throws IOException {
- reader.close();
- input.close();
+ // input is this reader's own handle, so it has to be released even
when the reader
+ // itself fails to close. Rethrow the original failure rather than a
wrapper.
+ try {
+ IOUtils.closeAll(reader, input);
+ } catch (IOException | RuntimeException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new IOException(e);
+ }
}
/** Returns a sequential iterator over all non-null key entries in this
index file. */
diff --git
a/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/BTreeIndexReaderCloseTest.java
b/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/BTreeIndexReaderCloseTest.java
new file mode 100644
index 0000000000..27b10a7181
--- /dev/null
+++
b/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/BTreeIndexReaderCloseTest.java
@@ -0,0 +1,217 @@
+/*
+ * 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.fs.FileIO;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.fs.SeekableInputStream;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.globalindex.GlobalIndexIOMeta;
+import org.apache.paimon.globalindex.GlobalIndexSingleColumnWriter;
+import org.apache.paimon.globalindex.KeySerializer;
+import org.apache.paimon.globalindex.ResultEntry;
+import org.apache.paimon.globalindex.io.GlobalIndexFileReader;
+import org.apache.paimon.globalindex.io.GlobalIndexFileWriter;
+import org.apache.paimon.io.cache.CacheKey;
+import org.apache.paimon.io.cache.CacheManager;
+import org.apache.paimon.options.MemorySize;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.IntType;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.util.List;
+import java.util.UUID;
+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 BTreeIndexReader} always releases the file handle it
opens. */
+public class BTreeIndexReaderCloseTest {
+
+ private static final int RECORD_NUM = 1000;
+
+ @TempDir private java.nio.file.Path tempPath;
+
+ private FileIO fileIO;
+ private KeySerializer keySerializer;
+ private GlobalIndexIOMeta meta;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ fileIO = LocalFileIO.create();
+ IntType dataType = new IntType();
+ keySerializer = KeySerializer.create(dataType);
+
+ GlobalIndexFileWriter fileWriter =
+ new GlobalIndexFileWriter() {
+ @Override
+ public String newFileName(String prefix) {
+ return "test-btree-" + UUID.randomUUID() + prefix;
+ }
+
+ @Override
+ public PositionOutputStream newOutputStream(String
fileName)
+ throws IOException {
+ return fileIO.newOutputStream(path(fileName), true);
+ }
+ };
+
+ BTreeGlobalIndexer indexer =
+ new BTreeGlobalIndexer(new DataField(1, "testField",
dataType), new Options());
+ GlobalIndexSingleColumnWriter writer =
indexer.createWriter(fileWriter);
+ for (int i = 0; i < RECORD_NUM; i++) {
+ writer.write(i, (long) i);
+ }
+ List<ResultEntry> results = writer.finish();
+ assertThat(results).hasSize(1);
+
+ ResultEntry entry = results.get(0);
+ Path filePath = path(entry.fileName());
+ meta = new GlobalIndexIOMeta(filePath, fileIO.getFileSize(filePath),
results.get(0).meta());
+ }
+
+ /** A reader over a healthy file keeps the handle open, and close()
releases it. */
+ @Test
+ public void testCloseReleasesTheInput() throws Exception {
+ AtomicInteger closed = new AtomicInteger();
+ BTreeIndexReader reader =
+ new BTreeIndexReader(
+ keySerializer,
+ tracking(closed),
+ meta,
+ new CacheManager(MemorySize.VALUE_8_MB, 0));
+ assertThat(closed).hasValue(0);
+
+ reader.close();
+ assertThat(closed).hasValue(1);
+ }
+
+ /**
+ * A corrupted footer makes the constructor fail after the file handle has
been opened. Nothing
+ * else holds a reference to it at that point, so the constructor has to
release it itself.
+ */
+ @Test
+ public void testFailedConstructionReleasesTheInput() throws Exception {
+ corruptFooterMagic();
+
+ AtomicInteger closed = new AtomicInteger();
+ assertThatThrownBy(
+ () ->
+ new BTreeIndexReader(
+ keySerializer,
+ tracking(closed),
+ meta,
+ new
CacheManager(MemorySize.VALUE_8_MB, 0)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("bad magic number");
+
+ assertThat(closed).hasValue(1);
+ }
+
+ /**
+ * The reader and the file handle are two separate resources. A reader
that fails to close must
+ * still surface its own failure unchanged, but it must not strand the
handle.
+ */
+ @Test
+ public void testCloseReleasesTheInputWhenTheReaderFails() throws Exception
{
+ FailingCacheManager cacheManager = new FailingCacheManager();
+ AtomicInteger closed = new AtomicInteger();
+ BTreeIndexReader reader =
+ new BTreeIndexReader(keySerializer, tracking(closed), meta,
cacheManager);
+
+ cacheManager.failing = true;
+ assertThatThrownBy(reader::close)
+ .isInstanceOf(RuntimeException.class)
+ .hasMessage("cache is down");
+
+ assertThat(closed).hasValue(1);
+ }
+
+ private Path path(String fileName) {
+ return new Path(new Path(tempPath.toUri()), fileName);
+ }
+
+ /** Overwrite the four magic-number bytes the footer ends with. */
+ private void corruptFooterMagic() throws IOException {
+ try (RandomAccessFile file =
+ new RandomAccessFile(new
java.io.File(meta.filePath().toUri()), "rw")) {
+ file.seek(file.length() - 4);
+ file.writeInt(~BTreeFileFooter.MAGIC_NUMBER);
+ }
+ }
+
+ private GlobalIndexFileReader tracking(AtomicInteger closed) {
+ return ioMeta -> {
+ SeekableInputStream delegate =
fileIO.newInputStream(ioMeta.filePath());
+ return new SeekableInputStream() {
+ @Override
+ public void seek(long desired) throws IOException {
+ delegate.seek(desired);
+ }
+
+ @Override
+ public long getPos() throws IOException {
+ return delegate.getPos();
+ }
+
+ @Override
+ public int read() throws IOException {
+ return delegate.read();
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) throws IOException
{
+ return delegate.read(b, off, len);
+ }
+
+ @Override
+ public void close() throws IOException {
+ closed.incrementAndGet();
+ delegate.close();
+ }
+ };
+ };
+ }
+
+ /** Fails page invalidation, which is what closing the reader's bloom
filter does. */
+ private static class FailingCacheManager extends CacheManager {
+
+ private boolean failing = false;
+
+ FailingCacheManager() {
+ super(MemorySize.VALUE_8_MB, 0);
+ }
+
+ @Override
+ public void invalidPage(CacheKey key) {
+ if (failing) {
+ throw new RuntimeException("cache is down");
+ }
+ super.invalidPage(key);
+ }
+ }
+}