fix(document): break DocumentService ↔ ThumbnailAsyncRunner ↔ ThumbnailService cycle
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m29s
CI / OCR Service Tests (push) Successful in 38s
CI / Backend Unit Tests (push) Failing after 1m54s
CI / Unit & Component Tests (pull_request) Failing after 28s
CI / OCR Service Tests (pull_request) Successful in 36s
CI / Backend Unit Tests (pull_request) Failing after 1m49s
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m29s
CI / OCR Service Tests (push) Successful in 38s
CI / Backend Unit Tests (push) Failing after 1m54s
CI / Unit & Component Tests (pull_request) Failing after 28s
CI / OCR Service Tests (pull_request) Successful in 36s
CI / Backend Unit Tests (pull_request) Failing after 1m49s
Spring Framework 7 prohibits constructor injection cycles even with @Lazy. Replace DocumentService dependencies in ThumbnailAsyncRunner and ThumbnailService with direct DocumentRepository calls — both are intra-domain reads/saves. Update ThumbnailServiceTest to mock DocumentRepository accordingly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -29,7 +29,6 @@ import org.raddatz.familienarchiv.ocr.TrainingLabel;
|
|||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.raddatz.familienarchiv.tag.Tag;
|
import org.raddatz.familienarchiv.tag.Tag;
|
||||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||||
import org.springframework.context.annotation.Lazy;
|
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
@@ -77,10 +76,6 @@ public class DocumentService {
|
|||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
private final TranscriptionBlockQueryService transcriptionBlockQueryService;
|
private final TranscriptionBlockQueryService transcriptionBlockQueryService;
|
||||||
private final AuditLogQueryService auditLogQueryService;
|
private final AuditLogQueryService auditLogQueryService;
|
||||||
// @Lazy breaks the DocumentService ↔ ThumbnailAsyncRunner cycle: the runner
|
|
||||||
// now reaches Document data through DocumentService (per the layering rule),
|
|
||||||
// and Spring needs a proxy here to defer the back-edge until both beans exist.
|
|
||||||
@Lazy
|
|
||||||
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
|
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
|
||||||
|
|
||||||
public record StoreResult(Document document, boolean isNew) {}
|
public record StoreResult(Document document, boolean isNew) {}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import java.util.concurrent.TimeoutException;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public class ThumbnailAsyncRunner {
|
public class ThumbnailAsyncRunner {
|
||||||
|
|
||||||
private final DocumentService documentService;
|
private final DocumentRepository documentRepository;
|
||||||
private final ThumbnailService thumbnailService;
|
private final ThumbnailService thumbnailService;
|
||||||
|
|
||||||
/** Per-document timeout for the whole generate() call — defense against corrupt PDFs. */
|
/** Per-document timeout for the whole generate() call — defense against corrupt PDFs. */
|
||||||
@@ -59,7 +59,7 @@ public class ThumbnailAsyncRunner {
|
|||||||
*/
|
*/
|
||||||
@Async("thumbnailExecutor")
|
@Async("thumbnailExecutor")
|
||||||
public void generateAsync(UUID documentId) {
|
public void generateAsync(UUID documentId) {
|
||||||
Optional<Document> docOpt = documentService.findById(documentId);
|
Optional<Document> docOpt = documentRepository.findById(documentId);
|
||||||
if (docOpt.isEmpty()) {
|
if (docOpt.isEmpty()) {
|
||||||
log.warn("Thumbnail generation skipped: document not found id={}", documentId);
|
log.warn("Thumbnail generation skipped: document not found id={}", documentId);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -62,16 +62,16 @@ public class ThumbnailService {
|
|||||||
|
|
||||||
private final FileService fileService;
|
private final FileService fileService;
|
||||||
private final S3Client s3Client;
|
private final S3Client s3Client;
|
||||||
private final DocumentService documentService;
|
private final DocumentRepository documentRepository;
|
||||||
|
|
||||||
@Value("${app.s3.bucket}")
|
@Value("${app.s3.bucket}")
|
||||||
private String bucketName;
|
private String bucketName;
|
||||||
|
|
||||||
public ThumbnailService(FileService fileService, S3Client s3Client,
|
public ThumbnailService(FileService fileService, S3Client s3Client,
|
||||||
DocumentService documentService) {
|
DocumentRepository documentRepository) {
|
||||||
this.fileService = fileService;
|
this.fileService = fileService;
|
||||||
this.s3Client = s3Client;
|
this.s3Client = s3Client;
|
||||||
this.documentService = documentService;
|
this.documentRepository = documentRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Outcome generate(Document doc) {
|
public Outcome generate(Document doc) {
|
||||||
@@ -167,7 +167,7 @@ public class ThumbnailService {
|
|||||||
doc.setThumbnailGeneratedAt(LocalDateTime.now());
|
doc.setThumbnailGeneratedAt(LocalDateTime.now());
|
||||||
doc.setThumbnailAspect(result.aspect());
|
doc.setThumbnailAspect(result.aspect());
|
||||||
doc.setPageCount(result.pageCount());
|
doc.setPageCount(result.pageCount());
|
||||||
documentService.updateThumbnailMetadata(doc);
|
documentRepository.save(doc);
|
||||||
return Outcome.SUCCESS;
|
return Outcome.SUCCESS;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// Thumbnail is already in S3 but the entity update failed. Because the S3
|
// Thumbnail is already in S3 but the entity update failed. Because the S3
|
||||||
|
|||||||
@@ -39,17 +39,17 @@ class ThumbnailServiceTest {
|
|||||||
|
|
||||||
private FileService fileService;
|
private FileService fileService;
|
||||||
private S3Client s3Client;
|
private S3Client s3Client;
|
||||||
private DocumentService documentService;
|
private DocumentRepository documentRepository;
|
||||||
private ThumbnailService thumbnailService;
|
private ThumbnailService thumbnailService;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
fileService = mock(FileService.class);
|
fileService = mock(FileService.class);
|
||||||
s3Client = mock(S3Client.class);
|
s3Client = mock(S3Client.class);
|
||||||
documentService = mock(DocumentService.class);
|
documentRepository = mock(DocumentRepository.class);
|
||||||
thumbnailService = new ThumbnailService(fileService, s3Client, documentService);
|
thumbnailService = new ThumbnailService(fileService, s3Client, documentRepository);
|
||||||
ReflectionTestUtils.setField(thumbnailService, "bucketName", "test-bucket");
|
ReflectionTestUtils.setField(thumbnailService, "bucketName", "test-bucket");
|
||||||
when(documentService.updateThumbnailMetadata(any(Document.class))).thenAnswer(i -> i.getArgument(0));
|
when(documentRepository.save(any(Document.class))).thenAnswer(i -> i.getArgument(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -103,7 +103,7 @@ class ThumbnailServiceTest {
|
|||||||
|
|
||||||
assertThat(doc.getThumbnailKey()).isEqualTo("thumbnails/" + doc.getId() + ".jpg");
|
assertThat(doc.getThumbnailKey()).isEqualTo("thumbnails/" + doc.getId() + ".jpg");
|
||||||
assertThat(doc.getThumbnailGeneratedAt()).isNotNull();
|
assertThat(doc.getThumbnailGeneratedAt()).isNotNull();
|
||||||
verify(documentService).updateThumbnailMetadata(doc);
|
verify(documentRepository).save(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -152,7 +152,7 @@ class ThumbnailServiceTest {
|
|||||||
|
|
||||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
|
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
|
||||||
assertThat(doc.getThumbnailKey()).isNull();
|
assertThat(doc.getThumbnailKey()).isNull();
|
||||||
verify(documentService, never()).updateThumbnailMetadata(any());
|
verify(documentRepository, never()).save(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -165,7 +165,7 @@ class ThumbnailServiceTest {
|
|||||||
|
|
||||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
|
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
|
||||||
verifyNoInteractions(s3Client);
|
verifyNoInteractions(s3Client);
|
||||||
verify(documentService, never()).updateThumbnailMetadata(any());
|
verify(documentRepository, never()).save(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -260,7 +260,7 @@ class ThumbnailServiceTest {
|
|||||||
|
|
||||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
|
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
|
||||||
verifyNoInteractions(s3Client);
|
verifyNoInteractions(s3Client);
|
||||||
verify(documentService, never()).updateThumbnailMetadata(any());
|
verify(documentRepository, never()).save(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -275,7 +275,7 @@ class ThumbnailServiceTest {
|
|||||||
|
|
||||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
|
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
|
||||||
verifyNoInteractions(s3Client);
|
verifyNoInteractions(s3Client);
|
||||||
verify(documentService, never()).updateThumbnailMetadata(any());
|
verify(documentRepository, never()).save(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -286,14 +286,14 @@ class ThumbnailServiceTest {
|
|||||||
Document doc = makeDoc("application/pdf", "documents/letter.pdf");
|
Document doc = makeDoc("application/pdf", "documents/letter.pdf");
|
||||||
when(fileService.downloadFileStream(anyString()))
|
when(fileService.downloadFileStream(anyString()))
|
||||||
.thenReturn(new ByteArrayInputStream(createSamplePdf()));
|
.thenReturn(new ByteArrayInputStream(createSamplePdf()));
|
||||||
when(documentService.updateThumbnailMetadata(any()))
|
when(documentRepository.save(any()))
|
||||||
.thenThrow(new RuntimeException("constraint violation"));
|
.thenThrow(new RuntimeException("constraint violation"));
|
||||||
|
|
||||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||||
|
|
||||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
|
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
|
||||||
verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||||
verify(documentService).updateThumbnailMetadata(any());
|
verify(documentRepository).save(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user