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

Samrat002 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 916e57972a3 [FLINK-40953][s3] Store the native S3 recoverable writer 
tail under the target directory
916e57972a3 is described below

commit 916e57972a3867041f01b143b54e0729915e3f87
Author: Gabor Somogyi <[email protected]>
AuthorDate: Tue Oct 20 18:35:38 2026 +0200

    [FLINK-40953][s3] Store the native S3 recoverable writer tail under the 
target directory
    
    * [FLINK-40953][s3] Store the native S3 recoverable writer tail under the 
target directory
    
    Generated-by: Claude Code (Sonnet 5.5)
    
    * [FLINK-40953][s3] Test restoring native S3 tails written by the 2.3 
release
    
    The 2.3 release stores the tail of an in-progress file under the target
    key. Cover that layout when recovering and cleaning up, and add a test
    that restores from such a checkpoint, checkpoints again into the new
    location, restores from the old checkpoint a second time and then from
    the new one.
    
    Generated-by: Claude Code (Sonnet 5.5)
---
 .../NativeS3RecoverableFsDataOutputStream.java     |   5 +-
 .../fs/s3native/writer/FakeNativeS3Operations.java | 145 ++++++++++++++
 .../NativeS3RecoverableFsDataOutputStreamTest.java | 100 ----------
 .../writer/NativeS3RecoverableSerializerTest.java  |  38 ++++
 .../NativeS3RecoverableWriterRecoveryITCase.java   |   5 +-
 .../writer/NativeS3RecoverableWriterTest.java      | 210 +++++++++++++++++++++
 6 files changed, 400 insertions(+), 103 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 79084c23eab..6fc6ff83043 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
@@ -277,7 +277,10 @@ class NativeS3RecoverableFsDataOutputStream extends 
RecoverableFsDataOutputStrea
 
             if (currentPartSize > 0) {
                 currentOutputStream.flush();
-                incompletePartKey = ".incomplete/" + uploadId + "/" + 
UUID.randomUUID();
+                // Keep the side object in the target's directory, so that 
anything scoped to the
+                // target's key prefix (for example IAM or session policies) 
covers it as well.
+                final String parent = key.substring(0, key.lastIndexOf('/') + 
1);
+                incompletePartKey = parent + ".incomplete/" + uploadId + "/" + 
UUID.randomUUID();
                 s3AccessHelper.putObject(incompletePartKey, currentTempFile);
                 incompletePartLength = currentPartSize;
             }
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/FakeNativeS3Operations.java
 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/FakeNativeS3Operations.java
new file mode 100644
index 00000000000..c8bad44d0ca
--- /dev/null
+++ 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/FakeNativeS3Operations.java
@@ -0,0 +1,145 @@
+/*
+ * 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 java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * In-memory {@link NativeS3ObjectOperations} fake for the recoverable writer 
tests. It keeps
+ * multipart uploads, completed objects and standalone objects in memory and 
has hooks to inject
+ * part-upload and abort failures, so {@link NativeS3RecoverableWriter} and 
{@link
+ * NativeS3RecoverableFsDataOutputStream} can be tested without an S3 endpoint.
+ */
+final class FakeNativeS3Operations extends NativeS3ObjectOperations {
+
+    final Map<String, byte[]> committedObjects = new HashMap<>();
+    final Map<String, Map<Integer, byte[]>> openMultipartUploads = new 
HashMap<>();
+
+    /** Standalone objects written with {@link #putObject}, such as the 
persisted tail objects. */
+    final Map<String, byte[]> objects = new HashMap<>();
+
+    boolean failUploadPart = false;
+    boolean deletePartFileAfterUpload = false;
+    int abortAttempts = 0;
+    int uploadPartAttempts = 0;
+
+    private final AtomicInteger uploadIdSeq = new AtomicInteger();
+
+    FakeNativeS3Operations() {
+        super(/* s3Client */ null, /* transferManager */ null, "test-bucket", 
false);
+    }
+
+    @Override
+    public String startMultiPartUpload(String key) {
+        String uploadId = "U" + uploadIdSeq.incrementAndGet();
+        openMultipartUploads.put(uploadId, new HashMap<>());
+        return uploadId;
+    }
+
+    @Override
+    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);
+        }
+        Map<Integer, byte[]> parts = openMultipartUploads.get(uploadId);
+        if (parts == null) {
+            throw new IOException("unknown uploadId: " + uploadId);
+        }
+        byte[] data = Files.readAllBytes(file.toPath());
+        if (data.length != length) {
+            throw new IOException(
+                    "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);
+    }
+
+    @Override
+    public CompleteMultipartUploadResult commitMultiPartUpload(
+            String key, String uploadId, List<UploadPartResult> parts, long 
length)
+            throws IOException {
+        Map<Integer, byte[]> uploaded = openMultipartUploads.remove(uploadId);
+        if (uploaded == null) {
+            throw new IOException("unknown uploadId: " + uploadId);
+        }
+        List<Integer> ordered = new ArrayList<>(parts.size());
+        for (UploadPartResult p : parts) {
+            ordered.add(p.getPartNumber());
+        }
+        Collections.sort(ordered);
+        ByteArrayOutputStream merged = new ByteArrayOutputStream();
+        for (int n : ordered) {
+            byte[] partData = uploaded.get(n);
+            if (partData == null) {
+                throw new IOException("missing part " + n + " for uploadId " + 
uploadId);
+            }
+            merged.write(partData);
+        }
+        byte[] finalBytes = merged.toByteArray();
+        if (finalBytes.length != length) {
+            throw new IOException(
+                    "committed length mismatch: expected " + length + ", got " 
+ finalBytes.length);
+        }
+        committedObjects.put(key, finalBytes);
+        return new CompleteMultipartUploadResult(
+                "test-bucket", key, "final-etag-" + uploadId, null);
+    }
+
+    @Override
+    public void abortMultiPartUpload(String key, String uploadId) throws 
IOException {
+        abortAttempts++;
+        openMultipartUploads.remove(uploadId);
+    }
+
+    @Override
+    public PutObjectResult putObject(String key, File inputFile) throws 
IOException {
+        objects.put(key, Files.readAllBytes(inputFile.toPath()));
+        return new PutObjectResult("etag-" + key);
+    }
+
+    @Override
+    public long getObject(String key, File targetLocation) throws IOException {
+        byte[] data = objects.get(key);
+        if (data == null) {
+            throw new IOException("Failed to get object for key: " + key);
+        }
+        Files.write(targetLocation.toPath(), data);
+        return data.length;
+    }
+
+    @Override
+    public boolean deleteObject(String key) {
+        return objects.remove(key) != null;
+    }
+}
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 c2d63ac9c32..3db9cabcaee 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
@@ -24,18 +24,11 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
-import java.io.ByteArrayOutputStream;
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.atomic.AtomicInteger;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -260,97 +253,4 @@ class NativeS3RecoverableFsDataOutputStreamTest {
         Arrays.fill(b, (byte) c);
         return b;
     }
-
-    /**
-     * In-memory {@link NativeS3ObjectOperations} fake with hooks to inject 
part-upload and abort
-     * failures, so this test can exercise {@link 
NativeS3RecoverableFsDataOutputStream}'s failure
-     * handling without an S3 endpoint.
-     */
-    private static final class FakeNativeS3Operations extends 
NativeS3ObjectOperations {
-
-        final Map<String, byte[]> committedObjects = new HashMap<>();
-        final Map<String, Map<Integer, byte[]>> openMultipartUploads = new 
HashMap<>();
-
-        boolean failUploadPart = false;
-        boolean deletePartFileAfterUpload = false;
-        int abortAttempts = 0;
-        int uploadPartAttempts = 0;
-
-        private final AtomicInteger uploadIdSeq = new AtomicInteger();
-
-        FakeNativeS3Operations() {
-            super(/* s3Client */ null, /* transferManager */ null, 
"test-bucket", false);
-        }
-
-        @Override
-        public String startMultiPartUpload(String key) {
-            String uploadId = "U" + uploadIdSeq.incrementAndGet();
-            openMultipartUploads.put(uploadId, new HashMap<>());
-            return uploadId;
-        }
-
-        @Override
-        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);
-            }
-            Map<Integer, byte[]> parts = openMultipartUploads.get(uploadId);
-            if (parts == null) {
-                throw new IOException("unknown uploadId: " + uploadId);
-            }
-            byte[] data = Files.readAllBytes(file.toPath());
-            if (data.length != length) {
-                throw new IOException(
-                        "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);
-        }
-
-        @Override
-        public CompleteMultipartUploadResult commitMultiPartUpload(
-                String key, String uploadId, List<UploadPartResult> parts, 
long length)
-                throws IOException {
-            Map<Integer, byte[]> uploaded = 
openMultipartUploads.remove(uploadId);
-            if (uploaded == null) {
-                throw new IOException("unknown uploadId: " + uploadId);
-            }
-            List<Integer> ordered = new ArrayList<>(parts.size());
-            for (UploadPartResult p : parts) {
-                ordered.add(p.getPartNumber());
-            }
-            Collections.sort(ordered);
-            ByteArrayOutputStream merged = new ByteArrayOutputStream();
-            for (int n : ordered) {
-                byte[] partData = uploaded.get(n);
-                if (partData == null) {
-                    throw new IOException("missing part " + n + " for uploadId 
" + uploadId);
-                }
-                merged.write(partData);
-            }
-            byte[] finalBytes = merged.toByteArray();
-            if (finalBytes.length != length) {
-                throw new IOException(
-                        "committed length mismatch: expected "
-                                + length
-                                + ", got "
-                                + finalBytes.length);
-            }
-            committedObjects.put(key, finalBytes);
-            return new CompleteMultipartUploadResult(
-                    "test-bucket", key, "final-etag-" + uploadId, null);
-        }
-
-        @Override
-        public void abortMultiPartUpload(String key, String uploadId) throws 
IOException {
-            abortAttempts++;
-            openMultipartUploads.remove(uploadId);
-        }
-    }
 }
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableSerializerTest.java
 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableSerializerTest.java
index 4c5ea143a55..c9b38954f70 100644
--- 
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableSerializerTest.java
+++ 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableSerializerTest.java
@@ -20,6 +20,8 @@ package org.apache.flink.fs.s3native.writer;
 
 import org.junit.jupiter.api.Test;
 
+import java.io.ByteArrayOutputStream;
+import java.io.DataOutputStream;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
@@ -110,4 +112,40 @@ class NativeS3RecoverableSerializerTest {
         NativeS3RecoverableSerializer serializer = 
NativeS3RecoverableSerializer.INSTANCE;
         assertThat(serializer.getVersion()).isGreaterThanOrEqualTo(1);
     }
+
+    /**
+     * Checkpoints and savepoints hold the tail key as an opaque string, so 
state written earlier
+     * must stay readable whatever the key looks like, and the bytes written 
must not change.
+     */
+    @Test
+    void testVersion1BytesWithTailKeyAtBucketRoot() throws IOException {
+        NativeS3RecoverableSerializer serializer = 
NativeS3RecoverableSerializer.INSTANCE;
+        String tailKey = 
".incomplete/upload-id/0f8fad5b-d9cb-469f-a165-70867728950e";
+
+        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+        DataOutputStream out = new DataOutputStream(bytes);
+        out.writeUTF("dir/out.txt");
+        out.writeUTF("upload-id");
+        out.writeLong(5242880L);
+        out.writeInt(1);
+        out.writeInt(1);
+        out.writeUTF("etag1");
+        out.writeBoolean(true);
+        out.writeUTF(tailKey);
+        out.writeLong(1024L);
+        out.flush();
+        byte[] version1 = bytes.toByteArray();
+
+        NativeS3Recoverable deserialized = serializer.deserialize(1, version1);
+
+        assertThat(deserialized.getObjectName()).isEqualTo("dir/out.txt");
+        assertThat(deserialized.uploadId()).isEqualTo("upload-id");
+        assertThat(deserialized.numBytesInParts()).isEqualTo(5242880L);
+        assertThat(deserialized.parts()).hasSize(1);
+        assertThat(deserialized.parts().get(0).getPartNumber()).isEqualTo(1);
+        assertThat(deserialized.parts().get(0).getETag()).isEqualTo("etag1");
+        assertThat(deserialized.incompleteObjectName()).isEqualTo(tailKey);
+        assertThat(deserialized.incompleteObjectLength()).isEqualTo(1024L);
+        assertThat(serializer.serialize(deserialized)).isEqualTo(version1);
+    }
 }
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableWriterRecoveryITCase.java
 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableWriterRecoveryITCase.java
index 1c71c1ec710..2fbbcccc0a1 100644
--- 
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableWriterRecoveryITCase.java
+++ 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableWriterRecoveryITCase.java
@@ -52,7 +52,8 @@ import static 
org.assertj.core.api.Assertions.assertThatThrownBy;
  *     +-- part 1: PART bytes, uploaded as a completed multipart upload part
  *     +-- tail: any bytes written after part 1, not yet part of a completed 
multipart part
  *
- *   side object ("&lt;key&gt;/.incomplete/&lt;uploadId&gt;/&lt;uuid&gt;", see 
#incompletePrefix())
+ *   side object ("&lt;dir&gt;/.incomplete/&lt;uploadId&gt;/&lt;uuid&gt;", see 
#incompletePrefix())
+ *     - lives in the target object's directory ("&lt;dir&gt;/" is empty for a 
root-level key)
  *     - written by persist() only when there IS a tail, so that the tail 
bytes survive a
  *       writer restart
  *     - read back by recover(), which downloads it locally and appends it to 
the in-progress
@@ -98,7 +99,7 @@ class NativeS3RecoverableWriterRecoveryITCase {
     }
 
     private String incompletePrefix(String uploadId) {
-        return ".incomplete/" + uploadId + "/";
+        return key.substring(0, key.lastIndexOf('/') + 1) + ".incomplete/" + 
uploadId + "/";
     }
 
     @Test
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableWriterTest.java
 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableWriterTest.java
new file mode 100644
index 00000000000..eff726c53e5
--- /dev/null
+++ 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableWriterTest.java
@@ -0,0 +1,210 @@
+/*
+ * 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.Path;
+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 org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.regex.Pattern;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests how {@link NativeS3RecoverableWriter} persists, restores and cleans 
up the tail of an
+ * in-progress file, i.e. the buffered bytes that are too small to be a 
multipart upload part.
+ */
+class NativeS3RecoverableWriterTest {
+
+    private static final long MIN_PART_SIZE = 10L;
+    private static final String KEY = "dir/out.txt";
+    private static final String UUID_REGEX =
+            "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
+
+    /** Where the 2.3 release stores the tail: under the target key. */
+    private static final String TAIL_KEY_OF_RELEASE_2_3 = KEY + 
"/.incomplete/U1/tail";
+
+    /** Where the tail is stored now: in the target's directory. */
+    private static final String TAIL_KEY_IN_TARGET_DIRECTORY = 
"dir/.incomplete/U1/tail";
+
+    /** Where the tail of a target without a directory is stored now: at the 
bucket root. */
+    private static final String TAIL_KEY_OF_ROOT_LEVEL_TARGET = 
".incomplete/U1/tail";
+
+    @TempDir java.nio.file.Path tmp;
+
+    private FakeNativeS3Operations s3;
+
+    @BeforeEach
+    void setUp() {
+        s3 = new FakeNativeS3Operations();
+    }
+
+    /** Recovery reads the tail by the key stored in the recoverable, whatever 
its layout. */
+    @ParameterizedTest
+    @ValueSource(
+            strings = {
+                TAIL_KEY_OF_RELEASE_2_3,
+                TAIL_KEY_IN_TARGET_DIRECTORY,
+                TAIL_KEY_OF_ROOT_LEVEL_TARGET
+            })
+    void recoverResumesFromTailAtStoredKey(String tailKey) throws Exception {
+        final String uploadId = s3.startMultiPartUpload(KEY);
+        s3.objects.put(tailKey, ascii("AAAAA"));
+        final NativeS3Recoverable recoverable =
+                new NativeS3Recoverable(KEY, uploadId, 
Collections.emptyList(), 0L, tailKey, 5L);
+
+        final RecoverableFsDataOutputStream resumed = 
writer().recover(recoverable);
+        resumed.write(ascii("BBB"), 0, 3);
+        resumed.closeForCommit().commit();
+
+        assertThat(s3.committedObjects.get(KEY)).isEqualTo(ascii("AAAAABBB"));
+    }
+
+    @ParameterizedTest
+    @ValueSource(
+            strings = {
+                TAIL_KEY_OF_RELEASE_2_3,
+                TAIL_KEY_IN_TARGET_DIRECTORY,
+                TAIL_KEY_OF_ROOT_LEVEL_TARGET
+            })
+    void cleanupDeletesOnlyTailAtStoredKey(String tailKey) throws Exception {
+        s3.objects.put(tailKey, ascii("AAAAA"));
+        s3.objects.put("dir/other", ascii("other"));
+        final NativeS3Recoverable recoverable =
+                new NativeS3Recoverable(KEY, "U1", Collections.emptyList(), 
0L, tailKey, 5L);
+
+        assertThat(writer().cleanupRecoverableState(recoverable)).isTrue();
+
+        assertThat(s3.objects).containsOnlyKeys("dir/other");
+    }
+
+    @Test
+    void cleanupWithoutTailDeletesNothing() throws Exception {
+        s3.objects.put("dir/other", ascii("other"));
+        final NativeS3Recoverable recoverable =
+                new NativeS3Recoverable(KEY, "U1", Collections.emptyList(), 
0L);
+
+        assertThat(writer().cleanupRecoverableState(recoverable)).isFalse();
+
+        assertThat(s3.objects).containsOnlyKeys("dir/other");
+    }
+
+    @Test
+    void persistWithoutBufferedBytesStoresNoTail() throws Exception {
+        final RecoverableFsDataOutputStream stream = 
writer().open(targetPath(KEY));
+        // A full part is uploaded right away, which leaves nothing buffered.
+        stream.write(ascii("AAAAAAAAAA"), 0, 10);
+
+        final NativeS3Recoverable recoverable = (NativeS3Recoverable) 
stream.persist();
+        stream.close();
+
+        assertThat(recoverable.incompleteObjectName()).isNull();
+        assertThat(s3.objects).isEmpty();
+    }
+
+    @Test
+    void persistStoresTailUnderTargetDirectory() throws Exception {
+        final NativeS3Recoverable recoverable = 
persistFiveBytes("a/b/out.txt");
+
+        assertThat(recoverable.incompleteObjectName())
+                .matches(
+                        Pattern.quote("a/b/.incomplete/" + 
recoverable.uploadId() + "/")
+                                + UUID_REGEX);
+        assertTailStored(recoverable, "AAAAA");
+    }
+
+    @Test
+    void persistStoresTailAtBucketRootForRootLevelTarget() throws Exception {
+        final NativeS3Recoverable recoverable = persistFiveBytes("out.txt");
+
+        assertThat(recoverable.incompleteObjectName())
+                .matches(Pattern.quote(".incomplete/" + recoverable.uploadId() 
+ "/") + UUID_REGEX);
+        assertTailStored(recoverable, "AAAAA");
+    }
+
+    /**
+     * A job upgraded from the 2.3 release restores from a tail stored under 
the target key, then
+     * checkpoints into the new location. The old checkpoint stays restorable 
until it is retired,
+     * retiring it leaves the new tail alone, and the new checkpoint restores 
as well.
+     */
+    @Test
+    void upgradeFromRelease23Checkpoint() throws Exception {
+        final String uploadId = s3.startMultiPartUpload(KEY);
+        s3.objects.put(TAIL_KEY_OF_RELEASE_2_3, ascii("AAAAA"));
+        final NativeS3Recoverable oldCheckpoint =
+                new NativeS3Recoverable(
+                        KEY, uploadId, Collections.emptyList(), 0L, 
TAIL_KEY_OF_RELEASE_2_3, 5L);
+
+        final RecoverableFsDataOutputStream resumed = 
writer().recover(oldCheckpoint);
+        resumed.write(ascii("BBB"), 0, 3);
+        final NativeS3Recoverable newCheckpoint = (NativeS3Recoverable) 
resumed.persist();
+        resumed.close();
+
+        assertThat(newCheckpoint.incompleteObjectName())
+                .startsWith("dir/.incomplete/" + uploadId + "/");
+
+        // A failure before the new checkpoint completes restores from the old 
one again.
+        final RecoverableFsDataOutputStream resumedAgain = 
writer().recover(oldCheckpoint);
+        assertThat(resumedAgain.getPos()).isEqualTo(5L);
+        resumedAgain.close();
+
+        assertThat(writer().cleanupRecoverableState(oldCheckpoint)).isTrue();
+        
assertThat(s3.objects).containsOnlyKeys(newCheckpoint.incompleteObjectName());
+
+        final RecoverableFsDataOutputStream restored = 
writer().recover(newCheckpoint);
+        restored.write(ascii("C"), 0, 1);
+        restored.closeForCommit().commit();
+
+        assertThat(s3.committedObjects.get(KEY)).isEqualTo(ascii("AAAAABBBC"));
+    }
+
+    private NativeS3Recoverable persistFiveBytes(String key) throws 
IOException {
+        final RecoverableFsDataOutputStream stream = 
writer().open(targetPath(key));
+        stream.write(ascii("AAAAA"), 0, 5);
+        final NativeS3Recoverable recoverable = (NativeS3Recoverable) 
stream.persist();
+        stream.close();
+        return recoverable;
+    }
+
+    private void assertTailStored(NativeS3Recoverable recoverable, String 
expectedContent) {
+        
assertThat(recoverable.incompleteObjectLength()).isEqualTo(expectedContent.length());
+        assertThat(s3.objects.get(recoverable.incompleteObjectName()))
+                .isEqualTo(ascii(expectedContent));
+    }
+
+    private NativeS3RecoverableWriter writer() {
+        return NativeS3RecoverableWriter.writer(s3, tmp.toString(), 
MIN_PART_SIZE, 1);
+    }
+
+    private static Path targetPath(String key) {
+        return new Path("s3://test-bucket/" + key);
+    }
+
+    private static byte[] ascii(String value) {
+        return value.getBytes(StandardCharsets.US_ASCII);
+    }
+}

Reply via email to