Spring boot Java heap space for downloading large files
Asked Answered
C

4

5

I am trying to write a REST api to allow users to download large files (ie > 2GB) on Spring boot. I am hitting with "Java Heap outOfMemoryException". I tried to triage the issue, i see that HttpServetResponse object is of type : ContentCachingResponseWrapper. This class caches all content written to the output stream and when cached data size becomes around 258MB, i get OutOfMemoryException. Why at 248 MB, because JVM has 256 MB of heap memory.

The default flushBuffer() method in ContentCachingResponseWrapper is empty. If i try to call copyBodyToResponse(), which is used to copy data from cache to stream, it works fine, but it closes the stream as well. This leads to only sending first chunk of data to client.

any suggestions ?

public void myDownloader(HttpServletRequest request, HttpServletResponse response) {
             //response.getClass() is: ContentCachingResponseWrapper  
             byte[] buffer = new byte[1048576];  // 1 MB Chunks
             FileInputStream inputStream = new FileInputStream(PATH_TO_SOME_VALID_FILE);
             int bytesRead= 0;
             ServletOutputStream outputStream = response.getOutputStream();

             while ((bytesRead = inputStream.read(buffer)) != -1) {              
                 outputStream.write(buffer, 0, bytesRead);
                 outputStream.flush();
                 response.flushBuffer();
               } 
}

I get following error:

Caused by: java.lang.OutOfMemoryError: Java heap space
    at org.springframework.util.FastByteArrayOutputStream.addBuffer(FastByteArrayOutputStream.java:303) ~[spring-core-5.2.8.RELEASE.jar!/:5.2.8.RELEASE]
    at org.springframework.util.FastByteArrayOutputStream.write(FastByteArrayOutputStream.java:118) ~[spring-core-5.2.8.RELEASE.jar!/:5.2.8.RELEASE]
    at org.springframework.web.util.ContentCachingResponseWrapper$ResponseServletOutputStream.write(ContentCachingResponseWrapper.java:239) ~[spring-web-5.2.8.RELEASE.jar!/:5.2.8.RELEASE]
Coelostat answered 22/12, 2021 at 19:19 Comment(2)
It’s Spring - return a Resource and let Spring deal with it. Why are you handling this yourself?Lovell
@borisTheSpider Thanks. Do you mean ResponseEntity<Resource>, will Spring chunk the resource and send to client ?Coelostat
H
6

This is a very basic way to download the file, but if you call it from a browser, the browser will display it on screen, and may spin forever (browser problem if you ask me):

@RequestMapping(path = "/downloadLargeFile", method = RequestMethod.GET)
public ResponseEntity<Resource> downloadLargeFile() {
    final File file = new File("c:/large.bin");
    final FileSystemResource resource = new FileSystemResource(file);
    return ResponseEntity.ok().body(resource);
}

So you can include some headers with info about the file, and the browser downloads it to a file in your Downloads directory, and does not spin:

@RequestMapping(path = "/downloadLargeFile2", method = RequestMethod.GET)
public ResponseEntity<Resource> downloadLargeFile2() {
    final HttpHeaders httpHeaders = new HttpHeaders();
    final File file = new File("c:/large.bin");
    final FileSystemResource resource = new FileSystemResource(file);
    httpHeaders.set(HttpHeaders.LAST_MODIFIED, String.valueOf(file.lastModified()));
    httpHeaders.set(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + file.getName() + "\"");
    httpHeaders.set(HttpHeaders.CONTENT_LENGTH, String.valueOf(file.length()));
    return ResponseEntity.ok()
        .headers(httpHeaders)
        .contentLength(file.length())
        .contentType(MediaType.APPLICATION_OCTET_STREAM)
        .body(resource);
}

To chunk the response, use InputStreamResource, and apparently Spring closes the InputStream:

@RequestMapping(path = "/pub/storage/downloadLargeFile4", method = RequestMethod.GET)
public ResponseEntity<InputStreamResource> downloadLargeFile4()
    throws Exception {
    final HttpHeaders httpHeaders = new HttpHeaders();
    final File file = new File("c:/large.bin");
    final InputStream inputStream = new FileInputStream(file);
    final InputStreamResource resource = new InputStreamResource(inputStream);
    httpHeaders.set(HttpHeaders.LAST_MODIFIED, String.valueOf(file.lastModified()));
    httpHeaders.set(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + file.getName() + "\"");
    httpHeaders.set(HttpHeaders.CONTENT_LENGTH, String.valueOf(file.length()));
    return ResponseEntity.ok()
        .headers(httpHeaders)
        .contentLength(file.length())
        .contentType(MediaType.APPLICATION_OCTET_STREAM)
        .body(resource);
}

Imports:

import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

I also find this thread to be useful: download a file from Spring boot rest service

Hummingbird answered 24/12, 2021 at 18:14 Comment(0)
M
4

To resolve this issue a better choice is to return ResponseEntity<StreamingResponseBody>, StreamingResponseBodyReturnValueHandler will handle the OutOfMemoryError caused by ShallowEtagHeaderFilter, eg.

@PostMapping("downloadLargeFile")
public ResponseEntity<StreamingResponseBody> test3(HttpServletRequest request, HttpServletResponse response) throws RDOException, FileNotFoundException {
    final HttpHeaders httpHeaders = new HttpHeaders();
    final File file = new File("C:/var/largeFile.zip");
    final InputStream inputStream = new FileInputStream(file);
    httpHeaders.set(HttpHeaders.LAST_MODIFIED, String.valueOf(file.lastModified()));
    httpHeaders.set(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + file.getName() + "\"");
    httpHeaders.set(HttpHeaders.CONTENT_LENGTH, String.valueOf(file.length()));

    StreamingResponseBody responseBody = outputStream -> {

        int numberOfBytesToWrite;
        byte[] data = new byte[64 * 1024];
        while ((numberOfBytesToWrite = inputStream.read(data, 0, data.length)) != -1) {
            outputStream.write(data, 0, numberOfBytesToWrite);
        }

        inputStream.close();
    };

    return ResponseEntity.ok()
            .headers(httpHeaders)
            .contentLength(file.length())
            .contentType(MediaType.APPLICATION_OCTET_STREAM)
            .body(responseBody);
}
Mayle answered 7/9, 2022 at 2:24 Comment(0)
L
1

I just bumped into a similar issue.

Despite the fact that my controller was returning a FileSystemResource (as shown in the code snippet in the accepted answer from ShortPasta), the whole file was still copied to memory first. For large files, this caused an OutOfMemory exception.

In my case, it was caused by the ShallowEtagHeaderFilter I'm using to add Etags to my responses. Despite a custom isEligibleForEtag implementation that would return false for the requests for large files, the contents was still written to memory.

The fix was to also replace the doFilterInternal method to indicate for the file download requests that there isn't a need for an Etag and hence also not for content caching:

    ShallowEtagHeaderFilter filter = new ShallowEtagHeaderFilter() {
      @Override
      protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //When not using etag, disable content caching
        if(!shouldUseEtag(request, response)){
          ShallowEtagHeaderFilter.disableContentCaching(request);
        }
        super.doFilterInternal(request, response, filterChain);
      }

      @Override
      protected boolean isEligibleForEtag(
          HttpServletRequest request,
          HttpServletResponse response,
          int responseStatusCode,
          InputStream inputStream
      ) {
        return shouldUseEtag(request,response)
      }

      private boolean shouldUseEtag(HttpServletRequest request,
                                    HttpServletResponse response){
        return !isLargeFileDownload(request);
      }
    };

Lavadalavage answered 19/8, 2022 at 16:0 Comment(0)
S
0

In addition to the accepted answer, if you need to stream a file (a PDF file in the example below) from an external service to the client without fully loading the file into memory, you can do it memory-efficiently using Spring WebFlux’s reactive programming features. Using this, you can pipe the output of the download file response into the response of your controller method as in the following snippet.

In this way, you don't need to increase your heap space or set spring.codec.max-in-memory-size parameter.

    @GetMapping(path = "/pdf/{id}", produces = "application/pdf")
    public void getPdfWithId(@PathVariable("id" String id, HttpServletResponse response){
        try {
            response.setContentType(MediaType.APPLICATION_PDF_VALUE);
            response.addHeader("Content-Disposition", "attachment; filename=\"export.pdf\"");
            Flux<DataBuffer> dataStream = downloadFileWithId(id);

            // Streams the stream from response instead of loading it all in memory
            DataBufferUtils.write( dataStream, response.getOutputStream() )
                .map( DataBufferUtils::release )
                .blockLast();

        } catch (IOException e) {
            response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
        }
    }

    public Flux<DataBuffer> downloadFileWithId(String id) throws IOException {

        // Request service to get file data
        // localApiClient is a flux web client you need to build with the URL of the file download service that you use.

        Flux<DataBuffer> dataBufferFlux = localApiClient.get()
            .uri(builder -> builder.path("/pdf").queryParam("id", id).build())
            .headers( // in case you need authentication
                httpHeaders -> {
                    httpHeaders.set("username", applicationParameters.getUsername());
                    httpHeaders.set("password", applicationParameters.getPassword());
                }
            )
            .accept(MediaType.APPLICATION_PDF)
            .retrieve()
            .bodyToFlux(DataBuffer.class);

        return dataBufferFlux;
    }

Stannfield answered 7/11 at 10:22 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.