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:
+26
@@ -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");
|
||||
}
|
||||
}
|
||||
+242
-9
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user