diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailBackfillService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailBackfillService.java new file mode 100644 index 00000000..60855ba6 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailBackfillService.java @@ -0,0 +1,104 @@ +package org.raddatz.familienarchiv.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.repository.DocumentRepository; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; + +/** + * Sequentially regenerates thumbnails for documents that have a file attached but no + * thumbnail yet. Runs on the {@code thumbnailExecutor} pool — single-threaded iteration + * is intentional: PDFBox + ImageIO are memory-heavy and we cap peak usage by processing + * documents one at a time. Only one backfill can run at a time; concurrent starts are + * rejected with {@link ErrorCode#THUMBNAIL_BACKFILL_ALREADY_RUNNING}. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class ThumbnailBackfillService { + + public enum State { IDLE, RUNNING, DONE, FAILED } + + public record BackfillStatus( + State state, + String message, + int total, + int processed, + int skipped, + int failed, + LocalDateTime startedAt + ) {} + + private final DocumentRepository documentRepository; + private final ThumbnailService thumbnailService; + + private volatile BackfillStatus currentStatus = new BackfillStatus( + State.IDLE, "Kein Backfill gestartet.", 0, 0, 0, 0, null); + + public BackfillStatus getStatus() { + return currentStatus; + } + + @Async("thumbnailExecutor") + public void runBackfillAsync() { + if (currentStatus.state() == State.RUNNING) { + throw DomainException.conflict(ErrorCode.THUMBNAIL_BACKFILL_ALREADY_RUNNING, + "Thumbnail-Backfill läuft bereits"); + } + + LocalDateTime startedAt = LocalDateTime.now(); + List docs; + try { + docs = documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull(); + } catch (Exception e) { + currentStatus = new BackfillStatus(State.FAILED, + "Backfill fehlgeschlagen: " + e.getMessage(), + 0, 0, 0, 0, startedAt); + log.warn("Thumbnail backfill aborted before starting: {}", e.getMessage()); + return; + } + + int total = docs.size(); + currentStatus = new BackfillStatus(State.RUNNING, + "Backfill läuft…", total, 0, 0, 0, startedAt); + log.info("Thumbnail backfill started: total={}", total); + + int processed = 0; + int skipped = 0; + int failed = 0; + for (Document doc : docs) { + ThumbnailService.Outcome outcome; + try { + outcome = thumbnailService.generate(doc); + } catch (Exception e) { + log.warn("Thumbnail generation failed for doc={} reason={}", + doc.getId(), e.getMessage()); + outcome = ThumbnailService.Outcome.FAILED; + } + switch (outcome) { + case SUCCESS -> processed++; + case SKIPPED -> skipped++; + case FAILED -> failed++; + } + currentStatus = new BackfillStatus(State.RUNNING, + "Backfill läuft…", total, processed, skipped, failed, startedAt); + } + + long durationMs = Duration.between(startedAt, LocalDateTime.now()).toMillis(); + log.info("Thumbnail backfill complete: total={} processed={} skipped={} failed={} durationMs={}", + total, processed, skipped, failed, durationMs); + + currentStatus = new BackfillStatus(State.DONE, + String.format("Fertig: %d erzeugt, %d übersprungen, %d fehlgeschlagen.", + processed, skipped, failed), + total, processed, skipped, failed, startedAt); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailBackfillServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailBackfillServiceTest.java new file mode 100644 index 00000000..4e076395 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailBackfillServiceTest.java @@ -0,0 +1,154 @@ +package org.raddatz.familienarchiv.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.repository.DocumentRepository; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class ThumbnailBackfillServiceTest { + + private DocumentRepository documentRepository; + private ThumbnailService thumbnailService; + private ThumbnailBackfillService backfillService; + + @BeforeEach + void setUp() { + documentRepository = mock(DocumentRepository.class); + thumbnailService = mock(ThumbnailService.class); + backfillService = new ThumbnailBackfillService(documentRepository, thumbnailService); + } + + @Test + void initialStatus_isIdle() { + ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus(); + + assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.IDLE); + assertThat(status.total()).isZero(); + assertThat(status.processed()).isZero(); + assertThat(status.startedAt()).isNull(); + } + + @Test + void runBackfillAsync_processesAllDocumentsAndFinishesDone() { + Document a = doc(); + Document b = doc(); + Document c = doc(); + when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull()) + .thenReturn(List.of(a, b, c)); + when(thumbnailService.generate(any())).thenReturn(ThumbnailService.Outcome.SUCCESS); + + backfillService.runBackfillAsync(); + + ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus(); + assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.DONE); + assertThat(status.total()).isEqualTo(3); + assertThat(status.processed()).isEqualTo(3); + assertThat(status.skipped()).isZero(); + assertThat(status.failed()).isZero(); + verify(thumbnailService, times(3)).generate(any()); + } + + @Test + void runBackfillAsync_countsSkippedSeparately() { + Document a = doc(); + Document b = doc(); + when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull()) + .thenReturn(List.of(a, b)); + when(thumbnailService.generate(a)).thenReturn(ThumbnailService.Outcome.SUCCESS); + when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.SKIPPED); + + backfillService.runBackfillAsync(); + + ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus(); + assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.DONE); + assertThat(status.processed()).isEqualTo(1); + assertThat(status.skipped()).isEqualTo(1); + assertThat(status.failed()).isZero(); + } + + @Test + void runBackfillAsync_continuesAfterFailureAndCountsIt() { + Document a = doc(); + Document b = doc(); + Document c = doc(); + when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull()) + .thenReturn(List.of(a, b, c)); + when(thumbnailService.generate(a)).thenReturn(ThumbnailService.Outcome.SUCCESS); + when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.FAILED); + when(thumbnailService.generate(c)).thenReturn(ThumbnailService.Outcome.SUCCESS); + + backfillService.runBackfillAsync(); + + ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus(); + assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.DONE); + assertThat(status.processed()).isEqualTo(2); + assertThat(status.failed()).isEqualTo(1); + verify(thumbnailService, times(3)).generate(any()); + } + + @Test + void runBackfillAsync_continuesWhenServiceThrowsUnexpectedException() { + Document a = doc(); + Document b = doc(); + when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull()) + .thenReturn(List.of(a, b)); + when(thumbnailService.generate(a)).thenThrow(new RuntimeException("boom")); + when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.SUCCESS); + + backfillService.runBackfillAsync(); + + ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus(); + assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.DONE); + assertThat(status.processed()).isEqualTo(1); + assertThat(status.failed()).isEqualTo(1); + } + + @Test + void runBackfillAsync_rejectsConcurrentStart() { + // Force state=RUNNING via reflection + ThumbnailBackfillService.BackfillStatus running = new ThumbnailBackfillService.BackfillStatus( + ThumbnailBackfillService.State.RUNNING, "running", 10, 5, 0, 0, LocalDateTime.now()); + ReflectionTestUtils.setField(backfillService, "currentStatus", running); + + assertThatThrownBy(() -> backfillService.runBackfillAsync()) + .isInstanceOf(DomainException.class) + .satisfies(ex -> assertThat(((DomainException) ex).getCode()) + .isEqualTo(ErrorCode.THUMBNAIL_BACKFILL_ALREADY_RUNNING)); + } + + @Test + void runBackfillAsync_setsStartedAtAndMessage() { + when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull()) + .thenReturn(List.of(doc())); + when(thumbnailService.generate(any())).thenReturn(ThumbnailService.Outcome.SUCCESS); + + LocalDateTime before = LocalDateTime.now().minusSeconds(1); + backfillService.runBackfillAsync(); + + ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus(); + assertThat(status.startedAt()).isAfter(before); + assertThat(status.message()).isNotBlank(); + } + + private Document doc() { + return Document.builder() + .id(UUID.randomUUID()) + .title("t") + .originalFilename("f.pdf") + .filePath("documents/f.pdf") + .contentType("application/pdf") + .build(); + } +}