feat(backend): add ThumbnailBackfillService for regenerating missing thumbnails
Sequentially processes all documents with a file but no thumbnail and tallies processed / skipped / failed counts. Runs on thumbnailExecutor so it shares back-pressure with live upload thumbnails but can never saturate them (single-threaded loop). Concurrent start rejected with THUMBNAIL_BACKFILL_ALREADY_RUNNING. Emits a structured summary log line on completion for operator visibility. Refs #307 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Document> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user