M7 Bootstrap, Startvalidierung und Exit-Code-Verhalten finalisiert
This commit is contained in:
@@ -159,13 +159,19 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
|
|||||||
/**
|
/**
|
||||||
* Loads candidates and processes them one by one.
|
* Loads candidates and processes them one by one.
|
||||||
* <p>
|
* <p>
|
||||||
* Tracks whether any document-level persistence failures occur during processing.
|
* Document-level failures — including content errors, transient technical errors,
|
||||||
* A persistence failure for a single document causes the overall batch outcome
|
* and individual persistence failures — do not affect the batch outcome. The batch
|
||||||
* to be FAILURE instead of SUCCESS.
|
* completes with {@link BatchRunOutcome#SUCCESS} as long as the source folder is accessible
|
||||||
|
* and the processing loop runs to completion without a hard infrastructure error.
|
||||||
|
* Document-level persistence failures are logged by the coordinator and retried in
|
||||||
|
* subsequent runs; they must not escalate to a hard batch failure.
|
||||||
|
* <p>
|
||||||
|
* Only a hard source folder access failure ({@link SourceDocumentAccessException}) prevents
|
||||||
|
* the batch from running at all, in which case {@link BatchRunOutcome#FAILURE} is returned.
|
||||||
*
|
*
|
||||||
* @param context the current batch run context
|
* @param context the current batch run context
|
||||||
* @return SUCCESS if all candidates were processed without persistence failures,
|
* @return {@link BatchRunOutcome#SUCCESS} after all candidates have been processed,
|
||||||
* FAILURE if source access fails or any document-level persistence failure occurred
|
* or {@link BatchRunOutcome#FAILURE} if the source folder is inaccessible
|
||||||
*/
|
*/
|
||||||
private BatchRunOutcome processCandidates(BatchRunContext context) {
|
private BatchRunOutcome processCandidates(BatchRunContext context) {
|
||||||
List<SourceDocumentCandidate> candidates;
|
List<SourceDocumentCandidate> candidates;
|
||||||
@@ -177,24 +183,13 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
|
|||||||
}
|
}
|
||||||
logger.info("Found {} PDF candidate(s) in source folder.", candidates.size());
|
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) {
|
for (SourceDocumentCandidate candidate : candidates) {
|
||||||
if (!processCandidate(candidate, context)) {
|
processCandidate(candidate, context);
|
||||||
anyPersistenceFailure = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Batch run completed. Processed {} candidate(s). RunId: {}",
|
logger.info("Batch run completed. Processed {} candidate(s). RunId: {}",
|
||||||
candidates.size(), context.runId());
|
candidates.size(), context.runId());
|
||||||
|
|
||||||
if (anyPersistenceFailure) {
|
|
||||||
logger.warn("Batch run completed with document-level persistence failure(s).");
|
|
||||||
return BatchRunOutcome.FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
return BatchRunOutcome.SUCCESS;
|
return BatchRunOutcome.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -450,11 +450,13 @@ class BatchRunProcessingUseCaseTest {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regression test: when a document-level persistence failure occurs,
|
* Document-level persistence failures must not escalate to a hard batch failure.
|
||||||
* the batch outcome must be FAILURE, not SUCCESS.
|
* The batch outcome must be SUCCESS even when a document's persistence step fails,
|
||||||
|
* because the batch loop ran to completion and the failed document will be retried
|
||||||
|
* in a subsequent run.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void execute_documentPersistenceFailure_batchOutcomeIsFailure() throws Exception {
|
void execute_documentPersistenceFailure_batchOutcomeIsSuccess() throws Exception {
|
||||||
MockRunLockPort lockPort = new MockRunLockPort();
|
MockRunLockPort lockPort = new MockRunLockPort();
|
||||||
RuntimeConfiguration config = buildConfig(tempDir);
|
RuntimeConfiguration config = buildConfig(tempDir);
|
||||||
|
|
||||||
@@ -487,16 +489,20 @@ class BatchRunProcessingUseCaseTest {
|
|||||||
|
|
||||||
BatchRunOutcome outcome = useCase.execute(context);
|
BatchRunOutcome outcome = useCase.execute(context);
|
||||||
|
|
||||||
assertTrue(outcome.isFailure(), "Document persistence failure should yield FAILURE outcome");
|
assertTrue(outcome.isSuccess(),
|
||||||
assertFalse(outcome.isSuccess(), "Batch must not succeed when document persistence failed");
|
"Document-level persistence failure must not escalate to batch FAILURE — "
|
||||||
|
+ "the batch ran to completion and the document will be retried");
|
||||||
|
assertFalse(outcome.isFailure(),
|
||||||
|
"Batch must not signal FAILURE when only a document-level persistence error occurred");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regression test: mixed batch where one document succeeds and one has persistence failure.
|
* Mixed batch: one document completes normally, one has a persistence failure.
|
||||||
* The batch outcome must be FAILURE due to the persistence failure.
|
* The batch outcome must still be SUCCESS because both documents were processed
|
||||||
|
* by the loop. Document-level failures do not escalate to exit code 1.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void execute_mixedBatch_oneCandidateSuccess_oneDocumentPersistenceFails_batchIsFailure() throws Exception {
|
void execute_mixedBatch_oneCandidateSuccess_oneDocumentPersistenceFails_batchIsSuccess() throws Exception {
|
||||||
MockRunLockPort lockPort = new MockRunLockPort();
|
MockRunLockPort lockPort = new MockRunLockPort();
|
||||||
RuntimeConfiguration config = buildConfig(tempDir);
|
RuntimeConfiguration config = buildConfig(tempDir);
|
||||||
|
|
||||||
@@ -534,9 +540,11 @@ class BatchRunProcessingUseCaseTest {
|
|||||||
|
|
||||||
BatchRunOutcome outcome = useCase.execute(context);
|
BatchRunOutcome outcome = useCase.execute(context);
|
||||||
|
|
||||||
assertTrue(outcome.isFailure(),
|
assertTrue(outcome.isSuccess(),
|
||||||
"Batch must fail when any document has a persistence failure, even if others succeeded");
|
"Batch must be SUCCESS even when one document had a persistence failure — "
|
||||||
assertFalse(outcome.isSuccess(), "Cannot be SUCCESS when persistence failed for any document");
|
+ "the batch loop ran to completion");
|
||||||
|
assertFalse(outcome.isFailure(),
|
||||||
|
"Document-level persistence failure in one candidate must not make the batch FAILURE");
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -587,43 +595,6 @@ class BatchRunProcessingUseCaseTest {
|
|||||||
"Bei Quellordner-Zugriffsfehler muss ein Fehler geloggt werden");
|
"Bei Quellordner-Zugriffsfehler muss ein Fehler geloggt werden");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void execute_withPersistenceFailure_logsWarning() throws Exception {
|
|
||||||
// Prüft, dass nach Batch-Lauf mit Persistenzfehler eine Warnung geloggt wird
|
|
||||||
CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
|
|
||||||
RuntimeConfiguration config = buildConfig(tempDir);
|
|
||||||
|
|
||||||
SourceDocumentCandidate candidate = makeCandidate("doc.pdf");
|
|
||||||
FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(candidate));
|
|
||||||
FixedExtractionPort extractionPort = new FixedExtractionPort(
|
|
||||||
new PdfExtractionSuccess("text", new PdfPageCount(1)));
|
|
||||||
|
|
||||||
// Coordinator der immer Persistenzfehler zurückgibt
|
|
||||||
DocumentProcessingCoordinator failingCoordinator = new DocumentProcessingCoordinator(
|
|
||||||
new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(),
|
|
||||||
new NoOpUnitOfWorkPort(), new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(),
|
|
||||||
new NoOpProcessingLogger(), 3) {
|
|
||||||
@Override
|
|
||||||
public boolean processDeferredOutcome(
|
|
||||||
de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate c,
|
|
||||||
de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint fp,
|
|
||||||
de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext ctx,
|
|
||||||
java.time.Instant start,
|
|
||||||
java.util.function.Function<de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate, de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome> exec) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase(
|
|
||||||
config, new MockRunLockPort(), candidatesPort, extractionPort,
|
|
||||||
new AlwaysSuccessFingerprintPort(), failingCoordinator, buildStubAiNamingService(), capturingLogger);
|
|
||||||
|
|
||||||
useCase.execute(new BatchRunContext(new RunId("persist-warn"), Instant.now()));
|
|
||||||
|
|
||||||
assertTrue(capturingLogger.warnCallCount > 0,
|
|
||||||
"Nach Batch-Lauf mit Persistenzfehler muss eine Warnung geloggt werden");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void execute_batchStart_logsInfo() throws Exception {
|
void execute_batchStart_logsInfo() throws Exception {
|
||||||
// Prüft, dass beim Batch-Start mindestens die erwarteten Info-Einträge geloggt werden.
|
// Prüft, dass beim Batch-Start mindestens die erwarteten Info-Einträge geloggt werden.
|
||||||
|
|||||||
@@ -245,6 +245,77 @@ class BootstrapRunnerTest {
|
|||||||
assertNotNull(runner, "Default constructor should create a valid BootstrapRunner");
|
assertNotNull(runner, "Default constructor should create a valid BootstrapRunner");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that max.retries.transient = 0 is rejected as invalid startup configuration
|
||||||
|
* through the full bootstrap path using the real validator. The value 0 is explicitly
|
||||||
|
* prohibited; only integers >= 1 are valid. Invalid startup configuration must prevent
|
||||||
|
* the batch from starting and must produce exit code 1.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void run_returnsOneWhenMaxRetriesTransientIsZeroViaRealValidator() throws Exception {
|
||||||
|
Path sourceDir = Files.createDirectories(tempDir.resolve("source-mrt"));
|
||||||
|
Path targetDir = Files.createDirectories(tempDir.resolve("target-mrt"));
|
||||||
|
Path dbFile = Files.createFile(tempDir.resolve("db-mrt.sqlite"));
|
||||||
|
Path promptFile = Files.createFile(tempDir.resolve("prompt-mrt.txt"));
|
||||||
|
|
||||||
|
StartConfiguration configWithZeroRetries = new StartConfiguration(
|
||||||
|
sourceDir,
|
||||||
|
targetDir,
|
||||||
|
dbFile,
|
||||||
|
java.net.URI.create("https://api.example.com"),
|
||||||
|
"gpt-4",
|
||||||
|
30,
|
||||||
|
0, // max.retries.transient = 0 is invalid (must be >= 1)
|
||||||
|
100,
|
||||||
|
50000,
|
||||||
|
promptFile,
|
||||||
|
tempDir.resolve("lock-mrt.lock"),
|
||||||
|
null,
|
||||||
|
"INFO",
|
||||||
|
"test-key",
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
BootstrapRunner runner = new BootstrapRunner(
|
||||||
|
() -> () -> configWithZeroRetries, // ConfigurationPortFactory → ConfigurationPort → StartConfiguration
|
||||||
|
lockFile -> new MockRunLockPort(),
|
||||||
|
StartConfigurationValidator::new, // use the real validator
|
||||||
|
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||||
|
(config, lock) -> new MockRunBatchProcessingUseCase(true),
|
||||||
|
SchedulerBatchCommand::new
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(1, runner.run(),
|
||||||
|
"max.retries.transient = 0 must be rejected as invalid startup configuration "
|
||||||
|
+ "and must produce exit code 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that an invalid boolean property value for logging configuration
|
||||||
|
* (such as a non-boolean log.ai.sensitive value) causes a ConfigurationLoadingException
|
||||||
|
* during the loading phase, which must propagate to exit code 1.
|
||||||
|
* This ensures that malformed boolean configuration values prevent the batch from starting.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void run_returnsOneWhenConfigurationLoadingFailsDueToInvalidBooleanProperty() {
|
||||||
|
BootstrapRunner runner = new BootstrapRunner(
|
||||||
|
() -> {
|
||||||
|
throw new de.gecheckt.pdf.umbenenner.adapter.out.configuration.ConfigurationLoadingException(
|
||||||
|
"Invalid value for log.ai.sensitive: 'maybe'. "
|
||||||
|
+ "Must be either 'true' or 'false' (case-insensitive).");
|
||||||
|
},
|
||||||
|
lockFile -> new MockRunLockPort(),
|
||||||
|
StartConfigurationValidator::new,
|
||||||
|
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||||
|
(config, lock) -> new MockRunBatchProcessingUseCase(true),
|
||||||
|
SchedulerBatchCommand::new
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(1, runner.run(),
|
||||||
|
"Invalid boolean property value for logging configuration must produce exit code 1 "
|
||||||
|
+ "via ConfigurationLoadingException");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hard startup failure test — schema initialization failure must be treated as
|
* Hard startup failure test — schema initialization failure must be treated as
|
||||||
* a startup error and result in exit code 1. This verifies that
|
* a startup error and result in exit code 1. This verifies that
|
||||||
|
|||||||
Reference in New Issue
Block a user