Ergaenze zweiten GUI-Tab fuer Verarbeitungslauf mit Live-Fortschritt
- Fuehrt neuen Inbound-Adapter-Subpfad batchrun/ mit Tab, Koordinator, Launcher-Port und Ergebniszeilen-Model ein; der Batch-Lauf laeuft auf einem Hintergrund-Worker, UI-Updates ausschliesslich via FX-Dispatcher. - Ergaenzt application.port.in um BatchRunProgressObserver, BatchRunCancellationToken, DocumentCompletionEvent/-Status und RunSummary; DefaultBatchRunProcessingUseCase und DocumentProcessingCoordinator melden Lauf-/Dokument-Ereignisse an den Beobachter und unterstuetzen Soft-Stop zwischen Kandidaten. - Verdrahtet BootstrapRunner so, dass die GUI den vollstaendigen Headless-Pipelinepfad (Migration, Validierung, Schema-Init, Lock, Use-Case) mit Observer und Cancellation ausfuehrt; headless-Verhalten bleibt unveraendert. - Editor-Workspace bettet den zweiten Tab ein, sperrt Tab 1 mit Hinweisbanner waehrend eines Laufs und fragt den Benutzer beim Schliessen waehrend eines laufenden Batches. - Fuegt Tests fuer Observer-Wiring, Koordinator-Lebenszyklus und Tab-Smoke-Verhalten ein; aktualisiert die GUI-Bedienanleitung und docs/betrieb.md auf den neuen Tab. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+40
@@ -0,0 +1,40 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||
|
||||
/**
|
||||
* Inbound cooperative cancellation token for a running batch.
|
||||
* <p>
|
||||
* The application layer consults the token at safe points between candidates to decide
|
||||
* whether the run should stop before starting the next candidate. The current candidate
|
||||
* is always processed to completion before the token is honoured (soft-stop semantics).
|
||||
* <p>
|
||||
* Implementations are typically shared between an inbound adapter (which sets the
|
||||
* cancellation request) and the use case (which polls it). They must be safe to read from
|
||||
* the batch thread while being written concurrently by the adapter thread.
|
||||
*
|
||||
* <h2>Default implementation</h2>
|
||||
* <p>
|
||||
* Callers that do not need cancellation (e.g. the headless batch entry point) supply
|
||||
* {@link #neverCancelled()} as the token.
|
||||
*/
|
||||
public interface BatchRunCancellationToken {
|
||||
|
||||
/**
|
||||
* Returns {@code true} if a cancellation has been requested and the batch should
|
||||
* stop before starting the next candidate.
|
||||
* <p>
|
||||
* Must be cheap to call; may be polled repeatedly during a run.
|
||||
*
|
||||
* @return {@code true} if the run should stop as soon as practical, {@code false}
|
||||
* otherwise
|
||||
*/
|
||||
boolean isCancellationRequested();
|
||||
|
||||
/**
|
||||
* Returns a singleton token that never reports a cancellation request.
|
||||
*
|
||||
* @return a non-null token that always returns {@code false}
|
||||
*/
|
||||
static BatchRunCancellationToken neverCancelled() {
|
||||
return NeverCancelledBatchRunCancellationToken.INSTANCE;
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||
|
||||
/**
|
||||
* Inbound observer port that receives progress callbacks over the life of a single
|
||||
* batch run.
|
||||
* <p>
|
||||
* The observer is an optional collaborator that an inbound adapter (e.g. a GUI) may
|
||||
* supply to follow a batch run in near real time. The callbacks never carry persistence
|
||||
* details; they only describe observable events at the use-case boundary.
|
||||
*
|
||||
* <h2>Invocation order</h2>
|
||||
* <p>
|
||||
* For a single run the observer is invoked in this order:
|
||||
* <ol>
|
||||
* <li>{@link #onRunStarted(RunId, int)} exactly once, once the total candidate count
|
||||
* is known (i.e. after the source folder scan succeeded and before the first
|
||||
* candidate is processed).</li>
|
||||
* <li>{@link #onDocumentCompleted(DocumentCompletionEvent)} once per candidate whose
|
||||
* processing reached a terminal resolution.</li>
|
||||
* <li>{@link #onRunEnded(RunSummary)} exactly once after the processing loop has
|
||||
* finished (normally, after a cancellation, or after a hard run-level error).</li>
|
||||
* </ol>
|
||||
*
|
||||
* <h2>Threading</h2>
|
||||
* <p>
|
||||
* Callbacks are invoked on the thread executing the batch run. Inbound adapters that
|
||||
* drive a UI must themselves dispatch any UI updates onto the appropriate UI thread
|
||||
* and must not block the reporting thread.
|
||||
*
|
||||
* <h2>Exception handling</h2>
|
||||
* <p>
|
||||
* Implementations must not throw checked exceptions. Runtime exceptions thrown by an
|
||||
* observer are caught by the application layer and logged; they never affect the batch
|
||||
* run outcome or alter persistence behaviour.
|
||||
*/
|
||||
public interface BatchRunProgressObserver {
|
||||
|
||||
/**
|
||||
* Invoked once when the run has determined how many candidates will be processed.
|
||||
*
|
||||
* @param runId identifier of the run; never {@code null}
|
||||
* @param totalCandidates total number of candidates detected in the source folder
|
||||
* at scan time; never negative
|
||||
*/
|
||||
void onRunStarted(RunId runId, int totalCandidates);
|
||||
|
||||
/**
|
||||
* Invoked once per candidate whose processing reached a terminal resolution.
|
||||
* <p>
|
||||
* The event is emitted after persistence has been attempted for the candidate, so
|
||||
* observers may rely on the reported status matching the persisted attempt status
|
||||
* for that candidate.
|
||||
*
|
||||
* @param event description of the candidate result; never {@code null}
|
||||
*/
|
||||
void onDocumentCompleted(DocumentCompletionEvent event);
|
||||
|
||||
/**
|
||||
* Invoked once after the processing loop has finished, regardless of whether the
|
||||
* run completed normally, was cancelled via a {@link BatchRunCancellationToken},
|
||||
* or aborted due to a hard run-level error after the start callback fired.
|
||||
*
|
||||
* @param summary aggregated outcome counts; never {@code null}
|
||||
*/
|
||||
void onRunEnded(RunSummary summary);
|
||||
|
||||
/**
|
||||
* Returns a singleton observer that silently ignores all callbacks.
|
||||
* <p>
|
||||
* Used as the default observer for callers that do not need progress notifications
|
||||
* (e.g. the headless batch entry point).
|
||||
*
|
||||
* @return a non-null no-op observer
|
||||
*/
|
||||
static BatchRunProgressObserver noOp() {
|
||||
return NoOpBatchRunProgressObserver.INSTANCE;
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Immutable event describing the outcome of processing exactly one candidate document.
|
||||
* <p>
|
||||
* Emitted by the application layer at every terminal resolution point of a candidate
|
||||
* (success, retryable failure, permanent failure, skip). Observers may use this event
|
||||
* to update a live progress view, write an audit record, or drive a UI list.
|
||||
* <p>
|
||||
* The event is deliberately decoupled from persistence types: it carries only what an
|
||||
* external observer needs to display or correlate a single candidate result.
|
||||
*
|
||||
* @param originalFileName the source candidate's unique identifier (typically the source
|
||||
* filename); never {@code null} or blank
|
||||
* @param status the aggregated outcome status; never {@code null}
|
||||
* @param finalFileName the final target filename, including any duplicate suffix;
|
||||
* never {@code null} for {@link DocumentCompletionStatus#SUCCESS},
|
||||
* always {@code null} for all other statuses
|
||||
* @param resolvedDate the resolved date of the naming proposal; never {@code null}
|
||||
* for {@link DocumentCompletionStatus#SUCCESS}, always {@code null}
|
||||
* for skip events. May be {@code null} for failure events.
|
||||
* @param aiReasoning the AI reasoning text associated with the naming proposal, if
|
||||
* any is available for this candidate (may be present on success
|
||||
* and on some failure paths where an AI call had previously
|
||||
* produced a reasoning); {@code null} when no reasoning exists
|
||||
* @param processingDuration the wall-clock duration spent on this candidate in the current
|
||||
* run; never {@code null} and never negative
|
||||
*/
|
||||
public record DocumentCompletionEvent(
|
||||
String originalFileName,
|
||||
DocumentCompletionStatus status,
|
||||
String finalFileName,
|
||||
LocalDate resolvedDate,
|
||||
String aiReasoning,
|
||||
Duration processingDuration) {
|
||||
|
||||
/**
|
||||
* Compact constructor validating mandatory fields.
|
||||
*
|
||||
* @throws NullPointerException if {@code originalFileName}, {@code status} or
|
||||
* {@code processingDuration} is {@code null}
|
||||
* @throws IllegalArgumentException if {@code originalFileName} is blank or
|
||||
* {@code processingDuration} is negative
|
||||
*/
|
||||
public DocumentCompletionEvent {
|
||||
Objects.requireNonNull(originalFileName, "originalFileName must not be null");
|
||||
if (originalFileName.isBlank()) {
|
||||
throw new IllegalArgumentException("originalFileName must not be blank");
|
||||
}
|
||||
Objects.requireNonNull(status, "status must not be null");
|
||||
Objects.requireNonNull(processingDuration, "processingDuration must not be null");
|
||||
if (processingDuration.isNegative()) {
|
||||
throw new IllegalArgumentException("processingDuration must not be negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||
|
||||
/**
|
||||
* Aggregated status classification reported to
|
||||
* {@link BatchRunProgressObserver#onDocumentCompleted(DocumentCompletionEvent)}
|
||||
* for one processed candidate.
|
||||
* <p>
|
||||
* This enum collapses the finer-grained internal processing status into the four
|
||||
* buckets that an observer (e.g. a GUI progress view) needs to distinguish:
|
||||
* successful completion, retryable failure, permanent failure, and an explicit
|
||||
* skip.
|
||||
* <p>
|
||||
* This classification is purely an observability concern — persistence,
|
||||
* retry decisions, and all other processing rules continue to work against the
|
||||
* detailed internal status.
|
||||
*/
|
||||
public enum DocumentCompletionStatus {
|
||||
|
||||
/**
|
||||
* The candidate was successfully renamed; the target copy is in place and the
|
||||
* persistence is consistent.
|
||||
*/
|
||||
SUCCESS,
|
||||
|
||||
/**
|
||||
* The candidate failed in the current run but will be retried in a later run
|
||||
* (transient technical error, not yet at the retry limit, or a first deterministic
|
||||
* content error).
|
||||
*/
|
||||
FAILED_RETRYABLE,
|
||||
|
||||
/**
|
||||
* The candidate failed permanently and will not be retried in later runs
|
||||
* (content error recorded twice, or transient retry budget exhausted).
|
||||
*/
|
||||
FAILED_PERMANENT,
|
||||
|
||||
/**
|
||||
* The candidate was skipped because it was already in a terminal state (either
|
||||
* previously successful or previously finally failed).
|
||||
*/
|
||||
SKIPPED
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||
|
||||
/**
|
||||
* Shared singleton token returned by
|
||||
* {@link BatchRunCancellationToken#neverCancelled()}.
|
||||
* <p>
|
||||
* Not intended for direct instantiation by callers.
|
||||
*/
|
||||
final class NeverCancelledBatchRunCancellationToken implements BatchRunCancellationToken {
|
||||
|
||||
static final NeverCancelledBatchRunCancellationToken INSTANCE =
|
||||
new NeverCancelledBatchRunCancellationToken();
|
||||
|
||||
private NeverCancelledBatchRunCancellationToken() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCancellationRequested() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||
|
||||
/**
|
||||
* Shared singleton no-op implementation of {@link BatchRunProgressObserver}.
|
||||
* <p>
|
||||
* Returned by {@link BatchRunProgressObserver#noOp()}; not intended for direct
|
||||
* instantiation by callers.
|
||||
*/
|
||||
final class NoOpBatchRunProgressObserver implements BatchRunProgressObserver {
|
||||
|
||||
static final NoOpBatchRunProgressObserver INSTANCE = new NoOpBatchRunProgressObserver();
|
||||
|
||||
private NoOpBatchRunProgressObserver() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRunStarted(RunId runId, int totalCandidates) {
|
||||
// intentionally empty
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDocumentCompleted(DocumentCompletionEvent event) {
|
||||
// intentionally empty
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRunEnded(RunSummary summary) {
|
||||
// intentionally empty
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||
|
||||
/**
|
||||
* Aggregated outcome counts of a complete batch run, reported once at the end of the run
|
||||
* to {@link BatchRunProgressObserver#onRunEnded(RunSummary)}.
|
||||
* <p>
|
||||
* The three counts are independent, non-negative and sum up to the total number of
|
||||
* candidates that were processed in the run (possibly fewer than the originally detected
|
||||
* candidate count if the run was cancelled mid-way).
|
||||
*
|
||||
* @param successCount number of candidates that completed with
|
||||
* {@link DocumentCompletionStatus#SUCCESS}; must be ≥ 0
|
||||
* @param failedCount number of candidates that completed with either
|
||||
* {@link DocumentCompletionStatus#FAILED_RETRYABLE} or
|
||||
* {@link DocumentCompletionStatus#FAILED_PERMANENT}; must be ≥ 0
|
||||
* @param skippedCount number of candidates that completed with
|
||||
* {@link DocumentCompletionStatus#SKIPPED}; must be ≥ 0
|
||||
*/
|
||||
public record RunSummary(int successCount, int failedCount, int skippedCount) {
|
||||
|
||||
/**
|
||||
* Compact constructor enforcing non-negative counts.
|
||||
*
|
||||
* @throws IllegalArgumentException if any count is negative
|
||||
*/
|
||||
public RunSummary {
|
||||
if (successCount < 0 || failedCount < 0 || skippedCount < 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"RunSummary counts must not be negative; was: "
|
||||
+ successCount + "/" + failedCount + "/" + skippedCount);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the total number of candidates reflected in this summary.
|
||||
*
|
||||
* @return {@code successCount + failedCount + skippedCount}; never negative
|
||||
*/
|
||||
public int totalProcessed() {
|
||||
return successCount + failedCount + skippedCount;
|
||||
}
|
||||
}
|
||||
+12
@@ -16,6 +16,18 @@
|
||||
* — Structured result of a batch run, designed for exit code mapping</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Progress observation (for interactive inbound adapters):
|
||||
* <ul>
|
||||
* <li>{@link de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver}
|
||||
* — Optional observer that receives per-run and per-candidate callbacks during a run</li>
|
||||
* <li>{@link de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken}
|
||||
* — Optional cooperative cancellation token polled between candidates</li>
|
||||
* <li>{@link de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent},
|
||||
* {@link de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus},
|
||||
* {@link de.gecheckt.pdf.umbenenner.application.port.in.RunSummary}
|
||||
* — Event and summary value types carried to the observer</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Architecture Rule: Inbound ports are independent of implementation and contain no business logic.
|
||||
* They define "what can be done to the application". All dependencies point inward;
|
||||
* adapters depend on ports, not vice versa.
|
||||
|
||||
+159
-4
@@ -1,10 +1,14 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentKnownProcessable;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
|
||||
@@ -158,6 +162,20 @@ public class DocumentProcessingCoordinator {
|
||||
private final int maxTitleLength;
|
||||
private final String activeProviderIdentifier;
|
||||
|
||||
/**
|
||||
* Optional per-run completion forwarder that is consulted by
|
||||
* {@link #publishCompletion(SourceDocumentCandidate, DocumentCompletionStatus, String,
|
||||
* LocalDate, String, Instant, Instant)} whenever a terminal candidate outcome is reached.
|
||||
* <p>
|
||||
* Assigned by the inbound use case for the duration of a single run and cleared before the
|
||||
* use case returns. A {@code null} value means no external observer is attached and the
|
||||
* completion event is dropped silently — the default for headless callers.
|
||||
* <p>
|
||||
* Accessed from the batch thread only. Not volatile because installation and read occur
|
||||
* on the same thread (the one executing the batch).
|
||||
*/
|
||||
private Consumer<DocumentCompletionEvent> completionForwarder;
|
||||
|
||||
/**
|
||||
* Creates the document processing coordinator with all required ports, logger,
|
||||
* the transient retry limit, the configured maximum base title length, and the
|
||||
@@ -235,6 +253,25 @@ public class DocumentProcessingCoordinator {
|
||||
this.maxRetriesTransient = maxRetriesTransient;
|
||||
this.maxTitleLength = maxTitleLength;
|
||||
this.activeProviderIdentifier = activeProviderIdentifier;
|
||||
this.completionForwarder = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs or removes a per-run completion forwarder.
|
||||
* <p>
|
||||
* When non-null, the forwarder is consulted at every terminal candidate resolution and
|
||||
* receives a {@link DocumentCompletionEvent} describing the outcome. A {@code null} value
|
||||
* detaches any previously installed forwarder.
|
||||
* <p>
|
||||
* This method is the single seam by which inbound adapters (e.g. the JavaFX GUI) attach a
|
||||
* live-progress observer to the document coordinator without widening the coordinator's
|
||||
* constructor surface. It must only be called from the thread that will drive the batch
|
||||
* run.
|
||||
*
|
||||
* @param forwarder the new forwarder, or {@code null} to detach the current one
|
||||
*/
|
||||
public void installCompletionForwarder(Consumer<DocumentCompletionEvent> forwarder) {
|
||||
this.completionForwarder = forwarder;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -509,7 +546,7 @@ public class DocumentProcessingCoordinator {
|
||||
|
||||
return persistTargetCopySuccess(
|
||||
candidate, fingerprint, existingRecord, context, attemptStart, now,
|
||||
resolvedFilename, targetFolderLocator);
|
||||
resolvedFilename, targetFolderLocator, proposalAttempt);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -518,7 +555,16 @@ public class DocumentProcessingCoordinator {
|
||||
* If the atomic persistence fails after the copy has already been written, a
|
||||
* best-effort rollback of the target file is attempted and
|
||||
* {@link ProcessingStatus#FAILED_RETRYABLE} is persisted instead.
|
||||
* <p>
|
||||
* On successful persistence, a terminal completion event is published to the attached
|
||||
* {@link BatchRunProgressObserver}; the event carries the resolved final filename,
|
||||
* the date and reasoning taken from the authoritative {@code PROPOSAL_READY} attempt.
|
||||
* On persistence failure the completion event is published by
|
||||
* {@link #persistTransientErrorAfterPersistenceFailure}.
|
||||
*
|
||||
* @param proposalAttempt the authoritative naming-proposal attempt used to populate
|
||||
* the observer event's date and reasoning fields; must not be
|
||||
* {@code null}
|
||||
* @return true if SUCCESS was persisted; false if persistence itself failed
|
||||
*/
|
||||
private boolean persistTargetCopySuccess(
|
||||
@@ -529,7 +575,8 @@ public class DocumentProcessingCoordinator {
|
||||
Instant attemptStart,
|
||||
Instant now,
|
||||
String resolvedFilename,
|
||||
String targetFolderLocator) {
|
||||
String targetFolderLocator,
|
||||
ProcessingAttempt proposalAttempt) {
|
||||
|
||||
try {
|
||||
int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint);
|
||||
@@ -550,6 +597,11 @@ public class DocumentProcessingCoordinator {
|
||||
|
||||
logger.info("Document '{}' successfully processed. Target: '{}'.",
|
||||
candidate.uniqueIdentifier(), resolvedFilename);
|
||||
publishCompletion(candidate, DocumentCompletionStatus.SUCCESS,
|
||||
resolvedFilename,
|
||||
proposalAttempt.resolvedDate(),
|
||||
proposalAttempt.aiReasoning(),
|
||||
attemptStart, now);
|
||||
return true;
|
||||
|
||||
} catch (DocumentPersistenceException e) {
|
||||
@@ -564,7 +616,8 @@ public class DocumentProcessingCoordinator {
|
||||
candidate, fingerprint, existingRecord, context, attemptStart,
|
||||
Instant.now(),
|
||||
"Persistence failed after successful target copy (best-effort rollback attempted): "
|
||||
+ e.getMessage());
|
||||
+ e.getMessage(),
|
||||
proposalAttempt);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -628,6 +681,10 @@ public class DocumentProcessingCoordinator {
|
||||
candidate.uniqueIdentifier(), fingerprint.sha256Hex(),
|
||||
updatedCounters.transientErrorCount(), maxRetriesTransient);
|
||||
}
|
||||
publishCompletion(candidate,
|
||||
retryable ? DocumentCompletionStatus.FAILED_RETRYABLE
|
||||
: DocumentCompletionStatus.FAILED_PERMANENT,
|
||||
null, null, null, attemptStart, now);
|
||||
return true;
|
||||
|
||||
} catch (DocumentPersistenceException persistEx) {
|
||||
@@ -654,7 +711,8 @@ public class DocumentProcessingCoordinator {
|
||||
BatchRunContext context,
|
||||
Instant attemptStart,
|
||||
Instant now,
|
||||
String errorMessage) {
|
||||
String errorMessage,
|
||||
ProcessingAttempt proposalAttempt) {
|
||||
|
||||
ProcessingOutcomeTransition.ProcessingOutcome transition =
|
||||
ProcessingOutcomeTransition.forKnownDocument(
|
||||
@@ -664,6 +722,7 @@ public class DocumentProcessingCoordinator {
|
||||
FailureCounters updatedCounters = transition.counters();
|
||||
ProcessingStatus errorStatus = transition.overallStatus();
|
||||
|
||||
boolean secondaryPersisted = false;
|
||||
try {
|
||||
int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint);
|
||||
ProcessingAttempt errorAttempt = ProcessingAttempt.withoutAiFields(
|
||||
@@ -679,11 +738,28 @@ public class DocumentProcessingCoordinator {
|
||||
txOps.saveProcessingAttempt(errorAttempt);
|
||||
txOps.updateDocumentRecord(errorRecord);
|
||||
});
|
||||
secondaryPersisted = true;
|
||||
|
||||
} catch (DocumentPersistenceException secondaryEx) {
|
||||
logger.error("Secondary persistence failure for '{}' after target copy rollback: {}",
|
||||
candidate.uniqueIdentifier(), secondaryEx.getMessage(), secondaryEx);
|
||||
}
|
||||
|
||||
// Observer notification: even when secondary persistence itself failed the candidate's
|
||||
// terminal resolution in this run is still a copy/persistence failure. Emitting a single
|
||||
// completion event keeps the observer in sync with the user-visible state even though
|
||||
// nothing new was persisted.
|
||||
String reasoning = proposalAttempt != null ? proposalAttempt.aiReasoning() : null;
|
||||
publishCompletion(candidate,
|
||||
transition.retryable()
|
||||
? DocumentCompletionStatus.FAILED_RETRYABLE
|
||||
: DocumentCompletionStatus.FAILED_PERMANENT,
|
||||
null, null, reasoning, attemptStart, now);
|
||||
|
||||
if (!secondaryPersisted) {
|
||||
logger.debug("Completion for '{}' reported without secondary persistence record.",
|
||||
candidate.uniqueIdentifier());
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -721,6 +797,8 @@ public class DocumentProcessingCoordinator {
|
||||
|
||||
logger.debug("Skip attempt #{} persisted for '{}' with status {}.",
|
||||
attemptNumber, candidate.uniqueIdentifier(), skipStatus);
|
||||
publishCompletion(candidate, DocumentCompletionStatus.SKIPPED,
|
||||
null, null, null, attemptStart, now);
|
||||
return true;
|
||||
|
||||
} catch (DocumentPersistenceException e) {
|
||||
@@ -985,6 +1063,13 @@ public class DocumentProcessingCoordinator {
|
||||
outcome.counters().contentErrorCount(),
|
||||
outcome.counters().transientErrorCount());
|
||||
}
|
||||
// Pipeline-path terminal resolutions are reported to the progress observer.
|
||||
// PROPOSAL_READY is an intermediate state; the subsequent finalisation publishes
|
||||
// the actual completion event (SUCCESS or transient-error failure).
|
||||
if (outcome.overallStatus() != ProcessingStatus.PROPOSAL_READY) {
|
||||
publishCompletion(candidate, toCompletionStatus(outcome),
|
||||
null, null, null, attemptStart, now);
|
||||
}
|
||||
return true;
|
||||
|
||||
} catch (DocumentPersistenceException e) {
|
||||
@@ -1097,4 +1182,74 @@ public class DocumentProcessingCoordinator {
|
||||
|
||||
return base + detail;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Progress observer dispatch
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Publishes a single terminal completion event for the candidate to the attached
|
||||
* {@link BatchRunProgressObserver}.
|
||||
* <p>
|
||||
* Must be called exactly once per terminal resolution of a candidate (success, retryable
|
||||
* failure, permanent failure, skip). Intermediate states such as
|
||||
* {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#PROPOSAL_READY} must not
|
||||
* produce a completion event.
|
||||
* <p>
|
||||
* Any runtime exception thrown by the observer is caught and logged at warn level and must
|
||||
* not affect persistence or batch flow.
|
||||
*
|
||||
* @param candidate the candidate being reported; must not be null
|
||||
* @param status the aggregated completion status; must not be null
|
||||
* @param finalFileName the final target filename on success; {@code null} otherwise
|
||||
* @param resolvedDate the resolved date on success; may be {@code null} otherwise
|
||||
* @param aiReasoning the AI reasoning when one is available for this result;
|
||||
* {@code null} otherwise
|
||||
* @param startInstant the moment processing of the candidate began in this run
|
||||
* @param endInstant the moment the terminal resolution was reached
|
||||
*/
|
||||
private void publishCompletion(
|
||||
SourceDocumentCandidate candidate,
|
||||
DocumentCompletionStatus status,
|
||||
String finalFileName,
|
||||
LocalDate resolvedDate,
|
||||
String aiReasoning,
|
||||
Instant startInstant,
|
||||
Instant endInstant) {
|
||||
Consumer<DocumentCompletionEvent> forwarder = completionForwarder;
|
||||
if (forwarder == null) {
|
||||
return;
|
||||
}
|
||||
Duration duration = Duration.between(startInstant, endInstant);
|
||||
if (duration.isNegative()) {
|
||||
duration = Duration.ZERO;
|
||||
}
|
||||
try {
|
||||
forwarder.accept(new DocumentCompletionEvent(
|
||||
candidate.uniqueIdentifier(),
|
||||
status,
|
||||
finalFileName,
|
||||
resolvedDate,
|
||||
aiReasoning,
|
||||
duration));
|
||||
} catch (RuntimeException forwarderFailure) {
|
||||
logger.warn("Progress forwarder threw while reporting completion for '{}': {}",
|
||||
candidate.uniqueIdentifier(), forwarderFailure.getMessage(), forwarderFailure);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the aggregated retryable/terminal semantics of a pipeline-path persistence outcome
|
||||
* to the observer-level {@link DocumentCompletionStatus}.
|
||||
* <p>
|
||||
* Callers guarantee that the outcome does not represent
|
||||
* {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#PROPOSAL_READY} — that
|
||||
* intermediate state is never reported as a completion.
|
||||
*/
|
||||
private static DocumentCompletionStatus toCompletionStatus(
|
||||
ProcessingOutcomeTransition.ProcessingOutcome outcome) {
|
||||
return outcome.retryable()
|
||||
? DocumentCompletionStatus.FAILED_RETRYABLE
|
||||
: DocumentCompletionStatus.FAILED_PERMANENT;
|
||||
}
|
||||
}
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
|
||||
|
||||
/**
|
||||
* Internal per-run adapter that forwards every
|
||||
* {@link DocumentCompletionEvent} emitted by the
|
||||
* {@link de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator}
|
||||
* to the configured {@link BatchRunProgressObserver}, while accumulating outcome counts
|
||||
* for the run's final {@link RunSummary}.
|
||||
* <p>
|
||||
* Used only by {@link DefaultBatchRunProcessingUseCase} for the lifetime of a single run.
|
||||
* Not thread-safe: all invocations must occur on the batch thread.
|
||||
*/
|
||||
final class CountingCompletionObserver implements Consumer<DocumentCompletionEvent> {
|
||||
|
||||
private final BatchRunProgressObserver observer;
|
||||
private final ProcessingLogger logger;
|
||||
private int successCount;
|
||||
private int failedCount;
|
||||
private int skippedCount;
|
||||
|
||||
CountingCompletionObserver(BatchRunProgressObserver observer, ProcessingLogger logger) {
|
||||
this.observer = Objects.requireNonNull(observer, "observer must not be null");
|
||||
this.logger = Objects.requireNonNull(logger, "logger must not be null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void accept(DocumentCompletionEvent event) {
|
||||
Objects.requireNonNull(event, "event must not be null");
|
||||
switch (event.status()) {
|
||||
case SUCCESS -> successCount++;
|
||||
case FAILED_RETRYABLE, FAILED_PERMANENT -> failedCount++;
|
||||
case SKIPPED -> skippedCount++;
|
||||
default -> {
|
||||
// Defensive — new status values would be a programming error.
|
||||
throw new IllegalStateException(
|
||||
"Unexpected DocumentCompletionStatus: " + event.status());
|
||||
}
|
||||
}
|
||||
try {
|
||||
observer.onDocumentCompleted(event);
|
||||
} catch (RuntimeException e) {
|
||||
logger.warn("Progress observer threw on onDocumentCompleted for '{}': {}",
|
||||
event.originalFileName(), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
RunSummary summary() {
|
||||
return new RunSummary(successCount, failedCount, skippedCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the completion status counts collected so far, including the terminal
|
||||
* contribution of the candidate currently being reported.
|
||||
*/
|
||||
int successCount() {
|
||||
return successCount;
|
||||
}
|
||||
|
||||
int failedCount() {
|
||||
return failedCount;
|
||||
}
|
||||
|
||||
int skippedCount() {
|
||||
return skippedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visible for tests that verify the mapping of completion statuses to summary buckets.
|
||||
*/
|
||||
static RunSummary summaryOf(int successCount, int failedCount, int skippedCount) {
|
||||
return new RunSummary(successCount, failedCount, skippedCount);
|
||||
}
|
||||
|
||||
/** Test hook to confirm the status classification. */
|
||||
static DocumentCompletionStatus classify(DocumentCompletionStatus status) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
+97
-4
@@ -5,8 +5,13 @@ import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintSuccess;
|
||||
@@ -80,6 +85,8 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
|
||||
private final DocumentProcessingCoordinator documentProcessingCoordinator;
|
||||
private final AiNamingService aiNamingService;
|
||||
private final ProcessingLogger logger;
|
||||
private final BatchRunProgressObserver progressObserver;
|
||||
private final BatchRunCancellationToken cancellationToken;
|
||||
|
||||
/**
|
||||
* Creates the batch use case with the runtime configuration and all required ports for the flow.
|
||||
@@ -112,6 +119,46 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
|
||||
DocumentProcessingCoordinator documentProcessingCoordinator,
|
||||
AiNamingService aiNamingService,
|
||||
ProcessingLogger logger) {
|
||||
this(runtimeConfiguration, runLockPort, sourceDocumentCandidatesPort, pdfTextExtractionPort,
|
||||
fingerprintPort, documentProcessingCoordinator, aiNamingService, logger,
|
||||
BatchRunProgressObserver.noOp(), BatchRunCancellationToken.neverCancelled());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the batch use case with a progress observer and cancellation token attached.
|
||||
* <p>
|
||||
* The observer is invoked on the batch thread for run start, per-candidate completion,
|
||||
* and run end. The cancellation token is polled between candidates; a requested
|
||||
* cancellation is honoured before starting the next candidate and never interrupts a
|
||||
* candidate that is already being processed (soft-stop).
|
||||
*
|
||||
* @param runtimeConfiguration the runtime configuration; must not be null
|
||||
* @param runLockPort run-lock port; must not be null
|
||||
* @param sourceDocumentCandidatesPort candidate source port; must not be null
|
||||
* @param pdfTextExtractionPort PDF text extraction port; must not be null
|
||||
* @param fingerprintPort fingerprint port; must not be null
|
||||
* @param documentProcessingCoordinator per-document coordinator; must not be null
|
||||
* @param aiNamingService AI naming service; must not be null
|
||||
* @param logger logger; must not be null
|
||||
* @param progressObserver progress observer; must not be null, use
|
||||
* {@link BatchRunProgressObserver#noOp()} when none is
|
||||
* needed
|
||||
* @param cancellationToken cancellation token; must not be null, use
|
||||
* {@link BatchRunCancellationToken#neverCancelled()}
|
||||
* when cancellation is not needed
|
||||
* @throws NullPointerException if any parameter is null
|
||||
*/
|
||||
public DefaultBatchRunProcessingUseCase(
|
||||
RuntimeConfiguration runtimeConfiguration,
|
||||
RunLockPort runLockPort,
|
||||
SourceDocumentCandidatesPort sourceDocumentCandidatesPort,
|
||||
PdfTextExtractionPort pdfTextExtractionPort,
|
||||
FingerprintPort fingerprintPort,
|
||||
DocumentProcessingCoordinator documentProcessingCoordinator,
|
||||
AiNamingService aiNamingService,
|
||||
ProcessingLogger logger,
|
||||
BatchRunProgressObserver progressObserver,
|
||||
BatchRunCancellationToken cancellationToken) {
|
||||
this.runtimeConfiguration = Objects.requireNonNull(runtimeConfiguration, "runtimeConfiguration must not be null");
|
||||
this.runLockPort = Objects.requireNonNull(runLockPort, "runLockPort must not be null");
|
||||
this.sourceDocumentCandidatesPort = Objects.requireNonNull(
|
||||
@@ -123,6 +170,8 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
|
||||
documentProcessingCoordinator, "documentProcessingCoordinator must not be null");
|
||||
this.aiNamingService = Objects.requireNonNull(aiNamingService, "aiNamingService must not be null");
|
||||
this.logger = Objects.requireNonNull(logger, "logger must not be null");
|
||||
this.progressObserver = Objects.requireNonNull(progressObserver, "progressObserver must not be null");
|
||||
this.cancellationToken = Objects.requireNonNull(cancellationToken, "cancellationToken must not be null");
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -183,16 +232,60 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
|
||||
}
|
||||
logger.info("Found {} PDF candidate(s) in source folder.", candidates.size());
|
||||
|
||||
for (SourceDocumentCandidate candidate : candidates) {
|
||||
processCandidate(candidate, context);
|
||||
// Notify observer of the known candidate count up-front so observers can size their
|
||||
// progress bars. The count reflects the source folder at scan time and remains fixed
|
||||
// for the remainder of the run (also when the run is cancelled early).
|
||||
try {
|
||||
progressObserver.onRunStarted(context.runId(), candidates.size());
|
||||
} catch (RuntimeException e) {
|
||||
logger.warn("Progress observer threw on onRunStarted: {}", e.getMessage(), e);
|
||||
}
|
||||
|
||||
logger.info("Batch run completed. Processed {} candidate(s). RunId: {}",
|
||||
candidates.size(), context.runId());
|
||||
// Wrap the user-supplied observer so the per-run summary can be computed by counting
|
||||
// forwarded completion events.
|
||||
CountingCompletionObserver forwardingObserver =
|
||||
new CountingCompletionObserver(progressObserver, logger);
|
||||
documentProcessingCoordinator.installCompletionForwarder(forwardingObserver);
|
||||
try {
|
||||
int processedCount = 0;
|
||||
boolean cancelled = false;
|
||||
for (SourceDocumentCandidate candidate : candidates) {
|
||||
if (cancellationTokenRequested()) {
|
||||
cancelled = true;
|
||||
logger.info("Cancellation requested before processing next candidate. "
|
||||
+ "Stopping batch run. RunId: {}, processed {}/{} candidate(s).",
|
||||
context.runId(), processedCount, candidates.size());
|
||||
break;
|
||||
}
|
||||
processCandidate(candidate, context);
|
||||
processedCount++;
|
||||
}
|
||||
|
||||
logger.info("Batch run {}. Processed {} candidate(s). RunId: {}",
|
||||
cancelled ? "cancelled" : "completed",
|
||||
processedCount, context.runId());
|
||||
} finally {
|
||||
documentProcessingCoordinator.installCompletionForwarder(null);
|
||||
try {
|
||||
progressObserver.onRunEnded(forwardingObserver.summary());
|
||||
} catch (RuntimeException e) {
|
||||
logger.warn("Progress observer threw on onRunEnded: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
return BatchRunOutcome.SUCCESS;
|
||||
}
|
||||
|
||||
private boolean cancellationTokenRequested() {
|
||||
try {
|
||||
return cancellationToken.isCancellationRequested();
|
||||
} catch (RuntimeException e) {
|
||||
logger.warn("Cancellation token threw while being polled; treating as not cancelled: {}",
|
||||
e.getMessage(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases the run lock if it was previously acquired.
|
||||
* <p>
|
||||
|
||||
+497
@@ -0,0 +1,497 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.AiContentSensitivity;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ClockPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordLookupResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintSuccess;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PdfTextExtractionPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ResolvedTargetFilename;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentCandidatesPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopySuccess;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFilenameResolutionResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.service.AiNamingService;
|
||||
import de.gecheckt.pdf.umbenenner.application.service.AiResponseValidator;
|
||||
import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionContentError;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionResult;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
|
||||
|
||||
/**
|
||||
* Focused tests for the progress observer and cancellation token behaviour wired into
|
||||
* {@link DefaultBatchRunProcessingUseCase} and forwarded to
|
||||
* {@link DocumentProcessingCoordinator}.
|
||||
*/
|
||||
class BatchRunProgressObservationTest {
|
||||
|
||||
private static final int TEST_MAX_TITLE = 60;
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
// =========================================================================
|
||||
// Value object invariants
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void documentCompletionEvent_rejectsBlankFilename() {
|
||||
assertThrows(IllegalArgumentException.class, () -> new DocumentCompletionEvent(
|
||||
" ", DocumentCompletionStatus.SUCCESS, null, null, null, Duration.ZERO));
|
||||
}
|
||||
|
||||
@Test
|
||||
void documentCompletionEvent_rejectsNegativeDuration() {
|
||||
assertThrows(IllegalArgumentException.class, () -> new DocumentCompletionEvent(
|
||||
"x.pdf", DocumentCompletionStatus.SUCCESS, null, null, null,
|
||||
Duration.ofSeconds(-1)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void documentCompletionEvent_carriesOptionalFields() {
|
||||
DocumentCompletionEvent event = new DocumentCompletionEvent(
|
||||
"x.pdf", DocumentCompletionStatus.SUCCESS, "2026-03-01 - Titel.pdf",
|
||||
LocalDate.of(2026, 3, 1), "weil wichtig", Duration.ofMillis(123));
|
||||
|
||||
assertEquals("x.pdf", event.originalFileName());
|
||||
assertEquals(DocumentCompletionStatus.SUCCESS, event.status());
|
||||
assertEquals("2026-03-01 - Titel.pdf", event.finalFileName());
|
||||
assertEquals(LocalDate.of(2026, 3, 1), event.resolvedDate());
|
||||
assertEquals("weil wichtig", event.aiReasoning());
|
||||
assertEquals(Duration.ofMillis(123), event.processingDuration());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSummary_rejectsNegativeCounts() {
|
||||
assertThrows(IllegalArgumentException.class, () -> new RunSummary(-1, 0, 0));
|
||||
assertThrows(IllegalArgumentException.class, () -> new RunSummary(0, -1, 0));
|
||||
assertThrows(IllegalArgumentException.class, () -> new RunSummary(0, 0, -1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSummary_totalProcessedSumsCounts() {
|
||||
RunSummary summary = new RunSummary(2, 3, 4);
|
||||
assertEquals(9, summary.totalProcessed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noOpObserver_isSingletonAndSilent() {
|
||||
BatchRunProgressObserver a = BatchRunProgressObserver.noOp();
|
||||
BatchRunProgressObserver b = BatchRunProgressObserver.noOp();
|
||||
assertSame(a, b);
|
||||
a.onRunStarted(new RunId("r-1"), 5);
|
||||
a.onDocumentCompleted(new DocumentCompletionEvent(
|
||||
"x.pdf", DocumentCompletionStatus.SKIPPED, null, null, null, Duration.ZERO));
|
||||
a.onRunEnded(new RunSummary(0, 0, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void neverCancelledToken_isSingletonAndAlwaysFalse() {
|
||||
BatchRunCancellationToken a = BatchRunCancellationToken.neverCancelled();
|
||||
BatchRunCancellationToken b = BatchRunCancellationToken.neverCancelled();
|
||||
assertSame(a, b);
|
||||
assertFalse(a.isCancellationRequested());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Use case lifecycle callbacks
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void useCase_emitsRunStartedAndEndedForEmptyFolder() {
|
||||
RecordingObserver observer = new RecordingObserver();
|
||||
CapturingCoordinator coordinator = new CapturingCoordinator();
|
||||
DefaultBatchRunProcessingUseCase useCase = buildUseCase(
|
||||
new NoOpLock(), new EmptyCandidatesPort(), coordinator, observer,
|
||||
BatchRunCancellationToken.neverCancelled());
|
||||
|
||||
BatchRunOutcome outcome = useCase.execute(new BatchRunContext(new RunId("empty"), Instant.now()));
|
||||
|
||||
assertTrue(outcome.isSuccess());
|
||||
assertEquals(List.of("started:0", "ended:0/0/0"), observer.events);
|
||||
}
|
||||
|
||||
@Test
|
||||
void useCase_forwardsCoordinatorCompletionEventsAndCountsThem() {
|
||||
RecordingObserver observer = new RecordingObserver();
|
||||
PublishingCoordinator coordinator = new PublishingCoordinator(List.of(
|
||||
DocumentCompletionStatus.SUCCESS,
|
||||
DocumentCompletionStatus.FAILED_RETRYABLE,
|
||||
DocumentCompletionStatus.FAILED_PERMANENT,
|
||||
DocumentCompletionStatus.SKIPPED));
|
||||
DefaultBatchRunProcessingUseCase useCase = buildUseCase(
|
||||
new NoOpLock(), new FixedCandidatesPort(
|
||||
makeCandidate("a.pdf"),
|
||||
makeCandidate("b.pdf"),
|
||||
makeCandidate("c.pdf"),
|
||||
makeCandidate("d.pdf")),
|
||||
coordinator, observer, BatchRunCancellationToken.neverCancelled());
|
||||
|
||||
BatchRunOutcome outcome = useCase.execute(new BatchRunContext(new RunId("mixed"), Instant.now()));
|
||||
|
||||
assertTrue(outcome.isSuccess());
|
||||
// Observer must see exactly one onStarted, 4 completed events, and one onEnded.
|
||||
assertEquals(6, observer.events.size(), () -> observer.events.toString());
|
||||
assertEquals("started:4", observer.events.get(0));
|
||||
assertEquals("ended:1/2/1", observer.events.get(observer.events.size() - 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void useCase_stopsBeforeNextCandidateWhenCancellationRequested() {
|
||||
RecordingObserver observer = new RecordingObserver();
|
||||
PublishingCoordinator coordinator = new PublishingCoordinator(List.of(
|
||||
DocumentCompletionStatus.SUCCESS,
|
||||
DocumentCompletionStatus.SUCCESS,
|
||||
DocumentCompletionStatus.SUCCESS));
|
||||
ToggleCancellationToken cancellation = new ToggleCancellationToken();
|
||||
// Cancel after the first candidate has been processed.
|
||||
coordinator.onBeforeReturn = () -> {
|
||||
if (coordinator.invocations() == 1) {
|
||||
cancellation.request();
|
||||
}
|
||||
};
|
||||
DefaultBatchRunProcessingUseCase useCase = buildUseCase(
|
||||
new NoOpLock(), new FixedCandidatesPort(
|
||||
makeCandidate("a.pdf"),
|
||||
makeCandidate("b.pdf"),
|
||||
makeCandidate("c.pdf")),
|
||||
coordinator, observer, cancellation);
|
||||
|
||||
BatchRunOutcome outcome = useCase.execute(new BatchRunContext(new RunId("cancel"), Instant.now()));
|
||||
|
||||
assertTrue(outcome.isSuccess());
|
||||
// Only the first candidate ran — cancellation is polled before the second.
|
||||
assertEquals(1, coordinator.invocations());
|
||||
// Observer saw the start, one completion, and the run end with (1,0,0).
|
||||
assertEquals(List.of("started:3", "completed:SUCCESS:a.pdf", "ended:1/0/0"),
|
||||
observer.events);
|
||||
}
|
||||
|
||||
@Test
|
||||
void useCase_isSafeWhenObserverThrowsFromCallbacks() {
|
||||
ThrowingObserver observer = new ThrowingObserver();
|
||||
PublishingCoordinator coordinator = new PublishingCoordinator(List.of(
|
||||
DocumentCompletionStatus.SUCCESS));
|
||||
DefaultBatchRunProcessingUseCase useCase = buildUseCase(
|
||||
new NoOpLock(), new FixedCandidatesPort(makeCandidate("x.pdf")),
|
||||
coordinator, observer, BatchRunCancellationToken.neverCancelled());
|
||||
|
||||
// Must not bubble up — thrown runtime exceptions are isolated.
|
||||
BatchRunOutcome outcome = useCase.execute(new BatchRunContext(new RunId("throw"), Instant.now()));
|
||||
assertTrue(outcome.isSuccess());
|
||||
assertTrue(observer.startedThrown.get(),
|
||||
"onRunStarted was invoked even though it threw");
|
||||
assertTrue(observer.completedThrown.get(),
|
||||
"onDocumentCompleted was invoked even though it threw");
|
||||
assertTrue(observer.endedThrown.get(),
|
||||
"onRunEnded was invoked even though it threw");
|
||||
}
|
||||
|
||||
@Test
|
||||
void useCase_removesForwarderAfterRun() {
|
||||
RecordingObserver observer = new RecordingObserver();
|
||||
PublishingCoordinator coordinator = new PublishingCoordinator(List.of(
|
||||
DocumentCompletionStatus.SUCCESS));
|
||||
DefaultBatchRunProcessingUseCase useCase = buildUseCase(
|
||||
new NoOpLock(), new FixedCandidatesPort(makeCandidate("x.pdf")),
|
||||
coordinator, observer, BatchRunCancellationToken.neverCancelled());
|
||||
|
||||
useCase.execute(new BatchRunContext(new RunId("clean"), Instant.now()));
|
||||
|
||||
assertNull(coordinator.currentForwarder(),
|
||||
"Forwarder must be removed after the run to avoid leaking observers across runs");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers / stubs
|
||||
// =========================================================================
|
||||
|
||||
private DefaultBatchRunProcessingUseCase buildUseCase(
|
||||
RunLockPort lock,
|
||||
SourceDocumentCandidatesPort candidates,
|
||||
DocumentProcessingCoordinator coordinator,
|
||||
BatchRunProgressObserver observer,
|
||||
BatchRunCancellationToken token) {
|
||||
RuntimeConfiguration runtimeConfig = new RuntimeConfiguration(
|
||||
3, 3, AiContentSensitivity.PROTECT_SENSITIVE_CONTENT);
|
||||
return new DefaultBatchRunProcessingUseCase(
|
||||
runtimeConfig,
|
||||
lock,
|
||||
candidates,
|
||||
new PassThroughExtractionPort(),
|
||||
new AlwaysSuccessFingerprintPort(),
|
||||
coordinator,
|
||||
buildStubAiNamingService(),
|
||||
new SilentLogger(),
|
||||
observer,
|
||||
token);
|
||||
}
|
||||
|
||||
private static AiNamingService buildStubAiNamingService() {
|
||||
AiInvocationPort stubAi = req -> {
|
||||
throw new IllegalStateException("AI must not be invoked in these tests");
|
||||
};
|
||||
PromptPort stubPrompt = () -> new PromptLoadingSuccess(
|
||||
new PromptIdentifier("stub-prompt"), "Prompt: {{text}}");
|
||||
ClockPort stubClock = () -> Instant.parse("2026-04-22T00:00:00Z");
|
||||
AiResponseValidator validator = new AiResponseValidator(stubClock, TEST_MAX_TITLE);
|
||||
return new AiNamingService(stubAi, stubPrompt, validator, "stub-model", 1000, TEST_MAX_TITLE);
|
||||
}
|
||||
|
||||
private static SourceDocumentCandidate makeCandidate(String filename) {
|
||||
return new SourceDocumentCandidate(filename, 1024L,
|
||||
new SourceDocumentLocator("/tmp/" + filename));
|
||||
}
|
||||
|
||||
private static final class NoOpLock implements RunLockPort {
|
||||
@Override public void acquire() { }
|
||||
@Override public void release() { }
|
||||
}
|
||||
|
||||
private static final class EmptyCandidatesPort implements SourceDocumentCandidatesPort {
|
||||
@Override public List<SourceDocumentCandidate> loadCandidates() { return List.of(); }
|
||||
}
|
||||
|
||||
private static final class FixedCandidatesPort implements SourceDocumentCandidatesPort {
|
||||
private final List<SourceDocumentCandidate> all;
|
||||
FixedCandidatesPort(SourceDocumentCandidate... items) { this.all = List.of(items); }
|
||||
@Override public List<SourceDocumentCandidate> loadCandidates() { return all; }
|
||||
}
|
||||
|
||||
private static final class PassThroughExtractionPort implements PdfTextExtractionPort {
|
||||
@Override
|
||||
public PdfExtractionResult extractTextAndPageCount(SourceDocumentCandidate candidate) {
|
||||
return new PdfExtractionContentError("nicht relevant für den Beobachter-Test");
|
||||
}
|
||||
}
|
||||
|
||||
private static final class AlwaysSuccessFingerprintPort implements FingerprintPort {
|
||||
@Override
|
||||
public FingerprintResult computeFingerprint(SourceDocumentCandidate candidate) {
|
||||
String hex = String.format("%064x",
|
||||
Math.abs((long) candidate.uniqueIdentifier().hashCode()));
|
||||
return new FingerprintSuccess(new DocumentFingerprint(hex.substring(0, 64)));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SilentLogger implements ProcessingLogger {
|
||||
@Override public void info(String message, Object... args) { }
|
||||
@Override public void warn(String message, Object... args) { }
|
||||
@Override public void error(String message, Object... args) { }
|
||||
@Override public void debug(String message, Object... args) { }
|
||||
@Override public void debugSensitiveAiContent(String message, Object... args) { }
|
||||
}
|
||||
|
||||
private static final class RecordingObserver implements BatchRunProgressObserver {
|
||||
final List<String> events = new ArrayList<>();
|
||||
@Override
|
||||
public void onRunStarted(RunId runId, int totalCandidates) {
|
||||
events.add("started:" + totalCandidates);
|
||||
}
|
||||
@Override
|
||||
public void onDocumentCompleted(DocumentCompletionEvent event) {
|
||||
events.add("completed:" + event.status() + ":" + event.originalFileName());
|
||||
}
|
||||
@Override
|
||||
public void onRunEnded(RunSummary summary) {
|
||||
events.add("ended:" + summary.successCount() + "/"
|
||||
+ summary.failedCount() + "/" + summary.skippedCount());
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ThrowingObserver implements BatchRunProgressObserver {
|
||||
final java.util.concurrent.atomic.AtomicBoolean startedThrown = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
final java.util.concurrent.atomic.AtomicBoolean completedThrown = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
final java.util.concurrent.atomic.AtomicBoolean endedThrown = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
@Override public void onRunStarted(RunId runId, int totalCandidates) {
|
||||
startedThrown.set(true);
|
||||
throw new IllegalStateException("boom-started");
|
||||
}
|
||||
@Override public void onDocumentCompleted(DocumentCompletionEvent event) {
|
||||
completedThrown.set(true);
|
||||
throw new IllegalStateException("boom-completed");
|
||||
}
|
||||
@Override public void onRunEnded(RunSummary summary) {
|
||||
endedThrown.set(true);
|
||||
throw new IllegalStateException("boom-ended");
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ToggleCancellationToken implements BatchRunCancellationToken {
|
||||
private volatile boolean requested = false;
|
||||
void request() { requested = true; }
|
||||
@Override public boolean isCancellationRequested() { return requested; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinator stub that lets the use case install a forwarder but never publishes.
|
||||
*/
|
||||
private static class CapturingCoordinator extends DocumentProcessingCoordinator {
|
||||
CapturingCoordinator() {
|
||||
super(NoRecords.INSTANCE, NoAttempts.INSTANCE, NoUow.INSTANCE,
|
||||
NoTargetFolder.INSTANCE, NoTargetCopy.INSTANCE,
|
||||
new SilentLogger(), 3, TEST_MAX_TITLE, "stub-provider");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean processDeferredOutcome(
|
||||
SourceDocumentCandidate candidate,
|
||||
DocumentFingerprint fingerprint,
|
||||
BatchRunContext context,
|
||||
Instant attemptStart,
|
||||
java.util.function.Function<SourceDocumentCandidate,
|
||||
de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome> pipelineExecutor) {
|
||||
// Nothing to process — this stub does not trigger the forwarder.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinator stub that publishes a configurable sequence of completion events to the
|
||||
* installed forwarder, one per candidate that the use case hands it.
|
||||
*/
|
||||
private static class PublishingCoordinator extends DocumentProcessingCoordinator {
|
||||
private final List<DocumentCompletionStatus> statuses;
|
||||
private Consumer<DocumentCompletionEvent> currentForwarder;
|
||||
private final AtomicInteger invocations = new AtomicInteger();
|
||||
Runnable onBeforeReturn = () -> { };
|
||||
|
||||
PublishingCoordinator(List<DocumentCompletionStatus> statuses) {
|
||||
super(NoRecords.INSTANCE, NoAttempts.INSTANCE, NoUow.INSTANCE,
|
||||
NoTargetFolder.INSTANCE, NoTargetCopy.INSTANCE,
|
||||
new SilentLogger(), 3, TEST_MAX_TITLE, "stub-provider");
|
||||
this.statuses = statuses;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void installCompletionForwarder(Consumer<DocumentCompletionEvent> forwarder) {
|
||||
this.currentForwarder = forwarder;
|
||||
}
|
||||
|
||||
Consumer<DocumentCompletionEvent> currentForwarder() {
|
||||
return currentForwarder;
|
||||
}
|
||||
|
||||
int invocations() {
|
||||
return invocations.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean processDeferredOutcome(
|
||||
SourceDocumentCandidate candidate,
|
||||
DocumentFingerprint fingerprint,
|
||||
BatchRunContext context,
|
||||
Instant attemptStart,
|
||||
java.util.function.Function<SourceDocumentCandidate,
|
||||
de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome> pipelineExecutor) {
|
||||
int index = invocations.getAndIncrement();
|
||||
if (index < statuses.size() && currentForwarder != null) {
|
||||
currentForwarder.accept(new DocumentCompletionEvent(
|
||||
candidate.uniqueIdentifier(),
|
||||
statuses.get(index),
|
||||
null, null, null, Duration.ofMillis(10)));
|
||||
}
|
||||
onBeforeReturn.run();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// No-op repo/port stubs used only to satisfy the coordinator constructor. The tests
|
||||
// never reach the underlying operations because PublishingCoordinator and
|
||||
// CapturingCoordinator override the public entry point of the coordinator.
|
||||
private static final class NoRecords implements DocumentRecordRepository {
|
||||
static final NoRecords INSTANCE = new NoRecords();
|
||||
@Override public DocumentRecordLookupResult findByFingerprint(DocumentFingerprint f) {
|
||||
return new DocumentUnknown();
|
||||
}
|
||||
@Override public void create(de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord record) { }
|
||||
@Override public void update(de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord record) { }
|
||||
}
|
||||
|
||||
private static final class NoAttempts implements ProcessingAttemptRepository {
|
||||
static final NoAttempts INSTANCE = new NoAttempts();
|
||||
@Override public int loadNextAttemptNumber(DocumentFingerprint fingerprint) { return 1; }
|
||||
@Override public void save(ProcessingAttempt attempt) { }
|
||||
@Override public List<ProcessingAttempt> findAllByFingerprint(DocumentFingerprint fingerprint) {
|
||||
return List.of();
|
||||
}
|
||||
@Override public ProcessingAttempt findLatestProposalReadyAttempt(
|
||||
DocumentFingerprint fingerprint) { return null; }
|
||||
}
|
||||
|
||||
private static final class NoUow implements UnitOfWorkPort {
|
||||
static final NoUow INSTANCE = new NoUow();
|
||||
@Override
|
||||
public void executeInTransaction(
|
||||
java.util.function.Consumer<TransactionOperations> operations) {
|
||||
throw new DocumentPersistenceException(
|
||||
"UnitOfWorkPort must not be called in BatchRunProgressObservationTest");
|
||||
}
|
||||
}
|
||||
|
||||
private static final class NoTargetFolder implements TargetFolderPort {
|
||||
static final NoTargetFolder INSTANCE = new NoTargetFolder();
|
||||
@Override public TargetFilenameResolutionResult resolveUniqueFilename(String baseFilename) {
|
||||
return new ResolvedTargetFilename(baseFilename);
|
||||
}
|
||||
@Override public String getTargetFolderLocator() { return "/tmp/target"; }
|
||||
@Override public void tryDeleteTargetFile(String filename) { }
|
||||
}
|
||||
|
||||
private static final class NoTargetCopy implements TargetFileCopyPort {
|
||||
static final NoTargetCopy INSTANCE = new NoTargetCopy();
|
||||
@Override public TargetFileCopyResult copyToTarget(
|
||||
SourceDocumentLocator source, String resolvedFilename) {
|
||||
return new TargetFileCopySuccess();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user