Migrate stream to resource for stability (#6160)

This commit is contained in:
Anthony Stirling
2026-04-23 15:56:31 +01:00
committed by GitHub
parent c294e9b2cb
commit 177c776658
109 changed files with 1390 additions and 855 deletions
@@ -10,6 +10,7 @@ import java.nio.file.Path;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
@@ -157,13 +158,48 @@ public class FileStorage {
success = true;
} finally {
if (!success) {
Files.deleteIfExists(filePath);
try {
Files.deleteIfExists(filePath);
} catch (IOException cleanupEx) {
log.warn(
"Failed to clean up partial file {} after store failure",
filePath,
cleanupEx);
}
}
}
log.debug("Stored StreamingResponseBody with ID: {}", fileId);
return fileId;
}
/**
* Persist a {@link Resource} body to disk, returning the generated file ID. Used by the async
* job pipeline to capture {@code ResponseEntity<Resource>} results produced by controllers.
*/
public String storeFromResource(Resource resource, String originalName) throws IOException {
String fileId = generateFileId();
Path filePath = getFilePath(fileId);
Files.createDirectories(filePath.getParent());
boolean success = false;
try (InputStream in = resource.getInputStream()) {
Files.copy(in, filePath);
success = true;
} finally {
if (!success) {
try {
Files.deleteIfExists(filePath);
} catch (IOException cleanupEx) {
log.warn(
"Failed to clean up partial file {} after store failure",
filePath,
cleanupEx);
}
}
}
log.debug("Stored Resource with ID: {}", fileId);
return fileId;
}
/**
* Delete a file by its ID
*
@@ -11,6 +11,7 @@ import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -321,6 +322,13 @@ public class JobExecutorService {
log.debug(
"Stored ResponseEntity<StreamingResponseBody> result with fileId: {}",
fileId);
} else if (body instanceof Resource resource) {
String filename = extractResponseFilename(response);
String contentType = extractResponseContentType(response);
String fileId = fileStorage.storeFromResource(resource, filename);
taskManager.setFileResult(jobId, fileId, filename, contentType);
log.debug("Stored ResponseEntity<Resource> result with fileId: {}", fileId);
} else {
// Check if the response body contains a fileId
if (body != null && body.toString().contains("fileId")) {
@@ -16,11 +16,11 @@ import java.util.zip.ZipOutputStream;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import com.vladsch.flexmark.html2md.converter.FlexmarkHtmlConverter;
import com.vladsch.flexmark.util.data.MutableDataSet;
@@ -49,7 +49,7 @@ public class PDFToFile {
this.runtimePathConfig = runtimePathConfig;
}
public ResponseEntity<StreamingResponseBody> processPdfToMarkdown(MultipartFile inputFile)
public ResponseEntity<Resource> processPdfToMarkdown(MultipartFile inputFile)
throws IOException, InterruptedException {
if (!MediaType.APPLICATION_PDF_VALUE.equals(inputFile.getContentType())) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
@@ -169,7 +169,7 @@ public class PDFToFile {
return PATTERN.matcher(markdown).replaceAll("$1(images/$2)");
}
public ResponseEntity<StreamingResponseBody> processPdfToHtml(MultipartFile inputFile)
public ResponseEntity<Resource> processPdfToHtml(MultipartFile inputFile)
throws IOException, InterruptedException {
if (!MediaType.APPLICATION_PDF_VALUE.equals(inputFile.getContentType())) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
@@ -232,7 +232,7 @@ public class PDFToFile {
finalOut, fileName, MediaType.APPLICATION_OCTET_STREAM);
}
public ResponseEntity<StreamingResponseBody> processPdfToOfficeFormat(
public ResponseEntity<Resource> processPdfToOfficeFormat(
MultipartFile inputFile, String outputFormat, String libreOfficeFilter)
throws IOException, InterruptedException {
@@ -1,19 +1,22 @@
package stirling.software.common.util;
import java.io.ByteArrayOutputStream;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import io.github.pixee.security.Filenames;
@@ -49,12 +52,7 @@ public class WebResponseUtils {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(mediaType);
headers.setContentLength(bytes.length);
String encodedDocName =
RegexPatternUtils.getInstance()
.getPlusSignPattern()
.matcher(URLEncoder.encode(docName, StandardCharsets.UTF_8))
.replaceAll("%20");
headers.setContentDispositionFormData("attachment", encodedDocName);
headers.setContentDispositionFormData("attachment", encodeAttachmentName(docName));
return new ResponseEntity<>(bytes, headers, HttpStatus.OK);
}
@@ -73,7 +71,17 @@ public class WebResponseUtils {
return baosToWebResponse(baos, docName);
}
public static ResponseEntity<StreamingResponseBody> pdfDocToWebResponse(
/**
* Save a {@link PDDocument} to a managed temp file and return it as a file-backed {@code
* ResponseEntity<Resource>}.
*
* <p>The returned {@link Resource} owns the supplied {@link TempFile} — the file is deleted
* when Spring closes the response {@link InputStream} after writing the body. This is a
* synchronous equivalent of the previous {@code StreamingResponseBody} pattern and avoids the
* async-dispatch hazards (response-committed races, filter incompatibility, silent write
* failures) that {@code StreamingResponseBody} introduced.
*/
public static ResponseEntity<Resource> pdfDocToWebResponse(
PDDocument document, String docName, TempFileManager tempFileManager)
throws IOException {
TempFile tempFile = tempFileManager.createManagedTempFile(".pdf");
@@ -87,38 +95,49 @@ public class WebResponseUtils {
}
/**
* Convert a File to a web response (PDF default).
* Convert a {@link TempFile} holding a PDF into a web response.
*
* <p>The temp file is deleted when Spring closes the response body stream.
*
* @param outputTempFile The temporary file to be sent as a response.
* @param docName The name of the document.
* @return A ResponseEntity containing the file as a resource.
* @return A ResponseEntity whose body streams the file, deleting it on close.
*/
public static ResponseEntity<StreamingResponseBody> pdfFileToWebResponse(
public static ResponseEntity<Resource> pdfFileToWebResponse(
TempFile outputTempFile, String docName) throws IOException {
return fileToWebResponse(outputTempFile, docName, MediaType.APPLICATION_PDF);
}
/**
* Convert a File to a web response (ZIP default).
* Convert a {@link TempFile} holding a ZIP archive into a web response.
*
* <p>The temp file is deleted when Spring closes the response body stream.
*
* @param outputTempFile The temporary file to be sent as a response.
* @param docName The name of the document.
* @return A ResponseEntity containing the file as a resource.
* @return A ResponseEntity whose body streams the file, deleting it on close.
*/
public static ResponseEntity<StreamingResponseBody> zipFileToWebResponse(
public static ResponseEntity<Resource> zipFileToWebResponse(
TempFile outputTempFile, String docName) throws IOException {
return fileToWebResponse(outputTempFile, docName, MediaType.APPLICATION_OCTET_STREAM);
}
/**
* Convert a File to a web response with explicit media type (e.g., ZIP).
* Convert a {@link TempFile} into a web response with an explicit media type.
*
* <p>The returned {@link ResponseEntity} carries a {@link ManagedTempFileResource} as its body.
* Spring's {@code ResourceHttpMessageConverter} calls {@link Resource#getInputStream()} once
* and closes the returned stream after writing — at which point the underlying {@link TempFile}
* is deleted. Everything runs synchronously on the request thread, so write failures propagate
* through normal Spring error handling and are logged, rather than silently truncating the
* response.
*
* @param outputTempFile The temporary file to be sent as a response.
* @param docName The name of the document.
* @param mediaType The content type to set on the response.
* @return A ResponseEntity containing the file as a resource.
* @return A ResponseEntity whose body streams the file, deleting it on close.
*/
public static ResponseEntity<StreamingResponseBody> fileToWebResponse(
public static ResponseEntity<Resource> fileToWebResponse(
TempFile outputTempFile, String docName, MediaType mediaType) throws IOException {
try {
@@ -127,23 +146,9 @@ public class WebResponseUtils {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(mediaType);
headers.setContentLength(len);
String encodedDocName =
RegexPatternUtils.getInstance()
.getPlusSignPattern()
.matcher(URLEncoder.encode(docName, StandardCharsets.UTF_8))
.replaceAll("%20");
headers.setContentDispositionFormData("attachment", encodedDocName);
StreamingResponseBody body =
os -> {
try (os) {
Files.copy(path, os);
os.flush();
} finally {
outputTempFile.close();
}
};
headers.setContentDispositionFormData("attachment", encodeAttachmentName(docName));
Resource body = new ManagedTempFileResource(outputTempFile);
return new ResponseEntity<>(body, headers, HttpStatus.OK);
} catch (IOException | RuntimeException e) {
try {
@@ -154,4 +159,115 @@ public class WebResponseUtils {
throw e;
}
}
private static String encodeAttachmentName(String docName) {
return RegexPatternUtils.getInstance()
.getPlusSignPattern()
.matcher(URLEncoder.encode(docName, StandardCharsets.UTF_8))
.replaceAll("%20");
}
/**
* {@link Resource} backed by a {@link TempFile}. The underlying temp file is deleted when the
* response {@code InputStream} is closed — i.e. after Spring has finished writing the body. Any
* {@link IOException} during the copy is logged via {@link ClosingInputStream} and propagates
* through Spring's normal error path. Since response headers are committed before the body
* transfer begins, a mid-body failure manifests as a server-side log entry plus an aborted
* connection rather than a silently-truncated success — which is the behaviour this class was
* added to restore.
*
* <p><b>Single-use contract:</b> {@link #getInputStream()} is intended to be called once by
* Spring's {@code ResourceHttpMessageConverter} on the normal write path. After the returned
* stream is closed the backing temp file is deleted, so subsequent {@code getInputStream()}
* calls will either see a deleted file (tests that mock {@link TempFile#close()} are an
* exception) or fail at read time. Callers that need to re-read the body must copy it first.
*/
public static final class ManagedTempFileResource extends FileSystemResource {
private final TempFile tempFile;
public ManagedTempFileResource(TempFile tempFile) {
super(tempFile.getFile());
this.tempFile = tempFile;
}
@Override
public InputStream getInputStream() throws IOException {
InputStream source;
try {
source = super.getInputStream();
} catch (IOException e) {
// Opening the input stream already failed; make sure we don't leak the temp file.
try {
tempFile.close();
} catch (Exception closeEx) {
e.addSuppressed(closeEx);
}
throw e;
}
return new ClosingInputStream(source, tempFile);
}
}
/**
* Stream wrapper that deletes its backing {@link TempFile} on close. Logs — but does not
* swallow — any IOException that happens while reading the body, so upstream handlers can
* surface the failure to the client.
*/
private static final class ClosingInputStream extends FilterInputStream {
private final TempFile tempFile;
private boolean closed;
ClosingInputStream(InputStream delegate, TempFile tempFile) {
super(delegate);
this.tempFile = tempFile;
}
@Override
public int read() throws IOException {
try {
return super.read();
} catch (IOException e) {
log.error(
"Failed to read temp response body {} while streaming to client",
tempFile.getAbsolutePath(),
e);
throw e;
}
}
@Override
public int read(byte[] b, int off, int len) throws IOException {
try {
return super.read(b, off, len);
} catch (IOException e) {
log.error(
"Failed to read temp response body {} while streaming to client",
tempFile.getAbsolutePath(),
e);
throw e;
}
}
@Override
public void close() throws IOException {
if (closed) {
return;
}
closed = true;
try {
super.close();
} finally {
try {
tempFile.close();
} catch (Exception closeEx) {
log.warn(
"Failed to clean up temp file {} after streaming response",
tempFile.getAbsolutePath(),
closeEx);
}
}
}
}
}