feat(backend): add ThumbnailAsyncRunner with afterCommit dispatch and timeout
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Document> 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<ThumbnailService.Outcome> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TransactionSynchronization> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user