javaamazon-s3spring-webfluxproject-reactoraws-java-sdk-2.x

S3AsyncClient (AWS SDK v2, Java) uploads 0 bytes despite non-empty content


S3AsyncUploadService.java

package com.util.s3;

import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import software.amazon.awssdk.core.async.AsyncRequestBody;
import software.amazon.awssdk.services.s3.S3AsyncClient;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;

@Component
public class S3AsyncUploadService {

    private final S3AsyncClient s3AsyncClient;

    public S3AsyncUploadService(S3AsyncClient s3AsyncClient) {
        this.s3AsyncClient = s3AsyncClient;
    }

    public Mono<Void> uploadFile(String bucketName, String fileName, byte[] fileContent) {
        PutObjectRequest putObjectRequest = PutObjectRequest.builder()
                .bucket(bucketName)
                .key(fileName)
                .build();

        return Mono.fromFuture(
                s3AsyncClient.putObject(
                        putObjectRequest,
                        AsyncRequestBody.fromBytes(fileContent)
                )
        ).then();
    }
}

S3UploadService.java

package com.util.s3;

import org.springframework.stereotype.Component;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;

@Component
public class S3UploadService {

    private final S3Client s3Client;

    public S3UploadService(S3Client s3Client) {
        this.s3Client = s3Client;
    }

    public void uploadFile(String bucketName, String fileName, byte[] fileContent) {
        PutObjectRequest putObjectRequest = PutObjectRequest.builder()
                .bucket(bucketName)
                .key(fileName)
                .build();

        s3Client.putObject(putObjectRequest, RequestBody.fromBytes(fileContent));
    }
}

S3Resource.java

package com.util.resource;

import com.util.s3.S3AsyncUploadService;
import com.util.s3.S3UploadService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Mono;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
import software.amazon.awssdk.services.s3.model.ListObjectsV2Response;
import software.amazon.awssdk.services.s3.model.S3Object;

import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.stream.Collectors;

@RestController
@RequestMapping("/s3")
public class S3Resource {

    private final S3UploadService s3UploadService;
    private final S3AsyncUploadService s3AsyncUploadService;
    private final S3Client s3Client;

    @Value("${aws.s3.bucket}")
    private String bucketName;

    public S3Resource(S3UploadService s3UploadService, S3AsyncUploadService s3AsyncUploadService, S3Client s3Client) {
        this.s3UploadService = s3UploadService;
        this.s3AsyncUploadService = s3AsyncUploadService;
        this.s3Client = s3Client;
    }

    @GetMapping("/upload-string")
    public Mono<ResponseEntity<Map<String, Long>>> uploadString(@RequestParam("content") String content) {
        s3Client.createBucket(CreateBucketRequest.builder().bucket(bucketName).build());
        String syncKey = "sync-uploaded.txt";
        String asyncKey = "async-uploaded.txt";
        byte[] bytes = content.getBytes(StandardCharsets.UTF_8);

        // 1. Synchronous upload
        s3UploadService.uploadFile(bucketName, syncKey, bytes);

        // 2. Asynchronous upload
        return s3AsyncUploadService.uploadFile(bucketName, asyncKey, bytes)
                .then(Mono.fromCallable(() -> {
                    ListObjectsV2Request listReq = ListObjectsV2Request.builder()
                            .bucket(bucketName)
                            .build();
                    ListObjectsV2Response listRes = s3Client.listObjectsV2(listReq);
                    Map<String, Long> keysWithSize = listRes.contents().stream()
                            .collect(Collectors.toMap(S3Object::key, S3Object::size));
                    return ResponseEntity.ok(keysWithSize);
                }));
    }
}

Problem:

I'm using Java 21 with Spring Boot WebFlux and AWS SDK v2. I have two services to upload files to S3:

  1. S3UploadService uses the synchronous S3Client — works as expected.
  2. S3AsyncUploadService uses the asynchronous S3AsyncClient — creates the S3 object (key), but the file has 0 bytes, even though the content is not empty.

Code Overview:

Observed Behavior:

Expected:

What I’ve Tried:

Question: What might be causing the S3AsyncClient to upload a 0-byte file, even though the content is present?


Solution

  • Try this, it's a little simpler.

    @Component
    public class S3AsyncUploadService {
    
        private final S3AsyncClient s3AsyncClient;
    
        public S3AsyncUploadService(S3AsyncClient s3AsyncClient) {
            this.s3AsyncClient = s3AsyncClient;
        }
    
        public Mono<PutObjectResponse> uploadFile(String bucketName, String fileName, byte[] fileContent) {
    
            final PutObjectRequest request = PutObjectRequest.builder()
                    .bucket(bucketName)
                    .key(fileName)
                    .build();
    
            return Mono.fromFuture(s3AsyncClient.putObject(request, AsyncRequestBody.fromBytes(fileContent)))
                    .doOnError(e -> System.err.println("Error uploading file: " + e.getMessage()))
                    .doOnSuccess(response -> System.out.println("Upload completed"));
        }
    }
    
    @GetMapping(value = "/test")
    public Mono<String> uploadString(@RequestParam("content") String content) {
        String bucketName = "test-async-rubn";
        String keyName = "test/reactive-test.txt";
    
        return s3AsyncUploadService.uploadFile(bucketName, keyName, content.getBytes(StandardCharsets.UTF_8))
                .thenReturn("Content uploaded successfully");
    }
    

    I have removed ListObjectsV2Request because you are also merging it with the synchronous client, and I don't see the point.

    enter image description here

    enter image description here

    enter image description here

    I have also added the other way to list the files and size of each one, as you were doing above.

    public Mono<Map<String, Long>> uploadFile(String bucketName, String fileName, byte[] fileContent) {
    
            final PutObjectRequest request = PutObjectRequest.builder()
                    .bucket(bucketName)
                    .key(fileName)
                    .build();
    
           
            return Mono.fromFuture(s3AsyncClient.putObject(request, AsyncRequestBody.fromBytes(fileContent)))
                    .doOnError(e -> System.err.println("Error uploading file: " + e.getMessage()))
                    .doOnSuccess(response -> System.out.println("Upload completed"))
                    .then(getFromFuture(bucketName));
    
    }
    
    private Mono<Map<String, Long>> getFromFuture(String bucketName) {
            return Mono.fromFuture(() -> {
    
                ListObjectsV2Request listReq = ListObjectsV2Request.builder()
                        .bucket(bucketName)
                        .build();
    
                return s3AsyncClient.listObjectsV2(listReq)
                        .thenApply((e) -> {
                            return e.contents().stream()
                                    .collect(Collectors.toMap(S3Object::key, S3Object::size));
                        });
            });
    }
    
    @GetMapping(value = "/test")
    public Mono<Map<String, Long>> uploadString(@RequestParam("content") String content) {
        String bucketName = "test-async-rubn";
        String keyName = "test/reactive-test.txt";
    
        return s3AsyncUploadService.uploadFile(bucketName, keyName, content.getBytes(StandardCharsets.UTF_8));
    }
    

    enter image description here

    enter image description here