M7 Zentrale Retry-Entscheidung vervollständigt und vereinheitlicht
This commit is contained in:
@@ -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.ProcessingStatus;
|
||||||
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
|
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
|
||||||
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
|
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -538,12 +539,11 @@ public class DocumentProcessingCoordinator {
|
|||||||
* Persists a transient error for a document-level technical failure during the
|
* Persists a transient error for a document-level technical failure during the
|
||||||
* target-copy finalization stage.
|
* target-copy finalization stage.
|
||||||
* <p>
|
* <p>
|
||||||
* The resulting status is {@link ProcessingStatus#FAILED_FINAL} if the incremented
|
* The retry decision (status and updated counters) is derived via the central
|
||||||
* transient error counter reaches {@code maxRetriesTransient}; otherwise
|
* rule in {@link ProcessingOutcomeTransition}, keeping the target-copy finalization
|
||||||
* {@link ProcessingStatus#FAILED_RETRYABLE}. The transient error counter is always
|
* path consistent with the AI pipeline path. The transient error counter is always
|
||||||
* incremented by exactly one. This method does not increment the laufübergreifenden
|
* incremented by exactly one. This method does not count the within-run immediate
|
||||||
* transient counter for the within-run immediate retry — only the combined outcome
|
* retry — only the combined outcome of the retry is reported here.
|
||||||
* of the retry is reported here.
|
|
||||||
*
|
*
|
||||||
* @return true if the error was persisted; false if the error persistence itself failed
|
* @return true if the error was persisted; false if the error persistence itself failed
|
||||||
*/
|
*/
|
||||||
@@ -556,12 +556,16 @@ public class DocumentProcessingCoordinator {
|
|||||||
Instant now,
|
Instant now,
|
||||||
String errorMessage) {
|
String errorMessage) {
|
||||||
|
|
||||||
FailureCounters updatedCounters =
|
// Delegate to the central retry rule so the target-copy path and the AI pipeline
|
||||||
existingRecord.failureCounters().withIncrementedTransientErrorCount();
|
// path are governed by the same logic without duplication.
|
||||||
boolean limitReached = updatedCounters.transientErrorCount() >= maxRetriesTransient;
|
ProcessingOutcomeTransition.ProcessingOutcome transition =
|
||||||
ProcessingStatus errorStatus = limitReached
|
ProcessingOutcomeTransition.forKnownDocument(
|
||||||
? ProcessingStatus.FAILED_FINAL
|
new TechnicalDocumentError(candidate, errorMessage, null),
|
||||||
: ProcessingStatus.FAILED_RETRYABLE;
|
existingRecord.failureCounters(),
|
||||||
|
maxRetriesTransient);
|
||||||
|
FailureCounters updatedCounters = transition.counters();
|
||||||
|
ProcessingStatus errorStatus = transition.overallStatus();
|
||||||
|
boolean retryable = transition.retryable();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint);
|
int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint);
|
||||||
@@ -569,7 +573,7 @@ public class DocumentProcessingCoordinator {
|
|||||||
fingerprint, context.runId(), attemptNumber, attemptStart, now,
|
fingerprint, context.runId(), attemptNumber, attemptStart, now,
|
||||||
errorStatus,
|
errorStatus,
|
||||||
errorStatus.name(),
|
errorStatus.name(),
|
||||||
errorMessage, !limitReached);
|
errorMessage, retryable);
|
||||||
|
|
||||||
DocumentRecord errorRecord = buildTransientErrorRecord(
|
DocumentRecord errorRecord = buildTransientErrorRecord(
|
||||||
existingRecord, candidate, updatedCounters, errorStatus, now);
|
existingRecord, candidate, updatedCounters, errorStatus, now);
|
||||||
@@ -579,7 +583,7 @@ public class DocumentProcessingCoordinator {
|
|||||||
txOps.updateDocumentRecord(errorRecord);
|
txOps.updateDocumentRecord(errorRecord);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (limitReached) {
|
if (!retryable) {
|
||||||
logger.info("Retry decision for '{}' (fingerprint: {}): FAILED_FINAL — "
|
logger.info("Retry decision for '{}' (fingerprint: {}): FAILED_FINAL — "
|
||||||
+ "transient error limit reached ({}/{} attempts). No further retry.",
|
+ "transient error limit reached ({}/{} attempts). No further retry.",
|
||||||
candidate.uniqueIdentifier(), fingerprint.sha256Hex(),
|
candidate.uniqueIdentifier(), fingerprint.sha256Hex(),
|
||||||
@@ -604,9 +608,10 @@ public class DocumentProcessingCoordinator {
|
|||||||
* following a successful target copy. This is a secondary persistence effort;
|
* following a successful target copy. This is a secondary persistence effort;
|
||||||
* its failure is logged but does not change the return value.
|
* its failure is logged but does not change the return value.
|
||||||
* <p>
|
* <p>
|
||||||
* Applies the same transient limit check as {@link #persistTransientError}: if the
|
* Applies the same transient limit check as {@link #persistTransientError} via the
|
||||||
* incremented counter reaches {@code maxRetriesTransient}, the secondary attempt
|
* central rule in {@link ProcessingOutcomeTransition}: if the incremented counter
|
||||||
* is persisted as {@link ProcessingStatus#FAILED_FINAL}.
|
* reaches {@code maxRetriesTransient}, the secondary attempt is persisted as
|
||||||
|
* {@link ProcessingStatus#FAILED_FINAL}.
|
||||||
*/
|
*/
|
||||||
private void persistTransientErrorAfterPersistenceFailure(
|
private void persistTransientErrorAfterPersistenceFailure(
|
||||||
SourceDocumentCandidate candidate,
|
SourceDocumentCandidate candidate,
|
||||||
@@ -617,12 +622,13 @@ public class DocumentProcessingCoordinator {
|
|||||||
Instant now,
|
Instant now,
|
||||||
String errorMessage) {
|
String errorMessage) {
|
||||||
|
|
||||||
FailureCounters updatedCounters =
|
ProcessingOutcomeTransition.ProcessingOutcome transition =
|
||||||
existingRecord.failureCounters().withIncrementedTransientErrorCount();
|
ProcessingOutcomeTransition.forKnownDocument(
|
||||||
boolean limitReached = updatedCounters.transientErrorCount() >= maxRetriesTransient;
|
new TechnicalDocumentError(candidate, errorMessage, null),
|
||||||
ProcessingStatus errorStatus = limitReached
|
existingRecord.failureCounters(),
|
||||||
? ProcessingStatus.FAILED_FINAL
|
maxRetriesTransient);
|
||||||
: ProcessingStatus.FAILED_RETRYABLE;
|
FailureCounters updatedCounters = transition.counters();
|
||||||
|
ProcessingStatus errorStatus = transition.overallStatus();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint);
|
int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint);
|
||||||
@@ -630,7 +636,7 @@ public class DocumentProcessingCoordinator {
|
|||||||
fingerprint, context.runId(), attemptNumber, attemptStart, now,
|
fingerprint, context.runId(), attemptNumber, attemptStart, now,
|
||||||
errorStatus,
|
errorStatus,
|
||||||
errorStatus.name(),
|
errorStatus.name(),
|
||||||
errorMessage, !limitReached);
|
errorMessage, transition.retryable());
|
||||||
|
|
||||||
DocumentRecord errorRecord = buildTransientErrorRecord(
|
DocumentRecord errorRecord = buildTransientErrorRecord(
|
||||||
existingRecord, candidate, updatedCounters, errorStatus, now);
|
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;
|
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>
|
* <p>
|
||||||
* This class encapsulates the deterministic rules for mapping a pipeline outcome
|
* 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
|
* (pre-check, naming proposal, or failure) to a processing status, updated failure
|
||||||
* failure counters, and retryability flag.
|
* 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>
|
* <p>
|
||||||
* The transition logic is independent of persistence, orchestration, or any
|
* The transition logic is independent of persistence, orchestration, or any
|
||||||
* infrastructure concern. It is purely declarative and stateless.
|
* infrastructure concern. It is purely declarative and stateless.
|
||||||
|
|||||||
@@ -1022,6 +1022,39 @@ class DocumentProcessingCoordinatorTest {
|
|||||||
"copyToTarget must have been called exactly twice: first attempt + one immediate retry");
|
"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
|
@Test
|
||||||
void processDeferredOutcome_proposalReady_immediateRetryDoesNotTriggerAiOrNewProposal() {
|
void processDeferredOutcome_proposalReady_immediateRetryDoesNotTriggerAiOrNewProposal() {
|
||||||
// Ensures that during the immediate retry path no pipeline (AI) execution happens
|
// Ensures that during the immediate retry path no pipeline (AI) execution happens
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user