Feature: Scheduler-Tick-Zaehlung korrigieren und Sitzungstotale einfuehren

Der Scheduler-Tab meldete nach erfolgreicher Verarbeitung faelschlich
"keine neuen Dokumente". Ursache war ein hartkodiertes RunSummary.noOp()
im BatchRunTrigger der Bootstrap; der echte Lauf-Summary wurde nie
gelesen.

- Bootstrap: BatchRunProgressObserver erfasst RunSummary aus onRunEnded
  und uebersetzt ihn in den ausgehenden RunSummary fuer das Tick-Ergebnis
- Neuer Wert-Typ SchedulerSessionTotals (success/failed) plus
  Optional-Feld in SchedulerStatus
- DefaultSchedulerControlUseCase setzt die Totale beim start() auf null
  zurueck, summiert pro Started-Tick auf, friert sie beim stop() ein
- GuiSchedulerTab zeigt pro Tick "X verarbeitet, Y Fehler" oder
  "keine neuen Dokumente" sowie ein zusaetzliches Label
  "Seit Scheduler-Start: X verarbeitet, Y Fehler"

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 14:51:36 +02:00
parent ac5b74917f
commit 368cb81b56
6 changed files with 233 additions and 10 deletions
@@ -13,6 +13,7 @@ import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerSessionTotals;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStartException;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerState;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStatus;
@@ -81,6 +82,7 @@ public final class GuiSchedulerTab {
private final Button stopButton = new Button("Scheduler stoppen");
private final Label nextTickLabel = new Label();
private final Label lastRunLabel = new Label("Noch kein Lauf in dieser Sitzung.");
private final Label sessionTotalsLabel = new Label();
private final Label lastErrorLabel = new Label();
private final TextField intervalField = new TextField();
private final Label intervalValidationLabel = new Label();
@@ -174,6 +176,7 @@ public final class GuiSchedulerTab {
updateButtons(status);
updateNextTickLabel(status);
updateLastRunLabel(status);
updateSessionTotalsLabel(status);
updateLastErrorLabel(status);
updateIntervalFieldEditability(status);
}
@@ -199,6 +202,11 @@ public final class GuiSchedulerTab {
lastRunLabel.setWrapText(true);
sessionTotalsLabel.setWrapText(true);
sessionTotalsLabel.setStyle("-fx-text-fill: #7f8c8d;");
sessionTotalsLabel.setVisible(false);
sessionTotalsLabel.setManaged(false);
lastErrorLabel.setStyle("-fx-text-fill: #c0392b;");
lastErrorLabel.setWrapText(true);
lastErrorLabel.setVisible(false);
@@ -219,6 +227,7 @@ public final class GuiSchedulerTab {
buttonBox,
nextTickLabel,
lastRunLabel,
sessionTotalsLabel,
lastErrorLabel,
new Separator(),
intervalBox,
@@ -337,19 +346,34 @@ public final class GuiSchedulerTab {
RunSummary summary = status.lastRunSummary().get();
String timeStr = TIME_FORMATTER.format(endedAt);
boolean noDocuments = summary.successCount() == 0
&& summary.failedCount() == 0
&& summary.skippedCount() == 0;
&& summary.failedCount() == 0;
if (noDocuments) {
lastRunLabel.setText("Letzter Lauf: " + timeStr + " keine neuen Dokumente");
} else {
lastRunLabel.setText("Letzter Lauf: " + timeStr + " "
+ summary.successCount() + " Dokumente verarbeitet");
+ summary.successCount() + " verarbeitet, "
+ summary.failedCount() + " Fehler");
}
} else {
lastRunLabel.setText("Noch kein Lauf in dieser Sitzung.");
}
}
private void updateSessionTotalsLabel(SchedulerStatus status) {
Optional<SchedulerSessionTotals> totals = status.sessionTotals();
if (totals.isPresent()) {
SchedulerSessionTotals t = totals.get();
sessionTotalsLabel.setText("Seit Scheduler-Start: "
+ t.successCount() + " verarbeitet, "
+ t.failedCount() + " Fehler");
sessionTotalsLabel.setVisible(true);
sessionTotalsLabel.setManaged(true);
} else {
sessionTotalsLabel.setVisible(false);
sessionTotalsLabel.setManaged(false);
}
}
private void updateLastErrorLabel(SchedulerStatus status) {
Optional<String> lastError = status.lastError();
if (lastError.isPresent() && !lastError.get().isBlank()) {
@@ -0,0 +1,58 @@
package de.gecheckt.pdf.umbenenner.application.port.in;
/**
* Aggregierte Zähler über alle abgeschlossenen Ticks der laufenden bzw. zuletzt
* gelaufenen Scheduler-Sitzung.
* <p>
* Eine Sitzung beginnt mit dem nächsten erfolgreichen
* {@link SchedulerControlUseCase#start()} und endet mit dem zugehörigen
* {@link SchedulerControlUseCase#stop()}. Beim Start einer neuen Sitzung
* werden die Zähler auf null zurückgesetzt; nach dem Stopp bleiben sie
* eingefroren sichtbar, bis der Scheduler erneut gestartet wird.
* <p>
* Übersprungene Dokumente werden in dieser Sitzungsstatistik bewusst nicht
* gezählt, da sie für den Bediener keine neue Verarbeitungsleistung darstellen.
*
* @param successCount Summe aller erfolgreich verarbeiteten Dokumente seit
* Sitzungsstart; nie negativ
* @param failedCount Summe aller fehlgeschlagenen Dokumente seit Sitzungsstart
* (retryable und final zusammengefasst); nie negativ
*/
public record SchedulerSessionTotals(int successCount, int failedCount) {
/**
* Validiert, dass beide Zähler nicht negativ sind.
*
* @throws IllegalArgumentException wenn einer der Zähler kleiner als null ist
*/
public SchedulerSessionTotals {
if (successCount < 0 || failedCount < 0) {
throw new IllegalArgumentException(
"SchedulerSessionTotals counts must not be negative; was: "
+ successCount + "/" + failedCount);
}
}
/**
* Liefert ein neutrales Ausgangsobjekt mit beiden Zählern auf null.
*
* @return Sitzungstotal mit allen Zählern auf null; nie {@code null}
*/
public static SchedulerSessionTotals zero() {
return new SchedulerSessionTotals(0, 0);
}
/**
* Liefert ein neues Sitzungstotal, in dem die übergebenen Werte additiv
* aufgenommen wurden. Das aktuelle Objekt bleibt unverändert.
*
* @param additionalSuccess hinzuzurechnende Erfolgreich-Zahl; muss &ge; 0 sein
* @param additionalFailed hinzuzurechnende Fehler-Zahl; muss &ge; 0 sein
* @return aufaddiertes Sitzungstotal; nie {@code null}
*/
public SchedulerSessionTotals plus(int additionalSuccess, int additionalFailed) {
return new SchedulerSessionTotals(
successCount + additionalSuccess,
failedCount + additionalFailed);
}
}
@@ -26,13 +26,19 @@ import de.gecheckt.pdf.umbenenner.application.port.out.RunSummary;
* @param lastError letzte aufgetretene deutsche Fehlermeldung;
* wird bei erfolgreichem Lauf gelöscht,
* bei {@code SkippedBusy} unverändert gelassen
* @param sessionTotals aggregierte Zähler seit dem letzten Sitzungsstart;
* leer vor dem allerersten {@code start()}, ab dem
* ersten erfolgreichen Start gefüllt und bei jedem
* weiteren Start auf null zurückgesetzt; nach dem
* Stopp bleibt der eingefrorene Endwert sichtbar
*/
public record SchedulerStatus(
SchedulerState state,
Optional<Instant> lastRunEndedAt,
Optional<RunSummary> lastRunSummary,
Optional<Instant> nextTickAt,
Optional<String> lastError
Optional<String> lastError,
Optional<SchedulerSessionTotals> sessionTotals
) {
/**
@@ -54,6 +60,9 @@ public record SchedulerStatus(
if (lastError == null) {
throw new IllegalArgumentException("lastError darf nicht null sein");
}
if (sessionTotals == null) {
throw new IllegalArgumentException("sessionTotals darf nicht null sein");
}
}
/**
@@ -70,6 +79,7 @@ public record SchedulerStatus(
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty()
);
}
@@ -1,6 +1,7 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerSessionTotals;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStartException;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerState;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStatus;
@@ -132,9 +133,15 @@ public class DefaultSchedulerControlUseCase implements SchedulerControlUseCase {
"Scheduler-Adapter konnte nicht gestartet werden.", e);
}
// Schritt 4: Zustand auf RUNNING_IDLE setzen
// Schritt 4: Zustand auf RUNNING_IDLE setzen, Sitzungstotal auf null zurücksetzen
Instant nextTick = Instant.now().plusSeconds(intervalSeconds);
statusRef.updateAndGet(s -> withState(s, SchedulerState.RUNNING_IDLE, Optional.of(nextTick)));
statusRef.updateAndGet(s -> new SchedulerStatus(
SchedulerState.RUNNING_IDLE,
s.lastRunEndedAt(),
s.lastRunSummary(),
Optional.of(nextTick),
s.lastError(),
Optional.of(SchedulerSessionTotals.zero())));
logger.info("Scheduler gestartet. Intervall: {} Sekunden.", intervalSeconds);
}
@@ -247,12 +254,17 @@ public class DefaultSchedulerControlUseCase implements SchedulerControlUseCase {
Optional<Instant> lastRunEndedAt = afterBatch.lastRunEndedAt();
Optional<RunSummary> lastRunSummary = afterBatch.lastRunSummary();
Optional<String> lastError = afterBatch.lastError();
Optional<SchedulerSessionTotals> sessionTotals = afterBatch.sessionTotals();
switch (result) {
case BatchRunTriggerResult.Started started -> {
lastRunEndedAt = Optional.of(started.endedAt());
lastRunSummary = Optional.of(started.summary());
lastError = Optional.empty();
sessionTotals = Optional.of(sessionTotals
.orElse(SchedulerSessionTotals.zero())
.plus(started.summary().successCount(),
started.summary().failedCount()));
logger.info("Scheduler-Tick abgeschlossen: {} erfolgreich, {} fehlgeschlagen, {} übersprungen.",
started.summary().successCount(),
started.summary().failedCount(),
@@ -271,7 +283,8 @@ public class DefaultSchedulerControlUseCase implements SchedulerControlUseCase {
lastRunEndedAt,
lastRunSummary,
nextTickAt,
lastError));
lastError,
sessionTotals));
if (stopping) {
lockPort.releaseLock();
@@ -292,6 +305,7 @@ public class DefaultSchedulerControlUseCase implements SchedulerControlUseCase {
base.lastRunEndedAt(),
base.lastRunSummary(),
nextTickAt,
base.lastError());
base.lastError(),
base.sessionTotals());
}
}
@@ -19,6 +19,7 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerSessionTotals;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStartException;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerState;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStatus;
@@ -63,6 +64,93 @@ class DefaultSchedulerControlUseCaseTest {
assertThat(status.lastRunSummary()).isEmpty();
assertThat(status.nextTickAt()).isEmpty();
assertThat(status.lastError()).isEmpty();
assertThat(status.sessionTotals()).isEmpty();
}
@Test
void start_initializesSessionTotalsToZero() {
DefaultSchedulerControlUseCase useCase = createUseCase();
useCase.start();
SchedulerStatus status = useCase.getStatus();
assertThat(status.sessionTotals()).contains(SchedulerSessionTotals.zero());
}
@Test
void tick_started_accumulatesIntoSessionTotals() {
DefaultSchedulerControlUseCase useCase = createUseCase();
useCase.start();
when(batchRunTrigger.triggerRun())
.thenReturn(new BatchRunTriggerResult.Started(Instant.now(), new RunSummary(2, 1, 4)))
.thenReturn(new BatchRunTriggerResult.Started(Instant.now(), new RunSummary(3, 0, 0)));
useCase.executeWrappedTick();
useCase.executeWrappedTick();
SchedulerStatus status = useCase.getStatus();
assertThat(status.sessionTotals()).contains(new SchedulerSessionTotals(5, 1));
}
@Test
void tick_skippedBusy_doesNotChangeSessionTotals() {
DefaultSchedulerControlUseCase useCase = createUseCase();
useCase.start();
when(batchRunTrigger.triggerRun())
.thenReturn(new BatchRunTriggerResult.Started(Instant.now(), new RunSummary(2, 1, 0)))
.thenReturn(new BatchRunTriggerResult.SkippedBusy());
useCase.executeWrappedTick();
useCase.executeWrappedTick();
SchedulerStatus status = useCase.getStatus();
assertThat(status.sessionTotals()).contains(new SchedulerSessionTotals(2, 1));
}
@Test
void tick_failed_doesNotChangeSessionTotals() {
DefaultSchedulerControlUseCase useCase = createUseCase();
useCase.start();
when(batchRunTrigger.triggerRun())
.thenReturn(new BatchRunTriggerResult.Started(Instant.now(), new RunSummary(1, 1, 0)))
.thenReturn(new BatchRunTriggerResult.Failed("Fehler", "tech"));
useCase.executeWrappedTick();
useCase.executeWrappedTick();
SchedulerStatus status = useCase.getStatus();
assertThat(status.sessionTotals()).contains(new SchedulerSessionTotals(1, 1));
}
@Test
void stop_keepsSessionTotalsVisible() {
DefaultSchedulerControlUseCase useCase = createUseCase();
useCase.start();
when(batchRunTrigger.triggerRun())
.thenReturn(new BatchRunTriggerResult.Started(Instant.now(), new RunSummary(7, 2, 0)));
useCase.executeWrappedTick();
useCase.stop();
SchedulerStatus status = useCase.getStatus();
assertThat(status.state()).isEqualTo(SchedulerState.STOPPED);
assertThat(status.sessionTotals()).contains(new SchedulerSessionTotals(7, 2));
}
@Test
void start_afterPreviousSession_resetsSessionTotalsToZero() {
DefaultSchedulerControlUseCase useCase = createUseCase();
useCase.start();
when(batchRunTrigger.triggerRun())
.thenReturn(new BatchRunTriggerResult.Started(Instant.now(), new RunSummary(5, 3, 0)));
useCase.executeWrappedTick();
useCase.stop();
assertThat(useCase.getStatus().sessionTotals())
.contains(new SchedulerSessionTotals(5, 3));
useCase.start();
assertThat(useCase.getStatus().sessionTotals()).contains(SchedulerSessionTotals.zero());
}
@Test
@@ -1094,16 +1094,45 @@ public class BootstrapRunner {
RunLockPort runLockPort = runLockPortFactory.create(
resolveLockFilePath(ctx.startConfiguration()));
BatchRunContext runContext = createRunContext();
java.util.concurrent.atomic.AtomicReference<de.gecheckt.pdf.umbenenner.application.port.in.RunSummary>
capturedSummary = new java.util.concurrent.atomic.AtomicReference<>();
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver capturingObserver =
new de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver() {
@Override
public void onRunStarted(RunId runId, int totalCandidates) {
// No GUI feedback for scheduler-driven runs.
}
@Override
public void onDocumentCompleted(
de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent event) {
// No per-document feedback for scheduler-driven runs.
}
@Override
public void onRunEnded(
de.gecheckt.pdf.umbenenner.application.port.in.RunSummary summary) {
capturedSummary.set(summary);
}
};
BatchRunProcessingUseCase useCase = buildProductionBatchUseCase(
ctx.startConfiguration(), runLockPort,
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver.noOp(),
capturingObserver,
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken.neverCancelled());
BatchRunOutcome outcome = useCase.execute(runContext);
runContext.setEndInstant(Instant.now());
if (outcome.isLockUnavailable()) {
return new BatchRunTriggerResult.SkippedBusy();
} else if (outcome.isSuccess()) {
return new BatchRunTriggerResult.Started(Instant.now(), RunSummary.noOp());
de.gecheckt.pdf.umbenenner.application.port.in.RunSummary inSummary =
capturedSummary.get();
RunSummary outSummary = inSummary == null
? RunSummary.noOp()
: new RunSummary(
inSummary.successCount(),
inSummary.failedCount(),
inSummary.skippedCount());
return new BatchRunTriggerResult.Started(Instant.now(), outSummary);
} else {
return new BatchRunTriggerResult.Failed(
"Verarbeitungslauf fehlgeschlagen.",