Skip to content

[common] Release the BTree index file on every path that abandons the writer - #9474

Open
PDGGK wants to merge 2 commits into
apache:masterfrom
PDGGK:fix-btree-writer-leak
Open

[common] Release the BTree index file on every path that abandons the writer#9474
PDGGK wants to merge 2 commits into
apache:masterfrom
PDGGK:fix-btree-writer-leak

Conversation

@PDGGK

@PDGGK PDGGK commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Purpose

BTreeIndexWriter opens a PositionOutputStream in its constructor and, before this change, gave it back on exactly one path: the success path of finish(). Three ways that leaves the file open.

1. The writer is the only one its owners cannot release. It is the only GlobalIndexSingleColumnWriter in src/main that does not implement Closeable:

writer Closeable
BitmapGlobalIndexWriter MultiValueBitmapIndexWriter FMGlobalIndexWriter NativeFullTextGlobalIndexWriter NativeVectorGlobalIndexWriter LuminaVectorGlobalIndexWriter ESIndexGlobalIndexWriter yes
BTreeIndexWriter no

Both cleanup paths a btree writer can reach guard on that:

// PkSortedIndexFile#build, in the finally block
if (writer instanceof AutoCloseable) {
    IOUtils.closeQuietly((AutoCloseable) writer);
}
// SortedSingleColumnIndexWriter#close, reached from SortedGlobalIndexWriter's
// try-with-resources and from the Flink SortedIndexBuildOperator
if (writer instanceof AutoCloseable) { ... }

For a btree writer both evaluate false, so the writer is skipped without a word. A build abandoned before finish() — the row source fails, or a Flink or Spark CREATE GLOBAL INDEX ... btree task is cancelled — leaves the stream open with nothing left that can reach it: the class exposes neither a close() nor any accessor for out. On the PkSortedIndexFile path, deleteCreatedFiles() then unlinks the target while that stream is still open.

2. A partially built writer keeps the file. The constructor opens the stream, then builds the comparator and the SstFileWriter. If either throws, the stream is already open and the object is never handed back.

3. finish() keeps the file when it fails. It writes the null bitmap, the bloom filter, the index block and the footer before closing. Anything failing part way — a full disk is the ordinary cause — went to catch (IOException e) and rethrew without closing.

Tests

BTreeIndexWriterCloseTest, three cases against a PositionOutputStream that counts closes and fails past a fixed capacity:

  • testTheOwnerCleanupPathReleasesAnAbandonedWriter — runs the exact instanceof AutoCloseable idiom the owners use, against a writer that never reaches finish().
  • testCloseIsIdempotentSortedGlobalIndexWriter holds the task writer in a try-with-resources, so close() can follow a successful finish(); the stream must not be closed twice.
  • testFinishReleasesTheFileWhenWritingFails — a write that fails mid-finish().

On master the first two fail and the third errors. Removing only Closeable from the class while keeping the close() method — so it still compiles and still has a way to be closed, just not one the owners can see — fails exactly one test, testTheOwnerCleanupPathReleasesAnAbandonedWriter with Expecting AtomicInteger(0) to have value: 1, which is what pins that the test discriminates the property being fixed rather than merely the presence of a method.

Worth noting for review: SortedGlobalIndexWriterTest already asserts this cleanup, in testSingleColumnWriterClosesActiveWriter and testBuildForSinglePartitionClosesWriterAfterFailure. Both build the subject with mock(GlobalIndexSingleColumnWriter.class, withSettings().extraInterfaces(Closeable.class)) — they add to the mock the one property the real class lacks — and the second names indexType btree but then overrides createWriter() to return that mock, so BTreeIndexWriter is never constructed. Both are green today.

mvn test -pl paimon-common -Dtest='org.apache.paimon.globalindex.**' — 349 tests, all passing. spotless:check and checkstyle:check clean.

Tests API and Compatibility

No API, format or configuration change. close() is additive and idempotent, so a successful finish() behaves exactly as before.

finish() writes the null bitmap, the bloom filter, the index block and the
footer before closing the output stream, and all of that sits in one try whose
catch only rethrows. A write that fails part way through - a full disk is the
ordinary cause - skips out.close() and leaves the handle open.

The constructor has the same gap in the other direction: it opens the output
stream and then builds the serializer, comparator and SstFileWriter, so a
failure there strands a file nothing else references.

BTreeIndexReader guards its own construction the same way.
@PDGGK
PDGGK force-pushed the fix-btree-writer-leak branch from 28973e4 to 3e3e6fb Compare September 1, 2026 12:03
BTreeIndexWriter is the only GlobalIndexSingleColumnWriter in src/main
that does not implement Closeable; the other seven do. Its constructor
opens a PositionOutputStream unconditionally, and the only out.close() is
on the success path of finish(). The owner cleanup paths -
PkSortedIndexFile#build and SortedSingleColumnIndexWriter#close - reach a
writer only through 'instanceof AutoCloseable', so for this one they
evaluate false and skip it silently. A build abandoned before finish(),
because the row source failed or a Flink or Spark index build task was
cancelled, leaves that stream open with nothing able to reach it.

Implement Closeable with an idempotent close(), so a build that never
reaches finish() releases the file through the path its owners already
use.
@PDGGK PDGGK changed the title [common] Release the BTree index file when writing it fails [common] Release the BTree index file on every path that abandons the writer Sep 1, 2026
this.comparator = keySerializer.createComparator();
// todo: we may enable bf to accelerate equal and in predicate in the future
this.writer = new SstFileWriter(out, blockSize, null, compressionFactory);
} catch (RuntimeException e) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Close the stream when constructor setup throws an Error

This constructor-cleanup guard still misses Error. In particular, new SstFileWriter(...) allocates its data and index block buffers after out has been opened, so an OutOfMemoryError leaves the constructor without returning an AutoCloseable object and strands the output stream. A task/runtime may catch that failure while keeping the JVM alive, just like the abandoned-build paths this PR addresses. Please catch and rethrow RuntimeException | Error (or use the usual catch (Throwable) constructor-cleanup pattern) so every post-open setup failure closes out.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants