From 07019f54e8a1488abf00133d56d7ac866821e8b8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 21:41:15 +0200 Subject: [PATCH] feat(backend): add FileService.downloadFileStream for memory-efficient reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thumbnail generation will call this for PDFs up to 50 MB — loading the full byte[] via downloadFileBytes would cause real memory pressure on the single-VPS deploy. Stream-based reads let PDFBox parse the first page without holding the whole file in heap. Refs #307 Co-Authored-By: Claude Opus 4.7 --- .../familienarchiv/service/FileService.java | 21 +++++++++++ .../service/FileServiceTest.java | 35 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/FileService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/FileService.java index 29ca2be6..3208455f 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/FileService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/FileService.java @@ -112,6 +112,27 @@ public class FileService { } } + /** + * Opens a streaming download from S3/MinIO. The caller is responsible for + * closing the returned stream — typically via try-with-resources. Preferred + * over {@link #downloadFileBytes(String)} for large files (multi-MB PDFs + * during thumbnail generation) because it avoids loading the entire file + * into heap memory. + */ + public InputStream downloadFileStream(String s3Key) throws IOException { + try { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .build(); + return s3Client.getObject(getObjectRequest); + } catch (NoSuchKeyException e) { + throw new StorageFileNotFoundException("File not found in storage: " + s3Key); + } catch (S3Exception e) { + throw new IOException("Failed to open stream from storage: " + e.getMessage(), e); + } + } + /** * Generates a presigned URL for downloading an object from S3/MinIO. * Valid for 1 hour — covers multi-page documents on CPU-only OCR hardware diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/FileServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/FileServiceTest.java index e043c3b7..3fd79033 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/FileServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/FileServiceTest.java @@ -197,4 +197,39 @@ class FileServiceTest { .isInstanceOf(IOException.class) .hasMessageContaining("Failed to download"); } + + // ─── downloadFileStream ──────────────────────────────────────────────────── + + @Test + void downloadFileStream_returnsStreamableContent() throws IOException { + byte[] content = "streamed bytes".getBytes(); + GetObjectResponse response = GetObjectResponse.builder().contentType("application/pdf").build(); + ResponseInputStream stream = new ResponseInputStream<>( + response, AbortableInputStream.create(new ByteArrayInputStream(content))); + when(s3Client.getObject(any(GetObjectRequest.class))).thenReturn(stream); + + try (java.io.InputStream result = fileService.downloadFileStream("documents/file.pdf")) { + assertThat(result.readAllBytes()).isEqualTo(content); + } + } + + @Test + void downloadFileStream_throwsStorageFileNotFoundException_whenNoSuchKey() { + NoSuchKeyException ex = NoSuchKeyException.builder().message("not found").statusCode(404).build(); + when(s3Client.getObject(any(GetObjectRequest.class))).thenThrow(ex); + + assertThatThrownBy(() -> fileService.downloadFileStream("missing/key.pdf")) + .isInstanceOf(FileService.StorageFileNotFoundException.class) + .hasMessageContaining("missing/key.pdf"); + } + + @Test + void downloadFileStream_throwsIOException_whenS3Exception() { + S3Exception ex = (S3Exception) S3Exception.builder().message("storage error").statusCode(503).build(); + when(s3Client.getObject(any(GetObjectRequest.class))).thenThrow(ex); + + assertThatThrownBy(() -> fileService.downloadFileStream("documents/file.pdf")) + .isInstanceOf(IOException.class) + .hasMessageContaining("Failed to open stream"); + } }