Repository: Aiven-Open/tiered-storage-for-apache-kafka
Pull request #664: Improve the s3 upload logic to reduce requests and fix retry fail issue
Author: login: jiafu1115
Created: 2025-03-19T10:10:39Z | Merged: 2025-04-29T18:58:29Z

### Part 1:  reduce s3 request for small file
Kafka have three type of the files which need to upload as followed:
![image](https://github.com/user-attachments/assets/c1e6e125-dbef-400e-8173-86fa51978f42)

Most of file with the type("rsm-manifest" and "indexes") don't have > 5M size (Which is the default upload part size). So for these type of the files and some small size log segement. There is no need to upload by multipart. 

This PR is to improve the upload logic to reduce the requests and related cost also (request is typical cost item).

I add some logs as test code to classify the issue: 
It send **3** requests for one file with size: 901.
[2025-03-18 06:28:38,160] INFO **CreateMultipartUploadRequest**: test_topic/27/00000000009398949865-GyuaUKJ3QEWC-ILUqyUt9A.rsm-manifest  cost: 16ms 
[2025-03-18 06:28:38,180] INFO **UploadPartRequest**: test_topic/27/00000000009398949865-GyuaUKJ3QEWC-ILUqyUt9A.rsm-manifest with size: 901, cost: 20ms
[2025-03-18 06:28:38,331] INFO **CompletedMultipartUpload**: test_topic/27/00000000009398949865-GyuaUKJ3QEWC-ILUqyUt9A.rsm-manifest  cost: 151ms  

After the changed code with debug log deployed:
It send only **1** request for the small file
[2025-03-18 07:06:03,179] INFO **PutObjectRequest**: test_topic/33/00000000000071382168-jsz4z8mTTAy9S-KwOEyMBg.rsm-manifest with size: 844, cost: 55ms 

### Part 2:  fix the upload's retry issue
The retry fail issue happen on upload's retry.  or you enable the checksum to calculate it. 
Example
java.lang.IllegalStateException: The request content has fewer bytes than the specified content-length: 1 bytes.
	at software.amazon.awssdk.core.internal.io.SdkLengthAwareInputStream.read(SdkLengthAwareInputStream.java:82)
	at software.amazon.awssdk.core.io.SdkFilterInputStream.read(SdkFilterInputStream.java:66)
	at java.base/java.io.FilterInputStream.read(FilterInputStream.java:132)
	at software.amazon.awssdk.core.internal.io.SdkLengthAwareInputStream.read(SdkLengthAwareInputStream.java:75)
	at org.apache.http.entity.InputStreamEntity.writeTo(InputStreamEntity.java:140)
	at software.amazon.awssdk.http.apache.internal.RepeatableInputStreamRequestEntity.writeTo(RepeatableInputStreamRequestEntity.java:157)
	at org.apache.http.impl.execchain.RequestEntityProxy.writeTo(RequestEntityProxy.java:121)
	at org.apache.http.impl.DefaultBHttpClientConnection.sendRequestEntity(DefaultBHttpClientConnection.java:156)
	at org.apache.http.impl.conn.CPoolProxy.sendRequestEntity(CPoolProxy.java:152)
	at org.apache.http.protocol.HttpRequestExecutor.doSendRequest(HttpRequestExecutor.java:238)
	at org.apache.http.protocol.HttpRequestExecutor.execute(HttpRequestExecutor.java:123)
	
Thanks for review. WDTY?



Diff:
diff --git a/checkstyle/suppressions.xml b/checkstyle/suppressions.xml
index 697b76d54..65ffc1292 100644
--- a/checkstyle/suppressions.xml
+++ b/checkstyle/suppressions.xml
@@ -18,6 +18,7 @@
         "-//Checkstyle//DTD SuppressionFilter Configuration 1.2//EN"
         "https://checkstyle.org/dtds/suppressions_1_2.dtd">
 <suppressions>
+    <suppress checks="RegexpHeader" files="ByteBufferMarkableInputStream.java"/>
     <suppress checks="AbbreviationAsWordInName" files="DataKeyAndAADEqualsTest"/>
     <suppress checks="ClassDataAbstractionCoupling" files=".*Test\.java"/>
     <suppress checks="ClassFanOutComplexity" files=".*Test\.java"/>
diff --git a/storage/core/src/testFixtures/java/io/aiven/kafka/tieredstorage/storage/BaseStorageTest.java b/storage/core/src/testFixtures/java/io/aiven/kafka/tieredstorage/storage/BaseStorageTest.java
index cb2311cb9..fce5257a4 100644
--- a/storage/core/src/testFixtures/java/io/aiven/kafka/tieredstorage/storage/BaseStorageTest.java
+++ b/storage/core/src/testFixtures/java/io/aiven/kafka/tieredstorage/storage/BaseStorageTest.java
@@ -64,6 +64,10 @@ void testUploadFetchDelete() throws IOException, StorageBackendException {
     @Test
     void testUploadANewFile() throws StorageBackendException, IOException {
         final String content = "content";
+        uploadContentAsFileAndVerify(content);
+    }
+
+    protected void uploadContentAsFileAndVerify(final String content) throws StorageBackendException, IOException {
         final ByteArrayInputStream in = new ByteArrayInputStream(content.getBytes());
         final long size = storage().upload(in, TOPIC_PARTITION_SEGMENT_KEY);
         assertThat(size).isEqualTo(content.length());
diff --git a/storage/s3/src/integration-test/java/io/aiven/kafka/tieredstorage/storage/s3/S3ErrorMetricsTest.java b/storage/s3/src/integration-test/java/io/aiven/kafka/tieredstorage/storage/s3/S3ErrorMetricsTest.java
index 4aee7fde1..abdb6cbb4 100644
--- a/storage/s3/src/integration-test/java/io/aiven/kafka/tieredstorage/storage/s3/S3ErrorMetricsTest.java
+++ b/storage/s3/src/integration-test/java/io/aiven/kafka/tieredstorage/storage/s3/S3ErrorMetricsTest.java
@@ -20,9 +20,11 @@
 import javax.management.MalformedObjectNameException;
 import javax.management.ObjectName;
 
-import java.io.InputStream;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
 import java.lang.management.ManagementFactory;
 import java.util.Map;
+import java.util.Random;
 
 import io.aiven.kafka.tieredstorage.storage.StorageBackendException;
 import io.aiven.kafka.tieredstorage.storage.TestObjectKey;
@@ -44,6 +46,9 @@
 import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
 import static com.github.tomakehurst.wiremock.client.WireMock.any;
 import static com.github.tomakehurst.wiremock.client.WireMock.anyUrl;
+import static com.github.tomakehurst.wiremock.client.WireMock.delete;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.put;
 import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
 import static com.github.tomakehurst.wiremock.common.ContentTypes.CONTENT_TYPE;
 import static org.apache.http.entity.ContentType.APPLICATION_XML;
@@ -59,6 +64,8 @@ class S3ErrorMetricsTest {
     private static final String BUCKET_NAME = "test-bucket";
     private S3Storage storage;
     private ObjectName s3MetricsObjectName;
+    private final Random random = new Random();
+    private static final int UPLOAD_PART_SIZE = 25 * 1024 * 1024;
 
     @BeforeEach
     void setUp() throws MalformedObjectNameException {
@@ -68,10 +75,13 @@ void setUp() throws MalformedObjectNameException {
 
     @ParameterizedTest
     @CsvSource({
-        HttpStatusCode.INTERNAL_SERVER_ERROR + ", server-errors",
-        HttpStatusCode.THROTTLING + ", throttling-errors",
+        HttpStatusCode.INTERNAL_SERVER_ERROR + "," + (UPLOAD_PART_SIZE + 1) + ", server-errors",
+        HttpStatusCode.INTERNAL_SERVER_ERROR + "," + (UPLOAD_PART_SIZE - 1) + ", server-errors",
+        HttpStatusCode.THROTTLING + "," + (UPLOAD_PART_SIZE + 1) + ", throttling-errors",
+        HttpStatusCode.THROTTLING + "," + (UPLOAD_PART_SIZE - 1) + ", throttling-errors",
     })
     void testS3ServerExceptions(final int statusCode,
+                                final int uploadFileSize,
                                 final String metricName,
                                 final WireMockRuntimeInfo wmRuntimeInfo) throws Exception {
         final Map<String, Object> configs = Map.of(
@@ -79,7 +89,8 @@ void testS3ServerExceptions(final int statusCode,
             "s3.region", Region.US_EAST_1.id(),
             "s3.endpoint.url", wmRuntimeInfo.getHttpBaseUrl(),
             "s3.path.style.access.enabled", "true",
-            "aws.credentials.provider.class", AnonymousCredentialsProvider.class.getName()
+            "aws.credentials.provider.class", AnonymousCredentialsProvider.class.getName(),
+            "s3.multipart.upload.part.size", UPLOAD_PART_SIZE
         );
         storage.configure(configs);
 
@@ -87,11 +98,25 @@ void testS3ServerExceptions(final int statusCode,
             .willReturn(aResponse().withStatus(statusCode)
                 .withHeader(CONTENT_TYPE, APPLICATION_XML.getMimeType())
                 .withBody(String.format(ERROR_RESPONSE_TEMPLATE, statusCode))));
-        final S3Exception s3Exception = catchThrowableOfType(
-            () -> storage.upload(InputStream.nullInputStream(), new TestObjectKey("key")),
-            S3Exception.class);
 
-        assertThat(s3Exception.statusCode()).isEqualTo(statusCode);
+        uploadAndVerifyResult(uploadFileSize, statusCode, metricName);
+    }
+
+    private void uploadAndVerifyResult(final int uploadFileSize,
+                                       final int statusCode,
+                                       final String metricName) throws Exception {
+        try (final ByteArrayInputStream inputStream = newInputStreamWithSize(uploadFileSize)) {
+            final StorageBackendException storageBackendException = catchThrowableOfType(
+                StorageBackendException.class,
+                () -> storage.upload(inputStream, new TestObjectKey("key"))
+            );
+            assertThat(storageBackendException.getCause())
+                .isInstanceOf(IOException.class)
+                .cause()
+                .isInstanceOf(S3Exception.class)
+                .extracting(e -> ((S3Exception) e).statusCode())
+                .isEqualTo(statusCode);
+        }
 
         // Comparing to 4 since the SDK makes 3 retries by default.
         assertThat(MBEAN_SERVER.getAttribute(s3MetricsObjectName, metricName + "-total"))
@@ -101,6 +126,46 @@ void testS3ServerExceptions(final int statusCode,
             .isGreaterThan(0.0);
     }
 
+    @Test
+    void testS3ServerExceptionsWhenUploadPartFail(final WireMockRuntimeInfo wmRuntimeInfo) throws Exception {
+        final Map<String, Object> configs = Map.of(
+            "s3.bucket.name", BUCKET_NAME,
+            "s3.region", Region.US_EAST_1.id(),
+            "s3.endpoint.url", wmRuntimeInfo.getHttpBaseUrl(),
+            "s3.path.style.access.enabled", "true",
+            "aws.credentials.provider.class", AnonymousCredentialsProvider.class.getName()
+        );
+        storage.configure(configs);
+
+        //stub with success response for the CreateMultipartUpload: POST /{Key}?uploads
+        final String responseString = "<InitiateMultipartUploadResult>"
+                                    + "<UploadId>testUploadId</UploadId>"
+                                    + "</InitiateMultipartUploadResult>";
+        stubFor(post(anyUrl())
+            .willReturn(aResponse().withStatus(HttpStatusCode.OK)
+                .withHeader(CONTENT_TYPE, APPLICATION_XML.getMimeType())
+                .withBody(responseString)));
+
+        //stub with error for the UploadPart: PUT /{Key}?partNumber=PartNumber&uploadId={Upload}
+        final int errorStatusCode = HttpStatusCode.INTERNAL_SERVER_ERROR;
+        stubFor(put(anyUrl())
+            .willReturn(aResponse().withStatus(errorStatusCode)
+                .withHeader(CONTENT_TYPE, APPLICATION_XML.getMimeType())
+                .withBody(String.format(ERROR_RESPONSE_TEMPLATE, errorStatusCode))));
+
+        //stub with success response for the AbortMultipartUpload: DELETE /{Key}?uploadId={Upload}
+        stubFor(delete(anyUrl())
+            .willReturn(aResponse().withStatus(HttpStatusCode.OK)));
+
+        uploadAndVerifyResult(UPLOAD_PART_SIZE + 1, errorStatusCode, "server-errors");
+    }
+
+    private ByteArrayInputStream newInputStreamWithSize(final int size) {
+        final byte[] buffer = new byte[size];
+        random.nextBytes(buffer);
+        return new ByteArrayInputStream(buffer);
+    }
+
     @Test
     void testOtherExceptions(final WireMockRuntimeInfo wmRuntimeInfo) throws Exception {
         final Map<String, Object> configs = Map.of(
@@ -120,9 +185,10 @@ void testOtherExceptions(final WireMockRuntimeInfo wmRuntimeInfo) throws Excepti
                 .withHeader(CONTENT_TYPE, APPLICATION_XML.getMimeType())
                 .withBody("unparsable_xml")));
 
-        assertThatThrownBy(() -> storage.upload(InputStream.nullInputStream(), new TestObjectKey("key")))
-            .isInstanceOf(SdkClientException.class)
-            .hasMessageStartingWith("Could not parse XML response.");
+        final ByteArrayInputStream inputStream = newInputStreamWithSize(UPLOAD_PART_SIZE + 1);
+        assertThatThrownBy(() -> storage.upload(inputStream, new TestObjectKey("key")))
+            .isInstanceOf(StorageBackendException.class)
+            .hasMessageStartingWith("Failed to upload key");
 
         assertThat(MBEAN_SERVER.getAttribute(s3MetricsObjectName, metricName + "-total"))
             .isEqualTo(1.0);
diff --git a/storage/s3/src/integration-test/java/io/aiven/kafka/tieredstorage/storage/s3/S3StorageMetricsTest.java b/storage/s3/src/integration-test/java/io/aiven/kafka/tieredstorage/storage/s3/S3StorageMetricsTest.java
index a4a24a9d8..31ec0b9d7 100644
--- a/storage/s3/src/integration-test/java/io/aiven/kafka/tieredstorage/storage/s3/S3StorageMetricsTest.java
+++ b/storage/s3/src/integration-test/java/io/aiven/kafka/tieredstorage/storage/s3/S3StorageMetricsTest.java
@@ -100,10 +100,9 @@ void setupStorage() {
 
     @Test
     void metricsShouldBeReported() throws Exception {
+        // Upload a big file
         final byte[] data = new byte[PART_SIZE + 1];
-
         final ObjectKey key = new TestObjectKey("x");
-
         storage.upload(new ByteArrayInputStream(data), key);
         try (final InputStream fetch = storage.fetch(key)) {
             fetch.readAllBytes();
@@ -114,6 +113,11 @@ void metricsShouldBeReported() throws Exception {
         storage.delete(key);
         storage.delete(Set.of(key));
 
+        // Upload a small file
+        final byte[] smallSizeData = new byte[1];
+        storage.upload(new ByteArrayInputStream(smallSizeData), key);
+
+        // Upload fail
         final InputStream failingInputStream = mock(InputStream.class);
         final IOException exception = new IOException("test");
         when(failingInputStream.transferTo(any())).thenThrow(exception);
@@ -135,13 +139,16 @@ void metricsShouldBeReported() throws Exception {
             .isGreaterThan(0.0);
 
         assertThat(MBEAN_SERVER.getAttribute(segmentCopyPerSecName, "put-object-requests-rate"))
-            .isEqualTo(0.0);
+            .asInstanceOf(DOUBLE)
+            .isGreaterThan(0.0);
         assertThat(MBEAN_SERVER.getAttribute(segmentCopyPerSecName, "put-object-requests-total"))
-            .isEqualTo(0.0);
+            .isEqualTo(1.0);
         assertThat(MBEAN_SERVER.getAttribute(segmentCopyPerSecName, "put-object-time-avg"))
-            .isEqualTo(Double.NaN);
+            .asInstanceOf(DOUBLE)
+            .isGreaterThan(0.0);
         assertThat(MBEAN_SERVER.getAttribute(segmentCopyPerSecName, "put-object-time-max"))
-            .isEqualTo(Double.NaN);
+            .asInstanceOf(DOUBLE)
+            .isGreaterThan(0.0);
 
         assertThat(MBEAN_SERVER.getAttribute(segmentCopyPerSecName, "delete-object-requests-rate"))
             .asInstanceOf(DOUBLE)
@@ -171,7 +178,7 @@ void metricsShouldBeReported() throws Exception {
             .asInstanceOf(DOUBLE)
             .isGreaterThan(0.0);
         assertThat(MBEAN_SERVER.getAttribute(segmentCopyPerSecName, "create-multipart-upload-requests-total"))
-            .isEqualTo(2.0);
+            .isEqualTo(1.0);
         assertThat(MBEAN_SERVER.getAttribute(segmentCopyPerSecName, "create-multipart-upload-time-avg"))
             .asInstanceOf(DOUBLE)
             .isGreaterThan(0.0);
@@ -205,9 +212,9 @@ void metricsShouldBeReported() throws Exception {
 
         assertThat(MBEAN_SERVER.getAttribute(segmentCopyPerSecName, "abort-multipart-upload-requests-rate"))
             .asInstanceOf(DOUBLE)
-            .isGreaterThan(0.0);
+            .isEqualTo(0.0);
         assertThat(MBEAN_SERVER.getAttribute(segmentCopyPerSecName, "abort-multipart-upload-requests-total"))
-            .isEqualTo(1.0);
+            .isEqualTo(0.0);
         assertThat(MBEAN_SERVER.getAttribute(segmentCopyPerSecName, "abort-multipart-upload-time-avg"))
             .asInstanceOf(DOUBLE)
             .isGreaterThan(0.0);
diff --git a/storage/s3/src/integration-test/java/io/aiven/kafka/tieredstorage/storage/s3/S3StorageTest.java b/storage/s3/src/integration-test/java/io/aiven/kafka/tieredstorage/storage/s3/S3StorageTest.java
index 4888fea23..3145a9860 100644
--- a/storage/s3/src/integration-test/java/io/aiven/kafka/tieredstorage/storage/s3/S3StorageTest.java
+++ b/storage/s3/src/integration-test/java/io/aiven/kafka/tieredstorage/storage/s3/S3StorageTest.java
@@ -16,6 +16,7 @@
 
 package io.aiven.kafka.tieredstorage.storage.s3;
 
+import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.util.List;
 import java.util.Map;
@@ -26,6 +27,7 @@
 import io.aiven.kafka.tieredstorage.storage.BaseStorageTest;
 import io.aiven.kafka.tieredstorage.storage.ObjectKey;
 import io.aiven.kafka.tieredstorage.storage.StorageBackend;
+import io.aiven.kafka.tieredstorage.storage.StorageBackendException;
 import io.aiven.kafka.tieredstorage.storage.TestObjectKey;
 import io.aiven.kafka.tieredstorage.storage.TestUtils;
 
@@ -39,13 +41,17 @@
 import org.testcontainers.containers.localstack.LocalStackContainer;
 import org.testcontainers.junit.jupiter.Container;
 import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils;
 import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
 import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.core.sync.RequestBody;
 import software.amazon.awssdk.regions.Region;
 import software.amazon.awssdk.services.s3.S3Client;
 import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
+import software.amazon.awssdk.services.s3.model.CreateMultipartUploadRequest;
 import software.amazon.awssdk.services.s3.model.DeleteObjectsRequest;
 import software.amazon.awssdk.services.s3.model.ObjectIdentifier;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -111,6 +117,38 @@ void partSizePassedToStream() throws IOException {
         }
     }
 
+    @Test
+    void testUploadANewFileWithBigSize() throws StorageBackendException, IOException {
+        final String content = generateFileContentWithSizeOverPartLimit();
+        uploadContentAsFileAndVerify(content);
+    }
+
+    private static String generateFileContentWithSizeOverPartLimit() {
+        return RandomStringUtils.randomAlphabetic(PART_SIZE + 1);
+    }
+
+    @Test
+    void testUploadRequestAsSingleFile() throws Exception {
+        final S3Storage storage = (S3Storage) storage();
+        storage.s3Client = Mockito.spy(storage.s3Client);
+
+        final String content = "AABBBBAA";
+        storage.upload(new ByteArrayInputStream(content.getBytes()), TOPIC_PARTITION_SEGMENT_KEY);
+        Mockito.verify(storage.s3Client, Mockito.times(1))
+            .putObject(Mockito.any(PutObjectRequest.class), Mockito.any(RequestBody.class));
+    }
+
+    @Test
+    void testUploadRequestAsMultiPart() throws Exception {
+        final S3Storage storage = (S3Storage) storage();
+        storage.s3Client = Mockito.spy(storage.s3Client);
+
+        final String content = generateFileContentWithSizeOverPartLimit();
+        storage.upload(new ByteArrayInputStream(content.getBytes()), TOPIC_PARTITION_SEGMENT_KEY);
+        Mockito.verify(storage.s3Client, Mockito.times(1))
+            .createMultipartUpload(Mockito.any(CreateMultipartUploadRequest.class));
+    }
+
     @Test
     void testDeleteMoreThanLimit() throws Exception {
         // spy s3Client
diff --git a/storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/ByteBufferMarkableInputStream.java b/storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/ByteBufferMarkableInputStream.java
new file mode 100644
index 000000000..8bfbf498a
--- /dev/null
+++ b/storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/ByteBufferMarkableInputStream.java
@@ -0,0 +1,70 @@
+/*
+ * 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 io.aiven.kafka.tieredstorage.storage.s3;
+
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.util.Objects;
+
+/**
+ * Wraps a {@link ByteBuffer} for access via the {@link InputStream} API.
+ * Its implement refers to {@link org.apache.kafka.common.utils.ByteBufferInputStream}
+ * but with marking and resetting operations added.
+ */
+public class ByteBufferMarkableInputStream extends InputStream {
+    private final ByteBuffer byteBuffer;
+
+    public ByteBufferMarkableInputStream(final ByteBuffer buffer) {
+        byteBuffer = Objects.requireNonNull(buffer);
+    }
+
+    public int read() {
+        return !this.byteBuffer.hasRemaining() ? -1 : this.byteBuffer.get() & 255;
+    }
+
+    public int read(final byte[] bytes, final int off, int len) {
+        if (len == 0) {
+            return 0;
+        } else if (!this.byteBuffer.hasRemaining()) {
+            return -1;
+        } else {
+            len = Math.min(len, this.byteBuffer.remaining());
+            this.byteBuffer.get(bytes, off, len);
+            return len;
+        }
+    }
+
+    public int available() {
+        return this.byteBuffer.remaining();
+    }
+
+    @Override
+    public boolean markSupported() {
+        return true;
+    }
+
+    @Override
+    public void mark(final int readLimit) {
+        byteBuffer.mark();
+    }
+
+    @Override
+    public void reset() {
+        byteBuffer.reset();
+    }
+}
diff --git a/storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3Storage.java b/storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3Storage.java
index 749b7cdf0..ca9a73edf 100644
--- a/storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3Storage.java
+++ b/storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3Storage.java
@@ -72,8 +72,8 @@ public long upload(final InputStream inputStream, final ObjectKey key) throws St
         return out.processedBytes();
     }
 
-    S3MultiPartOutputStream s3OutputStream(final ObjectKey key) {
-        return new S3MultiPartOutputStream(bucketName, key, storageClass, partSize, s3Client);
+    S3UploadOutputStream s3OutputStream(final ObjectKey key) {
+        return new S3UploadOutputStream(bucketName, key, storageClass, partSize, s3Client);
     }
 
     @Override
diff --git a/storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3MultiPartOutputStream.java b/storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStream.java
similarity index 62%
rename from storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3MultiPartOutputStream.java
rename to storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStream.java
index cf7a6cea9..c47d70541 100644
--- a/storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3MultiPartOutputStream.java
+++ b/storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStream.java
@@ -23,7 +23,6 @@
 import java.util.ArrayList;
 import java.util.List;
 
-import org.apache.kafka.common.utils.ByteBufferInputStream;
 
 import io.aiven.kafka.tieredstorage.storage.ObjectKey;
 
@@ -37,57 +36,54 @@
 import software.amazon.awssdk.services.s3.model.CompletedPart;
 import software.amazon.awssdk.services.s3.model.CreateMultipartUploadRequest;
 import software.amazon.awssdk.services.s3.model.CreateMultipartUploadResponse;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
 import software.amazon.awssdk.services.s3.model.StorageClass;
 import software.amazon.awssdk.services.s3.model.UploadPartRequest;
 import software.amazon.awssdk.services.s3.model.UploadPartResponse;
 
 /**
- * S3 multipart output stream.
- * Enable uploads to S3 with unknown size by feeding input bytes to multiple parts and upload them on close.
+ * S3 output stream.
+ * Enable uploads to S3 with unknown size by feeding input bytes to multiple parts or single file and upload.
  *
- * <p>Requires S3 client and starts a multipart transaction when instantiated. Do not reuse.
+ * <p>Requires S3 client and starts a multipart transaction when sending file over upload part size. Do not reuse.
  *
- * <p>{@link S3MultiPartOutputStream} is not thread-safe.
+ * <p>{@link S3UploadOutputStream} is not thread-safe.
  */
-public class S3MultiPartOutputStream extends OutputStream {
+public class S3UploadOutputStream extends OutputStream {
 
-    private static final Logger log = LoggerFactory.getLogger(S3MultiPartOutputStream.class);
+    private static final Logger log = LoggerFactory.getLogger(S3UploadOutputStream.class);
 
     private final S3Client client;
     private final ByteBuffer partBuffer;
     private final String bucketName;
     private final ObjectKey key;
+    private final StorageClass storageClass;
     final int partSize;
 
-    private final String uploadId;
+    private String uploadId;
     private final List<CompletedPart> completedParts = new ArrayList<>();
 
     private boolean closed;
     private long processedBytes;
 
-    public S3MultiPartOutputStream(final String bucketName,
-                                   final ObjectKey key,
-                                   final int partSize,
-                                   final S3Client client){
+    public S3UploadOutputStream(final String bucketName,
+                                final ObjectKey key,
+                                final int partSize,
+                                final S3Client client){
         this(bucketName, key, StorageClass.STANDARD, partSize, client);
     }
 
-    public S3MultiPartOutputStream(final String bucketName,
-                                   final ObjectKey key,
-                                   final StorageClass storageClass,
-                                   final int partSize,
-                                   final S3Client client) {
+    public S3UploadOutputStream(final String bucketName,
+                                final ObjectKey key,
+                                final StorageClass storageClass,
+                                final int partSize,
+                                final S3Client client) {
         this.bucketName = bucketName;
         this.key = key;
+        this.storageClass = storageClass;
         this.client = client;
         this.partSize = partSize;
         this.partBuffer = ByteBuffer.allocate(partSize);
-        final CreateMultipartUploadRequest initialRequest = CreateMultipartUploadRequest.builder().bucket(bucketName)
-            .storageClass(storageClass)
-            .key(key.value()).build();
-        final CreateMultipartUploadResponse initiateResult = client.createMultipartUpload(initialRequest);
-        log.debug("Create new multipart upload request: {}", initiateResult.uploadId());
-        this.uploadId = initiateResult.uploadId();
     }
 
     @Override
@@ -118,48 +114,95 @@ public void write(final byte[] b, final int off, final int len) throws IOExcepti
                 inputBuffer.position(positionAfterCopying);
 
                 if (!partBuffer.hasRemaining()) {
+                    if (uploadId == null){
+                        uploadId = createMultipartUploadRequest();
+                        // this is not expected (another exception should be thrown by S3) but adding for completeness
+                        if (uploadId == null || uploadId.isEmpty()) {
+                            throw new IOException("Failed to create multipart upload, uploadId is empty");
+                        }
+                    }
                     partBuffer.position(0);
                     partBuffer.limit(partSize);
-                    flushBuffer(partBuffer.slice(), partSize);
+                    flushBuffer(partBuffer.slice(), partSize, true);
                 }
             }
         } catch (final RuntimeException e) {
-            log.error("Failed to write to stream on upload {}, aborting transaction", uploadId, e);
-            abortUpload();
+            closed = true;
+            if (multiPartUploadStarted()) {
+                log.error("Failed to write to stream on upload {}, aborting transaction", uploadId, e);
+                abortUpload();
+            }
             throw new IOException(e);
         }
     }
 
+    private String createMultipartUploadRequest() {
+        final CreateMultipartUploadRequest initialRequest = CreateMultipartUploadRequest.builder().bucket(bucketName)
+                .storageClass(storageClass)
+                .key(key.value()).build();
+        final CreateMultipartUploadResponse initiateResult = client.createMultipartUpload(initialRequest);
+        log.debug("Create new multipart upload request: {}", initiateResult.uploadId());
+        return initiateResult.uploadId();
+    }
+
+    private boolean multiPartUploadStarted() {
+        return uploadId != null;
+    }
+
     @Override
     public void close() throws IOException {
         if (!isClosed()) {
+            closed = true;
             final int lastPosition = partBuffer.position();
             if (lastPosition > 0) {
                 try {
                     partBuffer.position(0);
                     partBuffer.limit(lastPosition);
-                    flushBuffer(partBuffer.slice(), lastPosition);
+                    flushBuffer(partBuffer.slice(), lastPosition, multiPartUploadStarted());
                 } catch (final RuntimeException e) {
-                    log.error("Failed to upload last part {}, aborting transaction", uploadId, e);
-                    abortUpload();
+                    if (multiPartUploadStarted()) {
+                        log.error("Failed to upload last part {}, aborting transaction", uploadId, e);
+                        abortUpload();
+                    } else {
+                        log.error("Failed to upload the file {}", key, e);
+                    }
                     throw new IOException(e);
                 }
             }
-            if (!completedParts.isEmpty()) {
-                try {
-                    completeUpload();
-                    log.debug("Completed multipart upload {}", uploadId);
-                } catch (final RuntimeException e) {
-                    log.error("Failed to complete multipart upload {}, aborting transaction", uploadId, e);
-                    abortUpload();
-                    throw new IOException(e);
-                }
-            } else {
+            if (multiPartUploadStarted()) {
+                completeOrAbortMultiPartUpload();
+            }
+        }
+    }
+
+    private void completeOrAbortMultiPartUpload() throws IOException {
+        if (!completedParts.isEmpty()) {
+            try {
+                completeUpload();
+                log.debug("Completed multipart upload {}", uploadId);
+            } catch (final RuntimeException e) {
+                log.error("Failed to complete multipart upload {}, aborting transaction", uploadId, e);
                 abortUpload();
+                throw new IOException(e);
             }
+        } else {
+            abortUpload();
         }
     }
 
+    /**
+     * Upload the {@code size} of {@code inputStream} as one whole single file to S3.
+     * The caller of this method should be responsible for closing the inputStream.
+     */
+    private void uploadAsSingleFile(final InputStream inputStream, final int size) {
+        final PutObjectRequest putObjectRequest = PutObjectRequest.builder().bucket(bucketName)
+            .storageClass(storageClass)
+            .key(key.value())
+            .build();
+        final RequestBody requestBody = RequestBody.fromInputStream(inputStream, size);
+        client.putObject(putObjectRequest, requestBody);
+    }
+
     public boolean isClosed() {
         return closed;
     }
@@ -175,7 +218,6 @@ private void completeUpload() {
             .multipartUpload(completedMultipartUpload)
             .build();
         client.completeMultipartUpload(request);
-        closed = true;
     }
 
     private void abortUpload() {
@@ -185,14 +227,20 @@ private void abortUpload() {
             .uploadId(uploadId)
             .build();
         client.abortMultipartUpload(request);
-        closed = true;
     }
 
     private void flushBuffer(final ByteBuffer buffer,
-                             final int actualPartSize) {
-        try (final InputStream in = new ByteBufferInputStream(buffer)) {
+                             final int actualPartSize,
+                             final boolean multiPartUpload) {
+        //When building the retry request for fail or computing checksum for request body,
+        //It needs the input stream supporting marking and resetting so that it can be read again.
+        try (final InputStream in = new ByteBufferMarkableInputStream(buffer)) {
             processedBytes += actualPartSize;
-            uploadPart(in, actualPartSize);
+            if (multiPartUpload){
+                uploadPart(in, actualPartSize);
+            } else {
+                uploadAsSingleFile(in, actualPartSize);
+            }
         } catch (final IOException e) {
             throw new RuntimeException(e);
         }
diff --git a/storage/s3/src/test/java/io/aiven/kafka/tieredstorage/storage/s3/ByteBufferMarkableInputStreamTest.java b/storage/s3/src/test/java/io/aiven/kafka/tieredstorage/storage/s3/ByteBufferMarkableInputStreamTest.java
new file mode 100644
index 000000000..95bcb10d5
--- /dev/null
+++ b/storage/s3/src/test/java/io/aiven/kafka/tieredstorage/storage/s3/ByteBufferMarkableInputStreamTest.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2025 Aiven Oy
+ *
+ * Licensed 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 io.aiven.kafka.tieredstorage.storage.s3;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.Random;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ByteBufferMarkableInputStreamTest {
+
+    @Test
+    void readAfterAllBytesReadWithoutReset() throws IOException {
+        final byte[] buffer = new byte[10];
+        new Random().nextBytes(buffer);
+        try (ByteBufferMarkableInputStream inputStream = new ByteBufferMarkableInputStream(ByteBuffer.wrap(buffer))){
+            assertThat(inputStream.markSupported()).isTrue();
+            assertThat(inputStream.available()).isEqualTo(10);
+            inputStream.readAllBytes();
+            assertThat(inputStream.available()).isEqualTo(0);
+            final int read = inputStream.read();
+            assertThat(read).isEqualTo(-1);
+        }
+    }
+
+    @Test
+    void readAfterAllBytesReadAndReset() throws IOException {
+        final byte[] buffer = new byte[10];
+        new Random().nextBytes(buffer);
+        try (ByteBufferMarkableInputStream inputStream = new ByteBufferMarkableInputStream(ByteBuffer.wrap(buffer))){
+            inputStream.mark(0);
+            inputStream.readAllBytes();
+            assertThat(inputStream.available()).isEqualTo(0);
+            //reset and try to read again
+            inputStream.reset();
+            assertThat(inputStream.available()).isEqualTo(10);
+            final int read = inputStream.read();
+            assertThat(read).isNotEqualTo(-1);
+        }
+    }
+}
diff --git a/storage/s3/src/test/java/io/aiven/kafka/tieredstorage/storage/s3/S3MultiPartOutputStreamTest.java b/storage/s3/src/test/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStreamTest.java
similarity index 85%
rename from storage/s3/src/test/java/io/aiven/kafka/tieredstorage/storage/s3/S3MultiPartOutputStreamTest.java
rename to storage/s3/src/test/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStreamTest.java
index ff7d97847..0299d034c 100644
--- a/storage/s3/src/test/java/io/aiven/kafka/tieredstorage/storage/s3/S3MultiPartOutputStreamTest.java
+++ b/storage/s3/src/test/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStreamTest.java
@@ -42,6 +42,8 @@
 import software.amazon.awssdk.services.s3.model.CompletedPart;
 import software.amazon.awssdk.services.s3.model.CreateMultipartUploadRequest;
 import software.amazon.awssdk.services.s3.model.CreateMultipartUploadResponse;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+import software.amazon.awssdk.services.s3.model.PutObjectResponse;
 import software.amazon.awssdk.services.s3.model.StorageClass;
 import software.amazon.awssdk.services.s3.model.UploadPartRequest;
 import software.amazon.awssdk.services.s3.model.UploadPartResponse;
@@ -50,13 +52,14 @@
 import static org.assertj.core.api.Assertions.assertThatCode;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.lenient;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 @ExtendWith(MockitoExtension.class)
-class S3MultiPartOutputStreamTest {
+class S3UploadOutputStreamTest {
     private static final String BUCKET_NAME = "some_bucket";
     private static final ObjectKey FILE_KEY = new TestObjectKey("some_key");
     private static final String UPLOAD_ID = "some_upload_id";
@@ -73,27 +76,38 @@ class S3MultiPartOutputStreamTest {
     @Captor
     ArgumentCaptor<UploadPartRequest> uploadPartRequestCaptor;
     @Captor
+    ArgumentCaptor<PutObjectRequest> putObjectRequestCaptor;
+    @Captor
     ArgumentCaptor<RequestBody> requestBodyCaptor;
 
     final Random random = new Random();
 
     @BeforeEach
     void setUp() {
-        when(mockedS3.createMultipartUpload(any(CreateMultipartUploadRequest.class)))
+        //In most cases, this stub is needed, except for a few.
+        //So, add 'lenient' to bypass strict stubbing.
+        lenient().when(mockedS3.createMultipartUpload(any(CreateMultipartUploadRequest.class)))
             .thenReturn(newInitiateMultipartUploadResult());
     }
 
     @Test
-    void completeMultipartUploadWithDefaultStorageClass() {
-        new S3MultiPartOutputStream(BUCKET_NAME, FILE_KEY, 1, mockedS3);
+    void completeMultipartUploadWithDefaultStorageClass() throws IOException {
+        final S3UploadOutputStream s3UploadOutputStream = new S3UploadOutputStream(BUCKET_NAME, FILE_KEY, 1, mockedS3);
+        when(mockedS3.uploadPart(any(UploadPartRequest.class), any(RequestBody.class)))
+            .thenReturn(newUploadPartResponse("SOME_ETAG#1"));
+        s3UploadOutputStream.write(1);
         verify(mockedS3).createMultipartUpload(createMultipartUploadRequest.capture());
         assertCreateMultipartUploadRequest(createMultipartUploadRequest.getValue(), StorageClass.STANDARD);
     }
 
     @Test
-    void completeMultipartUploadWithNonDefaultStorageClass() {
+    void completeMultipartUploadWithNonDefaultStorageClass() throws IOException {
         final StorageClass nonDefaultStorageClass = StorageClass.STANDARD_IA;
-        new S3MultiPartOutputStream(BUCKET_NAME, FILE_KEY, nonDefaultStorageClass, 1, mockedS3);
+        final S3UploadOutputStream s3UploadOutputStream = new S3UploadOutputStream(BUCKET_NAME, FILE_KEY,
+            nonDefaultStorageClass, 1, mockedS3);
+        when(mockedS3.uploadPart(any(UploadPartRequest.class), any(RequestBody.class)))
+            .thenReturn(newUploadPartResponse("SOME_ETAG#1"));
+        s3UploadOutputStream.write(1);
         verify(mockedS3).createMultipartUpload(createMultipartUploadRequest.capture());
         assertCreateMultipartUploadRequest(createMultipartUploadRequest.getValue(), nonDefaultStorageClass);
     }
@@ -104,7 +118,7 @@ void sendAbortForAnyExceptionWhileWriting() {
         when(mockedS3.uploadPart(any(UploadPartRequest.class), any(RequestBody.class)))
             .thenThrow(testException);
 
-        final var out = new S3MultiPartOutputStream(BUCKET_NAME, FILE_KEY, 1, mockedS3);
+        final var out = new S3UploadOutputStream(BUCKET_NAME, FILE_KEY, 1, mockedS3);
         assertThatThrownBy(() -> out.write(new byte[] {1, 2, 3}))
             .isInstanceOf(IOException.class)
             .hasRootCause(testException);
@@ -124,11 +138,12 @@ void sendAbortForAnyExceptionWhileWriting() {
     @Test
     void sendAbortForAnyExceptionWhenClosingUpload() throws Exception {
         when(mockedS3.uploadPart(any(UploadPartRequest.class), any(RequestBody.class)))
+            .thenReturn(newUploadPartResponse("SOME_ETAG#1"))
             .thenThrow(RuntimeException.class);
 
-        final var out = new S3MultiPartOutputStream(BUCKET_NAME, FILE_KEY, 10, mockedS3);
+        final var out = new S3UploadOutputStream(BUCKET_NAME, FILE_KEY, 10, mockedS3);
 
-        final byte[] buffer = new byte[5];
+        final byte[] buffer = new byte[15];
         random.nextBytes(buffer);
         out.write(buffer, 0, buffer.length);
 
@@ -153,9 +168,9 @@ void sendAbortForAnyExceptionWhenClosingComplete() throws Exception {
         when(mockedS3.completeMultipartUpload(any(CompleteMultipartUploadRequest.class)))
             .thenThrow(RuntimeException.class);
 
-        final var out = new S3MultiPartOutputStream(BUCKET_NAME, FILE_KEY, 10, mockedS3);
+        final var out = new S3UploadOutputStream(BUCKET_NAME, FILE_KEY, 10, mockedS3);
 
-        final byte[] buffer = new byte[5];
+        final byte[] buffer = new byte[10];
         random.nextBytes(buffer);
         out.write(buffer, 0, buffer.length);
 
@@ -174,14 +189,14 @@ void sendAbortForAnyExceptionWhenClosingComplete() throws Exception {
     }
 
     @Test
-    void writesOneByte() throws Exception {
+    void writesOnePartUploadByte() throws Exception {
         when(mockedS3.uploadPart(any(UploadPartRequest.class), any(RequestBody.class)))
             .thenReturn(newUploadPartResponse("SOME_ETAG"));
         when(mockedS3.completeMultipartUpload(any(CompleteMultipartUploadRequest.class)))
             .thenReturn(CompleteMultipartUploadResponse.builder().eTag("SOME_ETAG").build());
 
-        final var out = new S3MultiPartOutputStream(BUCKET_NAME, FILE_KEY, 100, mockedS3);
-        out.write(1);
+        final var out = new S3UploadOutputStream(BUCKET_NAME, FILE_KEY, 1, mockedS3);
+        out.write(new byte[] {1});
         out.close();
 
         assertThat(out.isClosed()).isTrue();
@@ -204,6 +219,24 @@ void writesOneByte() throws Exception {
         );
     }
 
+    @Test
+    void writesSmallFile() throws Exception {
+        when(mockedS3.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
+                .thenReturn(newPutObjectResponse("SOME_ETAG"));
+
+        final var out = new S3UploadOutputStream(BUCKET_NAME, FILE_KEY, 2, mockedS3);
+        out.write(new byte[] {1});
+        out.close();
+
+        assertThat(out.isClosed()).isTrue();
+        assertThatCode(out::close).doesNotThrowAnyException();
+
+        verify(mockedS3).putObject(putObjectRequestCaptor.capture(), requestBodyCaptor.capture());
+        verify(mockedS3, never()).completeMultipartUpload(any(CompleteMultipartUploadRequest.class));
+        verify(mockedS3, never()).createMultipartUpload(any(CreateMultipartUploadRequest.class));
+        verify(mockedS3, never()).uploadPart(any(UploadPartRequest.class), any(RequestBody.class));
+    }
+
     @Test
     void writesMultipleMessages() throws Exception {
         final int bufferSize = 10;
@@ -227,7 +260,7 @@ void writesMultipleMessages() throws Exception {
             .thenReturn(CompleteMultipartUploadResponse.builder().build());
 
         final List<byte[]> expectedMessagesList = new ArrayList<>();
-        final var out = new S3MultiPartOutputStream(BUCKET_NAME, FILE_KEY, bufferSize, mockedS3);
+        final var out = new S3UploadOutputStream(BUCKET_NAME, FILE_KEY, bufferSize, mockedS3);
         for (int i = 0; i < 3; i++) {
             final byte[] message = new byte[bufferSize];
             random.nextBytes(message);
@@ -288,7 +321,7 @@ void writesTailMessages() throws Exception {
         final byte[] expectedFullMessage = new byte[messageSize + 10];
         final byte[] expectedTailMessage = new byte[10];
 
-        final var out = new S3MultiPartOutputStream(BUCKET_NAME, FILE_KEY, messageSize + 10, mockedS3);
+        final var out = new S3UploadOutputStream(BUCKET_NAME, FILE_KEY, messageSize + 10, mockedS3);
         final byte[] message = new byte[messageSize];
         random.nextBytes(message);
         out.write(message);
@@ -352,7 +385,7 @@ void writesTailMessagesFromInputStreamBufferSmallerThanSize() throws Exception {
         System.arraycopy(message1, 0, expectedFullMessage1, 2, 6);
         System.arraycopy(message1, 6, expectedTailMessage, 0, 4);
         final var in = new SequenceInputStream(new ByteArrayInputStream(message0), new ByteArrayInputStream(message1));
-        final var out = new S3MultiPartOutputStream(BUCKET_NAME, FILE_KEY, 8, mockedS3);
+        final var out = new S3UploadOutputStream(BUCKET_NAME, FILE_KEY, 8, mockedS3);
         try (in; out) {
             in.transferTo(out);
         }
@@ -410,7 +443,7 @@ void writesTailMessagesFromInputStreamSizeSmallerThanBuffer() throws Exception {
         System.arraycopy(message1, 0, expectedFullMessage0, 10, 2);
         System.arraycopy(message1, 2, expectedTailMessage, 0, 8);
         final var in = new SequenceInputStream(new ByteArrayInputStream(message0), new ByteArrayInputStream(message1));
-        final var out = new S3MultiPartOutputStream(BUCKET_NAME, FILE_KEY, 12, mockedS3);
+        final var out = new S3UploadOutputStream(BUCKET_NAME, FILE_KEY, 12, mockedS3);
         try (in; out) {
             in.transferTo(out);
         }
@@ -434,23 +467,21 @@ void writesTailMessagesFromInputStreamSizeSmallerThanBuffer() throws Exception {
     }
 
     @Test
-    void sendAbortIfNoWritingHappened() throws IOException {
-        final var out = new S3MultiPartOutputStream(BUCKET_NAME, FILE_KEY, 100, mockedS3);
+    void closeNormallyIfNoWritingHappened() throws IOException {
+        final var out = new S3UploadOutputStream(BUCKET_NAME, FILE_KEY, 100, mockedS3);
         out.close();
 
-        verify(mockedS3).abortMultipartUpload(abortMultipartUploadRequestCaptor.capture());
-        assertAbortMultipartUploadRequest(abortMultipartUploadRequestCaptor.getValue());
+        verify(mockedS3, never()).abortMultipartUpload(any(AbortMultipartUploadRequest.class));
         assertThat(out.isClosed()).isTrue();
         assertThatCode(out::close).doesNotThrowAnyException();
     }
 
     @Test
     void failWhenUploadingPartAfterStreamIsClosed() throws IOException {
-        final var out = new S3MultiPartOutputStream(BUCKET_NAME, FILE_KEY, 100, mockedS3);
+        final var out = new S3UploadOutputStream(BUCKET_NAME, FILE_KEY, 100, mockedS3);
         out.close();
 
-        verify(mockedS3).abortMultipartUpload(abortMultipartUploadRequestCaptor.capture());
-        assertAbortMultipartUploadRequest(abortMultipartUploadRequestCaptor.getValue());
+        verify(mockedS3, never()).abortMultipartUpload(abortMultipartUploadRequestCaptor.capture());
         assertThat(out.isClosed()).isTrue();
         assertThatCode(out::close).doesNotThrowAnyException();
 
@@ -469,6 +500,10 @@ private static UploadPartResponse newUploadPartResponse(final String etag) {
         return UploadPartResponse.builder().eTag(etag).build();
     }
 
+    private static PutObjectResponse newPutObjectResponse(final String etag) {
+        return PutObjectResponse.builder().eTag(etag).build();
+    }
+
     private static void assertCreateMultipartUploadRequest(final CreateMultipartUploadRequest request,
                                                            final StorageClass expectedStorageClass) {
         assertThat(request.bucket()).isEqualTo(BUCKET_NAME);


Reviews:
user: login: jeqo
body: Thanks, @jiafu1115! Agree this is a valuable optimization and can help to reduce costs.

I wonder if we should consider hiding this within the S3MultiPartOutputStream instead. It will require some tweaking of course: first renaming it to something like S3UploadOutputStream, but most importantly: instead of initiating the MPU on instantiation, do it before the first upload if it's not when closing (i.e. there is more data to upload), and only if it's closed and no MPU is started, then just do a simple upload.

state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T11:10:34Z
user: login: jeqo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T13:55:24Z
user: login: jiafu1115
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T14:04:05Z
user: login: jiafu1115
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T15:17:33Z
user: login: jiafu1115
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-22T02:00:18Z
user: login: jeqo
body: thanks @jiafu1115! I took a first pass at the PR, it looks good! just a few comments to consider.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T17:31:16Z
user: login: jiafu1115
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T00:27:10Z
user: login: jiafu1115
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T01:01:56Z
user: login: jiafu1115
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T01:14:24Z
user: login: jeqo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T06:52:28Z
user: login: jiafu1115
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T07:14:37Z
user: login: jeqo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T09:41:01Z
user: login: jiafu1115
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T10:31:02Z
user: login: jeqo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T10:47:33Z
user: login: jeqo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T10:50:03Z
user: login: jeqo
body: @jiafu1115 thanks for the updates! left a couple more comments and asked for another review internally to get this merged. Thanks again!
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T10:50:31Z
user: login: jiafu1115
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T11:21:12Z
user: login: jiafu1115
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T11:25:29Z
user: login: jeqo
body: @jiafu1115 took another round with focus on testing
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T11:31:37Z
user: login: jiafu1115
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T12:51:50Z
user: login: jiafu1115
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T12:54:45Z
user: login: jiafu1115
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T12:58:22Z
user: login: jiafu1115
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T13:26:10Z
user: login: jiafu1115
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T13:28:38Z
user: login: jiafu1115
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T13:35:55Z
user: login: jiafu1115
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T13:45:13Z
user: login: jiafu1115
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T13:59:55Z
user: login: jiafu1115
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T14:00:19Z
user: login: jiafu1115
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T14:12:30Z
user: login: jiafu1115
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T14:14:05Z
user: login: jiafu1115
body: adopt the suggestions but update the code directly due to request change has one code style issue.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T23:07:27Z
user: login: jeqo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T07:11:17Z
user: login: jiafu1115
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T10:13:02Z
user: login: jiafu1115
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T16:23:11Z
user: login: jiafu1115
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T16:24:08Z
user: login: jeqo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T23:57:20Z
user: login: jeqo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T08:36:10Z
user: login: jeqo
body: LGTM, thanks @jiafu1115!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T18:57:43Z

Review comments:
diff_hunk: @@ -62,11 +64,30 @@ public void configure(final Map<String, ?> configs) {
 
     @Override
     public long upload(final InputStream inputStream, final ObjectKey key) throws StorageBackendException {
+        try {
+            final int availableSize = inputStream.available();
+            if (availableSize <= partSize) {
path: storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3Storage.java
user: login: jeqo
body: `InputStream#available` doesn't guarantee to return the total size. This is one of the reasons I was suggesting the S3UploadOutputStream approach as it is already reading the stream and the final size is available there. 
created_at: 2025-03-20T13:55:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -62,11 +64,30 @@ public void configure(final Map<String, ?> configs) {
 
     @Override
     public long upload(final InputStream inputStream, final ObjectKey key) throws StorageBackendException {
+        try {
+            final int availableSize = inputStream.available();
+            if (availableSize <= partSize) {
path: storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3Storage.java
user: login: jiafu1115
body: @jeqo I was also worry about it and tested with deploy.  Anyway. it is risk if we don't want to check the caller's code. Let me consider your proposal and make one code sample's PR without test. Then we can compare it more clearly. thanks
created_at: 2025-03-20T14:04:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -62,11 +64,30 @@ public void configure(final Map<String, ?> configs) {
 
     @Override
     public long upload(final InputStream inputStream, final ObjectKey key) throws StorageBackendException {
+        try {
+            final int availableSize = inputStream.available();
+            if (availableSize <= partSize) {
path: storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3Storage.java
user: login: jiafu1115
body: @jeqo Let me update this PR's code directly. thanks. BTW: you can give some comments for the core code. 
And I will take some times to add/update the tests case at the same time. Thanks
created_at: 2025-03-20T15:17:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -62,11 +64,30 @@ public void configure(final Map<String, ?> configs) {
 
     @Override
     public long upload(final InputStream inputStream, final ObjectKey key) throws StorageBackendException {
+        try {
+            final int availableSize = inputStream.available();
+            if (availableSize <= partSize) {
path: storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3Storage.java
user: login: jiafu1115
body: @jeqo  done with all code/test and squash to one commit. help to review. thanks
created_at: 2025-03-22T02:00:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -189,10 +223,14 @@ private void abortUpload() {
     }
 
     private void flushBuffer(final ByteBuffer buffer,
-                             final int actualPartSize) {
+                             final int actualPartSize, final boolean multiPartUpload) {
path: storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStream.java
user: login: jeqo
body: nit:
```suggestion
                             final int actualPartSize,
                             final boolean multiPartUpload) {
```
created_at: 2025-03-25T09:10:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -160,6 +185,15 @@ public void close() throws IOException {
         }
     }
 
+    private void uploadAsSingleFile(final InputStream inputStream, final int availableSize) {
+        final PutObjectRequest putObjectRequest = PutObjectRequest.builder().bucket(bucketName)
+            .storageClass(storageClass)
+            .key(key.value()).build();
path: storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStream.java
user: login: jeqo
body: nit
```suggestion
            .key(key.value())
            .build();
```
created_at: 2025-03-25T09:18:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -160,6 +185,15 @@ public void close() throws IOException {
         }
     }
 
+    private void uploadAsSingleFile(final InputStream inputStream, final int availableSize) {
+        final PutObjectRequest putObjectRequest = PutObjectRequest.builder().bucket(bucketName)
+            .storageClass(storageClass)
+            .key(key.value()).build();
+        final RequestBody requestBody = RequestBody.fromInputStream(inputStream, availableSize);
path: storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStream.java
user: login: jeqo
body: Worth adding a comment that this will take care of closing the input stream
created_at: 2025-03-25T09:18:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -160,6 +185,15 @@ public void close() throws IOException {
         }
     }
 
+    private void uploadAsSingleFile(final InputStream inputStream, final int availableSize) {
+        final PutObjectRequest putObjectRequest = PutObjectRequest.builder().bucket(bucketName)
+            .storageClass(storageClass)
+            .key(key.value()).build();
+        final RequestBody requestBody = RequestBody.fromInputStream(inputStream, availableSize);
+        client.putObject(putObjectRequest, requestBody);
+        closed = true;
path: storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStream.java
user: login: jeqo
body: maybe move this side-effect out of this method?
created_at: 2025-03-25T09:20:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -160,6 +185,15 @@ public void close() throws IOException {
         }
     }
 
+    private void uploadAsSingleFile(final InputStream inputStream, final int availableSize) {
+        final PutObjectRequest putObjectRequest = PutObjectRequest.builder().bucket(bucketName)
+            .storageClass(storageClass)
+            .key(key.value()).build();
+        final RequestBody requestBody = RequestBody.fromInputStream(inputStream, availableSize);
+        client.putObject(putObjectRequest, requestBody);
+        closed = true;
path: storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStream.java
user: login: jiafu1115
body: @jeqo thanks for your suggestion. I checked the code with all conditions and find it is useless due the close method had completed the 'closed' status' changing to true for all cases.
created_at: 2025-03-26T00:27:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -160,6 +185,15 @@ public void close() throws IOException {
         }
     }
 
+    private void uploadAsSingleFile(final InputStream inputStream, final int availableSize) {
+        final PutObjectRequest putObjectRequest = PutObjectRequest.builder().bucket(bucketName)
+            .storageClass(storageClass)
+            .key(key.value()).build();
+        final RequestBody requestBody = RequestBody.fromInputStream(inputStream, availableSize);
path: storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStream.java
user: login: jiafu1115
body: done
created_at: 2025-03-26T01:01:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -160,6 +185,15 @@ public void close() throws IOException {
         }
     }
 
+    private void uploadAsSingleFile(final InputStream inputStream, final int availableSize) {
+        final PutObjectRequest putObjectRequest = PutObjectRequest.builder().bucket(bucketName)
+            .storageClass(storageClass)
+            .key(key.value()).build();
+        final RequestBody requestBody = RequestBody.fromInputStream(inputStream, availableSize);
path: storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStream.java
user: login: jiafu1115
body: @jeqo  done for the first review's suggestions. Thanks. I resolve all the conversations. You can reopen it if any issue existed. 
created_at: 2025-03-26T01:14:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -118,14 +125,21 @@ public void write(final byte[] b, final int off, final int len) throws IOExcepti
                 inputBuffer.position(positionAfterCopying);
 
                 if (!partBuffer.hasRemaining()) {
+                    if (uploadId == null){
+                        multiPartUpload = true;
+                        uploadId = createMultipartUploadRequest();
+                    }
                     partBuffer.position(0);
                     partBuffer.limit(partSize);
-                    flushBuffer(partBuffer.slice(), partSize);
+                    flushBuffer(partBuffer.slice(), partSize, true);
                 }
             }
         } catch (final RuntimeException e) {
-            log.error("Failed to write to stream on upload {}, aborting transaction", uploadId, e);
-            abortUpload();
+            if (multiPartUpload && uploadId != null) {
path: storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStream.java
user: login: jeqo
body: I wonder if instead of a variable, `multiPartUpload` could be turned into a function `multiParrtUploadStarted() { return uploadId != null; }` so this validations could be simplified.
created_at: 2025-03-27T06:52:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -118,14 +125,21 @@ public void write(final byte[] b, final int off, final int len) throws IOExcepti
                 inputBuffer.position(positionAfterCopying);
 
                 if (!partBuffer.hasRemaining()) {
+                    if (uploadId == null){
+                        multiPartUpload = true;
+                        uploadId = createMultipartUploadRequest();
+                    }
                     partBuffer.position(0);
                     partBuffer.limit(partSize);
-                    flushBuffer(partBuffer.slice(), partSize);
+                    flushBuffer(partBuffer.slice(), partSize, true);
                 }
             }
         } catch (final RuntimeException e) {
-            log.error("Failed to write to stream on upload {}, aborting transaction", uploadId, e);
-            abortUpload();
+            if (multiPartUpload && uploadId != null) {
path: storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStream.java
user: login: jiafu1115
body: @jeqo Thanks.
In the first version's code. I only have uploadId != null to keep code simple but I find one case need to cover:
when it is using the multi upload. but creating createMultipartUploadRequest fail.  uploadId is null. 
in other hand: uploadId = null contains two case:
(1) no multiple upload. only small file size
(2) multiple upload but createMultipartUploadRequest failed.
So I add multiPartUpload to distinguish these two cases and make code more clear:
S3UploadOutputStream#close focus on multiPartUpload be more clear due to that:
( uploadId != null) != multiPartUpload 
WDYT?
created_at: 2025-03-27T07:14:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -118,14 +125,21 @@ public void write(final byte[] b, final int off, final int len) throws IOExcepti
                 inputBuffer.position(positionAfterCopying);
 
                 if (!partBuffer.hasRemaining()) {
+                    if (uploadId == null){
+                        multiPartUpload = true;
+                        uploadId = createMultipartUploadRequest();
+                    }
                     partBuffer.position(0);
                     partBuffer.limit(partSize);
-                    flushBuffer(partBuffer.slice(), partSize);
+                    flushBuffer(partBuffer.slice(), partSize, true);
                 }
             }
         } catch (final RuntimeException e) {
-            log.error("Failed to write to stream on upload {}, aborting transaction", uploadId, e);
-            abortUpload();
+            if (multiPartUpload && uploadId != null) {
path: storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStream.java
user: login: jeqo
body: Wouldn't the case 2 be covered by an exception? Maybe after creating a MPU add a check if the id is null and fail. 
created_at: 2025-03-27T09:41:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -118,14 +125,21 @@ public void write(final byte[] b, final int off, final int len) throws IOExcepti
                 inputBuffer.position(positionAfterCopying);
 
                 if (!partBuffer.hasRemaining()) {
+                    if (uploadId == null){
+                        multiPartUpload = true;
+                        uploadId = createMultipartUploadRequest();
+                    }
                     partBuffer.position(0);
                     partBuffer.limit(partSize);
-                    flushBuffer(partBuffer.slice(), partSize);
+                    flushBuffer(partBuffer.slice(), partSize, true);
                 }
             }
         } catch (final RuntimeException e) {
-            log.error("Failed to write to stream on upload {}, aborting transaction", uploadId, e);
-            abortUpload();
+            if (multiPartUpload && uploadId != null) {
path: storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStream.java
user: login: jiafu1115
body: @jeqo I try to remove the field with new commit and find it should be workable for all cases. you can help to check.  Thanks in advanced.
And if we need " creating a MPU add a check if the id is null and fail" or just keep current code to expect the creating throw the exception directly 
created_at: 2025-03-27T10:31:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -138,13 +154,24 @@ public void close() throws IOException {
                 try {
                     partBuffer.position(0);
                     partBuffer.limit(lastPosition);
-                    flushBuffer(partBuffer.slice(), lastPosition);
+                    flushBuffer(partBuffer.slice(), lastPosition, multiPartUploadStarted());
                 } catch (final RuntimeException e) {
-                    log.error("Failed to upload last part {}, aborting transaction", uploadId, e);
-                    abortUpload();
+                    if (multiPartUploadStarted()) {
+                        log.error("Failed to upload last part {}, aborting transaction", uploadId, e);
+                        abortUpload();
+                    } else {
+                        closed = true;
path: storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStream.java
user: login: jeqo
body: I realized that I asked you to remove the `closed = true` as part of the upload single file, while complete/abort MPU operation have this side-effect inside.
Now that there are two ways to complete the operation, I think it's worth moving those side-effects out and handle them here. So in this case you can clse outside the conditional (maybe before the try-catch? so we don't need to change the flag before throw and after try-catch?)
created_at: 2025-03-27T10:47:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -138,13 +154,24 @@ public void close() throws IOException {
                 try {
                     partBuffer.position(0);
                     partBuffer.limit(lastPosition);
-                    flushBuffer(partBuffer.slice(), lastPosition);
+                    flushBuffer(partBuffer.slice(), lastPosition, multiPartUploadStarted());
                 } catch (final RuntimeException e) {
-                    log.error("Failed to upload last part {}, aborting transaction", uploadId, e);
-                    abortUpload();
+                    if (multiPartUploadStarted()) {
+                        log.error("Failed to upload last part {}, aborting transaction", uploadId, e);
+                        abortUpload();
+                    } else {
+                        closed = true;
+                        log.error("Failed to upload the file {}", key, e);
+                    }
                     throw new IOException(e);
                 }
             }
+
+            if (!multiPartUploadStarted()) {
+                closed = true;
+                return;
+            }
+
             if (!completedParts.isEmpty()) {
path: storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStream.java
user: login: jeqo
body: If we remove the need to set the closed=true here, we could wrap the next condition only if MPU has started. 
created_at: 2025-03-27T10:50:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -138,13 +154,24 @@ public void close() throws IOException {
                 try {
                     partBuffer.position(0);
                     partBuffer.limit(lastPosition);
-                    flushBuffer(partBuffer.slice(), lastPosition);
+                    flushBuffer(partBuffer.slice(), lastPosition, multiPartUploadStarted());
                 } catch (final RuntimeException e) {
-                    log.error("Failed to upload last part {}, aborting transaction", uploadId, e);
-                    abortUpload();
+                    if (multiPartUploadStarted()) {
+                        log.error("Failed to upload last part {}, aborting transaction", uploadId, e);
+                        abortUpload();
+                    } else {
+                        closed = true;
path: storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStream.java
user: login: jiafu1115
body: @jeqo  done. you can help to check the latest commit. I set the close status before the try catch to avoid the exception throw. it really be better and simple the code logic.
created_at: 2025-03-27T11:21:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -138,13 +154,24 @@ public void close() throws IOException {
                 try {
                     partBuffer.position(0);
                     partBuffer.limit(lastPosition);
-                    flushBuffer(partBuffer.slice(), lastPosition);
+                    flushBuffer(partBuffer.slice(), lastPosition, multiPartUploadStarted());
                 } catch (final RuntimeException e) {
-                    log.error("Failed to upload last part {}, aborting transaction", uploadId, e);
-                    abortUpload();
+                    if (multiPartUploadStarted()) {
+                        log.error("Failed to upload last part {}, aborting transaction", uploadId, e);
+                        abortUpload();
+                    } else {
+                        closed = true;
+                        log.error("Failed to upload the file {}", key, e);
+                    }
                     throw new IOException(e);
                 }
             }
+
+            if (!multiPartUploadStarted()) {
+                closed = true;
+                return;
+            }
+
             if (!completedParts.isEmpty()) {
path: storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStream.java
user: login: jiafu1115
body: I already remove this. I like all the unit tests and integration test which make me can change the code more smoothly..

created_at: 2025-03-27T11:25:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -20,16 +20,18 @@
 import javax.management.MalformedObjectNameException;
 import javax.management.ObjectName;
 
-import java.io.InputStream;
+import java.io.ByteArrayInputStream;
 import java.lang.management.ManagementFactory;
 import java.util.Map;
+import java.util.Random;
 
 import io.aiven.kafka.tieredstorage.storage.StorageBackendException;
 import io.aiven.kafka.tieredstorage.storage.TestObjectKey;
 
 import com.github.tomakehurst.wiremock.http.Fault;
 import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
 import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import org.jetbrains.annotations.NotNull;
path: storage/s3/src/integration-test/java/io/aiven/kafka/tieredstorage/storage/s3/S3ErrorMetricsTest.java
user: login: jeqo
body: Do we need this jetbrains annotation? 
created_at: 2025-03-28T10:52:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -101,6 +110,14 @@ void testS3ServerExceptions(final int statusCode,
             .isGreaterThan(0.0);
     }
 
+    @NotNull
+    private ByteArrayInputStream newInputStreamWithSize(final int size) {
+        final byte[] buffer = new byte[size];
+        random.nextBytes(buffer);
+        final ByteArrayInputStream inputStream = new ByteArrayInputStream(buffer);
+        return inputStream;
+    }
path: storage/s3/src/integration-test/java/io/aiven/kafka/tieredstorage/storage/s3/S3ErrorMetricsTest.java
user: login: jeqo
body: a bit simpler
```suggestion
    private ByteArrayInputStream newInputStreamWithSize(final int size) {
        final byte[] buffer = new byte[size];
        random.nextBytes(buffer);
        return new ByteArrayInputStream(buffer);
    }
```
created_at: 2025-03-28T10:54:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -79,19 +83,24 @@ void testS3ServerExceptions(final int statusCode,
             "s3.region", Region.US_EAST_1.id(),
             "s3.endpoint.url", wmRuntimeInfo.getHttpBaseUrl(),
             "s3.path.style.access.enabled", "true",
-            "aws.credentials.provider.class", AnonymousCredentialsProvider.class.getName()
+            "aws.credentials.provider.class", AnonymousCredentialsProvider.class.getName(),
+            "s3.multipart.upload.part.size", UPLOAD_PART_SIZE
         );
         storage.configure(configs);
 
         stubFor(any(anyUrl())
             .willReturn(aResponse().withStatus(statusCode)
                 .withHeader(CONTENT_TYPE, APPLICATION_XML.getMimeType())
                 .withBody(String.format(ERROR_RESPONSE_TEMPLATE, statusCode))));
-        final S3Exception s3Exception = catchThrowableOfType(
-            () -> storage.upload(InputStream.nullInputStream(), new TestObjectKey("key")),
-            S3Exception.class);
 
-        assertThat(s3Exception.statusCode()).isEqualTo(statusCode);
+        final ByteArrayInputStream inputStream = newInputStreamWithSize(UPLOAD_PART_SIZE + 1);
+        final StorageBackendException storageBackendException = catchThrowableOfType(
+            () -> storage.upload(inputStream, new TestObjectKey("key")),
+                StorageBackendException.class);
+
+        final Throwable cause = storageBackendException.getCause().getCause();
+        assertThat(cause).isInstanceOf(S3Exception.class);
+        assertThat(((S3Exception) cause).statusCode()).isEqualTo(statusCode);
path: storage/s3/src/integration-test/java/io/aiven/kafka/tieredstorage/storage/s3/S3ErrorMetricsTest.java
user: login: jeqo
body: for correctness
```suggestion
        try (final ByteArrayInputStream inputStream = newInputStreamWithSize(UPLOAD_PART_SIZE + 1)) {
            final StorageBackendException storageBackendException = catchThrowableOfType(
                StorageBackendException.class,
                () -> storage.upload(inputStream, new TestObjectKey("key"))
            );
            assertThat(storageBackendException.getCause())
                .isInstanceOf(IOException.class)
                .cause()
                .isInstanceOf(S3Exception.class)
                .extracting(e -> ((S3Exception) e).statusCode())
                .isEqualTo(statusCode);
        }
```
created_at: 2025-03-28T11:15:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -120,9 +137,10 @@ void testOtherExceptions(final WireMockRuntimeInfo wmRuntimeInfo) throws Excepti
                 .withHeader(CONTENT_TYPE, APPLICATION_XML.getMimeType())
                 .withBody("unparsable_xml")));
 
-        assertThatThrownBy(() -> storage.upload(InputStream.nullInputStream(), new TestObjectKey("key")))
-            .isInstanceOf(SdkClientException.class)
-            .hasMessageStartingWith("Could not parse XML response.");
+        final ByteArrayInputStream inputStream = newInputStreamWithSize(UPLOAD_PART_SIZE + 1);
path: storage/s3/src/integration-test/java/io/aiven/kafka/tieredstorage/storage/s3/S3ErrorMetricsTest.java
user: login: jeqo
body: maybe? otherwise we are only testing MPU scenario but not single upload. Does it change anything here?
```suggestion
        final ByteArrayInputStream inputStream = newInputStreamWithSize(UPLOAD_PART_SIZE - 1);
```
created_at: 2025-03-28T11:15:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -171,7 +171,7 @@ void metricsShouldBeReported() throws Exception {
             .asInstanceOf(DOUBLE)
             .isGreaterThan(0.0);
         assertThat(MBEAN_SERVER.getAttribute(segmentCopyPerSecName, "create-multipart-upload-requests-total"))
-            .isEqualTo(2.0);
+            .isEqualTo(1.0);
path: storage/s3/src/integration-test/java/io/aiven/kafka/tieredstorage/storage/s3/S3StorageMetricsTest.java
user: login: jeqo
body: the decrease on this should mean an increase on the single upload metric. Could you add this as well? 
created_at: 2025-03-28T11:16:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -118,33 +124,53 @@ public void write(final byte[] b, final int off, final int len) throws IOExcepti
                 inputBuffer.position(positionAfterCopying);
 
                 if (!partBuffer.hasRemaining()) {
+                    if (uploadId == null){
+                        uploadId = createMultipartUploadRequest();
path: storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStream.java
user: login: jeqo
body: wonder if we should cover this as well
```suggestion
                        uploadId = createMultipartUploadRequest();
                        // this is not expected (another exception should be thrown by S3) but adding for completeness
                        if (uploadId == null || uploadId.isEmpty()) 
                            throw new IOException("Failed to create multipart upload, uploadId is empty");

```
created_at: 2025-03-28T11:23:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -118,33 +124,53 @@ public void write(final byte[] b, final int off, final int len) throws IOExcepti
                 inputBuffer.position(positionAfterCopying);
 
                 if (!partBuffer.hasRemaining()) {
+                    if (uploadId == null){
+                        uploadId = createMultipartUploadRequest();
+                    }
                     partBuffer.position(0);
                     partBuffer.limit(partSize);
-                    flushBuffer(partBuffer.slice(), partSize);
+                    flushBuffer(partBuffer.slice(), partSize, true);
                 }
             }
         } catch (final RuntimeException e) {
-            log.error("Failed to write to stream on upload {}, aborting transaction", uploadId, e);
-            abortUpload();
+            closed = true;
+            if (multiPartUploadStarted()) {
+                log.error("Failed to write to stream on upload {}, aborting transaction", uploadId, e);
+                abortUpload();
+            }
             throw new IOException(e);
         }
     }
 
+    private boolean multiPartUploadStarted() {
+        return uploadId != null;
+    }
+
     @Override
     public void close() throws IOException {
         if (!isClosed()) {
+            closed = true;
             final int lastPosition = partBuffer.position();
             if (lastPosition > 0) {
                 try {
                     partBuffer.position(0);
                     partBuffer.limit(lastPosition);
-                    flushBuffer(partBuffer.slice(), lastPosition);
+                    flushBuffer(partBuffer.slice(), lastPosition, multiPartUploadStarted());
                 } catch (final RuntimeException e) {
-                    log.error("Failed to upload last part {}, aborting transaction", uploadId, e);
-                    abortUpload();
+                    if (multiPartUploadStarted()) {
+                        log.error("Failed to upload last part {}, aborting transaction", uploadId, e);
+                        abortUpload();
+                    } else {
+                        log.error("Failed to upload the file {}", key, e);
+                    }
                     throw new IOException(e);
                 }
             }
+
+            if (!multiPartUploadStarted()) {
+                return;
+            }
+
             if (!completedParts.isEmpty()) {
path: storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStream.java
user: login: jeqo
body: nit: not sure if this is clearer as "this last bits are needed for MPU". 
```suggestion
            if (multiPartUploadStarted()) completeOrAbortMultiPartUpload() // this new method wrapping the upcoming logic
```
created_at: 2025-03-28T11:27:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -73,27 +76,36 @@ class S3MultiPartOutputStreamTest {
     @Captor
     ArgumentCaptor<UploadPartRequest> uploadPartRequestCaptor;
     @Captor
+    ArgumentCaptor<PutObjectRequest> putObjectRequestCaptor;
+    @Captor
     ArgumentCaptor<RequestBody> requestBodyCaptor;
 
     final Random random = new Random();
 
     @BeforeEach
     void setUp() {
-        when(mockedS3.createMultipartUpload(any(CreateMultipartUploadRequest.class)))
+        lenient().when(mockedS3.createMultipartUpload(any(CreateMultipartUploadRequest.class)))
             .thenReturn(newInitiateMultipartUploadResult());
path: storage/s3/src/test/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStreamTest.java
user: login: jeqo
body: nit: wonder if instead of having it as lenient, it should me moved explicitly to the cases where is needed. Maybe there are too many?

In that case, please add a comment on why it's lenient. 
created_at: 2025-03-28T11:29:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -20,16 +20,18 @@
 import javax.management.MalformedObjectNameException;
 import javax.management.ObjectName;
 
-import java.io.InputStream;
+import java.io.ByteArrayInputStream;
 import java.lang.management.ManagementFactory;
 import java.util.Map;
+import java.util.Random;
 
 import io.aiven.kafka.tieredstorage.storage.StorageBackendException;
 import io.aiven.kafka.tieredstorage.storage.TestObjectKey;
 
 import com.github.tomakehurst.wiremock.http.Fault;
 import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
 import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import org.jetbrains.annotations.NotNull;
path: storage/s3/src/integration-test/java/io/aiven/kafka/tieredstorage/storage/s3/S3ErrorMetricsTest.java
user: login: jiafu1115
body: sorry. my bad. no need it.
created_at: 2025-03-28T12:51:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -79,19 +83,24 @@ void testS3ServerExceptions(final int statusCode,
             "s3.region", Region.US_EAST_1.id(),
             "s3.endpoint.url", wmRuntimeInfo.getHttpBaseUrl(),
             "s3.path.style.access.enabled", "true",
-            "aws.credentials.provider.class", AnonymousCredentialsProvider.class.getName()
+            "aws.credentials.provider.class", AnonymousCredentialsProvider.class.getName(),
+            "s3.multipart.upload.part.size", UPLOAD_PART_SIZE
         );
         storage.configure(configs);
 
         stubFor(any(anyUrl())
             .willReturn(aResponse().withStatus(statusCode)
                 .withHeader(CONTENT_TYPE, APPLICATION_XML.getMimeType())
                 .withBody(String.format(ERROR_RESPONSE_TEMPLATE, statusCode))));
-        final S3Exception s3Exception = catchThrowableOfType(
-            () -> storage.upload(InputStream.nullInputStream(), new TestObjectKey("key")),
-            S3Exception.class);
 
-        assertThat(s3Exception.statusCode()).isEqualTo(statusCode);
+        final ByteArrayInputStream inputStream = newInputStreamWithSize(UPLOAD_PART_SIZE + 1);
+        final StorageBackendException storageBackendException = catchThrowableOfType(
+            () -> storage.upload(inputStream, new TestObjectKey("key")),
+                StorageBackendException.class);
+
+        final Throwable cause = storageBackendException.getCause().getCause();
+        assertThat(cause).isInstanceOf(S3Exception.class);
+        assertThat(((S3Exception) cause).statusCode()).isEqualTo(statusCode);
path: storage/s3/src/integration-test/java/io/aiven/kafka/tieredstorage/storage/s3/S3ErrorMetricsTest.java
user: login: jiafu1115
body: @jeqo look very well for the change. thanks
created_at: 2025-03-28T12:54:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -20,16 +20,18 @@
 import javax.management.MalformedObjectNameException;
 import javax.management.ObjectName;
 
-import java.io.InputStream;
+import java.io.ByteArrayInputStream;
 import java.lang.management.ManagementFactory;
 import java.util.Map;
+import java.util.Random;
 
 import io.aiven.kafka.tieredstorage.storage.StorageBackendException;
 import io.aiven.kafka.tieredstorage.storage.TestObjectKey;
 
 import com.github.tomakehurst.wiremock.http.Fault;
 import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
 import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import org.jetbrains.annotations.NotNull;
path: storage/s3/src/integration-test/java/io/aiven/kafka/tieredstorage/storage/s3/S3ErrorMetricsTest.java
user: login: jiafu1115
body: solve it. remove it. thanks!
created_at: 2025-03-28T12:58:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -171,7 +171,7 @@ void metricsShouldBeReported() throws Exception {
             .asInstanceOf(DOUBLE)
             .isGreaterThan(0.0);
         assertThat(MBEAN_SERVER.getAttribute(segmentCopyPerSecName, "create-multipart-upload-requests-total"))
-            .isEqualTo(2.0);
+            .isEqualTo(1.0);
path: storage/s3/src/integration-test/java/io/aiven/kafka/tieredstorage/storage/s3/S3StorageMetricsTest.java
user: login: jiafu1115
body: > the decrease on this should mean an increase on the single upload metric. Could you add this as well?

no. the original case's second part is exception case which won't have chance to create MPU. 
`        final InputStream failingInputStream = mock(InputStream.class);
        final IOException exception = new IOException("test");
        when(failingInputStream.transferTo(any())).thenThrow(exception);
        assertThatThrownBy(() -> storage.upload(failingInputStream, key))
            .hasRootCause(exception);`
. You know we don't create MPU when creating the stream object now.  So the behavior already changed as expected.

But it doesn't matter. I add the small file test  into it and also correct the metrics as well.  thanks
created_at: 2025-03-28T13:26:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -171,7 +171,7 @@ void metricsShouldBeReported() throws Exception {
             .asInstanceOf(DOUBLE)
             .isGreaterThan(0.0);
         assertThat(MBEAN_SERVER.getAttribute(segmentCopyPerSecName, "create-multipart-upload-requests-total"))
-            .isEqualTo(2.0);
+            .isEqualTo(1.0);
path: storage/s3/src/integration-test/java/io/aiven/kafka/tieredstorage/storage/s3/S3StorageMetricsTest.java
user: login: jiafu1115
body: I resolve this conversation after improvement. if you have concern. You can open it again. thanks!
created_at: 2025-03-28T13:28:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -120,9 +137,10 @@ void testOtherExceptions(final WireMockRuntimeInfo wmRuntimeInfo) throws Excepti
                 .withHeader(CONTENT_TYPE, APPLICATION_XML.getMimeType())
                 .withBody("unparsable_xml")));
 
-        assertThatThrownBy(() -> storage.upload(InputStream.nullInputStream(), new TestObjectKey("key")))
-            .isInstanceOf(SdkClientException.class)
-            .hasMessageStartingWith("Could not parse XML response.");
+        final ByteArrayInputStream inputStream = newInputStreamWithSize(UPLOAD_PART_SIZE + 1);
path: storage/s3/src/integration-test/java/io/aiven/kafka/tieredstorage/storage/s3/S3ErrorMetricsTest.java
user: login: jiafu1115
body: I add one small file with 1 byte into the test. thanks. You can help to check it again. Thanks
created_at: 2025-03-28T13:35:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -118,33 +124,53 @@ public void write(final byte[] b, final int off, final int len) throws IOExcepti
                 inputBuffer.position(positionAfterCopying);
 
                 if (!partBuffer.hasRemaining()) {
+                    if (uploadId == null){
+                        uploadId = createMultipartUploadRequest();
path: storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStream.java
user: login: jiafu1115
body: agree for this to avoid worry about if some case don't throw exception but uploadId is null. thanks!
created_at: 2025-03-28T13:45:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -118,33 +124,53 @@ public void write(final byte[] b, final int off, final int len) throws IOExcepti
                 inputBuffer.position(positionAfterCopying);
 
                 if (!partBuffer.hasRemaining()) {
+                    if (uploadId == null){
+                        uploadId = createMultipartUploadRequest();
+                    }
                     partBuffer.position(0);
                     partBuffer.limit(partSize);
-                    flushBuffer(partBuffer.slice(), partSize);
+                    flushBuffer(partBuffer.slice(), partSize, true);
                 }
             }
         } catch (final RuntimeException e) {
-            log.error("Failed to write to stream on upload {}, aborting transaction", uploadId, e);
-            abortUpload();
+            closed = true;
+            if (multiPartUploadStarted()) {
+                log.error("Failed to write to stream on upload {}, aborting transaction", uploadId, e);
+                abortUpload();
+            }
             throw new IOException(e);
         }
     }
 
+    private boolean multiPartUploadStarted() {
+        return uploadId != null;
+    }
+
     @Override
     public void close() throws IOException {
         if (!isClosed()) {
+            closed = true;
             final int lastPosition = partBuffer.position();
             if (lastPosition > 0) {
                 try {
                     partBuffer.position(0);
                     partBuffer.limit(lastPosition);
-                    flushBuffer(partBuffer.slice(), lastPosition);
+                    flushBuffer(partBuffer.slice(), lastPosition, multiPartUploadStarted());
                 } catch (final RuntimeException e) {
-                    log.error("Failed to upload last part {}, aborting transaction", uploadId, e);
-                    abortUpload();
+                    if (multiPartUploadStarted()) {
+                        log.error("Failed to upload last part {}, aborting transaction", uploadId, e);
+                        abortUpload();
+                    } else {
+                        log.error("Failed to upload the file {}", key, e);
+                    }
                     throw new IOException(e);
                 }
             }
+
+            if (!multiPartUploadStarted()) {
+                return;
+            }
+
             if (!completedParts.isEmpty()) {
path: storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStream.java
user: login: jiafu1115
body: more clear for new method extracted. thanks.
created_at: 2025-03-28T13:59:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -118,33 +124,53 @@ public void write(final byte[] b, final int off, final int len) throws IOExcepti
                 inputBuffer.position(positionAfterCopying);
 
                 if (!partBuffer.hasRemaining()) {
+                    if (uploadId == null){
+                        uploadId = createMultipartUploadRequest();
+                    }
                     partBuffer.position(0);
                     partBuffer.limit(partSize);
-                    flushBuffer(partBuffer.slice(), partSize);
+                    flushBuffer(partBuffer.slice(), partSize, true);
                 }
             }
         } catch (final RuntimeException e) {
-            log.error("Failed to write to stream on upload {}, aborting transaction", uploadId, e);
-            abortUpload();
+            closed = true;
+            if (multiPartUploadStarted()) {
+                log.error("Failed to write to stream on upload {}, aborting transaction", uploadId, e);
+                abortUpload();
+            }
             throw new IOException(e);
         }
     }
 
+    private boolean multiPartUploadStarted() {
+        return uploadId != null;
+    }
+
     @Override
     public void close() throws IOException {
         if (!isClosed()) {
+            closed = true;
             final int lastPosition = partBuffer.position();
             if (lastPosition > 0) {
                 try {
                     partBuffer.position(0);
                     partBuffer.limit(lastPosition);
-                    flushBuffer(partBuffer.slice(), lastPosition);
+                    flushBuffer(partBuffer.slice(), lastPosition, multiPartUploadStarted());
                 } catch (final RuntimeException e) {
-                    log.error("Failed to upload last part {}, aborting transaction", uploadId, e);
-                    abortUpload();
+                    if (multiPartUploadStarted()) {
+                        log.error("Failed to upload last part {}, aborting transaction", uploadId, e);
+                        abortUpload();
+                    } else {
+                        log.error("Failed to upload the file {}", key, e);
+                    }
                     throw new IOException(e);
                 }
             }
+
+            if (!multiPartUploadStarted()) {
+                return;
+            }
+
             if (!completedParts.isEmpty()) {
path: storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStream.java
user: login: jiafu1115
body: adopt the advice
created_at: 2025-03-28T14:00:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -73,27 +76,36 @@ class S3MultiPartOutputStreamTest {
     @Captor
     ArgumentCaptor<UploadPartRequest> uploadPartRequestCaptor;
     @Captor
+    ArgumentCaptor<PutObjectRequest> putObjectRequestCaptor;
+    @Captor
     ArgumentCaptor<RequestBody> requestBodyCaptor;
 
     final Random random = new Random();
 
     @BeforeEach
     void setUp() {
-        when(mockedS3.createMultipartUpload(any(CreateMultipartUploadRequest.class)))
+        lenient().when(mockedS3.createMultipartUpload(any(CreateMultipartUploadRequest.class)))
             .thenReturn(newInitiateMultipartUploadResult());
path: storage/s3/src/test/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStreamTest.java
user: login: jiafu1115
body: Yes !  10 of 13 case need the same stub. So I add it. I update the code as reason explained.  thanks
![image](https://github.com/user-attachments/assets/9acd85d4-543d-4a29-aed3-bcfb74fd8bf1)

created_at: 2025-03-28T14:12:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -118,33 +124,53 @@ public void write(final byte[] b, final int off, final int len) throws IOExcepti
                 inputBuffer.position(positionAfterCopying);
 
                 if (!partBuffer.hasRemaining()) {
+                    if (uploadId == null){
+                        uploadId = createMultipartUploadRequest();
path: storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStream.java
user: login: jiafu1115
body: code update. thanks
created_at: 2025-03-28T14:14:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -79,19 +83,24 @@ void testS3ServerExceptions(final int statusCode,
             "s3.region", Region.US_EAST_1.id(),
             "s3.endpoint.url", wmRuntimeInfo.getHttpBaseUrl(),
             "s3.path.style.access.enabled", "true",
-            "aws.credentials.provider.class", AnonymousCredentialsProvider.class.getName()
+            "aws.credentials.provider.class", AnonymousCredentialsProvider.class.getName(),
+            "s3.multipart.upload.part.size", UPLOAD_PART_SIZE
         );
         storage.configure(configs);
 
         stubFor(any(anyUrl())
             .willReturn(aResponse().withStatus(statusCode)
                 .withHeader(CONTENT_TYPE, APPLICATION_XML.getMimeType())
                 .withBody(String.format(ERROR_RESPONSE_TEMPLATE, statusCode))));
-        final S3Exception s3Exception = catchThrowableOfType(
-            () -> storage.upload(InputStream.nullInputStream(), new TestObjectKey("key")),
-            S3Exception.class);
 
-        assertThat(s3Exception.statusCode()).isEqualTo(statusCode);
+        final ByteArrayInputStream inputStream = newInputStreamWithSize(UPLOAD_PART_SIZE + 1);
+        final StorageBackendException storageBackendException = catchThrowableOfType(
+            () -> storage.upload(inputStream, new TestObjectKey("key")),
+                StorageBackendException.class);
+
+        final Throwable cause = storageBackendException.getCause().getCause();
+        assertThat(cause).isInstanceOf(S3Exception.class);
+        assertThat(((S3Exception) cause).statusCode()).isEqualTo(statusCode);
path: storage/s3/src/integration-test/java/io/aiven/kafka/tieredstorage/storage/s3/S3ErrorMetricsTest.java
user: login: jeqo
body: @jiafu1115 I think this one is missing? 
created_at: 2025-04-01T06:32:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -120,9 +137,10 @@ void testOtherExceptions(final WireMockRuntimeInfo wmRuntimeInfo) throws Excepti
                 .withHeader(CONTENT_TYPE, APPLICATION_XML.getMimeType())
                 .withBody("unparsable_xml")));
 
-        assertThatThrownBy(() -> storage.upload(InputStream.nullInputStream(), new TestObjectKey("key")))
-            .isInstanceOf(SdkClientException.class)
-            .hasMessageStartingWith("Could not parse XML response.");
+        final ByteArrayInputStream inputStream = newInputStreamWithSize(UPLOAD_PART_SIZE + 1);
path: storage/s3/src/integration-test/java/io/aiven/kafka/tieredstorage/storage/s3/S3ErrorMetricsTest.java
user: login: jeqo
body: Was this pushed? I don't see any change in here. 1 byte for this test sounds good btw.
created_at: 2025-04-01T07:10:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -79,19 +83,24 @@ void testS3ServerExceptions(final int statusCode,
             "s3.region", Region.US_EAST_1.id(),
             "s3.endpoint.url", wmRuntimeInfo.getHttpBaseUrl(),
             "s3.path.style.access.enabled", "true",
-            "aws.credentials.provider.class", AnonymousCredentialsProvider.class.getName()
+            "aws.credentials.provider.class", AnonymousCredentialsProvider.class.getName(),
+            "s3.multipart.upload.part.size", UPLOAD_PART_SIZE
         );
         storage.configure(configs);
 
         stubFor(any(anyUrl())
             .willReturn(aResponse().withStatus(statusCode)
                 .withHeader(CONTENT_TYPE, APPLICATION_XML.getMimeType())
                 .withBody(String.format(ERROR_RESPONSE_TEMPLATE, statusCode))));
-        final S3Exception s3Exception = catchThrowableOfType(
-            () -> storage.upload(InputStream.nullInputStream(), new TestObjectKey("key")),
-            S3Exception.class);
 
-        assertThat(s3Exception.statusCode()).isEqualTo(statusCode);
+        final ByteArrayInputStream inputStream = newInputStreamWithSize(UPLOAD_PART_SIZE + 1);
+        final StorageBackendException storageBackendException = catchThrowableOfType(
+            () -> storage.upload(inputStream, new TestObjectKey("key")),
+                StorageBackendException.class);
+
+        final Throwable cause = storageBackendException.getCause().getCause();
+        assertThat(cause).isInstanceOf(S3Exception.class);
+        assertThat(((S3Exception) cause).statusCode()).isEqualTo(statusCode);
path: storage/s3/src/integration-test/java/io/aiven/kafka/tieredstorage/storage/s3/S3ErrorMetricsTest.java
user: login: jiafu1115
body: @jeqo  sorry. thanks for your reminder. I commit it but rollback the change in another commit by mistake! I apply the change again. thanks 
created_at: 2025-04-01T10:13:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -120,9 +137,10 @@ void testOtherExceptions(final WireMockRuntimeInfo wmRuntimeInfo) throws Excepti
                 .withHeader(CONTENT_TYPE, APPLICATION_XML.getMimeType())
                 .withBody("unparsable_xml")));
 
-        assertThatThrownBy(() -> storage.upload(InputStream.nullInputStream(), new TestObjectKey("key")))
-            .isInstanceOf(SdkClientException.class)
-            .hasMessageStartingWith("Could not parse XML response.");
+        final ByteArrayInputStream inputStream = newInputStreamWithSize(UPLOAD_PART_SIZE + 1);
path: storage/s3/src/integration-test/java/io/aiven/kafka/tieredstorage/storage/s3/S3ErrorMetricsTest.java
user: login: jiafu1115
body: @jeqo  I add another case to here due to I thought that class is missing.
![image](https://github.com/user-attachments/assets/666d38db-d113-4a7f-9b09-29f91865dc22).

Anyway. I update the code to add the right tests into right position now. Thanks for your reminder
BTW: It seems I found one LD issue for upload retry. You can help to check the latest change. Thanks



created_at: 2025-04-01T16:23:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -120,9 +137,10 @@ void testOtherExceptions(final WireMockRuntimeInfo wmRuntimeInfo) throws Excepti
                 .withHeader(CONTENT_TYPE, APPLICATION_XML.getMimeType())
                 .withBody("unparsable_xml")));
 
-        assertThatThrownBy(() -> storage.upload(InputStream.nullInputStream(), new TestObjectKey("key")))
-            .isInstanceOf(SdkClientException.class)
-            .hasMessageStartingWith("Could not parse XML response.");
+        final ByteArrayInputStream inputStream = newInputStreamWithSize(UPLOAD_PART_SIZE + 1);
path: storage/s3/src/integration-test/java/io/aiven/kafka/tieredstorage/storage/s3/S3ErrorMetricsTest.java
user: login: jiafu1115
body: resolve the conversion. you can open it again if have concern or advices.
created_at: 2025-04-01T16:24:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -185,14 +227,18 @@ private void abortUpload() {
             .uploadId(uploadId)
             .build();
         client.abortMultipartUpload(request);
-        closed = true;
     }
 
     private void flushBuffer(final ByteBuffer buffer,
-                             final int actualPartSize) {
-        try (final InputStream in = new ByteBufferInputStream(buffer)) {
+                             final int actualPartSize,
+                             final boolean multiPartUpload) {
+        try (final InputStream in = new ByteBufferMarkableInputStream(buffer)) {
path: storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStream.java
user: login: jeqo
body: @jiafu1115 could you elaborate on the need for a markable inputstream here? Why is `ByteBufferInputStream` not enough?
created_at: 2025-04-01T23:57:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -185,14 +227,18 @@ private void abortUpload() {
             .uploadId(uploadId)
             .build();
         client.abortMultipartUpload(request);
-        closed = true;
     }
 
     private void flushBuffer(final ByteBuffer buffer,
-                             final int actualPartSize) {
-        try (final InputStream in = new ByteBufferInputStream(buffer)) {
+                             final int actualPartSize,
+                             final boolean multiPartUpload) {
+        try (final InputStream in = new ByteBufferMarkableInputStream(buffer)) {
path: storage/s3/src/main/java/io/aiven/kafka/tieredstorage/storage/s3/S3UploadOutputStream.java
user: login: jeqo
body: ping @jiafu1115 if you could have a look at this one.
created_at: 2025-04-02T08:36:10Z
author_association: CONTRIBUTOR

PR comments:
user: login: jiafu1115
created_at: 2025-03-20T13:40:00Z
body: @jeqo Thanks for your comment. I think about your proposal. It has the benefit that all upload logic in one class. But Maybe it is not a very good practice to combine the single file upload with multi part upload. 
BTW, In actually.  I thought one idea that if we can add new class name S3SingleFileOutputstream. But at last. I don't adopt it. 

I can share some more points about it: 
(1) Currently. S3MultiPartOutputStream is dedicated for upload by multi part. and its contain some special field such as partSize/uploadId, etc. So if put single file upload logic into it. It won't look well.
(2) S3Storage's other method such as fetch and delete also create request and call S3Client directly in it. So it is acceptable to add the upload single file into the class due to it keep same style.
(3) Though the commit seem that it contain too many code. but in actually. Most of the code are tests.  the core logic is simple as followed: 
![image](https://github.com/user-attachments/assets/03bf5f8d-f97b-4889-b053-d0ccf4c7c862)
and from code performance view. If we put it into XXXFileOutputstream. We will create the object with many useless field (uploadid/partsize) for single file's upload.  What's more, we just use very small part of codes as single file upload. It is hard for user to understand the logic fast.
So. what do you think ？ or do we have some more better idea?  thanks in advanced.
author_association: CONTRIBUTOR
user: login: jiafu1115
created_at: 2025-03-27T11:46:59Z
body: @jeqo thanks for your review. It help the code be more better.
BTW: I squash all the commits to only one to make the commit clean. 
author_association: CONTRIBUTOR
user: login: jiafu1115
created_at: 2025-03-28T14:25:35Z
body: > @jiafu1115 took another round with focus on testing

@jeqo done with all suggestions. Thank you for your careful review. 
BTW: I don't squash the commit now so that you can check the change by commits. 
author_association: CONTRIBUTOR
user: login: jiafu1115
created_at: 2025-04-01T16:48:24Z
body: BTW:  Don't know why the test fail. My test in local is ok:
![image](https://github.com/user-attachments/assets/aa22c984-d304-4015-9755-c2c73dadc4f6)

author_association: CONTRIBUTOR
user: login: jiafu1115
created_at: 2025-04-02T08:34:10Z
body: @jeqo update the status. I squash all the commits to two to make the commit clear:
1 reduce s3 request for small file. 
2 fix the LD issue: fail to build upload's retry request with body. 
Also I add some test cases for it and the tests should be enough now to cover all cases.
You can help to take a look when free.
Thanks
author_association: CONTRIBUTOR
user: login: jeqo
created_at: 2025-04-02T12:49:00Z
body: @jiafu1115 thanks! It's looking good! I only have one comment open before approving: https://github.com/Aiven-Open/tiered-storage-for-apache-kafka/pull/664#discussion_r2023818986 
author_association: CONTRIBUTOR
user: login: jiafu1115
created_at: 2025-04-02T12:57:15Z
body: > @jiafu1115 thanks! It's looking good! I only have one comment open before approving: [#664 (comment)](https://github.com/Aiven-Open/tiered-storage-for-apache-kafka/pull/664#discussion_r2023818986)

@jeqo  thanks. Do you mean that I need to add some comments on the code. Right?
author_association: CONTRIBUTOR
user: login: jeqo
created_at: 2025-04-02T13:03:56Z
body: No. I'd like to know more why we need ByteBufferMarkableInputStream. The inputstream in only used in a single operation, so I don't see the need to mark it -- unless I'm missing something? is the SDK requiring this to be markable?

author_association: CONTRIBUTOR
user: login: jiafu1115
created_at: 2025-04-02T13:48:03Z
body: @jeqo  here for more information maybe you miss the message?

> No. I'd like to know more why we need ByteBufferMarkableInputStream. The inputstream in only used in a single operation, so I don't see the need to mark it -- unless I'm missing something? is the SDK requiring this to be markable?

![image](https://github.com/user-attachments/assets/9a2ca424-d38e-4bac-84ee-d29981e71ee6)
![image](https://github.com/user-attachments/assets/4e7c4c08-3189-44a8-a366-8f4067a775bd)


author_association: CONTRIBUTOR
user: login: jiafu1115
created_at: 2025-04-02T13:51:24Z
body: > No. I'd like to know more why we need ByteBufferMarkableInputStream. The inputstream in only used in a single operation, so I don't see the need to mark it -- unless I'm missing something? is the SDK requiring this to be markable?

@jeqo  I debug the code yesterday and found **it is AWS sdk inner logic** to reread the data for fail retry or compute checksum before send.
If using the kafka util's BytebufferInputStream. It will throw exception directly due to the data had been read out. 

here for the read for checksum
software.amazon.awssdk.http.auth.aws.internal.signer.util.ChecksumUtil#readAll
here for retry:
software.amazon.awssdk.core.internal.http.pipeline.stages.RetryableStage#execute
![image](https://github.com/user-attachments/assets/08f0ca25-04fd-4078-88f5-e34111c6d96d)

author_association: CONTRIBUTOR
user: login: jiafu1115
created_at: 2025-04-02T14:05:30Z
body: @jeqo   you can copy the test code into the S3ErrorMetricsTest in main branch (using the MPU no matter the size of file.) to reproduce the issue:
    The request content has fewer bytes than the specified content-length: xxxxxxxxxx
 
    
    @ParameterizedTest
    @CsvSource({
            HttpStatusCode.INTERNAL_SERVER_ERROR + ", server-errors",
    })
    void testS3ServerExceptionsWhenUploadPartFail(final int statusCode,
                                final String metricName,
                                final WireMockRuntimeInfo wmRuntimeInfo) throws Exception {
        final Map<String, Object> configs = Map.of(
                "s3.bucket.name", BUCKET_NAME,
                "s3.region", Region.US_EAST_1.id(),
                "s3.endpoint.url", wmRuntimeInfo.getHttpBaseUrl(),
                "s3.path.style.access.enabled", "true",
                "aws.credentials.provider.class", AnonymousCredentialsProvider.class.getName()
        );
        storage.configure(configs);

        //stub with success response for the CreateMultipartUpload: POST /{Key}?uploads
        String responseBodyForCreateUploadRequest = "<InitiateMultipartUploadResult><UploadId>testUploadId</UploadId></InitiateMultipartUploadResult>";
        stubFor(post(anyUrl())
                .willReturn(aResponse().withStatus(HttpStatusCode.OK)
                        .withHeader(CONTENT_TYPE, APPLICATION_XML.getMimeType())
                        .withBody(responseBodyForCreateUploadRequest)));

        //stub with error for the UploadPart: PUT /{Key}?partNumber=PartNumber&uploadId={Upload}
        stubFor(put(anyUrl())
                .willReturn(aResponse().withStatus(statusCode)
                        .withHeader(CONTENT_TYPE, APPLICATION_XML.getMimeType())
                        .withBody(String.format(ERROR_RESPONSE_TEMPLATE, statusCode))));

        //stub with success response for the AbortMultipartUpload: DELETE /{Key}?uploadId={Upload}
        stubFor(delete(anyUrl())
                .willReturn(aResponse().withStatus(HttpStatusCode.OK)));

        catchThrowableOfType(
                () -> storage.upload(newInputStreamWithSize(1), new TestObjectKey("key")),
                StorageBackendException.class);

        // Comparing to 4 since the SDK makes 3 retries by default.
        assertThat(MBEAN_SERVER.getAttribute(s3MetricsObjectName, metricName + "-total"))
                .isEqualTo(4.0);
        assertThat(MBEAN_SERVER.getAttribute(s3MetricsObjectName, metricName + "-rate"))
                .asInstanceOf(DOUBLE)
                .isGreaterThan(0.0);
    }
author_association: CONTRIBUTOR
user: login: jeqo
created_at: 2025-04-02T15:18:48Z
body: @jiafu1115 thanks for sharing! The messages stay in "pending" on your session so haven't being send over 😄 

That's a great finding. Thanks for the research!

Given that `ByteBufferInputStream` is not extensible I see a couple of options: explore if we can compose it and have it as inner inputstream, so we don't have to copy the code. On the other hand, we could do the copy, but let's add a documentation referring to the original source, and copy the license header from the original source. Wdyt?
author_association: CONTRIBUTOR
user: login: jiafu1115
created_at: 2025-04-02T22:32:11Z
body: > @jiafu1115 thanks for sharing! The messages stay in "pending" on your session so haven't being send over 😄
> 
> That's a great finding. Thanks for the research!
> 
> Given that `ByteBufferInputStream` is not extensible I see a couple of options: explore if we can compose it and have it as inner inputstream, so we don't have to copy the code. On the other hand, we could do the copy, but let's add a documentation referring to the original source, and copy the license header from the original source. Wdyt?

”I want to punch myself” for "forget it is pending" , I write a lot of message for you 😄😂

Back to the ByteBufferMarkableInputStream's discuss:
Actually. I tried to find more open light-weight source implements to avoid write it by ourselves and keep it light-weight. And I found there are lots of the ByteBufferInputStream,  but only few support the reset such as:

com.azure.storage.common.implementation.ByteBufferMarkableInputStream
      io.netty.buffer.ByteBufInputStream  (only accept the io.netty.buffer.ByteBuf)
      
Thus it is very strange to import a azure jar into aws S3's sub project when the code line is few.
So at last I write it by myself refer to it and kafka util's and just add the method we need. For example. I don't add skip method as azure's implement

When you take a look all the similar open source. You will find all of the code look same. So I think it is no need to copy the license header from any original source. Ok?

BTW: I have the same thought that we can try "compose it as inner inputstream" But I don't do it after I recheck the code implement. It is so simple so that it isn't worth to involve one object ByteBufferInputStream into ByteBufferMarkableInputStream just have benefit on few code line reduced.

**So can we just keep current's implement without change? WDYT?  Thanks for your comments.**
BTW.  If it is ok for it. Can we merge and close this PR ASAP. I want to propose some other PRs to support some other features. Thus I write the code based on this PR's code to avoid fix conflict .😂 


FYI
not support reset:
      com.esotericsoftware.kryo.io.ByteBufferInputStream
      io.lettuce.core.codec.ByteBufferInputStream
      org.apache.zookeeper.server.ByteBufferInputStream
      com.fasterxml.jackson.databind.util.ByteBufferBackedInputStream
support reset:
      com.azure.storage.common.implementation.ByteBufferMarkableInputStream
      io.netty.buffer.ByteBufInputStream  (only accept the io.netty.buffer.ByteBuf)
You can see all of the code looks same.   
     
author_association: CONTRIBUTOR
user: login: jeqo
created_at: 2025-04-05T02:18:59Z
body: @jiafu1115 thanks for looking further into this. 
About copying the header and reference the original source code in the class comments, that's what we usually do when copying code from other codes; and I think it make sense in this case as we were originally using Kafka's ByteBufferInputStream.

You could at least start by adding a reference on the class docs, and I can double-check if the license header is needed. Wdyt?

Sorry this one is taking long. Let's aim to merge it next week if possible. Thanks again!
author_association: CONTRIBUTOR
user: login: jiafu1115
created_at: 2025-04-05T06:52:11Z
body: > @jiafu1115 thanks for looking further into this. About copying the header and reference the original source code in the class comments, that's what we usually do when copying code from other codes; and I think it make sense in this case as we were originally using Kafka's ByteBufferInputStream.
> 
> You could at least start by adding a reference on the class docs, and I can double-check if the license header is needed. Wdyt?
> 
> Sorry this one is taking long. Let's aim to merge it next week if possible. Thanks again!

@jeqo  it doesn't matter. Every advice and related change are valuable and I also learn a lot from them. Thanks
And I update the java doc to show which we are reference and the changes and squash the commits. It should be better. You can help to double check if we need to copy the license header again (I add one extra commit for this. If it is not must have. we can don't merge this commit). Thanks
author_association: CONTRIBUTOR
user: login: jiafu1115
created_at: 2025-04-14T05:04:59Z
body: @jeqo help to review. thanks!
author_association: CONTRIBUTOR