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:
+63
@@ -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();
|
||||
}
|
||||
+34
@@ -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);
|
||||
}
|
||||
}
|
||||
+72
@@ -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;
|
||||
}
|
||||
}
|
||||
+80
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
+36
@@ -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();
|
||||
}
|
||||
+87
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -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);
|
||||
}
|
||||
}
|
||||
+51
@@ -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();
|
||||
}
|
||||
+26
@@ -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();
|
||||
}
|
||||
+53
-31
@@ -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();
|
||||
}
|
||||
|
||||
+37
@@ -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);
|
||||
}
|
||||
}
|
||||
+26
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -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();
|
||||
}
|
||||
+35
@@ -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);
|
||||
}
|
||||
}
|
||||
+57
@@ -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;
|
||||
}
|
||||
+37
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user