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
@@ -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);
@@ -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.