diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/lock/FilesystemRunLockPortAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/lock/FilesystemRunLockPortAdapter.java index 757e98b..54002e8 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/lock/FilesystemRunLockPortAdapter.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/lock/FilesystemRunLockPortAdapter.java @@ -4,22 +4,26 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import de.gecheckt.pdf.umbenenner.application.port.out.RunLockHandle; import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort; import de.gecheckt.pdf.umbenenner.application.port.out.RunLockUnavailableException; /** - * File-based implementation of {@link RunLockPort} that uses a lock file to prevent concurrent runs. + * Dateibasierte Implementierung von {@link RunLockPort}. *
- * Creates an exclusive lock file on acquire and deletes it on release. - * If the lock file already exists, {@link #acquire()} throws {@link RunLockUnavailableException} - * to signal that another instance is already running. + * Verwendet eine Lock-Datei, um parallele Läufe zu verhindern. + * Beim Erwerb wird die Lock-Datei angelegt; bei der Freigabe wird sie gelöscht. + * Existiert die Datei bereits, ist der Lock belegt. *
- * The lock file contains the PID of the acquiring process. Release is best-effort: a failure - * to delete the lock file is logged as a warning but does not throw. + * Die Lock-Datei enthält die PID des erwerbenden Prozesses. + * Die Freigabe ist best-effort: Ein Fehler beim Löschen wird als Warnung + * geloggt, wirft aber keine Ausnahme. */ public class FilesystemRunLockPortAdapter implements RunLockPort { @@ -28,27 +32,31 @@ public class FilesystemRunLockPortAdapter implements RunLockPort { private final Path lockFile; /** - * Creates a new FilesystemRunLockPortAdapter for the given lock file path. + * Erstellt einen neuen {@code FilesystemRunLockPortAdapter} für den + * angegebenen Lock-Datei-Pfad. * - * @param lockFile path of the lock file to create on acquire and delete on release + * @param lockFile Pfad der Lock-Datei, die beim Erwerb angelegt und + * bei der Freigabe gelöscht wird */ public FilesystemRunLockPortAdapter(Path lockFile) { this.lockFile = lockFile; } /** - * Acquires the run lock by creating the lock file. + * Erwirbt den Run-Lock durch Anlegen der Lock-Datei (blockierend). *
- * If the lock file already exists, throws {@link RunLockUnavailableException}. - * If the parent directory does not exist, it is created before attempting file creation. + * Existiert die Lock-Datei bereits, wird eine + * {@link RunLockUnavailableException} geworfen. Das übergeordnete + * Verzeichnis wird bei Bedarf angelegt. * - * @throws RunLockUnavailableException if the lock file already exists or cannot be created + * @throws RunLockUnavailableException wenn die Lock-Datei bereits existiert + * oder nicht angelegt werden kann */ @Override public void acquire() { if (Files.exists(lockFile)) { throw new RunLockUnavailableException( - "Run lock file already exists - another instance may be running: " + lockFile); + "Run-Lock-Datei existiert bereits – eine andere Instanz könnte laufen: " + lockFile); } try { Path parent = lockFile.getParent(); @@ -57,26 +65,83 @@ public class FilesystemRunLockPortAdapter implements RunLockPort { } long pid = ProcessHandle.current().pid(); Files.writeString(lockFile, String.valueOf(pid), StandardOpenOption.CREATE_NEW); - LOG.debug("Run lock acquired: {} (PID {})", lockFile, pid); + LOG.debug("Run-Lock erworben: {} (PID {})", lockFile, pid); } catch (IOException e) { - throw new RunLockUnavailableException("Failed to acquire run lock file: " + lockFile, e); + throw new RunLockUnavailableException("Run-Lock-Datei konnte nicht angelegt werden: " + lockFile, e); } } /** - * Releases the run lock by deleting the lock file. + * Gibt den Run-Lock durch Löschen der Lock-Datei frei. *
- * If deletion fails, a warning is logged but no exception is thrown. + * Schlägt das Löschen fehl, wird eine Warnung geloggt; keine Ausnahme + * wird geworfen. */ @Override public void release() { try { boolean deleted = Files.deleteIfExists(lockFile); if (deleted) { - LOG.debug("Run lock released: {}", lockFile); + LOG.debug("Run-Lock freigegeben: {}", lockFile); } } catch (IOException e) { - LOG.warn("Failed to release run lock file: {} — manual cleanup may be required", lockFile, e); + LOG.warn("Run-Lock-Datei konnte nicht gelöscht werden: {} – manuelle Bereinigung erforderlich", + lockFile, e); + } + } + + /** + * Versucht nicht-blockierend, den Run-Lock zu erwerben. + *
+ * Existiert die Lock-Datei bereits, wird sofort {@link Optional#empty()} + * zurückgegeben. Andernfalls wird die Datei atomar mit + * {@link StandardOpenOption#CREATE_NEW} angelegt. Schlägt das Anlegen + * aufgrund einer Race-Condition fehl (z.B. gleichzeitiger Erwerb durch + * eine andere Instanz), wird ebenfalls {@link Optional#empty()} zurückgegeben. + *
+ * Das zurückgegebene {@link RunLockHandle} gibt den Lock idempotent frei.
+ *
+ * @return Handle mit dem erworbenen Lock, oder {@link Optional#empty()}
+ * wenn der Lock nicht verfügbar ist
+ */
+ @Override
+ public Optional
+ * Gibt den Lock idempotent frei. Mehrfaches Aufrufen von {@link #close()}
+ * hat nach dem ersten Aufruf keine Wirkung.
+ */
+ private class FilesystemRunLockHandle implements RunLockHandle {
+
+ private final AtomicBoolean released = new AtomicBoolean(false);
+
+ @Override
+ public void close() {
+ if (released.compareAndSet(false, true)) {
+ FilesystemRunLockPortAdapter.this.release();
+ }
}
}
}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/SchedulerControlUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/SchedulerControlUseCase.java
new file mode 100644
index 0000000..e17b1ca
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/SchedulerControlUseCase.java
@@ -0,0 +1,63 @@
+package de.gecheckt.pdf.umbenenner.application.port.in;
+
+/**
+ * Inbound-Port zur Steuerung des automatischen Schedulers.
+ *
+ * Dieser Use Case kapselt den vollständigen Scheduler-Lifecycle:
+ * Starten, Stoppen und Abfragen des aktuellen Zustands. Die Steuerung
+ * erfolgt ausschließlich über dieses Interface – GUI-Komponenten kennen
+ * weder den {@code SchedulerPort} noch Bootstrap-interne Typen.
+ *
+ * Alle Operationen sind idempotent: Ein {@link #start()} auf einem
+ * bereits laufenden Scheduler ist ein No-op; ein {@link #stop()} auf
+ * einem bereits gestoppten Scheduler ebenso.
+ *
+ * Implementierungen verwalten den Zustand threadsicher über eine
+ * {@code AtomicReference
+ * Ist der Scheduler bereits aktiv ({@code state != STOPPED}), hat
+ * dieser Aufruf keine Wirkung. Andernfalls wird der Scheduler über
+ * folgende Sequenz gestartet:
+ *
+ * Ist der Scheduler bereits gestoppt, hat dieser Aufruf keine Wirkung.
+ * Läuft gerade ein Tick, wechselt der Zustand zu
+ * {@code STOPPING_BATCH_ACTIVE}; der laufende Batch wird regulär
+ * zu Ende geführt. Danach werden {@code scheduler.enabled=false}
+ * persistiert und der OS-Lock freigegeben.
+ */
+ void stop();
+
+ /**
+ * Gibt den aktuellen Scheduler-Zustand als unveränderlichen Snapshot zurück.
+ *
+ * Der Snapshot kann von beliebigen Threads gelesen werden. Die GUI
+ * ruft diese Methode regelmäßig über eine zentrale Status-Refresh-Timeline
+ * auf und aktualisiert alle betroffenen Tabs entsprechend.
+ *
+ * @return aktueller Scheduler-Status; nie {@code null}
+ */
+ SchedulerStatus getStatus();
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/SchedulerStartException.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/SchedulerStartException.java
new file mode 100644
index 0000000..6b7bbe6
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/SchedulerStartException.java
@@ -0,0 +1,34 @@
+package de.gecheckt.pdf.umbenenner.application.port.in;
+
+/**
+ * Wird geworfen, wenn der Start des automatischen Schedulers fehlschlägt.
+ *
+ * Mögliche Ursachen sind: Fehler beim Erwerb des Konfigurations-Datei-Locks,
+ * Fehler beim Persistieren von {@code scheduler.enabled=true} oder
+ * technische Fehler beim Starten des Scheduler-Adapters.
+ *
+ * Diese Ausnahme ist ungeprüft (extends {@link RuntimeException}) und
+ * wird in der Callchain bis zum GUI-Layer weitergeleitet, der eine
+ * benutzerfreundliche deutsche Fehlermeldung anzeigt.
+ */
+public class SchedulerStartException extends RuntimeException {
+
+ /**
+ * Erstellt eine neue {@code SchedulerStartException} mit der angegebenen Nachricht.
+ *
+ * @param message benutzerlesbare deutsche Fehlerbeschreibung
+ */
+ public SchedulerStartException(String message) {
+ super(message);
+ }
+
+ /**
+ * Erstellt eine neue {@code SchedulerStartException} mit Nachricht und Ursache.
+ *
+ * @param message benutzerlesbare deutsche Fehlerbeschreibung
+ * @param cause technische Ursache
+ */
+ public SchedulerStartException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/SchedulerState.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/SchedulerState.java
new file mode 100644
index 0000000..8dcb5f1
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/SchedulerState.java
@@ -0,0 +1,72 @@
+package de.gecheckt.pdf.umbenenner.application.port.in;
+
+/**
+ * Zustandsautomat des automatischen Schedulers.
+ *
+ * Beschreibt den aktuellen Lebenszykluszustand des Schedulers und
+ * steuert, welche Aktionen (Starten, Stoppen, manuelle Läufe)
+ * in der GUI erlaubt sind.
+ */
+public enum SchedulerState {
+
+ /**
+ * Scheduler ist gestoppt.
+ *
+ * Manuelle Läufe sind erlaubt. Der Scheduler-Start-Button ist
+ * aktiv, sofern weitere Voraussetzungen erfüllt sind.
+ */
+ STOPPED,
+
+ /**
+ * Scheduler befindet sich im Startvorgang.
+ *
+ * Lock-Erwerb und initiale Einrichtung laufen. Manuelle Starts
+ * sind in diesem Übergangszustand deterministisch gesperrt.
+ */
+ STARTING,
+
+ /**
+ * Scheduler läuft und wartet auf den nächsten Tick.
+ *
+ * Kein Batch läuft gerade. Der Countdown bis zum nächsten Tick
+ * ist sichtbar. Manuelle Läufe sind gesperrt.
+ */
+ RUNNING_IDLE,
+
+ /**
+ * Scheduler läuft und ein Tick verarbeitet gerade einen Batch.
+ *
+ * Manuelle Läufe sind gesperrt. Der Stop-Button bleibt aktiv,
+ * bricht den laufenden Batch jedoch nicht ab.
+ */
+ RUNNING_BATCH_ACTIVE,
+
+ /**
+ * Stopp wurde angefordert, aber der laufende Batch läuft noch zu Ende.
+ *
+ * Nach Abschluss des Batches wechselt der Zustand zu {@link #STOPPED}.
+ * Der Status-Indikator zeigt „Gestoppt – aktueller Lauf läuft noch".
+ */
+ STOPPING_BATCH_ACTIVE;
+
+ /**
+ * Prüft, ob der Scheduler in diesem Zustand als aktiv gilt.
+ *
+ * Als aktiv gelten alle Zustände außer {@link #STOPPED}.
+ *
+ * @return {@code true}, wenn der Scheduler nicht gestoppt ist
+ */
+ public boolean isActive() {
+ return this != STOPPED;
+ }
+
+ /**
+ * Prüft, ob in diesem Zustand ein Batch verarbeitet wird.
+ *
+ * @return {@code true} bei {@link #RUNNING_BATCH_ACTIVE} oder
+ * {@link #STOPPING_BATCH_ACTIVE}
+ */
+ public boolean isBatchRunning() {
+ return this == RUNNING_BATCH_ACTIVE || this == STOPPING_BATCH_ACTIVE;
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/SchedulerStatus.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/SchedulerStatus.java
new file mode 100644
index 0000000..1f65eac
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/SchedulerStatus.java
@@ -0,0 +1,80 @@
+package de.gecheckt.pdf.umbenenner.application.port.in;
+
+import java.time.Instant;
+import java.util.Optional;
+
+import de.gecheckt.pdf.umbenenner.application.port.out.RunSummary;
+
+/**
+ * Unveränderlicher Snapshot des aktuellen Scheduler-Zustands.
+ *
+ * Instanzen dieses Records sind threadsicher, da alle Felder final sind.
+ * Im {@code DefaultSchedulerControlUseCase} werden Snapshots atomar über
+ * eine {@code AtomicReference
+ * Die GUI liest diesen Snapshot regelmäßig über eine zentrale
+ * Status-Refresh-Timeline (1 Hz) und aktualisiert Scheduler-Tab,
+ * Batch-Tab und Konfig-Tab entsprechend.
+ *
+ * @param state aktueller Lebenszykluszustand des Schedulers
+ * @param lastRunEndedAt Endzeitpunkt des letzten abgeschlossenen Laufs;
+ * leer vor dem ersten Lauf
+ * @param lastRunSummary Zusammenfassung des letzten abgeschlossenen Laufs;
+ * leer vor dem ersten Lauf
+ * @param nextTickAt geplanter Zeitpunkt des nächsten Ticks;
+ * nur befüllt wenn {@code state == RUNNING_IDLE}
+ * @param lastError letzte aufgetretene deutsche Fehlermeldung;
+ * wird bei erfolgreichem Lauf gelöscht,
+ * bei {@code SkippedBusy} unverändert gelassen
+ * @param autostartFailed {@code true}, wenn ein konfigurierter Autostart
+ * beim Programmstart fehlgeschlagen ist
+ */
+public record SchedulerStatus(
+ SchedulerState state,
+ Optional
+ * Zustand ist {@link SchedulerState#STOPPED}, alle optionalen Felder
+ * sind leer und {@code autostartFailed} ist {@code false}.
+ *
+ * @return initialer Scheduler-Status
+ */
+ public static SchedulerStatus initial() {
+ return new SchedulerStatus(
+ SchedulerState.STOPPED,
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ false
+ );
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/BatchRunTrigger.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/BatchRunTrigger.java
new file mode 100644
index 0000000..4b74950
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/BatchRunTrigger.java
@@ -0,0 +1,36 @@
+package de.gecheckt.pdf.umbenenner.application.port.out;
+
+/**
+ * Funktionales Interface zum Auslösen eines Verarbeitungslaufs.
+ *
+ * Dieses Interface entkoppelt den Scheduler-Adapter von konkreten
+ * Bootstrap- oder GUI-Klassen. Das Bootstrap-Modul erzeugt beim Start
+ * eine Implementierung als Lambda und übergibt sie beim Starten des
+ * Schedulers an {@link SchedulerPort#startScheduler(SchedulerConfig, BatchRunTrigger)}.
+ *
+ * Ausführungsmodell:
+ *
+ * Ist der RunLock nicht verfügbar, kehrt diese Methode sofort mit
+ * {@link BatchRunTriggerResult.SkippedBusy} zurück, ohne einen Lauf
+ * zu starten. Wird der Lauf gestartet, kehrt die Methode erst nach
+ * vollständigem Abschluss zurück.
+ *
+ * @return Ergebnis des Laufs; nie {@code null}
+ */
+ BatchRunTriggerResult triggerRun();
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/BatchRunTriggerResult.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/BatchRunTriggerResult.java
new file mode 100644
index 0000000..a62d074
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/BatchRunTriggerResult.java
@@ -0,0 +1,87 @@
+package de.gecheckt.pdf.umbenenner.application.port.out;
+
+import java.time.Instant;
+
+/**
+ * Ergebnis eines über {@link BatchRunTrigger#triggerRun()} ausgelösten
+ * Verarbeitungslaufs.
+ *
+ * Die sealed Hierarchie ermöglicht erschöpfendes Pattern-Matching:
+ *
+ * {@link Throwable}-Instanzen werden nicht im Result-Objekt transportiert.
+ * Stacktraces werden im Adapter geloggt; die GUI erhält ausschließlich
+ * benutzerfreundliche deutsche Meldungen.
+ */
+public sealed interface BatchRunTriggerResult
+ permits BatchRunTriggerResult.Started,
+ BatchRunTriggerResult.SkippedBusy,
+ BatchRunTriggerResult.Failed {
+
+ /**
+ * Der Lauf wurde erfolgreich gestartet und abgeschlossen.
+ *
+ * Das Ergebnis enthält den Endzeitpunkt des Laufs sowie eine
+ * {@link RunSummary} mit den aggregierten Verarbeitungszählern.
+ * Ein No-op-Lauf (keine Kandidaten) ist ebenfalls {@code Started}
+ * und liefert eine {@link RunSummary} mit allen Zählern gleich null.
+ *
+ * @param endedAt Zeitpunkt, zu dem der Lauf abgeschlossen wurde
+ * @param summary Zusammenfassung der Verarbeitungsergebnisse
+ */
+ record Started(Instant endedAt, RunSummary summary)
+ implements BatchRunTriggerResult {
+
+ /**
+ * Validiert, dass Pflichtfelder nicht null sind.
+ */
+ public Started {
+ if (endedAt == null) {
+ throw new IllegalArgumentException("endedAt darf nicht null sein");
+ }
+ if (summary == null) {
+ throw new IllegalArgumentException("summary darf nicht null sein");
+ }
+ }
+ }
+
+ /**
+ * Der Lauf wurde übersprungen, weil bereits ein anderer Lauf aktiv war.
+ *
+ * {@link BatchRunTrigger#triggerRun()} kehrt sofort zurück, ohne
+ * einen neuen Lauf zu starten. Dieser Zustand ist kein Fehler.
+ */
+ record SkippedBusy() implements BatchRunTriggerResult {}
+
+ /**
+ * Der Lauf ist mit einem technischen Fehler beendet worden.
+ *
+ * Enthält eine benutzerlesbare deutsche Meldung für die GUI-Anzeige
+ * sowie eine technische Meldung für Diagnose-Logging. Der zugehörige
+ * Stacktrace wurde bereits im Adapter auf ERROR geloggt und wird hier
+ * nicht transportiert.
+ *
+ * @param userMessage deutsche, GUI-taugliche Fehlermeldung für den Endanwender
+ * @param technicalMessage technische Detailmeldung für Diagnose und Logging
+ */
+ record Failed(String userMessage, String technicalMessage)
+ implements BatchRunTriggerResult {
+
+ /**
+ * Validiert, dass Pflichtfelder nicht null sind.
+ */
+ public Failed {
+ if (userMessage == null) {
+ throw new IllegalArgumentException("userMessage darf nicht null sein");
+ }
+ if (technicalMessage == null) {
+ throw new IllegalArgumentException("technicalMessage darf nicht null sein");
+ }
+ }
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ConfigurationFileLockException.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ConfigurationFileLockException.java
new file mode 100644
index 0000000..5ac14be
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ConfigurationFileLockException.java
@@ -0,0 +1,37 @@
+package de.gecheckt.pdf.umbenenner.application.port.out;
+
+/**
+ * Wird geworfen, wenn der exklusive OS-Lock auf die Konfigurationsdatei
+ * nicht erworben werden kann.
+ *
+ * Typische Ursachen sind: ein externer Prozess hält die Datei bereits
+ * gesperrt, ein Netzlaufwerk reagiert nicht innerhalb der Deadline,
+ * oder die Datei ist nicht zugänglich.
+ *
+ * Diese Ausnahme ist ungeprüft und wird vom Aufrufer
+ * ({@link ConfigurationFileLockPort#acquireLock()}) in eine
+ * benutzerfreundliche GUI-Meldung umgewandelt.
+ */
+public class ConfigurationFileLockException extends RuntimeException {
+
+ /**
+ * Erstellt eine neue {@code ConfigurationFileLockException} mit der
+ * angegebenen Nachricht.
+ *
+ * @param message deutsche Fehlerbeschreibung
+ */
+ public ConfigurationFileLockException(String message) {
+ super(message);
+ }
+
+ /**
+ * Erstellt eine neue {@code ConfigurationFileLockException} mit
+ * Nachricht und Ursache.
+ *
+ * @param message deutsche Fehlerbeschreibung
+ * @param cause technische Ursache
+ */
+ public ConfigurationFileLockException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ConfigurationFileLockPort.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ConfigurationFileLockPort.java
new file mode 100644
index 0000000..186dc4d
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ConfigurationFileLockPort.java
@@ -0,0 +1,51 @@
+package de.gecheckt.pdf.umbenenner.application.port.out;
+
+/**
+ * Outbound-Port für den exklusiven OS-Lock auf die Konfigurationsdatei.
+ *
+ * Solange der Scheduler läuft oder ein Verarbeitungslauf aktiv ist, hält
+ * die Anwendung einen exklusiven Lock auf die {@code .properties}-Datei.
+ * Dieser Lock schützt vor konkurrierenden Schreibzugriffen durch externe
+ * Prozesse (z.B. Texteditoren).
+ *
+ * Der Lock wird als bestmöglicher OS-Level-Schreibschutz betrachtet und
+ * erhebt keinen Anspruch darauf, alle externen Schreibstrategien zu
+ * verhindern (z.B. Delete-Rename durch manche Editoren).
+ *
+ * Alle Operationen müssen im Worker-Thread ausgeführt werden,
+ * niemals auf dem JavaFX Application Thread.
+ */
+public interface ConfigurationFileLockPort {
+
+ /**
+ * Erwirbt den exklusiven OS-Lock auf die Konfigurationsdatei.
+ *
+ * Falls die Datei bereits durch einen anderen Prozess gesperrt ist,
+ * wird der Erwerb mit einer konfigurierbaren Deadline-Schleife
+ * versucht. Schlägt der Erwerb nach Ablauf der Deadline fehl,
+ * wird eine {@link ConfigurationFileLockException} geworfen.
+ *
+ * Ist der Lock bereits durch diese Instanz gehalten, hat dieser
+ * Aufruf keine Wirkung.
+ *
+ * @throws ConfigurationFileLockException wenn der Lock nicht innerhalb
+ * der Deadline erworben werden kann
+ */
+ void acquireLock() throws ConfigurationFileLockException;
+
+ /**
+ * Gibt den exklusiven Lock frei.
+ *
+ * Ist kein Lock aktiv, hat dieser Aufruf keine Wirkung (idempotent).
+ * Implementierungen dürfen bei der Freigabe keine geprüfte Ausnahme
+ * werfen; Fehler werden geloggt und still übergangen.
+ */
+ void releaseLock();
+
+ /**
+ * Prüft, ob der Lock aktuell von dieser Instanz gehalten wird.
+ *
+ * @return {@code true}, wenn der Lock aktiv ist
+ */
+ boolean isLocked();
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/RunLockHandle.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/RunLockHandle.java
new file mode 100644
index 0000000..1f96bfb
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/RunLockHandle.java
@@ -0,0 +1,26 @@
+package de.gecheckt.pdf.umbenenner.application.port.out;
+
+/**
+ * Handle für einen erworbenen Run-Lock.
+ *
+ * Dieses Interface ermöglicht die Nutzung des Run-Locks in einem
+ * try-with-resources-Block. Das Schließen des Handles gibt den Lock
+ * idempotent frei – mehrfaches Aufrufen von {@link #close()} ist sicher
+ * und hat nach dem ersten Aufruf keine Wirkung.
+ *
+ * Instanzen dieses Typs werden ausschließlich von
+ * {@link RunLockPort#tryAcquire()} erzeugt und dürfen nicht
+ * weitergegeben oder gecacht werden.
+ */
+public interface RunLockHandle extends AutoCloseable {
+
+ /**
+ * Gibt den Run-Lock frei.
+ *
+ * Diese Methode ist idempotent: Mehrfaches Aufrufen hat nach dem
+ * ersten Aufruf keine weitere Wirkung. Implementierungen dürfen
+ * keine geprüfte Ausnahme werfen.
+ */
+ @Override
+ void close();
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/RunLockPort.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/RunLockPort.java
index b8ba21a..d5088ab 100644
--- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/RunLockPort.java
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/RunLockPort.java
@@ -1,54 +1,76 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
+import java.util.Optional;
+
/**
- * Outbound port for exclusive run locking.
+ * Outbound-Port für den exklusiven Run-Lock.
*
- * This port abstracts the mechanism for ensuring that only one instance of the PDF Umbenenner
- * is executing at any given time. The port defines the contract without prescribing the
- * implementation (e.g., file-based locks, OS-level locks, distributed locks).
+ * Stellt sicher, dass zu einem Zeitpunkt nur eine Instanz des PDF-Umbenenners
+ * einen Verarbeitungslauf ausführt. Der Port abstrahiert den Mechanismus
+ * (z.B. dateibasierter Lock) ohne eine konkrete Implementierung vorzuschreiben.
*
- * Responsibilities:
+ * Verantwortlichkeiten:
*
- * Lock Lifecycle:
- *
- * This method blocks or throws an exception if the lock cannot be acquired
- * (e.g., another instance already holds it). The behavior depends on the implementation.
- *
- * If this method returns normally, the caller holds the lock and must ensure
- * {@link #release()} is called to free it, typically in a finally block.
+ * Wenn der Lock nicht erworben werden kann (z.B. weil eine andere
+ * Instanz ihn bereits hält), wird eine {@link RunLockUnavailableException}
+ * geworfen. Bei normalem Rücksprung hält der Aufrufer den Lock und muss
+ * {@link #release()} in einem {@code finally}-Block aufrufen.
*
- * @throws RunLockUnavailableException if the lock cannot be acquired
- * (e.g., another instance already holds it or system error prevents acquiring)
- * @throws RuntimeException for other critical lock-related failures
+ * @throws RunLockUnavailableException wenn der Lock nicht erworben werden kann
+ * @throws RuntimeException bei anderen kritischen Fehlern
*/
void acquire();
/**
- * Releases the exclusive lock held by this batch run.
+ * Gibt den exklusiven Run-Lock frei.
*
- * This method is called after batch processing completes (successfully or not)
- * to allow other instances to run.
- *
- * Implementations should handle the case where release is called multiple times
- * or when no lock is currently held, avoiding exceptions if possible.
+ * Wird nach Abschluss des Laufs (erfolgreich oder fehlerhaft) aufgerufen.
+ * Implementierungen sollen keinen Fehler werfen, wenn der Lock bereits
+ * freigegeben wurde oder gar nicht gehalten wird.
*
- * @throws RuntimeException if lock release fails critically
+ * @throws RuntimeException wenn die Freigabe kritisch fehlschlägt
*/
void release();
+
+ /**
+ * Versucht nicht-blockierend, den Run-Lock zu erwerben.
+ *
+ * Gibt ein {@link RunLockHandle} zurück, wenn der Lock erfolgreich
+ * erworben wurde. Das Handle kann in einem try-with-resources-Block
+ * verwendet werden; {@link RunLockHandle#close()} gibt den Lock
+ * idempotent frei.
+ *
+ * Ist der Lock bereits durch eine andere Instanz gehalten, wird sofort
+ * {@link Optional#empty()} zurückgegeben – ohne zu warten oder zu queuen.
+ * Diese Methode ist race-condition-sicher und frei von check-then-act-Mustern.
+ *
+ * @return Handle mit dem erworbenen Lock, oder {@link Optional#empty()}
+ * wenn der Lock nicht verfügbar ist
+ * @throws RuntimeException bei kritischen technischen Fehlern beim
+ * Lock-Versuch (nicht bei normaler Nicht-Verfügbarkeit)
+ */
+ Optional
+ * Enthält die aggregierten Zähler für erfolgreich verarbeitete,
+ * fehlgeschlagene und übersprungene Dokumente. Ein No-op-Lauf –
+ * bei dem keine Kandidaten im Quellordner gefunden wurden – wird
+ * durch {@code RunSummary(0, 0, 0)} repräsentiert.
+ *
+ * @param successCount Anzahl der in diesem Lauf erfolgreich verarbeiteten Dokumente
+ * @param failedCount Anzahl der in diesem Lauf fehlgeschlagenen Dokumente
+ * (retryable und final zusammengefasst)
+ * @param skippedCount Anzahl der in diesem Lauf übersprungenen Dokumente
+ * (bereits verarbeitet oder dauerhaft fehlgeschlagen)
+ */
+public record RunSummary(int successCount, int failedCount, int skippedCount) {
+
+ /**
+ * Prüft, ob dieser Lauf ein No-op-Lauf war, d.h. keine Dokumente
+ * verarbeitet, fehlgeschlagen oder übersprungen wurden.
+ *
+ * @return {@code true}, wenn alle Zähler null sind
+ */
+ public boolean isNoOp() {
+ return successCount == 0 && failedCount == 0 && skippedCount == 0;
+ }
+
+ /**
+ * Erzeugt eine {@code RunSummary} für einen No-op-Lauf.
+ *
+ * @return {@code RunSummary} mit allen Zählern gleich null
+ */
+ public static RunSummary noOp() {
+ return new RunSummary(0, 0, 0);
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/SchedulerConfig.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/SchedulerConfig.java
new file mode 100644
index 0000000..009da28
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/SchedulerConfig.java
@@ -0,0 +1,26 @@
+package de.gecheckt.pdf.umbenenner.application.port.out;
+
+/**
+ * Betriebskonfiguration für den automatischen Scheduler.
+ *
+ * Enthält ausschließlich die für den Scheduler-Betrieb relevanten
+ * Laufzeitparameter. Die Konfiguration wird beim Start des Schedulers
+ * an {@link SchedulerPort#startScheduler(SchedulerConfig, BatchRunTrigger)}
+ * übergeben und bleibt für die Dauer des Scheduler-Betriebs unverändert.
+ *
+ * @param intervalSeconds Wartezeit in Sekunden zwischen dem Ende eines
+ * Laufs und dem Beginn des nächsten Ticks;
+ * muss mindestens 30 betragen
+ */
+public record SchedulerConfig(int intervalSeconds) {
+
+ /**
+ * Validiert, dass das Intervall mindestens 30 Sekunden beträgt.
+ */
+ public SchedulerConfig {
+ if (intervalSeconds < 30) {
+ throw new IllegalArgumentException(
+ "Scheduler-Intervall muss mindestens 30 Sekunden betragen, war: " + intervalSeconds);
+ }
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/SchedulerPort.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/SchedulerPort.java
new file mode 100644
index 0000000..942e64c
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/SchedulerPort.java
@@ -0,0 +1,42 @@
+package de.gecheckt.pdf.umbenenner.application.port.out;
+
+/**
+ * Outbound-Port zur Steuerung des technischen Scheduler-Mechanismus.
+ *
+ * Kapselt die Infrastruktur für das periodische Polling (z.B. einen
+ * {@code ScheduledExecutorService}). Die fachliche Scheduler-Steuerung
+ * liegt im Use Case {@code SchedulerControlUseCase}; dieser Port
+ * delegiert ausschließlich den technischen Lifecycle-Start und -Stop.
+ *
+ * Abhängigkeitsrichtung: Application → Adapter (hexagonal outbound).
+ */
+public interface SchedulerPort {
+
+ /**
+ * Startet den periodischen Scheduler-Mechanismus.
+ *
+ * Der erste Tick startet sofort (Initial Delay 0). Nachfolgende Ticks
+ * starten jeweils {@link SchedulerConfig#intervalSeconds()} Sekunden
+ * nach dem Ende des vorigen Ticks ({@code scheduleWithFixedDelay}).
+ *
+ * Der bereitgestellte {@link BatchRunTrigger} wird bei jedem Tick
+ * synchron aufgerufen. Der Scheduler-Adapter darf keine weiteren
+ * Entscheidungen treffen – er ruft {@code trigger.triggerRun()} auf
+ * und aktualisiert den Zustand anhand des Ergebnisses.
+ *
+ * @param config Betriebskonfiguration mit Intervall in Sekunden
+ * @param trigger Auslöser für den Verarbeitungslauf pro Tick
+ * @throws RuntimeException wenn der Scheduler-Mechanismus nicht
+ * gestartet werden kann
+ */
+ void startScheduler(SchedulerConfig config, BatchRunTrigger trigger);
+
+ /**
+ * Stoppt den periodischen Scheduler-Mechanismus.
+ *
+ * Laufende Ticks werden nicht abgebrochen; es werden lediglich keine
+ * weiteren Ticks geplant. Ist der Scheduler bereits gestoppt, hat
+ * dieser Aufruf keine Wirkung (idempotent).
+ */
+ void stopScheduler();
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/SchedulerSettings.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/SchedulerSettings.java
new file mode 100644
index 0000000..2263659
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/SchedulerSettings.java
@@ -0,0 +1,35 @@
+package de.gecheckt.pdf.umbenenner.application.port.out;
+
+/**
+ * Persistierte Scheduler-Einstellungen aus der {@code .properties}-Datei.
+ *
+ * Dieses DTO repräsentiert die beiden Scheduler-Properties
+ * {@code scheduler.enabled} und {@code scheduler.interval.seconds},
+ * wie sie aus der Konfigurationsdatei gelesen werden. Es wird von
+ * {@link SchedulerSettingsPort#loadSettings()} zurückgegeben und
+ * dient als Eingabe für die Autostart-Entscheidung und die
+ * Scheduler-Tab-Anzeige.
+ *
+ * @param enabled {@code true}, wenn der Scheduler beim nächsten
+ * Programmstart automatisch gestartet werden soll
+ * @param intervalSeconds konfigurierte Wartezeit in Sekunden zwischen
+ * Läufen; entspricht dem gelesenen Rohwert
+ * ohne weitere Validierung
+ */
+public record SchedulerSettings(boolean enabled, int intervalSeconds) {
+
+ /** Standardwert für {@code scheduler.enabled}, wenn der Key fehlt oder leer ist. */
+ public static final boolean DEFAULT_ENABLED = false;
+
+ /** Standardwert für {@code scheduler.interval.seconds}, wenn der Key fehlt oder leer ist. */
+ public static final int DEFAULT_INTERVAL_SECONDS = 180;
+
+ /**
+ * Erzeugt eine {@code SchedulerSettings}-Instanz mit Standardwerten.
+ *
+ * @return Instanz mit {@code enabled=false} und {@code intervalSeconds=180}
+ */
+ public static SchedulerSettings defaults() {
+ return new SchedulerSettings(DEFAULT_ENABLED, DEFAULT_INTERVAL_SECONDS);
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/SchedulerSettingsPort.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/SchedulerSettingsPort.java
new file mode 100644
index 0000000..5ce60ff
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/SchedulerSettingsPort.java
@@ -0,0 +1,57 @@
+package de.gecheckt.pdf.umbenenner.application.port.out;
+
+/**
+ * Outbound-Port zum Lesen und Schreiben der Scheduler-Einstellungen
+ * in der {@code .properties}-Konfigurationsdatei.
+ *
+ * Schreiboperationen aktualisieren ausschließlich die beiden
+ * Scheduler-Keys ({@code scheduler.enabled} und
+ * {@code scheduler.interval.seconds}). Alle übrigen Zeilen, Kommentare
+ * und unbekannten Properties bleiben unverändert erhalten.
+ *
+ * Schreibvorgänge sind atomar: Sie erfolgen über eine temporäre Datei,
+ * die erst nach vollständigem Schreiben an den Zielort verschoben wird.
+ * Bei einem Fehler bleibt die Originaldatei unverändert.
+ *
+ * Die Implementierung teilt sich einen {@code FileChannel} mit dem
+ * {@link ConfigurationFileLockPort}-Adapter, damit Settings auch
+ * während eines aktiven OS-Locks geschrieben werden können.
+ */
+public interface SchedulerSettingsPort {
+
+ /**
+ * Liest die aktuellen Scheduler-Einstellungen aus der
+ * Konfigurationsdatei.
+ *
+ * Fehlt ein Key oder ist er leer, wird der jeweilige Standardwert
+ * zurückgegeben (siehe {@link SchedulerSettings#defaults()}).
+ * Ungültige Werte (z.B. nicht-numerisches Intervall) werden als
+ * Fehler in {@code SchedulerSettings} signalisiert und führen nicht
+ * zu einer Exception in diesem Port.
+ *
+ * @return gelesene Scheduler-Einstellungen; nie {@code null}
+ */
+ SchedulerSettings loadSettings();
+
+ /**
+ * Schreibt den Wert von {@code scheduler.enabled} in die
+ * Konfigurationsdatei.
+ *
+ * Alle übrigen Inhalte der Datei bleiben unverändert.
+ *
+ * @param enabled neuer Wert für {@code scheduler.enabled}
+ * @throws SchedulerSettingsWriteException wenn der Schreibvorgang fehlschlägt
+ */
+ void saveEnabled(boolean enabled) throws SchedulerSettingsWriteException;
+
+ /**
+ * Schreibt den Wert von {@code scheduler.interval.seconds} in die
+ * Konfigurationsdatei.
+ *
+ * Alle übrigen Inhalte der Datei bleiben unverändert.
+ *
+ * @param seconds neues Intervall in Sekunden; muss mindestens 30 betragen
+ * @throws SchedulerSettingsWriteException wenn der Schreibvorgang fehlschlägt
+ */
+ void saveIntervalSeconds(int seconds) throws SchedulerSettingsWriteException;
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/SchedulerSettingsWriteException.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/SchedulerSettingsWriteException.java
new file mode 100644
index 0000000..00bc374
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/SchedulerSettingsWriteException.java
@@ -0,0 +1,37 @@
+package de.gecheckt.pdf.umbenenner.application.port.out;
+
+/**
+ * Wird geworfen, wenn das Schreiben der Scheduler-Einstellungen in die
+ * {@code .properties}-Datei fehlschlägt.
+ *
+ * Der Schreibvorgang ist atomar (über eine temporäre Datei), sodass die
+ * Konfigurationsdatei bei einem Fehler nicht in einem korrupten Zustand
+ * hinterlassen wird. Diese Ausnahme signalisiert, dass weder der neue
+ * noch ein partieller Stand geschrieben wurde.
+ *
+ * Ursachen können sein: fehlende Schreibrechte, Netzlaufwerksfehler,
+ * Festplatte voll oder ein aktiver exklusiver Lock durch einen anderen Prozess.
+ */
+public class SchedulerSettingsWriteException extends RuntimeException {
+
+ /**
+ * Erstellt eine neue {@code SchedulerSettingsWriteException} mit der
+ * angegebenen Nachricht.
+ *
+ * @param message deutsche Fehlerbeschreibung
+ */
+ public SchedulerSettingsWriteException(String message) {
+ super(message);
+ }
+
+ /**
+ * Erstellt eine neue {@code SchedulerSettingsWriteException} mit
+ * Nachricht und Ursache.
+ *
+ * @param message deutsche Fehlerbeschreibung
+ * @param cause technische Ursache
+ */
+ public SchedulerSettingsWriteException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java
index 50cacdf..f022044 100644
--- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java
+++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java
@@ -1121,6 +1121,11 @@ class BatchRunProcessingUseCaseTest {
@Override
public void release() { releaseCalled = true; }
+ @Override
+ public java.util.Optional
+ *
+ * Schlägt ein Schritt fehl, wird ein vollständiger Rollback
+ * durchgeführt und der Zustand auf {@code STOPPED} zurückgesetzt.
+ *
+ * @throws SchedulerStartException wenn der Start fehlschlägt und
+ * kein Rollback möglich ist; enthält eine deutsche Meldung
+ * für die GUI-Anzeige
+ */
+ void start() throws SchedulerStartException;
+
+ /**
+ * Stoppt den automatischen Scheduler.
+ *
+ *
+ */
+@FunctionalInterface
+public interface BatchRunTrigger {
+
+ /**
+ * Löst synchron einen Verarbeitungslauf aus.
+ *
+ *
+ *
- *
*
- *
- *
+ * Lock-Lifecycle (blockierender Pfad – headless und manueller GUI-Lauf):
+ *
+ *
+ * Lock-Lifecycle (nicht-blockierender Pfad – Scheduler-Tick):
+ *
+ *
*/
public interface RunLockPort {
/**
- * Acquires an exclusive lock for the batch run.
+ * Erwirbt den exklusiven Run-Lock (blockierend).
*