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:
Marcel
2026-04-22 22:02:20 +02:00
parent 0344a0c7ff
commit 09fc871756
2 changed files with 258 additions and 0 deletions

View File

@@ -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);
}
}