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 fea8bfb9ad [core] Abort single file writers on unchecked close
failures (#9016)
fea8bfb9ad is described below
commit fea8bfb9add68036cc54406cf664b5b084b01446
Author: Vova Kolmakov <[email protected]>
AuthorDate: Fri Aug 7 12:20:03 2026 +0700
[core] Abort single file writers on unchecked close failures (#9016)
---
.../paimon/io/FormatTableSingleFileWriter.java | 8 +-
.../org/apache/paimon/io/SingleFileWriter.java | 8 +-
.../paimon/io/FormatTableSingleFileWriterTest.java | 75 +++++++++++++++
.../org/apache/paimon/io/SingleFileWriterTest.java | 107 +++++++++++++++++++++
4 files changed, 194 insertions(+), 4 deletions(-)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/io/FormatTableSingleFileWriter.java
b/paimon-core/src/main/java/org/apache/paimon/io/FormatTableSingleFileWriter.java
index 8a01787f4c..6290d95391 100644
---
a/paimon-core/src/main/java/org/apache/paimon/io/FormatTableSingleFileWriter.java
+++
b/paimon-core/src/main/java/org/apache/paimon/io/FormatTableSingleFileWriter.java
@@ -168,9 +168,13 @@ public class FormatTableSingleFileWriter {
committer = ((TwoPhaseOutputStream) out).closeForCommit();
out = null;
}
- } catch (IOException e) {
+ } catch (Throwable e) {
LOG.warn("Exception occurs when closing file {}. Cleaning up.",
path, e);
- abort();
+ try {
+ abort();
+ } catch (Throwable t) {
+ e.addSuppressed(t);
+ }
throw e;
} finally {
closed = true;
diff --git
a/paimon-core/src/main/java/org/apache/paimon/io/SingleFileWriter.java
b/paimon-core/src/main/java/org/apache/paimon/io/SingleFileWriter.java
index 29c4a448a7..8ae8f4bdc0 100644
--- a/paimon-core/src/main/java/org/apache/paimon/io/SingleFileWriter.java
+++ b/paimon-core/src/main/java/org/apache/paimon/io/SingleFileWriter.java
@@ -247,9 +247,13 @@ public abstract class SingleFileWriter<T, R> implements
FileWriter<T, R> {
out.close();
out = null;
}
- } catch (IOException e) {
+ } catch (Throwable e) {
LOG.warn("Exception occurs when closing file {}. Cleaning up.",
path, e);
- abort();
+ try {
+ abort();
+ } catch (Throwable t) {
+ e.addSuppressed(t);
+ }
throw e;
} finally {
closed = true;
diff --git
a/paimon-core/src/test/java/org/apache/paimon/io/FormatTableSingleFileWriterTest.java
b/paimon-core/src/test/java/org/apache/paimon/io/FormatTableSingleFileWriterTest.java
index 0b81f3801b..7802b119c9 100644
---
a/paimon-core/src/test/java/org/apache/paimon/io/FormatTableSingleFileWriterTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/io/FormatTableSingleFileWriterTest.java
@@ -88,6 +88,48 @@ public class FormatTableSingleFileWriterTest {
assertThat(fileIO.readFileUtf8(path)).isEqualTo("keep me");
}
+ @Test
+ public void testRuntimeExceptionWhileClosingLeavesNoFileBehind() throws
IOException {
+ // several format writers wrap IO failures in unchecked exceptions on
the close path
+ FormatTableSingleFileWriter writer =
+ newWriter(
+ (out, compression) ->
+ new ThrowingCloseWriter(new
IllegalStateException("cannot close")));
+
+ assertThatThrownBy(writer::close)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("cannot close");
+
+ assertThat(fileIO.listFiles(new Path(tempDir.toString()),
true)).isEmpty();
+ }
+
+ @Test
+ public void testIOExceptionWhileClosingLeavesNoFileBehind() throws
IOException {
+ FormatTableSingleFileWriter writer =
+ newWriter((out, compression) -> new ThrowingCloseWriter(new
IOException("boom")));
+
+ // the checked failure must still reach the caller unwrapped
+
assertThatThrownBy(writer::close).isInstanceOf(IOException.class).hasMessage("boom");
+
+ assertThat(fileIO.listFiles(new Path(tempDir.toString()),
true)).isEmpty();
+ }
+
+ @Test
+ public void testCleanupFailureDoesNotReplaceOriginalException() {
+ FormatTableSingleFileWriter writer =
+ new FormatTableSingleFileWriter(
+ new DeleteFailingFileIO(),
+ (out, compression) ->
+ new ThrowingCloseWriter(new
IllegalStateException("cannot close")),
+ path,
+ "zstd");
+
+ assertThatThrownBy(writer::close)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("cannot close")
+ .hasSuppressedException(new RuntimeException("cannot delete"));
+ }
+
private FormatTableSingleFileWriter newWriter(FormatWriterFactory factory)
{
return new FormatTableSingleFileWriter(fileIO, factory, path, "zstd");
}
@@ -105,4 +147,37 @@ public class FormatTableSingleFileWriterTest {
@Override
public void close() {}
}
+
+ private static class DeleteFailingFileIO extends LocalFileIO {
+
+ @Override
+ public boolean delete(Path f, boolean recursive) {
+ throw new RuntimeException("cannot delete");
+ }
+ }
+
+ private static class ThrowingCloseWriter implements FormatWriter {
+
+ private final Throwable failure;
+
+ private ThrowingCloseWriter(Throwable failure) {
+ this.failure = failure;
+ }
+
+ @Override
+ public void addElement(InternalRow element) {}
+
+ @Override
+ public boolean reachTargetSize(boolean suggestedCheck, long
targetSize) {
+ return false;
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (failure instanceof IOException) {
+ throw (IOException) failure;
+ }
+ throw (RuntimeException) failure;
+ }
+ }
}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/io/SingleFileWriterTest.java
b/paimon-core/src/test/java/org/apache/paimon/io/SingleFileWriterTest.java
index 2f67f80ef4..1f90231b7a 100644
--- a/paimon-core/src/test/java/org/apache/paimon/io/SingleFileWriterTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/io/SingleFileWriterTest.java
@@ -26,7 +26,9 @@ import org.apache.paimon.format.SupportsDirectWrite;
import org.apache.paimon.fs.FileIO;
import org.apache.paimon.fs.Path;
import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.fs.PositionOutputStreamWrapper;
import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.utils.TraceableFileIO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -145,6 +147,65 @@ public class SingleFileWriterTest {
assertThat(fileIO.exists(path)).isFalse();
}
+ @Test
+ public void testRuntimeExceptionWhileClosingDeletesFile() throws
IOException {
+ // several format writers wrap IO failures in unchecked exceptions on
the close path
+ TestSingleFileWriter writer =
+ newWriter(
+ (out, compression) ->
+ new ThrowingCloseWriter(new
IllegalStateException("cannot close")));
+
+ assertThatThrownBy(writer::close)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("cannot close");
+
+ assertThat(fileIO.exists(path)).isFalse();
+ }
+
+ @Test
+ public void testIOExceptionWhileClosingDeletesFile() throws IOException {
+ TestSingleFileWriter writer =
+ newWriter((out, compression) -> new ThrowingCloseWriter(new
IOException("boom")));
+
+ // the checked failure must still reach the caller unwrapped
+
assertThatThrownBy(writer::close).isInstanceOf(IOException.class).hasMessage("boom");
+
+ assertThat(fileIO.exists(path)).isFalse();
+ }
+
+ @Test
+ public void testRuntimeExceptionWhileFlushingClosesStream() throws
IOException {
+ // the output stream can fail with an unchecked exception too, for
example
+ // AsyncPositionOutputStream when the writing thread is interrupted
+ FileIO trackedFileIO = new FlushFailingFileIO();
+ TestSingleFileWriter writer =
+ new TestSingleFileWriter(
+ trackedFileIO, (out, compression) -> new
NoOpFormatWriter(), path, false);
+
+ assertThatThrownBy(writer::close)
+ .isExactlyInstanceOf(RuntimeException.class)
+ .hasMessage("cannot flush");
+
+ assertThat(TraceableFileIO.openOutputStreams(path::equals)).isEmpty();
+ assertThat(trackedFileIO.exists(path)).isFalse();
+ }
+
+ @Test
+ public void testCleanupFailureDoesNotReplaceOriginalException() {
+ TestSingleFileWriter writer =
+ new TestSingleFileWriter(
+ new DeleteFailingFileIO(),
+ (out, compression) ->
+ new ThrowingCloseWriter(new
IllegalStateException("cannot close")),
+ path,
+ false);
+
+ assertThatThrownBy(writer::close)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("cannot close")
+ .hasSuppressedException(new RuntimeException("cannot delete"));
+ }
+
@Test
public void testSuccessfulOpenKeepsFile() throws IOException {
NoOpFormatWriter formatWriter = new NoOpFormatWriter();
@@ -226,6 +287,52 @@ public class SingleFileWriterTest {
}
}
+ private static class ThrowingCloseWriter implements FormatWriter {
+
+ private final Throwable failure;
+
+ private ThrowingCloseWriter(Throwable failure) {
+ this.failure = failure;
+ }
+
+ @Override
+ public void addElement(InternalRow element) {}
+
+ @Override
+ public boolean reachTargetSize(boolean suggestedCheck, long
targetSize) {
+ return false;
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (failure instanceof IOException) {
+ throw (IOException) failure;
+ }
+ throw (RuntimeException) failure;
+ }
+ }
+
+ private static class DeleteFailingFileIO extends LocalFileIO {
+
+ @Override
+ public boolean delete(Path f, boolean recursive) {
+ throw new RuntimeException("cannot delete");
+ }
+ }
+
+ private static class FlushFailingFileIO extends TraceableFileIO {
+
+ @Override
+ public PositionOutputStream newOutputStream(Path f, boolean overwrite)
throws IOException {
+ return new PositionOutputStreamWrapper(super.newOutputStream(f,
overwrite)) {
+ @Override
+ public void flush() {
+ throw new RuntimeException("cannot flush");
+ }
+ };
+ }
+ }
+
private static class DirectWriteFactory implements FormatWriterFactory,
SupportsDirectWrite {
private final FileAwareWriter writer = new FileAwareWriter();