This is an automated email from the ASF dual-hosted git repository.
gaborgsomogyi 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 5a07b9648fc [FLINK-39874][s3] Make temp-file cleanup idempotent in
NativeS3RecoverableFsDataOutputStream
5a07b9648fc is described below
commit 5a07b9648fc29302043ba3b5604422f96c6fbe01
Author: Li Guo <[email protected]>
AuthorDate: Wed Aug 26 03:45:27 2026 -0700
[FLINK-39874][s3] Make temp-file cleanup idempotent in
NativeS3RecoverableFsDataOutputStream
---
.../NativeS3RecoverableFsDataOutputStream.java | 8 +-
.../writer/InMemoryNativeS3Operations.java | 13 +++
.../NativeS3RecoverableFsDataOutputStreamTest.java | 106 +++++++++++++++++++++
3 files changed, 123 insertions(+), 4 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 2eb898b9e76..c883354f213 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
@@ -208,7 +208,7 @@ class NativeS3RecoverableFsDataOutputStream extends
RecoverableFsDataOutputStrea
completedParts.add(new PartETag(result.getPartNumber(),
result.getETag()));
numBytesInParts += currentPartSize;
- Files.delete(currentTempFile.toPath());
+ Files.deleteIfExists(currentTempFile.toPath());
}
@Override
@@ -226,7 +226,7 @@ class NativeS3RecoverableFsDataOutputStream extends
RecoverableFsDataOutputStrea
if (currentPartSize > 0) {
uploadCurrentPart();
} else {
- Files.delete(currentTempFile.toPath());
+ Files.deleteIfExists(currentTempFile.toPath());
}
recoverable =
@@ -302,7 +302,7 @@ class NativeS3RecoverableFsDataOutputStream extends
RecoverableFsDataOutputStrea
collected = ExceptionUtils.firstOrSuppressed(e, collected);
}
}
- if (currentTempFile != null && currentTempFile.exists()) {
+ if (currentTempFile != null) {
try {
deleteTempFile(currentTempFile);
} catch (IOException e) {
@@ -327,7 +327,7 @@ class NativeS3RecoverableFsDataOutputStream extends
RecoverableFsDataOutputStrea
@VisibleForTesting
protected void deleteTempFile(File file) throws IOException {
- Files.delete(file.toPath());
+ Files.deleteIfExists(file.toPath());
}
private void lock() throws IOException {
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 400f193291a..28d8edfeeef 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
@@ -70,9 +70,18 @@ public final class InMemoryNativeS3Operations extends
NativeS3ObjectOperations {
/** When {@code true}, {@link #uploadPart} throws to simulate a
part-upload failure. */
public boolean failUploadPart = false;
+ /**
+ * When {@code true}, {@link #uploadPart} deletes the local part file
after reading it,
+ * simulating an external cleaner that reaps {@code io.tmp.dirs} while the
part is in flight.
+ */
+ public boolean deletePartFileAfterUpload = false;
+
/** When {@code true}, {@link #abortMultiPartUpload} throws to simulate an
abort failure. */
public boolean failAbortMultiPartUpload = false;
+ /** Number of times {@link #uploadPart} was invoked, including failed
attempts. */
+ public int uploadPartAttempts = 0;
+
/** Number of times {@link #abortMultiPartUpload} was invoked, including
failed attempts. */
public int abortAttempts = 0;
@@ -100,6 +109,7 @@ public final class InMemoryNativeS3Operations extends
NativeS3ObjectOperations {
public UploadPartResult uploadPart(
String key, String uploadId, int partNumber, File file, long
length)
throws IOException {
+ uploadPartAttempts++;
if (failUploadPart) {
throw new IOException("injected uploadPart failure for uploadId: "
+ uploadId);
}
@@ -113,6 +123,9 @@ public final class InMemoryNativeS3Operations extends
NativeS3ObjectOperations {
"part length mismatch: expected " + length + ", got " +
data.length);
}
parts.put(partNumber, data);
+ if (deletePartFileAfterUpload) {
+ Files.delete(file.toPath());
+ }
return new UploadPartResult(partNumber, "etag-" + uploadId + "-" +
partNumber);
}
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
index 676893aec03..35163cfe6f2 100644
---
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
@@ -158,12 +158,118 @@ class NativeS3RecoverableFsDataOutputStreamTest {
assertThat(s3.committedObjects.get(KEY)).containsExactly(bytes('A',
5));
}
+ @Test
+ void closeForCommitDeletesTempFileOnSuccess() throws Exception {
+ assertThat(countLocalFilesIn(tmp))
+ .as("the pending part is buffered in a temp file")
+ .isOne();
+
+ stream.closeForCommit();
+
+ assertThat(s3.openMultipartUploads.get(uploadId))
+ .as("the commit must upload the pending part")
+ .containsOnlyKeys(1);
+ assertThat(countLocalFilesIn(tmp)).as("a successful commit deletes the
temp file").isZero();
+ }
+
+ @Test
+ void closeForCommitDeletesAlreadyRemovedTempFile() throws Exception {
+ Path dir = tmp.resolve("empty-commit");
+ String uid = s3.startMultiPartUpload(KEY);
+ // No write(), so there is no pending part and closeForCommit() only
deletes the temp file.
+ NativeS3RecoverableFsDataOutputStream emptyStream =
+ new NativeS3RecoverableFsDataOutputStream(
+ s3, KEY, uid, dir.toString(), MIN_PART_SIZE);
+ assertThat(countLocalFilesIn(dir)).as("the stream creates its temp
file on open").isOne();
+
+ Files.delete(onlyFileIn(dir).toPath());
+
+ assertThat(emptyStream.closeForCommit()).as("the commit must still
succeed").isNotNull();
+
+ assertThat(s3.abortAttempts).as("a healthy commit must not abort the
upload").isZero();
+ assertThat(countLocalFilesIn(dir)).isZero();
+ }
+
+ @Test
+ void partUploadFailureLeavesTempFileForClose() throws Exception {
+ s3.failUploadPart = true;
+ assertThat(countLocalFilesIn(tmp))
+ .as("the pending part is buffered in a temp file")
+ .isOne();
+
+ // setUp() wrote 5 bytes; 5 more reach MIN_PART_SIZE and flush the
part from write().
+ assertThatThrownBy(() -> stream.write(bytes('B', 5), 0, 5))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("injected uploadPart failure");
+
+ assertThat(countLocalFilesIn(tmp))
+ .as("a failed part upload keeps the temp file so the exception
is not masked")
+ .isOne();
+
+ stream.close();
+
+ assertThat(countLocalFilesIn(tmp)).as("close() reclaims the temp
file").isZero();
+ }
+
+ @Test
+ void partUploadDeletesAlreadyRemovedTempFile() throws Exception {
+ s3.deletePartFileAfterUpload = true;
+ File flushedFile = onlyFileIn(tmp);
+
+ stream.write(bytes('B', 5), 0, 5);
+
+ assertThat(s3.uploadPartAttempts).as("exactly one part was
uploaded").isOne();
+ assertThat(flushedFile).as("the uploaded part file is
gone").doesNotExist();
+ assertThat(onlyFileIn(tmp))
+ .as("write() rotated to a fresh temp file")
+ .isNotEqualTo(flushedFile);
+
+ stream.closeForCommit().commit();
+
+ assertThat(s3.committedObjects.get(KEY))
+ .as("the uploaded part is still committed")
+ .hasSize((int) MIN_PART_SIZE);
+ assertThat(countLocalFilesIn(tmp)).as("the commit deletes the rotated
temp file").isZero();
+ }
+
+ @Test
+ void closeDeletesTempFileRemovedDuringCleanup() throws Exception {
+ Path dir = tmp.resolve("close-race");
+ String uid = s3.startMultiPartUpload(KEY);
+ // close() may run concurrently with the writer thread during
cancellation, so the temp
+ // file may already be gone when close() deletes it.
+ NativeS3RecoverableFsDataOutputStream racingStream =
+ new NativeS3RecoverableFsDataOutputStream(
+ s3, KEY, uid, dir.toString(), MIN_PART_SIZE) {
+ @Override
+ protected void deleteTempFile(File file) throws
IOException {
+ Files.delete(file.toPath());
+ super.deleteTempFile(file);
+ }
+ };
+ racingStream.write(bytes('A', 5), 0, 5);
+ assertThat(countLocalFilesIn(dir))
+ .as("the pending part is buffered in a temp file")
+ .isOne();
+
+ racingStream.close();
+
+ assertThat(s3.abortAttempts).isEqualTo(1);
+ assertThat(countLocalFilesIn(dir)).isZero();
+ }
+
private NativeS3RecoverableFsDataOutputStream newStream(
InMemoryNativeS3Operations ops, String uid) throws IOException {
return new NativeS3RecoverableFsDataOutputStream(
ops, KEY, uid, tmp.toString(), MIN_PART_SIZE);
}
+ private static File onlyFileIn(Path dir) {
+ File[] files = dir.toFile().listFiles();
+ assertThat(files).hasSize(1);
+ return files[0];
+ }
+
private NativeS3RecoverableFsDataOutputStream newFailingDeleteStream()
throws IOException {
String uid = s3.startMultiPartUpload(KEY);
return new NativeS3RecoverableFsDataOutputStream(