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

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


The following commit(s) were added to refs/heads/master by this push:
     new 3b2a93b0aaa [FLINK-39786][s3]closeForCommit() leaks orphan multipart 
uploads on upload failure (#28951)
3b2a93b0aaa is described below

commit 3b2a93b0aaa2f355dd89330d1278c0cc671df26e
Author: Samrat <[email protected]>
AuthorDate: Wed Aug 19 19:50:51 2026 +0530

    [FLINK-39786][s3]closeForCommit() leaks orphan multipart uploads on upload 
failure (#28951)
    
    * [FLINK-39786][s3]closeForCommit() leaks orphan multipart uploads on 
upload failure
    
    * Address to review comments
---
 .../NativeS3RecoverableFsDataOutputStream.java     | 107 +++++++-----
 .../writer/InMemoryNativeS3Operations.java         |  18 +-
 .../NativeS3RecoverableFsDataOutputStreamTest.java | 192 +++++++++++++++++++++
 3 files changed, 274 insertions(+), 43 deletions(-)

diff --git 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableFsDataOutputStream.java
 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableFsDataOutputStream.java
index 0a0376fcfac..2eb898b9e76 100644
--- 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableFsDataOutputStream.java
+++ 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableFsDataOutputStream.java
@@ -18,6 +18,7 @@
 
 package org.apache.flink.fs.s3native.writer;
 
+import org.apache.flink.annotation.VisibleForTesting;
 import org.apache.flink.core.fs.RecoverableFsDataOutputStream;
 import org.apache.flink.core.fs.RecoverableWriter;
 import org.apache.flink.fs.s3native.writer.NativeS3Recoverable.PartETag;
@@ -26,7 +27,6 @@ import org.apache.flink.util.ExceptionUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import javax.annotation.Nullable;
 import javax.annotation.concurrent.NotThreadSafe;
 
 import java.io.BufferedOutputStream;
@@ -96,7 +96,7 @@ class NativeS3RecoverableFsDataOutputStream extends 
RecoverableFsDataOutputStrea
             long minPartSize,
             List<PartETag> existingParts,
             long numBytesInParts,
-            @Nullable File incompleteTailFile)
+            File incompleteTailFile)
             throws IOException {
         this.s3AccessHelper = s3AccessHelper;
         this.key = key;
@@ -198,8 +198,8 @@ class NativeS3RecoverableFsDataOutputStream extends 
RecoverableFsDataOutputStrea
         currentOutputStream.close();
 
         // Do not delete the temp file if uploadPart fails: propagate the 
original exception
-        // unmasked and let close() perform cleanup. nextPartNumber is only 
advanced on success so a
-        // failed attempt does not leave a gap in the part sequence.
+        // unmasked and let the cleanup path (close() or the closeForCommit() 
failure handler)
+        // delete it and abort the upload. nextPartNumber is only advanced on 
success.
         NativeS3ObjectOperations.UploadPartResult result =
                 s3AccessHelper.uploadPart(
                         key, uploadId, nextPartNumber, currentTempFile, 
currentPartSize);
@@ -219,17 +219,30 @@ class NativeS3RecoverableFsDataOutputStream extends 
RecoverableFsDataOutputStrea
                 throw new IOException("Stream is already closed");
             }
 
-            currentOutputStream.close();
+            final NativeS3Recoverable recoverable;
+            try {
+                currentOutputStream.close();
 
-            if (currentPartSize > 0) {
-                uploadCurrentPart();
-            } else {
-                Files.delete(currentTempFile.toPath());
-            }
+                if (currentPartSize > 0) {
+                    uploadCurrentPart();
+                } else {
+                    Files.delete(currentTempFile.toPath());
+                }
 
-            NativeS3Recoverable recoverable =
-                    new NativeS3Recoverable(
-                            key, uploadId, new ArrayList<>(completedParts), 
numBytesInParts);
+                recoverable =
+                        new NativeS3Recoverable(
+                                key, uploadId, new 
ArrayList<>(completedParts), numBytesInParts);
+            } catch (IOException e) {
+                // The commit failed after the multipart upload had been 
created and parts may
+                // already have been uploaded. Abort it so it does not leak as 
an orphan upload.
+                closed = true;
+                try {
+                    tryAbortUploadAndReleaseResources();
+                } catch (IOException cleanup) {
+                    e.addSuppressed(cleanup);
+                }
+                throw e;
+            }
 
             closed = true;
             return new NativeS3Committer(s3AccessHelper, recoverable);
@@ -272,41 +285,51 @@ class NativeS3RecoverableFsDataOutputStream extends 
RecoverableFsDataOutputStrea
         try {
             if (!closed) {
                 closed = true;
-                IOException cleanupException = null;
-                if (currentOutputStream != null) {
-                    try {
-                        currentOutputStream.close();
-                    } catch (IOException e) {
-                        cleanupException = ExceptionUtils.firstOrSuppressed(e, 
cleanupException);
-                    }
-                }
-                if (currentTempFile != null && currentTempFile.exists()) {
-                    try {
-                        Files.delete(currentTempFile.toPath());
-                    } catch (IOException e) {
-                        cleanupException = ExceptionUtils.firstOrSuppressed(e, 
cleanupException);
-                    }
-                }
-
-                try {
-                    s3AccessHelper.abortMultiPartUpload(key, uploadId);
-                } catch (IOException e) {
-                    LOG.warn(
-                            "Multipart upload failed (key={}, uploadId={}). "
-                                    + "S3 lifecycle rules should eventually 
clean up the incomplete upload.",
-                            key,
-                            uploadId,
-                            e);
-                }
-                if (cleanupException != null) {
-                    throw cleanupException;
-                }
+                tryAbortUploadAndReleaseResources();
             }
         } finally {
             unlock();
         }
     }
 
+    /** Aborts the multipart upload and releases local resources on the best 
effort basis. */
+    private void tryAbortUploadAndReleaseResources() throws IOException {
+        IOException collected = null;
+        if (currentOutputStream != null) {
+            try {
+                currentOutputStream.close();
+            } catch (IOException e) {
+                collected = ExceptionUtils.firstOrSuppressed(e, collected);
+            }
+        }
+        if (currentTempFile != null && currentTempFile.exists()) {
+            try {
+                deleteTempFile(currentTempFile);
+            } catch (IOException e) {
+                collected = ExceptionUtils.firstOrSuppressed(e, collected);
+            }
+        }
+        try {
+            s3AccessHelper.abortMultiPartUpload(key, uploadId);
+        } catch (IOException e) {
+            LOG.warn(
+                    "Failed to abort multipart upload (key={}, uploadId={}); 
it may be left as an "
+                            + "orphan upload in S3. Propagating the failure to 
the caller.",
+                    key,
+                    uploadId,
+                    e);
+            collected = ExceptionUtils.firstOrSuppressed(e, collected);
+        }
+        if (collected != null) {
+            throw collected;
+        }
+    }
+
+    @VisibleForTesting
+    protected void deleteTempFile(File file) throws IOException {
+        Files.delete(file.toPath());
+    }
+
     private void lock() throws IOException {
         try {
             lock.lockInterruptibly();
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/InMemoryNativeS3Operations.java
 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/InMemoryNativeS3Operations.java
index 39bfe6a4d5c..400f193291a 100644
--- 
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/InMemoryNativeS3Operations.java
+++ 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/InMemoryNativeS3Operations.java
@@ -67,6 +67,15 @@ public final class InMemoryNativeS3Operations extends 
NativeS3ObjectOperations {
     /** uploadId → partNumber → uploaded bytes for in-flight MPUs. */
     public final Map<String, Map<Integer, byte[]>> openMultipartUploads = new 
HashMap<>();
 
+    /** When {@code true}, {@link #uploadPart} throws to simulate a 
part-upload failure. */
+    public boolean failUploadPart = false;
+
+    /** When {@code true}, {@link #abortMultiPartUpload} throws to simulate an 
abort failure. */
+    public boolean failAbortMultiPartUpload = false;
+
+    /** Number of times {@link #abortMultiPartUpload} was invoked, including 
failed attempts. */
+    public int abortAttempts = 0;
+
     private final String bucketName;
     private final AtomicInteger uploadIdSeq = new AtomicInteger();
     private final AtomicInteger putObjectSeq = new AtomicInteger();
@@ -91,6 +100,9 @@ public final class InMemoryNativeS3Operations extends 
NativeS3ObjectOperations {
     public UploadPartResult uploadPart(
             String key, String uploadId, int partNumber, File file, long 
length)
             throws IOException {
+        if (failUploadPart) {
+            throw new IOException("injected uploadPart failure for uploadId: " 
+ uploadId);
+        }
         Map<Integer, byte[]> parts = openMultipartUploads.get(uploadId);
         if (parts == null) {
             throw new IOException("unknown uploadId: " + uploadId);
@@ -154,7 +166,11 @@ public final class InMemoryNativeS3Operations extends 
NativeS3ObjectOperations {
     }
 
     @Override
-    public void abortMultiPartUpload(String key, String uploadId) {
+    public void abortMultiPartUpload(String key, String uploadId) throws 
IOException {
+        abortAttempts++;
+        if (failAbortMultiPartUpload) {
+            throw new IOException("injected abort failure for uploadId: " + 
uploadId);
+        }
         openMultipartUploads.remove(uploadId);
     }
 
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableFsDataOutputStreamTest.java
 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableFsDataOutputStreamTest.java
new file mode 100644
index 00000000000..676893aec03
--- /dev/null
+++ 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableFsDataOutputStreamTest.java
@@ -0,0 +1,192 @@
+/*
+ * 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.flink.fs.s3native.writer;
+
+import org.apache.flink.core.fs.RecoverableFsDataOutputStream;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Test {@link NativeS3RecoverableFsDataOutputStream}. */
+class NativeS3RecoverableFsDataOutputStreamTest {
+
+    private static final String KEY = "out.txt";
+    private static final long MIN_PART_SIZE = 10L;
+
+    @TempDir Path tmp;
+
+    InMemoryNativeS3Operations s3;
+    String uploadId;
+    NativeS3RecoverableFsDataOutputStream stream;
+
+    @BeforeEach
+    void setUp() throws IOException {
+        s3 = new InMemoryNativeS3Operations();
+        uploadId = s3.startMultiPartUpload(KEY);
+        stream = newStream(s3, uploadId);
+        stream.write(bytes('A', 5), 0, 5); // < MIN_PART_SIZE, so it is 
uploaded during commit
+    }
+
+    @Test
+    void closeForCommitAbortsMultipartUploadWhenPartUploadFails() throws 
Exception {
+        s3.failUploadPart = true;
+
+        assertThatThrownBy(stream::closeForCommit)
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("injected uploadPart failure");
+
+        assertThat(s3.abortAttempts)
+                .as("closeForCommit must abort the upload on failure")
+                .isEqualTo(1);
+        assertThat(s3.openMultipartUploads)
+                .as("the multipart upload must not leak after a failed commit")
+                .doesNotContainKey(uploadId);
+        assertThat(countLocalFilesIn(tmp)).as("the local temp file must be 
cleaned up").isZero();
+    }
+
+    @Test
+    void closeForCommitSurfacesAbortFailureWhenBothUploadAndAbortFail() throws 
Exception {
+        s3.failUploadPart = true;
+        s3.failAbortMultiPartUpload = true;
+
+        assertThatThrownBy(stream::closeForCommit)
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("injected uploadPart failure")
+                .satisfies(
+                        t ->
+                                assertThat(t.getSuppressed())
+                                        .as("the abort failure must be 
surfaced, not swallowed")
+                                        .anySatisfy(
+                                                s ->
+                                                        assertThat(s)
+                                                                
.hasMessageContaining(
+                                                                        
"injected abort failure")));
+
+        assertThat(s3.abortAttempts).isEqualTo(1);
+    }
+
+    @Test
+    void closeSurfacesAbortFailureInsteadOfSwallowingIt() throws Exception {
+        s3.failAbortMultiPartUpload = true;
+
+        assertThatThrownBy(stream::close)
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("injected abort failure");
+
+        assertThat(s3.abortAttempts).isEqualTo(1);
+        assertThat(countLocalFilesIn(tmp))
+                .as("local resources are still released even when the abort 
fails")
+                .isZero();
+    }
+
+    /** An abnormal {@code close()} aborts the upload and releases local 
state. */
+    @Test
+    void closeAbortsMultipartUploadOnAbnormalClose() throws Exception {
+        stream.close();
+
+        assertThat(s3.abortAttempts).isEqualTo(1);
+        assertThat(s3.openMultipartUploads).doesNotContainKey(uploadId);
+        assertThat(countLocalFilesIn(tmp)).isZero();
+    }
+
+    @Test
+    void closeSurfacesTempFileDeletionFailure() throws Exception {
+        NativeS3RecoverableFsDataOutputStream failingStream = 
newFailingDeleteStream();
+        failingStream.write(bytes('A', 5), 0, 5);
+
+        assertThatThrownBy(failingStream::close)
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("injected temp-file delete failure");
+
+        assertThat(s3.abortAttempts)
+                .as("abort is still attempted despite delete failure")
+                .isEqualTo(1);
+    }
+
+    @Test
+    void closeForCommitDoesNotAbortOnSuccess() throws Exception {
+        RecoverableFsDataOutputStream.Committer committer = 
stream.closeForCommit();
+
+        assertThat(s3.abortAttempts).as("a successful commit must not abort 
the upload").isZero();
+        assertThat(s3.openMultipartUploads)
+                .as("the upload stays open until the committer commits it")
+                .containsKey(uploadId);
+
+        committer.commit();
+
+        assertThat(s3.committedObjects.get(KEY)).containsExactly(bytes('A', 
5));
+        assertThat(s3.openMultipartUploads).doesNotContainKey(uploadId);
+    }
+
+    @Test
+    void closeAfterSuccessfulCloseForCommitIsNoOp() throws Exception {
+        RecoverableFsDataOutputStream.Committer committer = 
stream.closeForCommit();
+        stream.close();
+
+        assertThat(s3.abortAttempts)
+                .as("close() after a successful commit must not abort the 
pending upload")
+                .isZero();
+        assertThat(s3.openMultipartUploads).containsKey(uploadId);
+
+        committer.commit();
+        assertThat(s3.committedObjects.get(KEY)).containsExactly(bytes('A', 
5));
+    }
+
+    private NativeS3RecoverableFsDataOutputStream newStream(
+            InMemoryNativeS3Operations ops, String uid) throws IOException {
+        return new NativeS3RecoverableFsDataOutputStream(
+                ops, KEY, uid, tmp.toString(), MIN_PART_SIZE);
+    }
+
+    private NativeS3RecoverableFsDataOutputStream newFailingDeleteStream() 
throws IOException {
+        String uid = s3.startMultiPartUpload(KEY);
+        return new NativeS3RecoverableFsDataOutputStream(
+                s3, KEY, uid, tmp.toString(), MIN_PART_SIZE) {
+            @Override
+            protected void deleteTempFile(File file) throws IOException {
+                throw new IOException("injected temp-file delete failure");
+            }
+        };
+    }
+
+    private static long countLocalFilesIn(Path dir) throws IOException {
+        if (!Files.isDirectory(dir)) {
+            return 0;
+        }
+        try (java.util.stream.Stream<Path> s = Files.list(dir)) {
+            return s.count();
+        }
+    }
+
+    private static byte[] bytes(char c, int n) {
+        byte[] b = new byte[n];
+        Arrays.fill(b, (byte) c);
+        return b;
+    }
+}

Reply via email to