1
0

M7 Zentrale Retry-Entscheidung vervollständigt und vereinheitlicht

This commit is contained in:
2026-04-08 11:12:08 +02:00
parent cab9fed5b0
commit a5d687d625
4 changed files with 386 additions and 28 deletions

View File

@@ -33,6 +33,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.NamingProposalReady;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
import de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError;
import java.time.Instant;
import java.util.Objects;
@@ -538,12 +539,11 @@ public class DocumentProcessingCoordinator {
* Persists a transient error for a document-level technical failure during the
* target-copy finalization stage.
* <p>
* The resulting status is {@link ProcessingStatus#FAILED_FINAL} if the incremented
* transient error counter reaches {@code maxRetriesTransient}; otherwise
* {@link ProcessingStatus#FAILED_RETRYABLE}. The transient error counter is always
* incremented by exactly one. This method does not increment the laufübergreifenden
* transient counter for the within-run immediate retry — only the combined outcome
* of the retry is reported here.
* The retry decision (status and updated counters) is derived via the central
* rule in {@link ProcessingOutcomeTransition}, keeping the target-copy finalization
* path consistent with the AI pipeline path. The transient error counter is always
* incremented by exactly one. This method does not count the within-run immediate
* retry — only the combined outcome of the retry is reported here.
*
* @return true if the error was persisted; false if the error persistence itself failed
*/
@@ -556,12 +556,16 @@ public class DocumentProcessingCoordinator {
Instant now,
String errorMessage) {
FailureCounters updatedCounters =
existingRecord.failureCounters().withIncrementedTransientErrorCount();
boolean limitReached = updatedCounters.transientErrorCount() >= maxRetriesTransient;
ProcessingStatus errorStatus = limitReached
? ProcessingStatus.FAILED_FINAL
: ProcessingStatus.FAILED_RETRYABLE;
// Delegate to the central retry rule so the target-copy path and the AI pipeline
// path are governed by the same logic without duplication.
ProcessingOutcomeTransition.ProcessingOutcome transition =
ProcessingOutcomeTransition.forKnownDocument(
new TechnicalDocumentError(candidate, errorMessage, null),
existingRecord.failureCounters(),
maxRetriesTransient);
FailureCounters updatedCounters = transition.counters();
ProcessingStatus errorStatus = transition.overallStatus();
boolean retryable = transition.retryable();
try {
int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint);
@@ -569,7 +573,7 @@ public class DocumentProcessingCoordinator {
fingerprint, context.runId(), attemptNumber, attemptStart, now,
errorStatus,
errorStatus.name(),
errorMessage, !limitReached);
errorMessage, retryable);
DocumentRecord errorRecord = buildTransientErrorRecord(
existingRecord, candidate, updatedCounters, errorStatus, now);
@@ -579,7 +583,7 @@ public class DocumentProcessingCoordinator {
txOps.updateDocumentRecord(errorRecord);
});
if (limitReached) {
if (!retryable) {
logger.info("Retry decision for '{}' (fingerprint: {}): FAILED_FINAL — "
+ "transient error limit reached ({}/{} attempts). No further retry.",
candidate.uniqueIdentifier(), fingerprint.sha256Hex(),
@@ -604,9 +608,10 @@ public class DocumentProcessingCoordinator {
* following a successful target copy. This is a secondary persistence effort;
* its failure is logged but does not change the return value.
* <p>
* Applies the same transient limit check as {@link #persistTransientError}: if the
* incremented counter reaches {@code maxRetriesTransient}, the secondary attempt
* is persisted as {@link ProcessingStatus#FAILED_FINAL}.
* Applies the same transient limit check as {@link #persistTransientError} via the
* central rule in {@link ProcessingOutcomeTransition}: if the incremented counter
* reaches {@code maxRetriesTransient}, the secondary attempt is persisted as
* {@link ProcessingStatus#FAILED_FINAL}.
*/
private void persistTransientErrorAfterPersistenceFailure(
SourceDocumentCandidate candidate,
@@ -617,12 +622,13 @@ public class DocumentProcessingCoordinator {
Instant now,
String errorMessage) {
FailureCounters updatedCounters =
existingRecord.failureCounters().withIncrementedTransientErrorCount();
boolean limitReached = updatedCounters.transientErrorCount() >= maxRetriesTransient;
ProcessingStatus errorStatus = limitReached
? ProcessingStatus.FAILED_FINAL
: ProcessingStatus.FAILED_RETRYABLE;
ProcessingOutcomeTransition.ProcessingOutcome transition =
ProcessingOutcomeTransition.forKnownDocument(
new TechnicalDocumentError(candidate, errorMessage, null),
existingRecord.failureCounters(),
maxRetriesTransient);
FailureCounters updatedCounters = transition.counters();
ProcessingStatus errorStatus = transition.overallStatus();
try {
int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint);
@@ -630,7 +636,7 @@ public class DocumentProcessingCoordinator {
fingerprint, context.runId(), attemptNumber, attemptStart, now,
errorStatus,
errorStatus.name(),
errorMessage, !limitReached);
errorMessage, transition.retryable());
DocumentRecord errorRecord = buildTransientErrorRecord(
existingRecord, candidate, updatedCounters, errorStatus, now);

View File

@@ -10,11 +10,13 @@ import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError;
/**
* Pure status and counter transition policy for document processing outcomes.
* Authoritative, stateless retry decision rule for all document processing outcomes.
* <p>
* This class encapsulates the deterministic rules for mapping a pipeline outcome
* (pre-check, naming proposal, or failure) to a processing status, updated
* failure counters, and retryability flag.
* This class is the single production source of truth for mapping a pipeline outcome
* (pre-check, naming proposal, or failure) to a processing status, updated failure
* counters, and retryability flag. Both the AI pipeline path and the target-copy
* finalization path in {@link DocumentProcessingCoordinator} delegate to this class,
* so that no duplicate retry logic exists elsewhere.
* <p>
* The transition logic is independent of persistence, orchestration, or any
* infrastructure concern. It is purely declarative and stateless.

View File

@@ -1022,6 +1022,39 @@ class DocumentProcessingCoordinatorTest {
"copyToTarget must have been called exactly twice: first attempt + one immediate retry");
}
@Test
void processDeferredOutcome_proposalReady_bothCopyAttemptsFail_maxRetriesTransient1_persistsFailedFinal() {
// maxRetriesTransient=1: both copy attempts fail → the transient error immediately finalises
// because the central retry rule (ProcessingOutcomeTransition) treats the first cross-run
// transient error as FAILED_FINAL when the limit is 1.
// The within-run immediate retry does NOT count toward this limit.
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
attemptRepo.savedAttempts.add(buildValidProposalAttempt());
CountingTargetFileCopyPort failingCopy = new CountingTargetFileCopyPort(2); // fail both
DocumentProcessingCoordinator coordinatorWith1Retry = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), failingCopy, new NoOpProcessingLogger(), 1);
coordinatorWith1Retry.processDeferredOutcome(
candidate, fingerprint, context, attemptStart, c -> null);
ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
.filter(a -> a.status() == ProcessingStatus.FAILED_FINAL)
.findFirst()
.orElse(null);
assertNotNull(errorAttempt,
"With maxRetriesTransient=1, both copy attempts failing must produce FAILED_FINAL");
assertFalse(errorAttempt.retryable(),
"With maxRetriesTransient=1, the error must not be retryable");
DocumentRecord updated = recordRepo.updatedRecords.get(0);
assertEquals(ProcessingStatus.FAILED_FINAL, updated.overallStatus());
assertEquals(1, updated.failureCounters().transientErrorCount(),
"Transient error counter must be 1 after the first cross-run transient error");
}
@Test
void processDeferredOutcome_proposalReady_immediateRetryDoesNotTriggerAiOrNewProposal() {
// Ensures that during the immediate retry path no pipeline (AI) execution happens

View File

@@ -0,0 +1,317 @@
package de.gecheckt.pdf.umbenenner.application.service;
import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters;
import de.gecheckt.pdf.umbenenner.domain.model.AiFunctionalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.AiAttemptContext;
import de.gecheckt.pdf.umbenenner.domain.model.AiTechnicalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.DateSource;
import de.gecheckt.pdf.umbenenner.domain.model.NamingProposal;
import de.gecheckt.pdf.umbenenner.domain.model.NamingProposalReady;
import de.gecheckt.pdf.umbenenner.domain.model.PreCheckFailed;
import de.gecheckt.pdf.umbenenner.domain.model.PreCheckFailureReason;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
import de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for {@link ProcessingOutcomeTransition} — the authoritative central retry rule.
* <p>
* These tests prove that:
* <ul>
* <li>Deterministic content errors follow the first-retryable / second-final rule.</li>
* <li>Transient technical errors respect the configured {@code maxRetriesTransient} limit.</li>
* <li>{@code maxRetriesTransient = 1} immediately finalises on the first transient error.</li>
* <li>Naming proposals yield {@code PROPOSAL_READY} with unchanged counters.</li>
* <li>AI functional failures are governed by the same content-error rule.</li>
* <li>AI technical failures are governed by the same transient-error rule.</li>
* </ul>
* <p>
* Skip events ({@code SKIPPED_ALREADY_PROCESSED}, {@code SKIPPED_FINAL_FAILURE}) are not
* routed through this class — they never modify any failure counter.
*/
class ProcessingOutcomeTransitionTest {
// -------------------------------------------------------------------------
// Test fixtures
// -------------------------------------------------------------------------
private static final int LIMIT_1 = 1;
private static final int LIMIT_2 = 2;
private static final int LIMIT_3 = 3;
private static SourceDocumentCandidate candidate() {
return new SourceDocumentCandidate("test.pdf", 1024L,
new SourceDocumentLocator("/tmp/test.pdf"));
}
private static AiAttemptContext aiContext() {
return new AiAttemptContext("model", "prompt.txt", 1, 100, "{}");
}
// -------------------------------------------------------------------------
// Naming proposal → PROPOSAL_READY (counters unchanged)
// -------------------------------------------------------------------------
@Test
void forNewDocument_namingProposalReady_returnsProposalReady_countersUnchanged() {
NamingProposal proposal = new NamingProposal(
LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, "Rechnung", "reason");
NamingProposalReady outcome = new NamingProposalReady(candidate(), proposal, aiContext());
ProcessingOutcomeTransition.ProcessingOutcome result =
ProcessingOutcomeTransition.forNewDocument(outcome, LIMIT_1);
assertEquals(ProcessingStatus.PROPOSAL_READY, result.overallStatus());
assertFalse(result.retryable());
assertEquals(0, result.counters().contentErrorCount());
assertEquals(0, result.counters().transientErrorCount());
}
@Test
void forKnownDocument_namingProposalReady_countersUnchangedFromExisting() {
NamingProposal proposal = new NamingProposal(
LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, "Rechnung", "reason");
NamingProposalReady outcome = new NamingProposalReady(candidate(), proposal, aiContext());
FailureCounters existing = new FailureCounters(1, 2);
ProcessingOutcomeTransition.ProcessingOutcome result =
ProcessingOutcomeTransition.forKnownDocument(outcome, existing, LIMIT_3);
assertEquals(ProcessingStatus.PROPOSAL_READY, result.overallStatus());
assertFalse(result.retryable());
// Counters must be preserved unchanged on a successful naming proposal
assertEquals(1, result.counters().contentErrorCount(),
"Content error counter must remain unchanged on PROPOSAL_READY");
assertEquals(2, result.counters().transientErrorCount(),
"Transient error counter must remain unchanged on PROPOSAL_READY");
}
// -------------------------------------------------------------------------
// Deterministic content errors (PreCheckFailed)
// -------------------------------------------------------------------------
@Test
void forNewDocument_firstPreCheckFailed_returnsFailedRetryable_contentCounterOne() {
PreCheckFailed outcome = new PreCheckFailed(candidate(), PreCheckFailureReason.NO_USABLE_TEXT);
ProcessingOutcomeTransition.ProcessingOutcome result =
ProcessingOutcomeTransition.forNewDocument(outcome, LIMIT_1);
assertEquals(ProcessingStatus.FAILED_RETRYABLE, result.overallStatus());
assertTrue(result.retryable());
assertEquals(1, result.counters().contentErrorCount());
assertEquals(0, result.counters().transientErrorCount());
}
@Test
void forKnownDocument_secondPreCheckFailed_returnsFailedFinal_contentCounterTwo() {
PreCheckFailed outcome = new PreCheckFailed(candidate(), PreCheckFailureReason.PAGE_LIMIT_EXCEEDED);
FailureCounters existing = new FailureCounters(1, 0);
ProcessingOutcomeTransition.ProcessingOutcome result =
ProcessingOutcomeTransition.forKnownDocument(outcome, existing, LIMIT_1);
assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus());
assertFalse(result.retryable());
assertEquals(2, result.counters().contentErrorCount());
assertEquals(0, result.counters().transientErrorCount());
}
@Test
void forKnownDocument_anySubsequentContentError_remainsFailedFinal() {
// Count >= 1 before the attempt always leads to FAILED_FINAL (covers legacy data)
for (int priorCount = 1; priorCount <= 5; priorCount++) {
FailureCounters existing = new FailureCounters(priorCount, 0);
PreCheckFailed outcome =
new PreCheckFailed(candidate(), PreCheckFailureReason.NO_USABLE_TEXT);
ProcessingOutcomeTransition.ProcessingOutcome result =
ProcessingOutcomeTransition.forKnownDocument(outcome, existing, LIMIT_2);
assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus(),
"Expected FAILED_FINAL for prior contentErrorCount=" + priorCount);
assertFalse(result.retryable());
assertEquals(priorCount + 1, result.counters().contentErrorCount());
}
}
@Test
void forNewDocument_contentError_transientCounterIsIrrelevant() {
PreCheckFailed outcome = new PreCheckFailed(candidate(), PreCheckFailureReason.NO_USABLE_TEXT);
// Counter before: 0 content errors (first occurrence), transient ignored
ProcessingOutcomeTransition.ProcessingOutcome result =
ProcessingOutcomeTransition.forKnownDocument(
outcome, new FailureCounters(0, 5), LIMIT_1);
assertEquals(ProcessingStatus.FAILED_RETRYABLE, result.overallStatus(),
"Non-zero transient counter must not affect the content error decision");
assertTrue(result.retryable());
}
// -------------------------------------------------------------------------
// Deterministic content errors (AiFunctionalFailure)
// -------------------------------------------------------------------------
@Test
void forNewDocument_firstAiFunctionalFailure_returnsFailedRetryable_contentCounterOne() {
AiFunctionalFailure outcome = new AiFunctionalFailure(candidate(), "Generic title", aiContext());
ProcessingOutcomeTransition.ProcessingOutcome result =
ProcessingOutcomeTransition.forNewDocument(outcome, LIMIT_1);
assertEquals(ProcessingStatus.FAILED_RETRYABLE, result.overallStatus());
assertTrue(result.retryable());
assertEquals(1, result.counters().contentErrorCount());
}
@Test
void forKnownDocument_secondAiFunctionalFailure_returnsFailedFinal() {
AiFunctionalFailure outcome = new AiFunctionalFailure(candidate(), "Generic title", aiContext());
FailureCounters existing = new FailureCounters(1, 0);
ProcessingOutcomeTransition.ProcessingOutcome result =
ProcessingOutcomeTransition.forKnownDocument(outcome, existing, LIMIT_1);
assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus());
assertFalse(result.retryable());
assertEquals(2, result.counters().contentErrorCount());
}
// -------------------------------------------------------------------------
// Transient technical errors (TechnicalDocumentError)
// -------------------------------------------------------------------------
@Test
void forNewDocument_transientError_limitOne_immediatelyFinal() {
TechnicalDocumentError outcome = new TechnicalDocumentError(candidate(), "I/O error", null);
ProcessingOutcomeTransition.ProcessingOutcome result =
ProcessingOutcomeTransition.forNewDocument(outcome, LIMIT_1);
assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus(),
"With limit=1, the first transient error must immediately finalise");
assertFalse(result.retryable());
assertEquals(1, result.counters().transientErrorCount());
assertEquals(0, result.counters().contentErrorCount());
}
@Test
void forNewDocument_transientError_limitTwo_firstErrorRetryable() {
TechnicalDocumentError outcome = new TechnicalDocumentError(candidate(), "Timeout", null);
ProcessingOutcomeTransition.ProcessingOutcome result =
ProcessingOutcomeTransition.forNewDocument(outcome, LIMIT_2);
assertEquals(ProcessingStatus.FAILED_RETRYABLE, result.overallStatus());
assertTrue(result.retryable());
assertEquals(1, result.counters().transientErrorCount());
}
@Test
void forKnownDocument_transientError_limitTwo_secondErrorFinal() {
TechnicalDocumentError outcome = new TechnicalDocumentError(candidate(), "Timeout again", null);
FailureCounters existing = new FailureCounters(0, 1);
ProcessingOutcomeTransition.ProcessingOutcome result =
ProcessingOutcomeTransition.forKnownDocument(outcome, existing, LIMIT_2);
assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus(),
"Second transient error with limit=2 must finalise");
assertFalse(result.retryable());
assertEquals(2, result.counters().transientErrorCount());
}
@Test
void forKnownDocument_transientError_limitThree_sequence() {
TechnicalDocumentError outcome = new TechnicalDocumentError(candidate(), "error", null);
// First error: counter 0→1, 1 < 3 → retryable
ProcessingOutcomeTransition.ProcessingOutcome first =
ProcessingOutcomeTransition.forKnownDocument(
outcome, new FailureCounters(0, 0), LIMIT_3);
assertEquals(ProcessingStatus.FAILED_RETRYABLE, first.overallStatus());
assertEquals(1, first.counters().transientErrorCount());
// Second error: counter 1→2, 2 < 3 → retryable
ProcessingOutcomeTransition.ProcessingOutcome second =
ProcessingOutcomeTransition.forKnownDocument(
outcome, new FailureCounters(0, 1), LIMIT_3);
assertEquals(ProcessingStatus.FAILED_RETRYABLE, second.overallStatus());
assertEquals(2, second.counters().transientErrorCount());
// Third error: counter 2→3 = limit=3 → FAILED_FINAL
ProcessingOutcomeTransition.ProcessingOutcome third =
ProcessingOutcomeTransition.forKnownDocument(
outcome, new FailureCounters(0, 2), LIMIT_3);
assertEquals(ProcessingStatus.FAILED_FINAL, third.overallStatus(),
"Third transient error with limit=3 must finalise");
assertFalse(third.retryable());
assertEquals(3, third.counters().transientErrorCount());
}
@Test
void forKnownDocument_transientError_legacyHighCounters_stillFinalise() {
// Legacy data from M4-M6 may have counters well above normal expectations.
// The threshold check must still apply correctly.
TechnicalDocumentError outcome = new TechnicalDocumentError(candidate(), "error", null);
FailureCounters existing = new FailureCounters(3, 10); // already far above limit
ProcessingOutcomeTransition.ProcessingOutcome result =
ProcessingOutcomeTransition.forKnownDocument(outcome, existing, LIMIT_3);
assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus(),
"Transient counter above limit must still produce FAILED_FINAL");
assertEquals(11, result.counters().transientErrorCount());
}
@Test
void forNewDocument_transientError_contentCounterIsIrrelevant() {
TechnicalDocumentError outcome = new TechnicalDocumentError(candidate(), "error", null);
// Non-zero content error counter must not affect the transient error decision
ProcessingOutcomeTransition.ProcessingOutcome result =
ProcessingOutcomeTransition.forKnownDocument(
outcome, new FailureCounters(2, 0), LIMIT_2);
assertEquals(ProcessingStatus.FAILED_RETRYABLE, result.overallStatus(),
"Content error counter must not affect transient error decision");
assertEquals(1, result.counters().transientErrorCount());
}
// -------------------------------------------------------------------------
// Transient technical errors (AiTechnicalFailure)
// -------------------------------------------------------------------------
@Test
void forNewDocument_aiTechnicalFailure_limitOne_immediatelyFinal() {
AiTechnicalFailure outcome = new AiTechnicalFailure(candidate(), "HTTP timeout", null, aiContext());
ProcessingOutcomeTransition.ProcessingOutcome result =
ProcessingOutcomeTransition.forNewDocument(outcome, LIMIT_1);
assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus(),
"With limit=1, first AI technical failure must immediately finalise");
assertFalse(result.retryable());
assertEquals(1, result.counters().transientErrorCount());
}
@Test
void forKnownDocument_aiTechnicalFailure_limitTwo_secondFinal() {
AiTechnicalFailure outcome = new AiTechnicalFailure(candidate(), "HTTP timeout", null, aiContext());
FailureCounters existing = new FailureCounters(0, 1);
ProcessingOutcomeTransition.ProcessingOutcome result =
ProcessingOutcomeTransition.forKnownDocument(outcome, existing, LIMIT_2);
assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus());
assertEquals(2, result.counters().transientErrorCount());
}
}