Scheduler-Ports und Typen im Application-Modul anlegen (Schritt 3)

Neue Typen (port/in):
- SchedulerControlUseCase – Inbound-Port: start(), stop(), getStatus()
- SchedulerState – Enum: STOPPED, STARTING, RUNNING_IDLE, RUNNING_BATCH_ACTIVE, STOPPING_BATCH_ACTIVE
- SchedulerStatus – Immutable Record mit AtomicReference-ready Snapshot
- SchedulerStartException – Unchecked Exception für Start-Fehler

Neue Typen (port/out):
- RunLockHandle – AutoCloseable für tryAcquire() in try-with-resources
- RunSummary – Aggregierte Lauf-Ergebniszähler (success/failed/skipped)
- BatchRunTrigger – @FunctionalInterface für synchronen Lauf-Trigger
- BatchRunTriggerResult – Sealed Interface: Started, SkippedBusy, Failed
- SchedulerConfig – Betriebskonfiguration (intervalSeconds >= 30)
- SchedulerSettings – Persistierte Properties-Werte mit Defaults
- SchedulerPort – startScheduler() / stopScheduler()
- ConfigurationFileLockPort – acquireLock() / releaseLock() / isLocked()
- ConfigurationFileLockException – Unchecked bei Lock-Erwerb-Fehler
- SchedulerSettingsPort – loadSettings() / saveEnabled() / saveIntervalSeconds()
- SchedulerSettingsWriteException – Unchecked bei Schreib-Fehler

Erweiterungen:
- RunLockPort: neue Methode tryAcquire() → Optional<RunLockHandle>
- FilesystemRunLockPortAdapter: implementiert tryAcquire() atomar via
  CREATE_NEW; idempotentes Handle via AtomicBoolean

Test-Fixes:
- 9 Mock-Klassen in application- und bootstrap-Tests um tryAcquire()
  ergänzt (liefern Optional.empty(), da nur blockierender Pfad getestet)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 12:42:42 +02:00
parent 93a2473c36
commit c2a7921675
24 changed files with 898 additions and 50 deletions
@@ -0,0 +1,63 @@
package de.gecheckt.pdf.umbenenner.application.port.in;
/**
* Inbound-Port zur Steuerung des automatischen Schedulers.
* <p>
* 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.
* <p>
* 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.
* <p>
* Implementierungen verwalten den Zustand threadsicher über eine
* {@code AtomicReference<SchedulerStatus>}.
*/
public interface SchedulerControlUseCase {
/**
* Startet den automatischen Scheduler.
* <p>
* Ist der Scheduler bereits aktiv ({@code state != STOPPED}), hat
* dieser Aufruf keine Wirkung. Andernfalls wird der Scheduler über
* folgende Sequenz gestartet:
* <ol>
* <li>Zustand auf {@code STARTING} setzen</li>
* <li>{@code scheduler.enabled=true} persistieren</li>
* <li>Exklusiven OS-Lock auf Konfigurationsdatei erwerben</li>
* <li>Scheduler-Adapter starten (erster Tick sofort)</li>
* <li>Zustand auf {@code RUNNING_IDLE} setzen</li>
* </ol>
* 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.
* <p>
* 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.
* <p>
* 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();
}
@@ -0,0 +1,34 @@
package de.gecheckt.pdf.umbenenner.application.port.in;
/**
* Wird geworfen, wenn der Start des automatischen Schedulers fehlschlägt.
* <p>
* 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.
* <p>
* 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);
}
}
@@ -0,0 +1,72 @@
package de.gecheckt.pdf.umbenenner.application.port.in;
/**
* Zustandsautomat des automatischen Schedulers.
* <p>
* 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.
* <p>
* Manuelle Läufe sind erlaubt. Der Scheduler-Start-Button ist
* aktiv, sofern weitere Voraussetzungen erfüllt sind.
*/
STOPPED,
/**
* Scheduler befindet sich im Startvorgang.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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;
}
}
@@ -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.
* <p>
* Instanzen dieses Records sind threadsicher, da alle Felder final sind.
* Im {@code DefaultSchedulerControlUseCase} werden Snapshots atomar über
* eine {@code AtomicReference<SchedulerStatus>} ausgetauscht.
* <p>
* 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<Instant> lastRunEndedAt,
Optional<RunSummary> lastRunSummary,
Optional<Instant> nextTickAt,
Optional<String> lastError,
boolean autostartFailed
) {
/**
* Validiert, dass Pflichtfelder und Optional-Felder nicht null sind.
*/
public SchedulerStatus {
if (state == null) {
throw new IllegalArgumentException("state darf nicht null sein");
}
if (lastRunEndedAt == null) {
throw new IllegalArgumentException("lastRunEndedAt darf nicht null sein");
}
if (lastRunSummary == null) {
throw new IllegalArgumentException("lastRunSummary darf nicht null sein");
}
if (nextTickAt == null) {
throw new IllegalArgumentException("nextTickAt darf nicht null sein");
}
if (lastError == null) {
throw new IllegalArgumentException("lastError darf nicht null sein");
}
}
/**
* Erzeugt den initialen Scheduler-Status beim Programmstart.
* <p>
* 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
);
}
}
@@ -0,0 +1,36 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Funktionales Interface zum Auslösen eines Verarbeitungslaufs.
* <p>
* 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)}.
* <p>
* <strong>Ausführungsmodell:</strong>
* <ul>
* <li>Der Aufruf ist synchron und blockiert bis zum Laufende.</li>
* <li>Ist der Run-Lock nicht verfügbar (anderer Lauf aktiv), kehrt die
* Methode sofort mit {@link BatchRunTriggerResult.SkippedBusy} zurück.</li>
* <li>Tritt ein technischer Fehler auf, liefert die Methode
* {@link BatchRunTriggerResult.Failed} mit deutschen Meldungen.
* Exceptions werden nicht propagiert; Stacktraces werden im Adapter
* geloggt und nicht im Result-Objekt transportiert.</li>
* </ul>
*/
@FunctionalInterface
public interface BatchRunTrigger {
/**
* Löst synchron einen Verarbeitungslauf aus.
* <p>
* 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();
}
@@ -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.
* <p>
* Die sealed Hierarchie ermöglicht erschöpfendes Pattern-Matching:
* <ul>
* <li>{@link Started} Lauf wurde gestartet und ist abgeschlossen.</li>
* <li>{@link SkippedBusy} Lauf wurde übersprungen, weil bereits ein
* Lauf aktiv war.</li>
* <li>{@link Failed} Lauf ist mit einem technischen Fehler beendet.</li>
* </ul>
* <p>
* {@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.
* <p>
* 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.
* <p>
* {@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.
* <p>
* 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");
}
}
}
}
@@ -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.
* <p>
* 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.
* <p>
* 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);
}
}
@@ -0,0 +1,51 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Outbound-Port für den exklusiven OS-Lock auf die Konfigurationsdatei.
* <p>
* 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).
* <p>
* 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).
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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();
}
@@ -0,0 +1,26 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Handle für einen erworbenen Run-Lock.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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();
}
@@ -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.
* <p>
* 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.
* <p>
* Responsibilities:
* Verantwortlichkeiten:
* <ul>
* <li>Guarantee exclusive access to shared resources (SQLite database, target directory)</li>
* <li>Prevent concurrent batch runs from overwriting each other's work or causing inconsistencies</li>
* <li>Allow controlled startup failure if another instance is already running</li>
* <li>Exklusiven Zugriff auf gemeinsame Ressourcen (SQLite, Zielordner) sicherstellen</li>
* <li>Parallele Läufe verhindern</li>
* <li>Kontrollierten Startabbruch ermöglichen, wenn bereits eine Instanz läuft</li>
* </ul>
* <p>
* Lock Lifecycle:
* <ul>
* <li>Acquire the lock at batch startup (before any processing)</li>
* <li>Hold the lock for the entire batch run</li>
* <li>Release the lock cleanly at batch end (even on failure, if possible)</li>
* </ul>
*
* Lock-Lifecycle (blockierender Pfad headless und manueller GUI-Lauf):
* <ol>
* <li>Lock beim Laufstart erwerben ({@link #acquire()})</li>
* <li>Lock für die gesamte Dauer des Laufs halten</li>
* <li>Lock am Laufende freigeben ({@link #release()}), auch bei Fehler</li>
* </ol>
* Lock-Lifecycle (nicht-blockierender Pfad Scheduler-Tick):
* <ol>
* <li>Lock nicht-blockierend versuchen ({@link #tryAcquire()})</li>
* <li>Bei leerem Optional sofort mit {@code SkippedBusy} abbrechen</li>
* <li>Bei vorhandenem Handle in try-with-resources verwenden</li>
* </ol>
*/
public interface RunLockPort {
/**
* Acquires an exclusive lock for the batch run.
* Erwirbt den exklusiven Run-Lock (blockierend).
* <p>
* 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.
* <p>
* 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.
* <p>
* This method is called after batch processing completes (successfully or not)
* to allow other instances to run.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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<RunLockHandle> tryAcquire();
}
@@ -0,0 +1,37 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Zusammenfassung eines abgeschlossenen Verarbeitungslaufs.
* <p>
* 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);
}
}
@@ -0,0 +1,26 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Betriebskonfiguration für den automatischen Scheduler.
* <p>
* 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);
}
}
}
@@ -0,0 +1,42 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Outbound-Port zur Steuerung des technischen Scheduler-Mechanismus.
* <p>
* 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.
* <p>
* Abhängigkeitsrichtung: Application → Adapter (hexagonal outbound).
*/
public interface SchedulerPort {
/**
* Startet den periodischen Scheduler-Mechanismus.
* <p>
* Der erste Tick startet sofort (Initial Delay 0). Nachfolgende Ticks
* starten jeweils {@link SchedulerConfig#intervalSeconds()} Sekunden
* nach dem Ende des vorigen Ticks ({@code scheduleWithFixedDelay}).
* <p>
* 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.
* <p>
* 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();
}
@@ -0,0 +1,35 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Persistierte Scheduler-Einstellungen aus der {@code .properties}-Datei.
* <p>
* 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);
}
}
@@ -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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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;
}
@@ -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.
* <p>
* 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.
* <p>
* 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);
}
}
@@ -1121,6 +1121,11 @@ class BatchRunProcessingUseCaseTest {
@Override
public void release() { releaseCalled = true; }
@Override
public java.util.Optional<de.gecheckt.pdf.umbenenner.application.port.out.RunLockHandle> tryAcquire() {
return java.util.Optional.empty();
}
boolean wasAcquireCalled() { return acquireCalled; }
boolean wasReleaseCalled() { return releaseCalled; }
}
@@ -1142,6 +1147,11 @@ class BatchRunProcessingUseCaseTest {
@Override
public void release() { releaseCount++; }
@Override
public java.util.Optional<de.gecheckt.pdf.umbenenner.application.port.out.RunLockHandle> tryAcquire() {
return java.util.Optional.empty();
}
int acquireCallCount() { return acquireCount; }
int releaseCallCount() { return releaseCount; }
}
@@ -1157,6 +1167,11 @@ class BatchRunProcessingUseCaseTest {
@Override
public void release() { releaseCalled = true; }
@Override
public java.util.Optional<de.gecheckt.pdf.umbenenner.application.port.out.RunLockHandle> tryAcquire() {
return java.util.Optional.empty();
}
boolean wasAcquireCalled() { return acquireCalled; }
boolean wasReleaseCalled() { return releaseCalled; }
}
@@ -302,6 +302,10 @@ class BatchRunProgressObservationTest {
private static final class NoOpLock implements RunLockPort {
@Override public void acquire() { }
@Override public void release() { }
@Override
public java.util.Optional<de.gecheckt.pdf.umbenenner.application.port.out.RunLockHandle> tryAcquire() {
return java.util.Optional.empty();
}
}
private static final class EmptyCandidatesPort implements SourceDocumentCandidatesPort {