Bootstrap-Refactoring: Init/Run-Trennung mit ApplicationRunContext

Führt ApplicationRunContext als package-private Record ein, der beim
GUI-Start einmalig aus der validierten Konfiguration gebaut wird
(migrate → load → validate → schema-init). Das Ergebnis wird in
guiApplicationRunContext gecacht und von launchGuiBatchRun,
launchGuiMiniBatchRun und resetDocumentStatusForGui wiederverwendet,
sodass die Init-Sequenz nicht bei jedem Lauf wiederholt wird.

GuiStartupContext erhält das neue Feld applicationContextError
(Optional<String>), das einen deutschen Fehlertext trägt, wenn der
Kontext bei Startup nicht initialisiert werden konnte. Alle bisherigen
Konstruktoren und die blank()-Fabrik wurden rückwärtskompatibel
ergänzt.

Der Test-Helfer runnerWithGuiFactory wirft jetzt
ConfigurationLoadingException statt AssertionError, damit
initializeApplicationRunContext() den Fehler gracefully abfangen
und in applicationContextError speichern kann.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 12:07:39 +02:00
parent ca26d181f3
commit 407f1e0422
4 changed files with 294 additions and 15 deletions
@@ -0,0 +1,26 @@
package de.gecheckt.pdf.umbenenner.bootstrap;
import java.util.Objects;
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
/**
* Immutable context built once at GUI startup for a specific validated configuration file.
* <p>
* Holds the fully validated {@link StartConfiguration} and the resolved JDBC URL for the
* active SQLite database. This context is created after a successful migrate → load →
* validate → schema-init sequence and is reused for all subsequent batch runs and
* database operations without repeating those steps.
* <p>
* When the configuration changes (e.g., the user opens a different file), a new context
* must be built. Structural configuration changes require a GUI restart to take effect.
* <p>
* Thread-safety: this record is immutable and safe for concurrent access.
*/
record ApplicationRunContext(StartConfiguration startConfiguration, String jdbcUrl) {
ApplicationRunContext {
Objects.requireNonNull(startConfiguration, "startConfiguration must not be null");
Objects.requireNonNull(jdbcUrl, "jdbcUrl must not be null");
}
}
@@ -231,6 +231,21 @@ public class BootstrapRunner {
*/
private final DatabaseCreationPort databaseCreationPort = new SqliteDatabaseCreationAdapter();
/**
* The application run context built once at GUI startup from the initially loaded
* configuration file. When present, it carries a fully validated
* {@link de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration} and
* the resolved JDBC URL, so that batch runs and reset operations can skip the
* migrate → load → validate → schema-init sequence for each call.
* <p>
* Written by {@link #initializeApplicationRunContext(Path)} during
* {@link #buildGuiStartupContext(Optional)} and read by
* {@link #launchGuiBatchRun}, {@link #launchGuiMiniBatchRun}, and
* {@link #resetDocumentStatusForGui}. {@code volatile} ensures visibility
* across threads without explicit synchronisation on the happy path.
*/
private volatile Optional<ApplicationRunContext> guiApplicationRunContext = Optional.empty();
/**
* Functional interface encapsulating the legacy configuration migration step.
* <p>
@@ -878,7 +893,8 @@ public class BootstrapRunner {
historyResetPort,
deleteHistoryPort,
this::buildGuiPromptEditorPort,
createNewDatabasePort);
createNewDatabasePort,
Optional.empty());
}
Path configPath = Paths.get(configPathOverride.get());
@@ -910,7 +926,8 @@ public class BootstrapRunner {
historyResetPort,
deleteHistoryPort,
this::buildGuiPromptEditorPort,
createNewDatabasePort);
createNewDatabasePort,
Optional.empty());
}
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
@@ -918,13 +935,14 @@ public class BootstrapRunner {
GuiConfigurationEditorState loadedState = loadGuiConfigurationState(configPath);
GuiPromptEditorPort promptEditorPort = buildGuiPromptEditorPort(
loadedState.values().promptTemplateFile());
Optional<String> contextError = initializeApplicationRunContext(configPath);
return new GuiStartupContext(loadedState, Optional.empty(), loader, writer,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
miniRunLauncher, resetPort, manualRenamePort, manualCopyPort,
historicalDocumentContextPort, applicationVersion, promptEditorPort,
historyOverviewPort, historyDetailsPort, historyResetPort, deleteHistoryPort,
this::buildGuiPromptEditorPort, createNewDatabasePort);
this::buildGuiPromptEditorPort, createNewDatabasePort, contextError);
} catch (GuiConfigurationLoadException e) {
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
e.getMessage(), e);
@@ -952,7 +970,8 @@ public class BootstrapRunner {
historyResetPort,
deleteHistoryPort,
this::buildGuiPromptEditorPort,
createNewDatabasePort);
createNewDatabasePort,
Optional.empty());
}
}
@@ -1032,13 +1051,176 @@ public class BootstrapRunner {
};
}
/**
* Attempts to build and store an {@link ApplicationRunContext} from the given configuration
* file path.
* <p>
* Runs the full init sequence (migrate → load → validate → schema-init) and, on success,
* stores the result in {@link #guiApplicationRunContext}. On any failure the field is
* cleared and an Optional carrying a human-readable German error message is returned so
* the caller can surface it in the startup context without aborting the GUI launch.
*
* @param configFilePath path to the {@code .properties} configuration file; must exist on disk
* @return {@link Optional#empty()} on success; an Optional with the error message on failure
*/
private Optional<String> initializeApplicationRunContext(Path configFilePath) {
try {
migrateConfigurationIfNeeded(configFilePath);
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
initializeSchema(config);
guiApplicationRunContext = Optional.of(
new ApplicationRunContext(config, resolveActiveJdbcUrl(config)));
LOG.info("GUI-Anwendungskontext initialisiert für Konfiguration: {}", configFilePath);
return Optional.empty();
} catch (ConfigurationLoadingException e) {
LOG.warn("GUI-Anwendungskontext: Konfiguration konnte nicht geladen werden: {}",
e.getMessage());
guiApplicationRunContext = Optional.empty();
return Optional.of("Konfiguration konnte nicht geladen werden: " + e.getMessage());
} catch (InvalidStartConfigurationException e) {
LOG.warn("GUI-Anwendungskontext: Konfiguration nicht lauffähig: {}", e.getMessage());
guiApplicationRunContext = Optional.empty();
return Optional.of("Konfiguration nicht lauffähig: " + e.getMessage());
} catch (DocumentPersistenceException e) {
LOG.warn("GUI-Anwendungskontext: SQLite-Initialisierung fehlgeschlagen: {}",
e.getMessage());
guiApplicationRunContext = Optional.empty();
return Optional.of("SQLite konnte nicht initialisiert werden: " + e.getMessage());
} catch (RuntimeException e) {
LOG.warn("GUI-Anwendungskontext: Unerwarteter Fehler bei Initialisierung: {}",
e.getMessage());
guiApplicationRunContext = Optional.empty();
return Optional.of("Unerwarteter Fehler bei der Kontextinitialisierung: "
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
}
}
/**
* Executes one batch run using a pre-built {@link ApplicationRunContext}.
* <p>
* Skips the migrate → load → validate → schema-init sequence because the context was
* already built at startup. Only the run-lock, use-case wiring and execution are performed.
*
* @param ctx the validated application run context; must not be null
* @param progressObserver observer forwarded into the use case; must not be null
* @param cancellationToken token forwarded into the use case; must not be null
* @return the outcome for the run; never null
*/
private GuiBatchRunLaunchOutcome executeRun(
ApplicationRunContext ctx,
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver progressObserver,
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken cancellationToken) {
try {
RunLockPort runLockPort = runLockPortFactory.create(
resolveLockFilePath(ctx.startConfiguration()));
BatchRunContext runContext = createRunContext();
BatchRunProcessingUseCase useCase = buildProductionBatchUseCase(
ctx.startConfiguration(), runLockPort, progressObserver, cancellationToken);
BatchRunOutcome outcome = useCase.execute(runContext);
runContext.setEndInstant(Instant.now());
return mapGuiRunOutcome(outcome, runContext);
} catch (RuntimeException e) {
LOG.error("GUI-Verarbeitungslauf: Unerwarteter Fehler: {}", e.getMessage(), e);
return GuiBatchRunLaunchOutcome.failedAfterStart(
"Unerwarteter Fehler im Verarbeitungslauf: "
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
}
}
/**
* Executes one targeted mini-run using a pre-built {@link ApplicationRunContext}.
* <p>
* Skips the migrate → load → validate → schema-init sequence because the context was
* already built at startup. Only the run-lock, use-case wiring, fingerprint filter and
* execution are performed.
*
* @param ctx the validated application run context; must not be null
* @param fingerprintFilter the set of document fingerprints to process; must not be null
* @param progressObserver observer forwarded into the use case; must not be null
* @param cancellationToken token forwarded into the use case; must not be null
* @return the outcome for the mini-run; never null
*/
private GuiBatchRunLaunchOutcome executeRunWithFilter(
ApplicationRunContext ctx,
Set<DocumentFingerprint> fingerprintFilter,
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver progressObserver,
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken cancellationToken) {
try {
RunLockPort runLockPort = runLockPortFactory.create(
resolveLockFilePath(ctx.startConfiguration()));
BatchRunContext runContext = createRunContext();
BatchRunContext filteredContext = runContext.withFingerprintFilter(fingerprintFilter);
BatchRunProcessingUseCase useCase = buildProductionBatchUseCase(
ctx.startConfiguration(), runLockPort, progressObserver, cancellationToken);
BatchRunOutcome outcome = useCase.execute(filteredContext);
filteredContext.setEndInstant(Instant.now());
return mapGuiRunOutcome(outcome, filteredContext);
} catch (RuntimeException e) {
LOG.error("GUI-Mini-Verarbeitungslauf: Unerwarteter Fehler: {}", e.getMessage(), e);
return GuiBatchRunLaunchOutcome.failedAfterStart(
"Unerwarteter Fehler im Mini-Verarbeitungslauf: "
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
}
}
/**
* Executes a document-status reset using a pre-built {@link ApplicationRunContext}.
* <p>
* Skips the migrate → load → validate → schema-init sequence. Only the run-lock
* acquisition and the reset use-case execution are performed.
*
* @param ctx the validated application run context; must not be null
* @param fingerprints the set of fingerprints to reset; must not be null
* @return the result of the reset operation; never null
*/
private ResetDocumentStatusResult executeResetWithContext(
ApplicationRunContext ctx,
Set<DocumentFingerprint> fingerprints) {
try {
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(ctx.jdbcUrl());
ProcessingLogger resetLogger = new Log4jProcessingLogger(
DefaultResetDocumentStatusUseCase.class,
resolveAiContentSensitivity(ctx.startConfiguration().logAiSensitive()));
RunLockPort runLockPort = runLockPortFactory.create(
resolveLockFilePath(ctx.startConfiguration()));
try {
runLockPort.acquire();
} catch (de.gecheckt.pdf.umbenenner.application.port.out.RunLockUnavailableException e) {
LOG.warn("GUI-Status-Reset: Laufsperre nicht verfügbar — Reset abgelehnt.");
return allFailures(fingerprints, "Lauf blockiert — Lock nicht verfügbar");
}
try {
DefaultResetDocumentStatusUseCase useCase =
new DefaultResetDocumentStatusUseCase(unitOfWorkPort, resetLogger);
ResetDocumentStatusResult result = useCase.reset(fingerprints);
LOG.info("GUI-Status-Reset abgeschlossen: {} erfolgreich, {} fehlgeschlagen.",
result.successCount(), result.failureCount());
return result;
} finally {
try {
runLockPort.release();
} catch (RuntimeException e) {
LOG.warn("GUI-Status-Reset: Laufsperre konnte nicht freigegeben werden: {}",
e.getMessage(), e);
}
}
} catch (RuntimeException e) {
LOG.error("GUI-Status-Reset: Unerwarteter Fehler: {}", e.getMessage(), e);
return allFailures(fingerprints, "Unerwarteter Fehler: "
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
}
}
/**
* Executes exactly one batch run triggered by the GUI's processing-run tab.
* <p>
* Mirrors the headless bootstrap pipeline: legacy migration, configuration loading and
* validation, SQLite schema initialisation, run-lock acquisition, use-case wiring, and
* execution. Forwards the supplied observer and cancellation token into the wired use
* case so the GUI receives live progress callbacks and can request a soft-stop.
* When a pre-built {@link ApplicationRunContext} is available (built at GUI startup),
* it is used directly and the migrate → load → validate → schema-init sequence is
* skipped. When no context is available (e.g., configuration was invalid at startup),
* the full init sequence runs per call.
* <p>
* Forwards the supplied observer and cancellation token into the wired use case so the
* GUI receives live progress callbacks and can request a soft-stop.
* <p>
* All known hard startup failures are mapped to
* {@link GuiBatchRunLaunchOutcome#rejected(String)}. Unexpected runtime exceptions
@@ -1059,6 +1241,13 @@ public class BootstrapRunner {
Objects.requireNonNull(progressObserver, "progressObserver must not be null");
Objects.requireNonNull(cancellationToken, "cancellationToken must not be null");
LOG.info("GUI-Verarbeitungslauf: Startanforderung für Konfiguration {}.", configFilePath);
Optional<ApplicationRunContext> ctx = guiApplicationRunContext;
if (ctx.isPresent()) {
LOG.debug("GUI-Verarbeitungslauf: Verwende vorbereiteten Anwendungskontext.");
return executeRun(ctx.get(), progressObserver, cancellationToken);
}
try {
if (!Files.exists(configFilePath)) {
return GuiBatchRunLaunchOutcome.rejected(
@@ -1114,6 +1303,23 @@ public class BootstrapRunner {
* @param cancellationToken token forwarded into the use case; must not be null
* @return the outcome for the mini-run; never null
*/
/**
* Executes a targeted mini batch run restricted to the supplied fingerprint set,
* triggered by the GUI's "Erneut verarbeiten" action.
* <p>
* When a pre-built {@link ApplicationRunContext} is available (built at GUI startup),
* it is used directly and the migrate → load → validate → schema-init sequence is
* skipped. When no context is available, the full init sequence runs per call.
* <p>
* The run lock is acquired to prevent racing with a concurrent headless run. If the lock
* is unavailable, all fingerprints are returned as failures with a German message.
*
* @param configFilePath path to the {@code .properties} configuration; must exist on disk
* @param fingerprintFilter the set of document fingerprints to process; must not be null
* @param progressObserver observer forwarded into the use case; must not be null
* @param cancellationToken token forwarded into the use case; must not be null
* @return the outcome for the mini-run; never null
*/
GuiBatchRunLaunchOutcome launchGuiMiniBatchRun(
Path configFilePath,
Set<DocumentFingerprint> fingerprintFilter,
@@ -1125,6 +1331,13 @@ public class BootstrapRunner {
Objects.requireNonNull(cancellationToken, "cancellationToken must not be null");
LOG.info("GUI-Mini-Verarbeitungslauf: Startanforderung für {} Dokument(e), Konfiguration {}.",
fingerprintFilter.size(), configFilePath);
Optional<ApplicationRunContext> ctx = guiApplicationRunContext;
if (ctx.isPresent()) {
LOG.debug("GUI-Mini-Verarbeitungslauf: Verwende vorbereiteten Anwendungskontext.");
return executeRunWithFilter(ctx.get(), fingerprintFilter, progressObserver, cancellationToken);
}
try {
if (!Files.exists(configFilePath)) {
return GuiBatchRunLaunchOutcome.rejected(
@@ -1135,7 +1348,6 @@ public class BootstrapRunner {
initializeSchema(config);
RunLockPort runLockPort = runLockPortFactory.create(resolveLockFilePath(config));
BatchRunContext runContext = createRunContext();
// Apply the fingerprint filter to restrict processing to the selected documents
BatchRunContext filteredContext = runContext.withFingerprintFilter(fingerprintFilter);
BatchRunProcessingUseCase useCase = buildProductionBatchUseCase(
config, runLockPort, progressObserver, cancellationToken);
@@ -1180,6 +1392,21 @@ public class BootstrapRunner {
* @param fingerprints the set of document fingerprints to reset; must not be null
* @return the result of the reset operation; never null
*/
/**
* Resets the processing status of the specified documents by deleting all persistence
* data for their fingerprints, triggered by the GUI's "Status zurücksetzen" action.
* <p>
* When a pre-built {@link ApplicationRunContext} is available (built at GUI startup),
* it is used directly and the migrate → load → validate → schema-init sequence is
* skipped. When no context is available, the full init sequence runs per call.
* <p>
* The run lock is acquired to avoid racing with a concurrent headless run. If the lock
* is unavailable, all fingerprints are returned as failures with a German message.
*
* @param configFilePath path to the {@code .properties} configuration; must exist on disk
* @param fingerprints the set of document fingerprints to reset; must not be null
* @return the result of the reset operation; never null
*/
ResetDocumentStatusResult resetDocumentStatusForGui(
Path configFilePath,
Set<DocumentFingerprint> fingerprints) {
@@ -1188,6 +1415,12 @@ public class BootstrapRunner {
LOG.info("GUI-Status-Reset: {} Dokument(e) zurücksetzen, Konfiguration {}.",
fingerprints.size(), configFilePath);
Optional<ApplicationRunContext> ctx = guiApplicationRunContext;
if (ctx.isPresent()) {
LOG.debug("GUI-Status-Reset: Verwende vorbereiteten Anwendungskontext.");
return executeResetWithContext(ctx.get(), fingerprints);
}
if (!Files.exists(configFilePath)) {
String msg = "Konfigurationsdatei nicht gefunden: " + configFilePath;
LOG.error("GUI-Status-Reset: {}", msg);