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

rkhachatryan 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 757749336ea [FLINK-39778][s3] Recoverable writer silently loses the 
in-flight tail on resume (#28268)
757749336ea is described below

commit 757749336eafba19220b9d2223128018078d42a8
Author: Samrat <[email protected]>
AuthorDate: Mon Jun 15 13:38:50 2026 +0530

    [FLINK-39778][s3] Recoverable writer silently loses the in-flight tail on 
resume (#28268)
    
    [FLINK-39778][s3] Recoverable writer silently loses the in-flight tail on 
resume
---
 .../NativeS3RecoverableFsDataOutputStream.java     |  23 +-
 .../s3native/writer/NativeS3RecoverableWriter.java | 107 ++++++-
 .../writer/InMemoryNativeS3Operations.java         | 165 ++++++++++
 .../NativeS3RecoverableWriterRecoveryTest.java     | 349 +++++++++++++++++++++
 4 files changed, 632 insertions(+), 12 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 56e77291126..7eabcb9db2b 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
@@ -25,6 +25,7 @@ import 
org.apache.flink.fs.s3native.writer.NativeS3Recoverable.PartETag;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import javax.annotation.Nullable;
 import javax.annotation.concurrent.NotThreadSafe;
 
 import java.io.BufferedOutputStream;
@@ -83,7 +84,7 @@ class NativeS3RecoverableFsDataOutputStream extends 
RecoverableFsDataOutputStrea
             String localTmpDir,
             long minPartSize)
             throws IOException {
-        this(s3AccessHelper, key, uploadId, localTmpDir, minPartSize, new 
ArrayList<>(), 0L);
+        this(s3AccessHelper, key, uploadId, localTmpDir, minPartSize, new 
ArrayList<>(), 0L, null);
     }
 
     public NativeS3RecoverableFsDataOutputStream(
@@ -93,7 +94,8 @@ class NativeS3RecoverableFsDataOutputStream extends 
RecoverableFsDataOutputStrea
             String localTmpDir,
             long minPartSize,
             List<PartETag> existingParts,
-            long numBytesInParts)
+            long numBytesInParts,
+            @Nullable File incompleteTailFile)
             throws IOException {
         this.s3AccessHelper = s3AccessHelper;
         this.key = key;
@@ -106,7 +108,22 @@ class NativeS3RecoverableFsDataOutputStream extends 
RecoverableFsDataOutputStrea
         this.currentPartSize = 0;
         this.closed = false;
 
-        createNewTempFile();
+        if (incompleteTailFile != null) {
+            resumeFromIncompleteTail(incompleteTailFile);
+        } else {
+            createNewTempFile();
+        }
+    }
+
+    private void resumeFromIncompleteTail(File tailFile) throws IOException {
+        if (!tailFile.exists()) {
+            throw new IOException("Incomplete-tail file does not exist: " + 
tailFile);
+        }
+        currentTempFile = tailFile;
+        currentPartSize = tailFile.length();
+        // Append mode so subsequent writes land after the recovered bytes.
+        currentFileStream = new FileOutputStream(currentTempFile, true);
+        currentOutputStream = new BufferedOutputStream(currentFileStream, 
BUFFER_SIZE);
     }
 
     private void createNewTempFile() throws IOException {
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableWriter.java
 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableWriter.java
index 942b1d3e80b..ab84189a6a3 100644
--- 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableWriter.java
+++ 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableWriter.java
@@ -27,7 +27,10 @@ import org.apache.flink.core.io.SimpleVersionedSerializer;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.io.File;
 import java.io.IOException;
+import java.nio.file.Files;
+import java.util.UUID;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 import static org.apache.flink.util.Preconditions.checkNotNull;
@@ -84,15 +87,101 @@ public class NativeS3RecoverableWriter implements 
RecoverableWriter, AutoCloseab
     @Override
     public RecoverableFsDataOutputStream recover(ResumeRecoverable 
recoverable) throws IOException {
         checkNotClosed();
-        NativeS3Recoverable s3recoverable = 
castToNativeS3Recoverable(recoverable);
-        return new NativeS3RecoverableFsDataOutputStream(
-                s3AccessHelper,
-                s3recoverable.getObjectName(),
-                s3recoverable.uploadId(),
-                localTmpDir,
-                userDefinedMinPartSize,
-                s3recoverable.parts(),
-                s3recoverable.numBytesInParts());
+        final NativeS3Recoverable s3recoverable = 
castToNativeS3Recoverable(recoverable);
+
+        File incompleteTail = null;
+        if (s3recoverable.incompleteObjectName() != null) {
+            if (s3recoverable.incompleteObjectLength() <= 0) {
+                throw new IOException(
+                        "Incomplete-tail object "
+                                + s3recoverable.incompleteObjectName()
+                                + " is referenced with non-positive length "
+                                + s3recoverable.incompleteObjectLength()
+                                + ". A side object is only written when 
buffered tail bytes "
+                                + "exist, so this indicates corrupt 
recoverable metadata. "
+                                + "Recovery cannot proceed and this failure is 
NOT retriable "
+                                + "from the same checkpoint.");
+            }
+            incompleteTail = downloadIncompleteTail(s3recoverable);
+        }
+
+        try {
+            LOG.debug(
+                    "Resuming stream - key: {}, uploadId: {}, parts: {}, 
bytesInParts: {}, incompleteTail: {} ({} bytes)",
+                    s3recoverable.getObjectName(),
+                    s3recoverable.uploadId(),
+                    s3recoverable.parts().size(),
+                    s3recoverable.numBytesInParts(),
+                    s3recoverable.incompleteObjectName(),
+                    s3recoverable.incompleteObjectLength());
+            return new NativeS3RecoverableFsDataOutputStream(
+                    s3AccessHelper,
+                    s3recoverable.getObjectName(),
+                    s3recoverable.uploadId(),
+                    localTmpDir,
+                    userDefinedMinPartSize,
+                    s3recoverable.parts(),
+                    s3recoverable.numBytesInParts(),
+                    incompleteTail);
+        } catch (Exception e) {
+            // The downloaded tail file is owned by recover() until the 
constructor takes
+            // ownership. If construction fails, drop the local file so we 
don't leak it.
+            if (incompleteTail != null) {
+                try {
+                    Files.deleteIfExists(incompleteTail.toPath());
+                } catch (IOException cleanup) {
+                    e.addSuppressed(cleanup);
+                }
+            }
+            throw e;
+        }
+    }
+
+    /**
+     * Downloads the side object holding the previously-persisted 
sub-part-size tail into a fresh
+     * file under {@link #localTmpDir}. The side object itself is left in 
place so that a repeated
+     * recovery from the same checkpoint remains correct; cleanup is the 
responsibility of {@link
+     * #cleanupRecoverableState(ResumeRecoverable)} which Flink invokes when 
the checkpoint is
+     * retired.
+     */
+    private File downloadIncompleteTail(NativeS3Recoverable s3recoverable) 
throws IOException {
+        final File tmpDir = new File(localTmpDir);
+        if (!tmpDir.exists() && !tmpDir.mkdirs()) {
+            throw new IOException("Cannot create local tmp dir: " + 
localTmpDir);
+        }
+        final File target = new File(tmpDir, "s3-resume-" + UUID.randomUUID());
+        try {
+            final long downloaded =
+                    
s3AccessHelper.getObject(s3recoverable.incompleteObjectName(), target);
+            if (downloaded != s3recoverable.incompleteObjectLength()) {
+                throw new IOException(
+                        "Incomplete-tail object "
+                                + s3recoverable.incompleteObjectName()
+                                + " has unexpected length (expected "
+                                + s3recoverable.incompleteObjectLength()
+                                + " bytes, got "
+                                + downloaded
+                                + " bytes). The side object holding the 
in-flight tail "
+                                + "has been truncated, overwritten, or 
replaced out-of-band "
+                                + "since the checkpoint was taken. Recovery 
cannot proceed: "
+                                + "the writer state is inconsistent with the 
checkpoint and "
+                                + "this failure is NOT retriable from the same 
checkpoint. "
+                                + "Either restore the side object to its 
original length or "
+                                + "roll back to an earlier checkpoint that 
does not reference it.");
+            }
+            return target;
+        } catch (IOException e) {
+            try {
+                Files.deleteIfExists(target.toPath());
+            } catch (IOException cleanup) {
+                LOG.warn(
+                        "Failed to delete partial download {} after error: {}",
+                        target,
+                        cleanup.getMessage());
+                e.addSuppressed(cleanup);
+            }
+            throw e;
+        }
     }
 
     @Override
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
new file mode 100644
index 00000000000..39bfe6a4d5c
--- /dev/null
+++ 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/InMemoryNativeS3Operations.java
@@ -0,0 +1,165 @@
+/*
+ * 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 implementation for {@link NativeS3ObjectOperations}.
+ *
+ * <p>Backs every reachable S3 operation with hash maps so writer/committer 
logic can be exercised
+ * without an S3 endpoint (no MinIO/Testcontainers required). The parent's 
{@code S3Client} / {@code
+ * S3TransferManager} constructor arguments are passed as {@code null} because 
no overridden method
+ * dereferences them.
+ *
+ * <p><b>State exposure:</b> the storage maps are exposed as public final 
fields so tests can
+ * inspect them, corrupt them, or simulate object loss directly:
+ *
+ * <ul>
+ *   <li>{@link #storedObjects} — keys written via {@link #putObject(String, 
File)} (e.g. the
+ *       incomplete-tail side objects persisted by {@link 
NativeS3RecoverableFsDataOutputStream}).
+ *   <li>{@link #committedObjects} — keys finalized via {@link 
#commitMultiPartUpload}.
+ *   <li>{@link #openMultipartUploads} — uploadId → partNumber → bytes for 
in-flight MPUs; entries
+ *       are removed on commit or abort.
+ * </ul>
+ *
+ * <p>{@link #getObject} reads from <em>both</em> {@link #storedObjects} and 
{@link
+ * #committedObjects} so tests can fetch a committed object the same way real 
S3 would serve it.
+ *
+ * <p><b>Thread safety:</b> not thread-safe. Use a single thread per instance, 
matching the
+ * single-thread invariant of the production {@link 
NativeS3RecoverableFsDataOutputStream}.
+ */
+public final class InMemoryNativeS3Operations extends NativeS3ObjectOperations 
{
+
+    public static final String DEFAULT_BUCKET = "test-bucket";
+
+    /** Keys written via {@link #putObject(String, File)}. */
+    public final Map<String, byte[]> storedObjects = new HashMap<>();
+
+    /** Keys finalized via {@link #commitMultiPartUpload}. */
+    public final Map<String, byte[]> committedObjects = new HashMap<>();
+
+    /** uploadId → partNumber → uploaded bytes for in-flight MPUs. */
+    public final Map<String, Map<Integer, byte[]>> openMultipartUploads = new 
HashMap<>();
+
+    private final String bucketName;
+    private final AtomicInteger uploadIdSeq = new AtomicInteger();
+    private final AtomicInteger putObjectSeq = new AtomicInteger();
+
+    public InMemoryNativeS3Operations() {
+        this(DEFAULT_BUCKET);
+    }
+
+    public InMemoryNativeS3Operations(String bucketName) {
+        super(/* s3Client */ null, /* transferManager */ null, bucketName, /* 
useAsync */ false);
+        this.bucketName = bucketName;
+    }
+
+    @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 {
+        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);
+        return new UploadPartResult(partNumber, "etag-" + uploadId + "-" + 
partNumber);
+    }
+
+    @Override
+    public PutObjectResult putObject(String key, File file) throws IOException 
{
+        storedObjects.put(key, Files.readAllBytes(file.toPath()));
+        return new PutObjectResult("etag-put-" + 
putObjectSeq.incrementAndGet());
+    }
+
+    @Override
+    public long getObject(String key, File targetLocation) throws IOException {
+        byte[] data = storedObjects.get(key);
+        if (data == null) {
+            data = committedObjects.get(key);
+        }
+        if (data == null) {
+            throw new IOException("not found: " + key);
+        }
+        Files.write(targetLocation.toPath(), data);
+        return data.length;
+    }
+
+    @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(bucketName, key, 
"final-etag-" + uploadId, null);
+    }
+
+    @Override
+    public void abortMultiPartUpload(String key, String uploadId) {
+        openMultipartUploads.remove(uploadId);
+    }
+
+    @Override
+    public boolean deleteObject(String key) {
+        return storedObjects.remove(key) != null || 
committedObjects.remove(key) != null;
+    }
+}
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableWriterRecoveryTest.java
 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableWriterRecoveryTest.java
new file mode 100644
index 00000000000..b6d8e14840b
--- /dev/null
+++ 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableWriterRecoveryTest.java
@@ -0,0 +1,349 @@
+/*
+ * 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.apache.flink.core.fs.RecoverableWriter;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.Arrays;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link NativeS3RecoverableWriter#recover}. */
+class NativeS3RecoverableWriterRecoveryTest {
+
+    private static final String BUCKET = 
InMemoryNativeS3Operations.DEFAULT_BUCKET;
+    private static final String KEY = "out.txt";
+    private static final long MIN_PART_SIZE = 10L;
+
+    @TempDir java.nio.file.Path tmp;
+
+    @Test
+    void persistThenRecoverPreservesTailBytes() throws Exception {
+        InMemoryNativeS3Operations s3 = new InMemoryNativeS3Operations();
+        NativeS3RecoverableWriter writer1 =
+                NativeS3RecoverableWriter.writer(
+                        s3, tmp.toString(), MIN_PART_SIZE, /* maxConcurrent */ 
1);
+
+        // --- Phase 1: write 15 bytes; minPartSize=10 ⇒ 10B uploaded as part 
#1, 5B tail in memory.
+        RecoverableFsDataOutputStream out = writer1.open(new Path("s3://" + 
BUCKET + "/" + KEY));
+        byte[] firstChunk = bytes('A', 10); // becomes part #1
+        byte[] tail = bytes('E', 5); // becomes the persisted side object
+        out.write(firstChunk, 0, firstChunk.length);
+        out.write(tail, 0, tail.length);
+
+        // --- Phase 2: checkpoint barrier: persist() and round-trip metadata 
through the
+        //              serializer (this is what Flink does when storing 
checkpoint state).
+        RecoverableWriter.ResumeRecoverable r = out.persist();
+        byte[] checkpointed =
+                
NativeS3RecoverableSerializer.INSTANCE.serialize((NativeS3Recoverable) r);
+
+        // Sanity: the 5-byte tail is sitting in S3 as a side object right now.
+        assertThat(s3.storedObjects).hasSize(1);
+        String sideObjectKey = s3.storedObjects.keySet().iterator().next();
+        assertThat(sideObjectKey).startsWith(KEY + "/.incomplete/");
+        assertThat(s3.storedObjects.get(sideObjectKey)).containsExactly(tail);
+
+        // --- Phase 3: simulate task crash + restore from checkpoint. We 
deliberately do NOT
+        //              close `out` because in a real crash the JVM dies; 
close() would also abort
+        //              the MPU and invalidate the recoverable.
+        NativeS3Recoverable restored =
+                NativeS3RecoverableSerializer.INSTANCE.deserialize(
+                        NativeS3RecoverableSerializer.INSTANCE.getVersion(), 
checkpointed);
+        assertThat(restored.incompleteObjectName())
+                .as("metadata survives the checkpoint round-trip")
+                .isEqualTo(sideObjectKey);
+        assertThat(restored.incompleteObjectLength()).isEqualTo(tail.length);
+
+        NativeS3RecoverableWriter writer2 =
+                NativeS3RecoverableWriter.writer(s3, tmp.toString(), 
MIN_PART_SIZE, 1);
+        RecoverableFsDataOutputStream resumed = writer2.recover(restored);
+
+        // --- Phase 4: continue writing 10 more bytes, then commit.
+        byte[] afterResume = bytes('F', 10);
+        resumed.write(afterResume, 0, afterResume.length);
+        RecoverableFsDataOutputStream.Committer committer = 
resumed.closeForCommit();
+        committer.commit();
+
+        // --- Phase 5: read what landed in S3.
+        byte[] finalObject = s3.committedObjects.get(KEY);
+        assertThat(finalObject).isNotNull();
+
+        // EXPECTED (correct exactly-once): 25 bytes — "AAAAAAAAAA" + "EEEEE" 
+ "FFFFFFFFFF"
+        ByteArrayOutputStream expected = new ByteArrayOutputStream();
+        expected.write(firstChunk);
+        expected.write(tail);
+        expected.write(afterResume);
+
+        // ON UN-FIXED CODE this assertion FAILS:
+        //   expected length 25 ("AAAAAAAAAAEEEEEFFFFFFFFFF")
+        //   actual   length 20 ("AAAAAAAAAAFFFFFFFFFF")
+        // The 5 'E' bytes — the tail durably persisted to S3 at checkpoint 
time — would be gone.
+        assertThat(finalObject)
+                .as("recover() must replay the persisted tail before 
continuing")
+                .containsExactly(expected.toByteArray());
+
+        // The side object is intentionally NOT deleted by recover() so that 
re-recovery from the
+        // same checkpoint stays correct. Cleanup is owned by 
cleanupRecoverableState(), which
+        // Flink invokes once the checkpoint is retired.
+        assertThat(s3.storedObjects)
+                .as("side object outlives recover() to support re-recovery 
from same checkpoint")
+                .containsKey(sideObjectKey);
+        assertThat(writer2.cleanupRecoverableState(restored)).isTrue();
+        assertThat(s3.storedObjects)
+                .as("cleanupRecoverableState must delete the side object")
+                .doesNotContainKey(sideObjectKey);
+    }
+
+    /**
+     * Pre-existing happy path: a recoverable with NO incomplete tail (parts 
only) must still work.
+     */
+    @Test
+    void recoverWithoutIncompleteTailStillWorks() throws Exception {
+        InMemoryNativeS3Operations s3 = new InMemoryNativeS3Operations();
+        NativeS3RecoverableWriter writer1 =
+                NativeS3RecoverableWriter.writer(s3, tmp.toString(), 
MIN_PART_SIZE, 1);
+
+        // Write exactly 20 bytes => two full parts, currentPartSize=0, no 
side object on persist.
+        RecoverableFsDataOutputStream out = writer1.open(new Path("s3://" + 
BUCKET + "/" + KEY));
+        out.write(bytes('A', 10), 0, 10);
+        out.write(bytes('B', 10), 0, 10);
+        RecoverableWriter.ResumeRecoverable r = out.persist();
+        assertThat(((NativeS3Recoverable) r).incompleteObjectName())
+                .as("no tail => no side object")
+                .isNull();
+        assertThat(s3.storedObjects).isEmpty();
+
+        // Resume and append another 10 bytes, commit.
+        NativeS3RecoverableWriter writer2 =
+                NativeS3RecoverableWriter.writer(s3, tmp.toString(), 
MIN_PART_SIZE, 1);
+        RecoverableFsDataOutputStream resumed = writer2.recover(r);
+        resumed.write(bytes('C', 10), 0, 10);
+        resumed.closeForCommit().commit();
+
+        assertThat(s3.committedObjects.get(KEY))
+                .containsExactly(concat(bytes('A', 10), bytes('B', 10), 
bytes('C', 10)));
+    }
+
+    /** If the side object is gone from S3, recover() must fail cleanly and 
clean up local state. */
+    @Test
+    void recoverFailsCleanlyWhenSideObjectMissing() throws Exception {
+        InMemoryNativeS3Operations s3 = new InMemoryNativeS3Operations();
+        NativeS3RecoverableWriter writer1 =
+                NativeS3RecoverableWriter.writer(s3, tmp.toString(), 
MIN_PART_SIZE, 1);
+        RecoverableFsDataOutputStream out = writer1.open(new Path("s3://" + 
BUCKET + "/" + KEY));
+        out.write(bytes('A', 10), 0, 10);
+        out.write(bytes('E', 5), 0, 5);
+        NativeS3Recoverable r = (NativeS3Recoverable) out.persist();
+        String sideObjectKey = r.incompleteObjectName();
+        assertThat(sideObjectKey).isNotNull();
+
+        s3.storedObjects.remove(sideObjectKey);
+        long localFilesBefore = countLocalFilesIn(tmp);
+        NativeS3RecoverableWriter writer2 =
+                NativeS3RecoverableWriter.writer(s3, tmp.toString(), 
MIN_PART_SIZE, 1);
+
+        assertThatThrownBy(() -> writer2.recover(r))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("not found");
+
+        assertThat(countLocalFilesIn(tmp))
+                .as("partial download must be cleaned up on failure")
+                .isEqualTo(localFilesBefore);
+    }
+
+    /**
+     * If the side object is the wrong length, recover() must fail and clean 
up the partial file.
+     */
+    @Test
+    void recoverFailsCleanlyOnLengthMismatch() throws Exception {
+        InMemoryNativeS3Operations s3 = new InMemoryNativeS3Operations();
+        NativeS3RecoverableWriter writer1 =
+                NativeS3RecoverableWriter.writer(s3, tmp.toString(), 
MIN_PART_SIZE, 1);
+        RecoverableFsDataOutputStream out = writer1.open(new Path("s3://" + 
BUCKET + "/" + KEY));
+        out.write(bytes('A', 10), 0, 10);
+        out.write(bytes('E', 5), 0, 5);
+        NativeS3Recoverable r = (NativeS3Recoverable) out.persist();
+        String sideObjectKey = r.incompleteObjectName();
+
+        // Corrupt the side object so its actual length disagrees with the 
metadata.
+        s3.storedObjects.put(sideObjectKey, bytes('X', 99));
+
+        long localFilesBefore = countLocalFilesIn(tmp);
+        NativeS3RecoverableWriter writer2 =
+                NativeS3RecoverableWriter.writer(s3, tmp.toString(), 
MIN_PART_SIZE, 1);
+
+        assertThatThrownBy(() -> writer2.recover(r))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("unexpected length");
+
+        assertThat(countLocalFilesIn(tmp))
+                .as("partial download must be cleaned up on failure")
+                .isEqualTo(localFilesBefore);
+    }
+
+    /**
+     * persist → recover → persist → recover chain. Models multiple checkpoint 
cycles, each one
+     * recovering from the previous. Every persisted byte must end up in the 
committed object.
+     */
+    @Test
+    void recoverThenPersistThenRecoverPreservesAllBytes() throws Exception {
+        InMemoryNativeS3Operations s3 = new InMemoryNativeS3Operations();
+
+        // Cycle 1: write 12 bytes (10 -> part, 2 -> tail), persist.
+        NativeS3RecoverableWriter w1 =
+                NativeS3RecoverableWriter.writer(s3, tmp.toString(), 
MIN_PART_SIZE, 1);
+        RecoverableFsDataOutputStream s1 = w1.open(new Path("s3://" + BUCKET + 
"/" + KEY));
+        s1.write(bytes('A', 10), 0, 10);
+        s1.write(bytes('B', 2), 0, 2);
+        NativeS3Recoverable r1 = (NativeS3Recoverable) s1.persist();
+        String firstSideObject = r1.incompleteObjectName();
+        assertThat(firstSideObject).isNotNull();
+
+        // Cycle 2: recover from r1, write 3 bytes => total tail now 2+3=5 
(still < minPartSize),
+        // persist again => new side object containing the combined 5-byte 
tail.
+        NativeS3RecoverableWriter w2 =
+                NativeS3RecoverableWriter.writer(s3, tmp.toString(), 
MIN_PART_SIZE, 1);
+        RecoverableFsDataOutputStream s2 = w2.recover(r1);
+        s2.write(bytes('C', 3), 0, 3);
+        NativeS3Recoverable r2 = (NativeS3Recoverable) s2.persist();
+        String secondSideObject = r2.incompleteObjectName();
+        assertThat(secondSideObject).isNotEqualTo(firstSideObject);
+        assertThat(s3.storedObjects.get(secondSideObject))
+                .as("second side object must contain old tail + new bytes")
+                .containsExactly(concat(bytes('B', 2), bytes('C', 3)));
+
+        // Cycle 3: recover from r2, write 7 more bytes => tail becomes 5+7=12 
>= minPartSize =>
+        // upload.
+        // Then commit.
+        NativeS3RecoverableWriter w3 =
+                NativeS3RecoverableWriter.writer(s3, tmp.toString(), 
MIN_PART_SIZE, 1);
+        RecoverableFsDataOutputStream s3stream = w3.recover(r2);
+        s3stream.write(bytes('D', 7), 0, 7);
+        s3stream.closeForCommit().commit();
+
+        // Final object must be A*10 + B*2 + C*3 + D*7 = 22 bytes.
+        assertThat(s3.committedObjects.get(KEY))
+                .containsExactly(
+                        concat(bytes('A', 10), bytes('B', 2), bytes('C', 3), 
bytes('D', 7)));
+
+        // Both old side objects survive until cleanupRecoverableState is 
called per checkpoint.
+        assertThat(s3.storedObjects).containsKeys(firstSideObject, 
secondSideObject);
+        assertThat(w3.cleanupRecoverableState(r1)).isTrue();
+        assertThat(w3.cleanupRecoverableState(r2)).isTrue();
+        assertThat(s3.storedObjects).doesNotContainKeys(firstSideObject, 
secondSideObject);
+    }
+
+    @Test
+    void dataWrittenAfterLastPersistIsDiscardedOnRecovery() throws Exception {
+        InMemoryNativeS3Operations s3 = new InMemoryNativeS3Operations();
+        NativeS3RecoverableWriter writer1 =
+                NativeS3RecoverableWriter.writer(s3, tmp.toString(), 
MIN_PART_SIZE, 1);
+
+        RecoverableFsDataOutputStream out = writer1.open(new Path("s3://" + 
BUCKET + "/" + KEY));
+        out.write(bytes('A', 10), 0, 10); // becomes part #1
+        out.write(bytes('E', 5), 0, 5); // the tail captured by persist()
+        NativeS3Recoverable r = (NativeS3Recoverable) out.persist();
+        out.write(bytes('Z', 3), 0, 3);
+
+        // Crash + restore from the checkpoint taken at persist()
+        NativeS3RecoverableWriter writer2 =
+                NativeS3RecoverableWriter.writer(s3, tmp.toString(), 
MIN_PART_SIZE, 1);
+        RecoverableFsDataOutputStream resumed = writer2.recover(r);
+        resumed.write(bytes('F', 4), 0, 4); // legitimately appended after 
recovery
+        resumed.closeForCommit().commit();
+
+        assertThat(s3.committedObjects.get(KEY))
+                .as("post-persist 'Z' bytes are dropped; only persisted + 
post-recovery bytes land")
+                .containsExactly(concat(bytes('A', 10), bytes('E', 5), 
bytes('F', 4)));
+    }
+
+    @Test
+    void secondRecoveryAttemptCanStillReadSidePart() throws Exception {
+        InMemoryNativeS3Operations s3 = new InMemoryNativeS3Operations();
+        NativeS3RecoverableWriter writer1 =
+                NativeS3RecoverableWriter.writer(s3, tmp.toString(), 
MIN_PART_SIZE, 1);
+
+        RecoverableFsDataOutputStream out = writer1.open(new Path("s3://" + 
BUCKET + "/" + KEY));
+        out.write(bytes('A', 10), 0, 10); // becomes part #1
+        out.write(bytes('E', 5), 0, 5); // becomes the persisted side object
+        NativeS3Recoverable r = (NativeS3Recoverable) out.persist();
+        String sideObjectKey = r.incompleteObjectName();
+        assertThat(sideObjectKey).isNotNull();
+
+        // First recovery attempt successfully seeds the tail. We model this 
attempt crashing before
+        // commit, so we intentionally do NOT close/commit it (close() would 
abort the shared MPU).
+        NativeS3RecoverableWriter writer2 =
+                NativeS3RecoverableWriter.writer(s3, tmp.toString(), 
MIN_PART_SIZE, 1);
+        RecoverableFsDataOutputStream firstAttempt = writer2.recover(r);
+        assertThat(firstAttempt.getPos())
+                .as("first attempt restores the 5-byte tail behind the 10 
uploaded bytes")
+                .isEqualTo(15L);
+        assertThat(s3.storedObjects)
+                .as("recover() must not delete the side object")
+                .containsKey(sideObjectKey);
+
+        // Second recovery attempt from the SAME checkpoint must still find 
the side object.
+        NativeS3RecoverableWriter writer3 =
+                NativeS3RecoverableWriter.writer(s3, tmp.toString(), 
MIN_PART_SIZE, 1);
+        RecoverableFsDataOutputStream secondAttempt = writer3.recover(r);
+        secondAttempt.write(bytes('F', 4), 0, 4);
+        secondAttempt.closeForCommit().commit();
+
+        assertThat(s3.committedObjects.get(KEY))
+                .as("second attempt replays the tail and commits every 
persisted byte")
+                .containsExactly(concat(bytes('A', 10), bytes('E', 5), 
bytes('F', 4)));
+    }
+
+    private static long countLocalFilesIn(java.nio.file.Path dir) throws 
IOException {
+        if (!java.nio.file.Files.isDirectory(dir)) {
+            return 0;
+        }
+        try (java.util.stream.Stream<java.nio.file.Path> s = 
java.nio.file.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;
+    }
+
+    private static byte[] concat(byte[]... chunks) {
+        int total = 0;
+        for (byte[] c : chunks) {
+            total += c.length;
+        }
+        byte[] out = new byte[total];
+        int off = 0;
+        for (byte[] c : chunks) {
+            System.arraycopy(c, 0, out, off, c.length);
+            off += c.length;
+        }
+        return out;
+    }
+}


Reply via email to