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
@@ -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}.
* <p>
* 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.
* <p>
* 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).
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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<RunLockHandle> tryAcquire() {
if (Files.exists(lockFile)) {
LOG.debug("Run-Lock nicht verfügbar (Datei existiert): {}", lockFile);
return Optional.empty();
}
try {
Path parent = lockFile.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
long pid = ProcessHandle.current().pid();
Files.writeString(lockFile, String.valueOf(pid), StandardOpenOption.CREATE_NEW);
LOG.debug("Run-Lock (tryAcquire) erworben: {} (PID {})", lockFile, pid);
return Optional.of(new FilesystemRunLockHandle());
} catch (IOException e) {
// CREATE_NEW schlägt mit FileAlreadyExistsException fehl wenn eine
// Race-Condition vorliegt kein Fehler, sondern normaler Busy-Zustand
LOG.debug("Run-Lock (tryAcquire) nicht verfügbar: {} {}", lockFile, e.getMessage());
return Optional.empty();
}
}
/**
* Handle für einen über {@link #tryAcquire()} erworbenen Run-Lock.
* <p>
* 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();
}
}
}
}