M4 AP-006 Rollback-Semantik und Bootstrap-Scope bereinigen
This commit is contained in:
@@ -51,7 +51,8 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
|
|||||||
connection.commit();
|
connection.commit();
|
||||||
logger.debug("Transaction committed successfully");
|
logger.debug("Transaction committed successfully");
|
||||||
|
|
||||||
} catch (SQLException e) {
|
} catch (Exception e) {
|
||||||
|
// Rollback for ANY exception, not just SQLException
|
||||||
if (connection != null) {
|
if (connection != null) {
|
||||||
try {
|
try {
|
||||||
connection.rollback();
|
connection.rollback();
|
||||||
@@ -60,6 +61,10 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
|
|||||||
logger.error("Failed to rollback transaction: {}", rollbackEx.getMessage(), rollbackEx);
|
logger.error("Failed to rollback transaction: {}", rollbackEx.getMessage(), rollbackEx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Re-throw as DocumentPersistenceException if not already
|
||||||
|
if (e instanceof DocumentPersistenceException) {
|
||||||
|
throw (DocumentPersistenceException) e;
|
||||||
|
}
|
||||||
throw new DocumentPersistenceException("Transaction failed: " + e.getMessage(), e);
|
throw new DocumentPersistenceException("Transaction failed: " + e.getMessage(), e);
|
||||||
} finally {
|
} finally {
|
||||||
if (connection != null) {
|
if (connection != null) {
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for {@link SqliteUnitOfWorkAdapter}.
|
||||||
|
* <p>
|
||||||
|
* Tests verify transactional semantics: successful commits, rollback on first-write failure,
|
||||||
|
* rollback on second-write failure, and proper handling of DocumentPersistenceException.
|
||||||
|
*
|
||||||
|
* @since M4-AP-006
|
||||||
|
*/
|
||||||
|
class SqliteUnitOfWorkAdapterTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
private String jdbcUrl;
|
||||||
|
private SqliteUnitOfWorkAdapter unitOfWorkAdapter;
|
||||||
|
private SqliteSchemaInitializationAdapter schemaAdapter;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() throws Exception {
|
||||||
|
Path dbFile = tempDir.resolve("test.db");
|
||||||
|
jdbcUrl = "jdbc:sqlite:" + dbFile.toAbsolutePath().toString().replace('\\', '/');
|
||||||
|
|
||||||
|
// Initialize schema
|
||||||
|
schemaAdapter = new SqliteSchemaInitializationAdapter(jdbcUrl);
|
||||||
|
schemaAdapter.initializeSchema();
|
||||||
|
|
||||||
|
// Create the unit of work adapter
|
||||||
|
unitOfWorkAdapter = new SqliteUnitOfWorkAdapter(jdbcUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies DocumentPersistenceException is properly re-thrown
|
||||||
|
* without double-wrapping.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void executeInTransaction_reThrowsDocumentPersistenceExceptionAsIs() {
|
||||||
|
DocumentPersistenceException originalException =
|
||||||
|
new DocumentPersistenceException("Original error");
|
||||||
|
|
||||||
|
DocumentPersistenceException thrownException = assertThrows(
|
||||||
|
DocumentPersistenceException.class,
|
||||||
|
() -> {
|
||||||
|
unitOfWorkAdapter.executeInTransaction(txOps -> {
|
||||||
|
throw originalException;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assertSame(originalException, thrownException,
|
||||||
|
"DocumentPersistenceException should be re-thrown as-is, not wrapped");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies rollback occurs when the first write fails.
|
||||||
|
* The transaction should not commit any data.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void executeInTransaction_rollsBackWhenFirstWriteFails() {
|
||||||
|
DocumentFingerprint fingerprint = new DocumentFingerprint("2222222222222222222222222222222222222222222222222222222222222222");
|
||||||
|
|
||||||
|
// Create repositories for verification
|
||||||
|
SqliteDocumentRecordRepositoryAdapter docRepository =
|
||||||
|
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
|
||||||
|
|
||||||
|
assertThrows(DocumentPersistenceException.class, () -> {
|
||||||
|
unitOfWorkAdapter.executeInTransaction(txOps -> {
|
||||||
|
// First write: throw exception directly (simulates write failure)
|
||||||
|
throw new DocumentPersistenceException("Simulated first write failure");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify no records were persisted (rollback occurred)
|
||||||
|
var lookupResult = docRepository.findByFingerprint(fingerprint);
|
||||||
|
assertTrue(lookupResult instanceof de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown,
|
||||||
|
"No DocumentRecord should be persisted after rollback");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies rollback occurs when the second write fails after the first write succeeds.
|
||||||
|
* The transaction should not commit the first write without the second.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void executeInTransaction_rollsBackWhenSecondWriteFails() {
|
||||||
|
DocumentFingerprint fingerprint = new DocumentFingerprint("3333333333333333333333333333333333333333333333333333333333333333");
|
||||||
|
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
|
||||||
|
DocumentRecord record = new DocumentRecord(
|
||||||
|
fingerprint,
|
||||||
|
new SourceDocumentLocator("/source/test.pdf"),
|
||||||
|
"test.pdf",
|
||||||
|
ProcessingStatus.PROCESSING,
|
||||||
|
FailureCounters.zero(),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create repositories for verification
|
||||||
|
SqliteDocumentRecordRepositoryAdapter docRepository =
|
||||||
|
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
|
||||||
|
|
||||||
|
assertThrows(DocumentPersistenceException.class, () -> {
|
||||||
|
unitOfWorkAdapter.executeInTransaction(txOps -> {
|
||||||
|
// First write: succeeds
|
||||||
|
txOps.createDocumentRecord(record);
|
||||||
|
|
||||||
|
// Second write: fails by throwing DocumentPersistenceException
|
||||||
|
throw new DocumentPersistenceException("Simulated write failure");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify no records were persisted due to rollback
|
||||||
|
var lookupResult = docRepository.findByFingerprint(fingerprint);
|
||||||
|
assertTrue(lookupResult instanceof de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown,
|
||||||
|
"DocumentRecord should be rolled back when second write fails");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies rollback occurs when an arbitrary RuntimeException is thrown.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void executeInTransaction_rollsBackOnArbitraryRuntimeException() {
|
||||||
|
DocumentFingerprint fingerprint = new DocumentFingerprint("4444444444444444444444444444444444444444444444444444444444444444");
|
||||||
|
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
|
||||||
|
DocumentRecord record = new DocumentRecord(
|
||||||
|
fingerprint,
|
||||||
|
new SourceDocumentLocator("/source/test.pdf"),
|
||||||
|
"test.pdf",
|
||||||
|
ProcessingStatus.PROCESSING,
|
||||||
|
FailureCounters.zero(),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
);
|
||||||
|
|
||||||
|
RuntimeException customException = new RuntimeException("Custom runtime error");
|
||||||
|
|
||||||
|
// Create repositories for verification
|
||||||
|
SqliteDocumentRecordRepositoryAdapter docRepository =
|
||||||
|
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
|
||||||
|
|
||||||
|
DocumentPersistenceException thrownException = assertThrows(
|
||||||
|
DocumentPersistenceException.class,
|
||||||
|
() -> {
|
||||||
|
unitOfWorkAdapter.executeInTransaction(txOps -> {
|
||||||
|
txOps.createDocumentRecord(record);
|
||||||
|
throw customException;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the exception was wrapped in DocumentPersistenceException
|
||||||
|
assertSame(customException, thrownException.getCause(),
|
||||||
|
"RuntimeException should be wrapped in DocumentPersistenceException");
|
||||||
|
|
||||||
|
// Verify rollback occurred
|
||||||
|
var lookupResult = docRepository.findByFingerprint(fingerprint);
|
||||||
|
assertTrue(lookupResult instanceof de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown,
|
||||||
|
"DocumentRecord should be rolled back on runtime exception");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,8 +43,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
|||||||
* <ol>
|
* <ol>
|
||||||
* <li>Load and validate the startup configuration.</li>
|
* <li>Load and validate the startup configuration.</li>
|
||||||
* <li>Resolve the run-lock file path (with default fallback).</li>
|
* <li>Resolve the run-lock file path (with default fallback).</li>
|
||||||
* <li>Initialise the SQLite schema (M4: before the batch document loop begins).</li>
|
* <li>Create and wire all ports and adapters via configured factories.</li>
|
||||||
* <li>Create and wire all ports and adapters, including the M4 persistence ports.</li>
|
|
||||||
* <li>Start the CLI adapter and execute the batch use case.</li>
|
* <li>Start the CLI adapter and execute the batch use case.</li>
|
||||||
* <li>Map the batch outcome to a process exit code.</li>
|
* <li>Map the batch outcome to a process exit code.</li>
|
||||||
* </ol>
|
* </ol>
|
||||||
@@ -54,17 +53,17 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
|||||||
* <li>{@code 0}: Batch run executed successfully; individual document failures do not
|
* <li>{@code 0}: Batch run executed successfully; individual document failures do not
|
||||||
* change the exit code as long as the run itself completed without a hard
|
* change the exit code as long as the run itself completed without a hard
|
||||||
* infrastructure error.</li>
|
* infrastructure error.</li>
|
||||||
* <li>{@code 1}: Hard start, bootstrap, configuration, or schema-initialisation failure
|
* <li>{@code 1}: Hard start, bootstrap, configuration, or persistence failure
|
||||||
* that prevented the run from beginning, or a critical infrastructure failure
|
* that prevented the run from beginning, or a critical infrastructure failure
|
||||||
* during the run.</li>
|
* during the run.</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <h2>M4 wiring</h2>
|
* <h2>M4 wiring</h2>
|
||||||
* <p>
|
* <p>
|
||||||
* The production constructor wires the following M4 adapters:
|
* The production constructor wires the following M4 adapters via the UseCaseFactory:
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>{@link Sha256FingerprintAdapter} — SHA-256 content fingerprinting.</li>
|
* <li>{@link Sha256FingerprintAdapter} — SHA-256 content fingerprinting.</li>
|
||||||
* <li>{@link SqliteSchemaInitializationAdapter} — schema initialisation at startup.</li>
|
* <li>{@link SqliteSchemaInitializationAdapter} — schema initialisation (AP-007).</li>
|
||||||
* <li>{@link SqliteDocumentRecordRepositoryAdapter} — document master record CRUD.</li>
|
* <li>{@link SqliteDocumentRecordRepositoryAdapter} — document master record CRUD.</li>
|
||||||
* <li>{@link SqliteProcessingAttemptRepositoryAdapter} — attempt history CRUD.</li>
|
* <li>{@link SqliteProcessingAttemptRepositoryAdapter} — attempt history CRUD.</li>
|
||||||
* <li>{@link SqliteUnitOfWorkAdapter} — atomic persistence operations.</li>
|
* <li>{@link SqliteUnitOfWorkAdapter} — atomic persistence operations.</li>
|
||||||
@@ -141,8 +140,8 @@ public class BootstrapRunner {
|
|||||||
* <li>{@link SqliteUnitOfWorkAdapter} for atomic persistence operations.</li>
|
* <li>{@link SqliteUnitOfWorkAdapter} for atomic persistence operations.</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
* Schema initialisation is performed in {@link #run()} before the use case is created,
|
* Schema initialisation is performed by the UseCaseFactory when the use case is created,
|
||||||
* using {@link SqliteSchemaInitializationAdapter}.
|
* using {@link SqliteSchemaInitializationAdapter}. (AP-007 responsibility)
|
||||||
*/
|
*/
|
||||||
public BootstrapRunner() {
|
public BootstrapRunner() {
|
||||||
this.configPortFactory = PropertiesConfigurationPortAdapter::new;
|
this.configPortFactory = PropertiesConfigurationPortAdapter::new;
|
||||||
@@ -150,6 +149,11 @@ public class BootstrapRunner {
|
|||||||
this.validatorFactory = StartConfigurationValidator::new;
|
this.validatorFactory = StartConfigurationValidator::new;
|
||||||
this.useCaseFactory = (config, lock) -> {
|
this.useCaseFactory = (config, lock) -> {
|
||||||
String jdbcUrl = buildJdbcUrl(config);
|
String jdbcUrl = buildJdbcUrl(config);
|
||||||
|
// AP-007: Initialize schema when the use case is created
|
||||||
|
if (config.sqliteFile() != null) {
|
||||||
|
PersistenceSchemaInitializationPort schemaPort = new SqliteSchemaInitializationAdapter(jdbcUrl);
|
||||||
|
schemaPort.initializeSchema();
|
||||||
|
}
|
||||||
FingerprintPort fingerprintPort = new Sha256FingerprintAdapter();
|
FingerprintPort fingerprintPort = new Sha256FingerprintAdapter();
|
||||||
DocumentRecordRepository documentRecordRepository =
|
DocumentRecordRepository documentRecordRepository =
|
||||||
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
|
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
|
||||||
@@ -197,14 +201,11 @@ public class BootstrapRunner {
|
|||||||
* M4 additions:
|
* M4 additions:
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>Derives the SQLite JDBC URL from the configured {@code sqlite.file} path.</li>
|
* <li>Derives the SQLite JDBC URL from the configured {@code sqlite.file} path.</li>
|
||||||
* <li>Initialises the M4 SQLite schema via
|
* <li>Creates the M4-aware use case via the {@link UseCaseFactory}, which handles
|
||||||
* {@link PersistenceSchemaInitializationPort#initializeSchema()} before the
|
* SQLite schema initialisation and wiring of persistence ports.</li>
|
||||||
* batch document loop begins. A schema initialisation failure aborts the run
|
|
||||||
* with exit code 1.</li>
|
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* @return exit code: 0 for success, 1 for invalid configuration, schema failure,
|
* @return exit code: 0 for success, 1 for invalid configuration or unexpected bootstrap failure
|
||||||
* or unexpected bootstrap failure
|
|
||||||
*/
|
*/
|
||||||
public int run() {
|
public int run() {
|
||||||
LOG.info("Bootstrap flow started.");
|
LOG.info("Bootstrap flow started.");
|
||||||
@@ -228,24 +229,21 @@ public class BootstrapRunner {
|
|||||||
}
|
}
|
||||||
RunLockPort runLockPort = runLockPortFactory.create(lockFilePath);
|
RunLockPort runLockPort = runLockPortFactory.create(lockFilePath);
|
||||||
|
|
||||||
// Step 5 (M4): Initialise the SQLite schema before the batch loop begins.
|
// Step 5: Create the batch run context
|
||||||
// A failure here is a hard start error → exit code 1.
|
|
||||||
initializeSchema(config);
|
|
||||||
|
|
||||||
// Step 6: Create the batch run context
|
|
||||||
RunId runId = new RunId(UUID.randomUUID().toString());
|
RunId runId = new RunId(UUID.randomUUID().toString());
|
||||||
BatchRunContext runContext = new BatchRunContext(runId, Instant.now());
|
BatchRunContext runContext = new BatchRunContext(runId, Instant.now());
|
||||||
LOG.info("Batch run started. RunId: {}", runId);
|
LOG.info("Batch run started. RunId: {}", runId);
|
||||||
|
|
||||||
// Step 7: Create the use case with the validated config and run lock.
|
// Step 6: Create the use case with the validated config and run lock.
|
||||||
// Config is passed directly; the use case does not re-read the properties file.
|
// Config is passed directly; the use case does not re-read the properties file.
|
||||||
// Adapters (source document port, PDF extraction port, M4 ports) are wired by the factory.
|
// Adapters (source document port, PDF extraction port, M4 ports) are wired by the factory.
|
||||||
|
// Schema initialization happens within the UseCaseFactory. (AP-007 responsibility)
|
||||||
BatchRunProcessingUseCase useCase = useCaseFactory.create(config, runLockPort);
|
BatchRunProcessingUseCase useCase = useCaseFactory.create(config, runLockPort);
|
||||||
|
|
||||||
// Step 8: Create the CLI command adapter with the use case
|
// Step 7: Create the CLI command adapter with the use case
|
||||||
SchedulerBatchCommand command = commandFactory.create(useCase);
|
SchedulerBatchCommand command = commandFactory.create(useCase);
|
||||||
|
|
||||||
// Step 9: Execute the command with the run context and handle the outcome
|
// Step 8: Execute the command with the run context and handle the outcome
|
||||||
BatchRunOutcome outcome = command.run(runContext);
|
BatchRunOutcome outcome = command.run(runContext);
|
||||||
|
|
||||||
// Mark run as completed
|
// Mark run as completed
|
||||||
@@ -271,8 +269,9 @@ public class BootstrapRunner {
|
|||||||
LOG.error("Configuration loading failed: {}", e.getMessage());
|
LOG.error("Configuration loading failed: {}", e.getMessage());
|
||||||
return 1;
|
return 1;
|
||||||
} catch (DocumentPersistenceException e) {
|
} catch (DocumentPersistenceException e) {
|
||||||
// Schema initialisation failed – hard start error
|
// Persistence operation during startup failed – hard start error
|
||||||
LOG.error("SQLite schema initialisation failed: {}", e.getMessage(), e);
|
// (e.g., schema initialisation in UseCaseFactory)
|
||||||
|
LOG.error("Persistence operation during startup failed: {}", e.getMessage(), e);
|
||||||
return 1;
|
return 1;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.error("Bootstrap failure during startup.", e);
|
LOG.error("Bootstrap failure during startup.", e);
|
||||||
@@ -280,33 +279,6 @@ public class BootstrapRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialises the M4 SQLite schema using the configured SQLite file path.
|
|
||||||
* <p>
|
|
||||||
* This method is called once at startup, before the batch document loop begins.
|
|
||||||
* It uses the production {@link SqliteSchemaInitializationAdapter} directly because
|
|
||||||
* schema initialisation is a startup concern, not a per-document concern, and the
|
|
||||||
* {@link UseCaseFactory} abstraction is not the right place for it.
|
|
||||||
* <p>
|
|
||||||
* If the {@code sqlite.file} configuration is null or blank, schema initialisation
|
|
||||||
* is skipped with a warning. This allows the existing test infrastructure (which
|
|
||||||
* uses the custom {@link UseCaseFactory}) to continue working without a real SQLite
|
|
||||||
* file.
|
|
||||||
*
|
|
||||||
* @param config the validated startup configuration
|
|
||||||
* @throws DocumentPersistenceException if schema initialisation fails
|
|
||||||
*/
|
|
||||||
private void initializeSchema(StartConfiguration config) {
|
|
||||||
if (config.sqliteFile() == null) {
|
|
||||||
LOG.warn("sqlite.file not configured – skipping schema initialisation.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
String jdbcUrl = buildJdbcUrl(config);
|
|
||||||
PersistenceSchemaInitializationPort schemaPort = new SqliteSchemaInitializationAdapter(jdbcUrl);
|
|
||||||
schemaPort.initializeSchema();
|
|
||||||
LOG.info("M4 SQLite schema initialised at: {}", jdbcUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the JDBC URL for the SQLite database from the configured file path.
|
* Builds the JDBC URL for the SQLite database from the configured file path.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user