From 3b7ef6117e7781de0b48e5b10a74ead8180a46a9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 21:49:26 +0200 Subject: [PATCH] feat(backend): add ThumbnailAsyncRunner with afterCommit dispatch and timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridges @Transactional upload paths to the async thumbnail pipeline. dispatchAfterCommit registers a TransactionSynchronization so the async task only fires after the surrounding commit (and is silently skipped on rollback) — mirrors the AuditService.logAfterCommit pattern. generateAsync wraps the full ThumbnailService.generate call in a 30s watchdog so a hung PDFBox render cannot occupy a thumbnailExecutor slot indefinitely. Refs #307 Co-Authored-By: Claude Opus 4.7 --- .../service/ThumbnailAsyncRunner.java | 92 ++++++++++++++ .../service/ThumbnailAsyncRunnerTest.java | 118 ++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailAsyncRunner.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailAsyncRunnerTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailAsyncRunner.java b/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailAsyncRunner.java new file mode 100644 index 00000000..a90a7a39 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailAsyncRunner.java @@ -0,0 +1,92 @@ +package org.raddatz.familienarchiv.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.repository.DocumentRepository; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * Bridges document upload paths to asynchronous thumbnail generation. Use + * {@link #dispatchAfterCommit(UUID)} from inside {@code @Transactional} service methods — + * it registers a post-commit hook so the async task only fires when the surrounding + * transaction actually commits, and is silently skipped on rollback. Mirrors + * {@link org.raddatz.familienarchiv.audit.AuditService#logAfterCommit}. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class ThumbnailAsyncRunner { + + private final DocumentRepository documentRepository; + private final ThumbnailService thumbnailService; + + /** Per-document timeout for the whole generate() call — defense against corrupt PDFs. */ + private long generateTimeoutSeconds = 30L; + + /** + * Registers a post-commit hook that triggers asynchronous thumbnail generation for the + * given document. When no transaction is active the task is dispatched immediately. + * Safe to call from inside {@code @Transactional} service methods. + */ + public void dispatchAfterCommit(UUID documentId) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + generateAsync(documentId); + } + }); + } else { + generateAsync(documentId); + } + } + + /** + * Runs thumbnail generation on the {@code thumbnailExecutor} pool, wrapped in a watchdog + * timeout so a hung PDFBox render cannot occupy a pool thread indefinitely. Never throws: + * all errors and timeouts are logged and swallowed so upload paths are not affected. + */ + @Async("thumbnailExecutor") + public void generateAsync(UUID documentId) { + Optional docOpt = documentRepository.findById(documentId); + if (docOpt.isEmpty()) { + log.warn("Thumbnail generation skipped: document not found id={}", documentId); + return; + } + Document doc = docOpt.get(); + + ExecutorService timeoutWorker = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "Thumbnail-Render-" + documentId); + t.setDaemon(true); + return t; + }); + try { + Future future = timeoutWorker.submit( + () -> thumbnailService.generate(doc)); + try { + future.get(generateTimeoutSeconds, TimeUnit.SECONDS); + } catch (TimeoutException e) { + future.cancel(true); + log.warn("Thumbnail generation timed out after {}s for doc={}", + generateTimeoutSeconds, documentId); + } catch (Exception e) { + log.warn("Thumbnail generation errored for doc={} reason={}", + documentId, e.getMessage()); + } + } finally { + timeoutWorker.shutdownNow(); + } + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailAsyncRunnerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailAsyncRunnerTest.java new file mode 100644 index 00000000..2f55b889 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailAsyncRunnerTest.java @@ -0,0 +1,118 @@ +package org.raddatz.familienarchiv.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.repository.DocumentRepository; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class ThumbnailAsyncRunnerTest { + + private DocumentRepository documentRepository; + private ThumbnailService thumbnailService; + private ThumbnailAsyncRunner runner; + + @BeforeEach + void setUp() { + documentRepository = mock(DocumentRepository.class); + thumbnailService = mock(ThumbnailService.class); + runner = new ThumbnailAsyncRunner(documentRepository, thumbnailService); + } + + @Test + void dispatchAfterCommit_whenNoTransaction_dispatchesImmediately() { + UUID id = UUID.randomUUID(); + Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + + runner.dispatchAfterCommit(id); + + verify(thumbnailService).generate(doc); + } + + @Test + void dispatchAfterCommit_whenTransactionActive_registersAfterCommitSynchronization() { + UUID id = UUID.randomUUID(); + Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + + TransactionSynchronizationManager.initSynchronization(); + try { + runner.dispatchAfterCommit(id); + + // Nothing fired yet — registered, not executed + verify(thumbnailService, never()).generate(any()); + + // Simulate commit + ArgumentCaptor captor = + ArgumentCaptor.forClass(TransactionSynchronization.class); + assertThat(TransactionSynchronizationManager.getSynchronizations()).hasSize(1); + TransactionSynchronizationManager.getSynchronizations().get(0).afterCommit(); + + verify(thumbnailService).generate(doc); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + @Test + void dispatchAfterCommit_whenRollback_doesNotDispatch() { + UUID id = UUID.randomUUID(); + Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + + TransactionSynchronizationManager.initSynchronization(); + try { + runner.dispatchAfterCommit(id); + + // Simulate rollback — afterCompletion with STATUS_ROLLED_BACK, no afterCommit fired + TransactionSynchronizationManager.getSynchronizations().get(0) + .afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK); + + verify(thumbnailService, never()).generate(any()); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + @Test + void generateAsync_skipsWhenDocumentMissing() { + UUID id = UUID.randomUUID(); + when(documentRepository.findById(id)).thenReturn(Optional.empty()); + + runner.generateAsync(id); + + verifyNoInteractions(thumbnailService); + } + + @Test + void generateAsync_timesOutWhenGenerateExceedsLimit() throws Exception { + UUID id = UUID.randomUUID(); + Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + // generate sleeps longer than the timeout — simulates a hung PDFBox render + when(thumbnailService.generate(doc)).thenAnswer(inv -> { + Thread.sleep(5_000); + return ThumbnailService.Outcome.SUCCESS; + }); + // Shrink timeout for the test + ReflectionTestUtils.setField(runner, "generateTimeoutSeconds", 1L); + + long start = System.currentTimeMillis(); + runner.generateAsync(id); + long elapsed = System.currentTimeMillis() - start; + + // Must return before the 5s sleep — within ~2s with timeout=1s plus overhead + assertThat(elapsed).isLessThan(3_000); + } +}