diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java index 16695cb..0cc653a 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java @@ -129,8 +129,10 @@ public class DocumentProcessingCoordinator { * must not be null * @param attemptStart the instant at which processing of this candidate began; * must not be null + * @return true if processing and persistence succeeded for this document, false if a + * persistence failure occurred */ - public void process( + public boolean process( SourceDocumentCandidate candidate, DocumentFingerprint fingerprint, DocumentProcessingOutcome outcome, @@ -143,7 +145,7 @@ public class DocumentProcessingCoordinator { Objects.requireNonNull(context, "context must not be null"); Objects.requireNonNull(attemptStart, "attemptStart must not be null"); - processDeferredOutcome(candidate, fingerprint, context, attemptStart, ignored -> outcome); + return processDeferredOutcome(candidate, fingerprint, context, attemptStart, ignored -> outcome); } /** @@ -172,8 +174,10 @@ public class DocumentProcessingCoordinator { * must not be null * @param pipelineExecutor functional interface that executes the extraction and pre-check * pipeline when needed; must not be null + * @return true if processing and persistence succeeded for this document, false if a + * persistence failure occurred (lookup, attempt write, or record write) */ - public void processDeferredOutcome( + public boolean processDeferredOutcome( SourceDocumentCandidate candidate, DocumentFingerprint fingerprint, BatchRunContext context, @@ -194,16 +198,16 @@ public class DocumentProcessingCoordinator { if (lookupResult instanceof PersistenceLookupTechnicalFailure failure) { logger.error("Cannot process '{}': master record lookup failed: {}", candidate.uniqueIdentifier(), failure.errorMessage()); - return; + return false; } // Step 3: Determine the action based on the lookup result - switch (lookupResult) { + return switch (lookupResult) { case DocumentTerminalSuccess terminalSuccess -> { // Document already successfully processed → skip logger.info("Skipping '{}': already successfully processed (fingerprint: {}).", candidate.uniqueIdentifier(), fingerprint.sha256Hex()); - persistSkipAttempt( + yield persistSkipAttempt( candidate, fingerprint, terminalSuccess.record(), ProcessingStatus.SKIPPED_ALREADY_PROCESSED, context, attemptStart); @@ -213,7 +217,7 @@ public class DocumentProcessingCoordinator { // Document finally failed → skip logger.info("Skipping '{}': already finally failed (fingerprint: {}).", candidate.uniqueIdentifier(), fingerprint.sha256Hex()); - persistSkipAttempt( + yield persistSkipAttempt( candidate, fingerprint, terminalFailure.record(), ProcessingStatus.SKIPPED_FINAL_FAILURE, context, attemptStart); @@ -222,22 +226,24 @@ public class DocumentProcessingCoordinator { case DocumentUnknown ignored -> { // New document – execute pipeline and process DocumentProcessingOutcome outcome = pipelineExecutor.apply(candidate); - processAndPersistNewDocument(candidate, fingerprint, outcome, context, attemptStart); + yield processAndPersistNewDocument(candidate, fingerprint, outcome, context, attemptStart); } case DocumentKnownProcessable knownProcessable -> { // Known but not terminal – execute pipeline and process DocumentProcessingOutcome outcome = pipelineExecutor.apply(candidate); - processAndPersistKnownDocument( + yield processAndPersistKnownDocument( candidate, fingerprint, outcome, knownProcessable.record(), context, attemptStart); } - default -> + default -> { // Exhaustive sealed hierarchy; this branch is unreachable logger.error("Unexpected lookup result type for '{}': {}", candidate.uniqueIdentifier(), lookupResult.getClass().getSimpleName()); - } + yield false; + } + }; } // ------------------------------------------------------------------------- @@ -257,8 +263,9 @@ public class DocumentProcessingCoordinator { * or {@link ProcessingStatus#SKIPPED_FINAL_FAILURE}) * @param context the current batch run context * @param attemptStart the start instant of this processing attempt + * @return true if persistence succeeded, false if a persistence exception occurred */ - private void persistSkipAttempt( + private boolean persistSkipAttempt( SourceDocumentCandidate candidate, DocumentFingerprint fingerprint, DocumentRecord existingRecord, @@ -293,10 +300,12 @@ public class DocumentProcessingCoordinator { logger.debug("Skip attempt #{} persisted for '{}' with status {}.", attemptNumber, candidate.uniqueIdentifier(), skipStatus); + return true; } catch (DocumentPersistenceException e) { logger.error("Failed to persist skip attempt for '{}': {}", candidate.uniqueIdentifier(), e.getMessage(), e); + return false; } } @@ -305,7 +314,7 @@ public class DocumentProcessingCoordinator { // ------------------------------------------------------------------------- /** Maps the pipeline outcome for a new document and persists attempt + new master record. */ - private void processAndPersistNewDocument( + private boolean processAndPersistNewDocument( SourceDocumentCandidate candidate, DocumentFingerprint fingerprint, DocumentProcessingOutcome pipelineOutcome, @@ -315,7 +324,7 @@ public class DocumentProcessingCoordinator { Instant now = Instant.now(); ProcessingOutcomeTransition.ProcessingOutcome outcome = mapOutcomeForNewDocument(pipelineOutcome); DocumentRecord newRecord = buildNewDocumentRecord(fingerprint, candidate, outcome, now); - persistAttemptAndRecord(candidate, fingerprint, context, attemptStart, now, outcome, + return persistAttemptAndRecord(candidate, fingerprint, context, attemptStart, now, outcome, txOps -> txOps.createDocumentRecord(newRecord)); } @@ -324,7 +333,7 @@ public class DocumentProcessingCoordinator { // ------------------------------------------------------------------------- /** Maps the pipeline outcome for a known document and persists attempt + updated master record. */ - private void processAndPersistKnownDocument( + private boolean processAndPersistKnownDocument( SourceDocumentCandidate candidate, DocumentFingerprint fingerprint, DocumentProcessingOutcome pipelineOutcome, @@ -335,7 +344,7 @@ public class DocumentProcessingCoordinator { Instant now = Instant.now(); ProcessingOutcomeTransition.ProcessingOutcome outcome = mapOutcomeForKnownDocument(pipelineOutcome, existingRecord.failureCounters()); DocumentRecord updatedRecord = buildUpdatedDocumentRecord(existingRecord, candidate, outcome, now); - persistAttemptAndRecord(candidate, fingerprint, context, attemptStart, now, outcome, + return persistAttemptAndRecord(candidate, fingerprint, context, attemptStart, now, outcome, txOps -> txOps.updateDocumentRecord(updatedRecord)); } @@ -440,8 +449,10 @@ public class DocumentProcessingCoordinator { * {@code recordWriter} performs either {@code createDocumentRecord} or * {@code updateDocumentRecord} depending on whether the document is new or known. * All persistence failures are caught and logged; the batch run continues. + * + * @return true if persistence succeeded, false if a persistence exception occurred */ - private void persistAttemptAndRecord( + private boolean persistAttemptAndRecord( SourceDocumentCandidate candidate, DocumentFingerprint fingerprint, BatchRunContext context, @@ -465,10 +476,12 @@ public class DocumentProcessingCoordinator { outcome.overallStatus(), outcome.counters().contentErrorCount(), outcome.counters().transientErrorCount()); + return true; } catch (DocumentPersistenceException e) { logger.error("Failed to persist processing result for '{}': {}", candidate.uniqueIdentifier(), e.getMessage(), e); + return false; } } diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultBatchRunProcessingUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultBatchRunProcessingUseCase.java index 9d64450..724c293 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultBatchRunProcessingUseCase.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultBatchRunProcessingUseCase.java @@ -158,9 +158,14 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa /** * Loads candidates and processes them one by one. + *

+ * Tracks whether any document-level persistence failures occur during processing. + * A persistence failure for a single document causes the overall batch outcome + * to be FAILURE instead of SUCCESS. * * @param context the current batch run context - * @return SUCCESS if all candidates were processed, FAILURE if source access fails + * @return SUCCESS if all candidates were processed without persistence failures, + * FAILURE if source access fails or any document-level persistence failure occurred */ private BatchRunOutcome processCandidates(BatchRunContext context) { List candidates; @@ -172,13 +177,24 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa } logger.info("Found {} PDF candidate(s) in source folder.", candidates.size()); + // Track whether any document-level persistence failures occurred + boolean anyPersistenceFailure = false; + // Process each candidate for (SourceDocumentCandidate candidate : candidates) { - processCandidate(candidate, context); + if (!processCandidate(candidate, context)) { + anyPersistenceFailure = true; + } } logger.info("Batch run completed. Processed {} candidate(s). RunId: {}", candidates.size(), context.runId()); + + if (anyPersistenceFailure) { + logger.warn("Batch run completed with document-level persistence failure(s)."); + return BatchRunOutcome.FAILURE; + } + return BatchRunOutcome.SUCCESS; } @@ -209,7 +225,7 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa *

  • Record the attempt start instant.
  • *
  • Compute the SHA-256 fingerprint of the candidate file content.
  • *
  • If fingerprint computation fails: log as non-identifiable run event and - * return — no SQLite record is created.
  • + * return true — no SQLite record is created, but no persistence failure occurred. *
  • Load document master record.
  • *
  • If already {@code SUCCESS} → persist skip attempt with * {@code SKIPPED_ALREADY_PROCESSED}.
  • @@ -226,21 +242,23 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa * * @param candidate the candidate to process * @param context the current batch run context + * @return true if the candidate was processed without persistence failures (fingerprint + * errors return true; persistence failures return false) */ - private void processCandidate(SourceDocumentCandidate candidate, BatchRunContext context) { + private boolean processCandidate(SourceDocumentCandidate candidate, BatchRunContext context) { logger.debug("Processing candidate: {}", candidate.uniqueIdentifier()); Instant attemptStart = Instant.now(); FingerprintResult fingerprintResult = fingerprintPort.computeFingerprint(candidate); - switch (fingerprintResult) { + return switch (fingerprintResult) { case FingerprintTechnicalError fingerprintError -> { handleFingerprintError(candidate, fingerprintError); + yield true; // fingerprint errors are not persistence failures } - case FingerprintSuccess fingerprintSuccess -> { + case FingerprintSuccess fingerprintSuccess -> handleFingerprintSuccess(candidate, fingerprintSuccess, context, attemptStart); - } - } + }; } /** @@ -264,8 +282,9 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa * @param fingerprintSuccess the successful fingerprint result * @param context the batch run context * @param attemptStart the instant when processing started + * @return true if processing and persistence succeeded, false if a persistence failure occurred */ - private void handleFingerprintSuccess( + private boolean handleFingerprintSuccess( SourceDocumentCandidate candidate, FingerprintSuccess fingerprintSuccess, BatchRunContext context, @@ -274,7 +293,7 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa logger.debug("Fingerprint computed for '{}': {}", candidate.uniqueIdentifier(), fingerprint.sha256Hex()); - documentProcessingCoordinator.processDeferredOutcome( + return documentProcessingCoordinator.processDeferredOutcome( candidate, fingerprint, context, diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java index 092fc42..c35351d 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java @@ -424,6 +424,98 @@ class BatchRunProcessingUseCaseTest { assertEquals(3, processor.processCallCount(), "processor should be called once per candidate"); } + // ------------------------------------------------------------------------- + // Document-level persistence failure handling + // ------------------------------------------------------------------------- + + /** + * Regression test: when a document-level persistence failure occurs, + * the batch outcome must be FAILURE, not SUCCESS. + */ + @Test + void execute_documentPersistenceFailure_batchOutcomeIsFailure() throws Exception { + MockRunLockPort lockPort = new MockRunLockPort(); + RuntimeConfiguration config = buildConfig(tempDir); + + SourceDocumentCandidate candidate = makeCandidate("document.pdf"); + PdfExtractionSuccess success = new PdfExtractionSuccess("Invoice text", new PdfPageCount(1)); + FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(candidate)); + FixedExtractionPort extractionPort = new FixedExtractionPort(success); + + // Use a coordinator that always fails persistence + DocumentProcessingCoordinator failingProcessor = new DocumentProcessingCoordinator( + new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), + new NoOpUnitOfWorkPort(), new NoOpProcessingLogger()) { + @Override + public boolean processDeferredOutcome( + de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate candidate, + de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint fingerprint, + de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext context, + java.time.Instant attemptStart, + java.util.function.Function pipelineExecutor) { + // Always report persistence failure + return false; + } + }; + + DefaultBatchRunProcessingUseCase useCase = buildUseCase( + config, lockPort, candidatesPort, extractionPort, + new AlwaysSuccessFingerprintPort(), failingProcessor); + BatchRunContext context = new BatchRunContext(new RunId("persist-fail"), Instant.now()); + + BatchRunOutcome outcome = useCase.execute(context); + + assertTrue(outcome.isFailure(), "Document persistence failure should yield FAILURE outcome"); + assertFalse(outcome.isSuccess(), "Batch must not succeed when document persistence failed"); + } + + /** + * Regression test: mixed batch where one document succeeds and one has persistence failure. + * The batch outcome must be FAILURE due to the persistence failure. + */ + @Test + void execute_mixedBatch_oneCandidateSuccess_oneDocumentPersistenceFails_batchIsFailure() throws Exception { + MockRunLockPort lockPort = new MockRunLockPort(); + RuntimeConfiguration config = buildConfig(tempDir); + + SourceDocumentCandidate goodCandidate = makeCandidate("good.pdf"); + SourceDocumentCandidate failCandidate = makeCandidate("fails.pdf"); + + PdfExtractionSuccess success = new PdfExtractionSuccess("Invoice text", new PdfPageCount(1)); + FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(goodCandidate, failCandidate)); + FixedExtractionPort extractionPort = new FixedExtractionPort(success); + + // Coordinator that succeeds for first document, fails persistence for second + DocumentProcessingCoordinator selectiveFailingProcessor = new DocumentProcessingCoordinator( + new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), + new NoOpUnitOfWorkPort(), new NoOpProcessingLogger()) { + private int callCount = 0; + + @Override + public boolean processDeferredOutcome( + de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate candidate, + de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint fingerprint, + de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext context, + java.time.Instant attemptStart, + java.util.function.Function pipelineExecutor) { + callCount++; + // First document succeeds, second fails persistence + return callCount == 1; + } + }; + + DefaultBatchRunProcessingUseCase useCase = buildUseCase( + config, lockPort, candidatesPort, extractionPort, + new AlwaysSuccessFingerprintPort(), selectiveFailingProcessor); + BatchRunContext context = new BatchRunContext(new RunId("mixed-persist-fail"), Instant.now()); + + BatchRunOutcome outcome = useCase.execute(context); + + assertTrue(outcome.isFailure(), + "Batch must fail when any document has a persistence failure, even if others succeeded"); + assertFalse(outcome.isSuccess(), "Cannot be SUCCESS when persistence failed for any document"); + } + // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- @@ -615,7 +707,7 @@ class BatchRunProcessingUseCaseTest { } @Override - public void process( + public boolean process( de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate candidate, de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint fingerprint, de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome outcome, @@ -623,11 +715,11 @@ class BatchRunProcessingUseCaseTest { java.time.Instant attemptStart) { processCallCount++; // Delegate to super so the real logic runs (with no-op repos) - super.process(candidate, fingerprint, outcome, context, attemptStart); + return super.process(candidate, fingerprint, outcome, context, attemptStart); } @Override - public void processDeferredOutcome( + public boolean processDeferredOutcome( de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate candidate, de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint fingerprint, de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext context, @@ -635,7 +727,7 @@ class BatchRunProcessingUseCaseTest { java.util.function.Function pipelineExecutor) { processCallCount++; // Delegate to super so the real logic runs (with no-op repos) - super.processDeferredOutcome(candidate, fingerprint, context, attemptStart, pipelineExecutor); + return super.processDeferredOutcome(candidate, fingerprint, context, attemptStart, pipelineExecutor); } int processCallCount() { return processCallCount; }