diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/PropertiesConfigurationPortAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/PropertiesConfigurationPortAdapter.java index b57e1d7..a32269e 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/PropertiesConfigurationPortAdapter.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/PropertiesConfigurationPortAdapter.java @@ -1,11 +1,5 @@ package de.gecheckt.pdf.umbenenner.adapter.out.configuration; -import de.gecheckt.pdf.umbenenner.application.config.StartConfiguration; -import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - import java.io.IOException; import java.io.StringReader; import java.net.URI; @@ -17,6 +11,12 @@ import java.nio.file.Paths; import java.util.Properties; import java.util.function.Function; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import de.gecheckt.pdf.umbenenner.application.config.StartConfiguration; +import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort; + /** * Properties-based implementation of {@link ConfigurationPort}. * AP-005: Loads configuration from config/application.properties with environment variable precedence. diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/fingerprint/Sha256FingerprintAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/fingerprint/Sha256FingerprintAdapter.java index 3917154..7eb77dc 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/fingerprint/Sha256FingerprintAdapter.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/fingerprint/Sha256FingerprintAdapter.java @@ -1,13 +1,5 @@ package de.gecheckt.pdf.umbenenner.adapter.out.fingerprint; -import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintPort; -import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintResult; -import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintSuccess; -import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintTechnicalError; -import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; -import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate; -import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator; - import java.io.IOException; import java.nio.file.Files; import java.nio.file.InvalidPathException; @@ -19,6 +11,14 @@ import java.security.NoSuchAlgorithmException; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintPort; +import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintResult; +import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintSuccess; +import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintTechnicalError; +import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; +import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate; +import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator; + /** * SHA-256-based implementation of {@link FingerprintPort}. *

diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/lock/FilesystemRunLockPortAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/lock/FilesystemRunLockPortAdapter.java index 5e88b04..58cfd5d 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/lock/FilesystemRunLockPortAdapter.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/lock/FilesystemRunLockPortAdapter.java @@ -1,16 +1,16 @@ package de.gecheckt.pdf.umbenenner.adapter.out.lock; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; + import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort; import de.gecheckt.pdf.umbenenner.application.port.out.RunLockUnavailableException; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; - /** * File-based implementation of {@link RunLockPort} that uses a lock file to prevent concurrent runs. *

diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/pdfextraction/PdfTextExtractionPortAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/pdfextraction/PdfTextExtractionPortAdapter.java index 5dc08f0..db4516e 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/pdfextraction/PdfTextExtractionPortAdapter.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/pdfextraction/PdfTextExtractionPortAdapter.java @@ -1,19 +1,20 @@ package de.gecheckt.pdf.umbenenner.adapter.out.pdfextraction; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Objects; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; + import de.gecheckt.pdf.umbenenner.application.port.out.PdfTextExtractionPort; import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionResult; import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionSuccess; import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionTechnicalError; import de.gecheckt.pdf.umbenenner.domain.model.PdfPageCount; import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate; -import org.apache.pdfbox.Loader; -import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.text.PDFTextStripper; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.util.Objects; /** * PDFBox-based implementation of {@link PdfTextExtractionPort}. diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sourcedocument/SourceDocumentCandidatesPortAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sourcedocument/SourceDocumentCandidatesPortAdapter.java index 60052e6..c30d07d 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sourcedocument/SourceDocumentCandidatesPortAdapter.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sourcedocument/SourceDocumentCandidatesPortAdapter.java @@ -1,16 +1,16 @@ package de.gecheckt.pdf.umbenenner.adapter.out.sourcedocument; -import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentAccessException; -import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentCandidatesPort; -import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate; -import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator; - import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.stream.Stream; +import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentAccessException; +import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentCandidatesPort; +import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate; +import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator; + /** * File-system based implementation of {@link SourceDocumentCandidatesPort}. *

diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDocumentRecordRepositoryAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDocumentRecordRepositoryAdapter.java index 30adaef..45ee143 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDocumentRecordRepositoryAdapter.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDocumentRecordRepositoryAdapter.java @@ -1,16 +1,29 @@ package de.gecheckt.pdf.umbenenner.adapter.out.sqlite; -import de.gecheckt.pdf.umbenenner.application.port.out.*; -import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; -import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus; -import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.util.Objects; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import java.sql.*; -import java.time.Instant; -import java.util.Objects; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentKnownProcessable; +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.DocumentRecordLookupResult; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalFinalFailure; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalSuccess; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown; +import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters; +import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceLookupTechnicalFailure; +import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; +import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus; +import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator; /** * SQLite implementation of {@link DocumentRecordRepository}. @@ -79,7 +92,7 @@ public class SqliteDocumentRecordRepositoryAdapter implements DocumentRecordRepo WHERE fingerprint = ? """; - try (Connection connection = DriverManager.getConnection(jdbcUrl); + try (Connection connection = getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.setString(1, fingerprint.sha256Hex()); @@ -138,7 +151,7 @@ public class SqliteDocumentRecordRepositoryAdapter implements DocumentRecordRepo ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """; - try (Connection connection = DriverManager.getConnection(jdbcUrl); + try (Connection connection = getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.setString(1, record.fingerprint().sha256Hex()); @@ -197,7 +210,7 @@ public class SqliteDocumentRecordRepositoryAdapter implements DocumentRecordRepo WHERE fingerprint = ? """; - try (Connection connection = DriverManager.getConnection(jdbcUrl); + try (Connection connection = getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.setString(1, record.lastKnownSourceLocator().value()); @@ -282,4 +295,16 @@ public class SqliteDocumentRecordRepositoryAdapter implements DocumentRecordRepo public String getJdbcUrl() { return jdbcUrl; } + + /** + * Gets a connection to the database. + *

+ * This method can be overridden by subclasses to provide a shared connection. + * + * @return a new database connection + * @throws SQLException if the connection cannot be established + */ + protected Connection getConnection() throws SQLException { + return DriverManager.getConnection(jdbcUrl); + } } \ No newline at end of file diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapter.java index a077826..cf0badf 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapter.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapter.java @@ -1,19 +1,24 @@ package de.gecheckt.pdf.umbenenner.adapter.out.sqlite; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException; import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt; import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository; import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.sql.*; -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; - /** * SQLite implementation of {@link ProcessingAttemptRepository}. *

@@ -72,7 +77,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem WHERE fingerprint = ? """; - try (Connection connection = DriverManager.getConnection(jdbcUrl); + try (Connection connection = getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { // Enable foreign key enforcement for this connection @@ -129,7 +134,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """; - try (Connection connection = DriverManager.getConnection(jdbcUrl); + try (Connection connection = getConnection(); Statement pragmaStmt = connection.createStatement(); PreparedStatement statement = connection.prepareStatement(sql)) { @@ -198,7 +203,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem ORDER BY attempt_number ASC """; - try (Connection connection = DriverManager.getConnection(jdbcUrl); + try (Connection connection = getConnection(); Statement pragmaStmt = connection.createStatement(); PreparedStatement statement = connection.prepareStatement(sql)) { @@ -255,4 +260,16 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem public String getJdbcUrl() { return jdbcUrl; } -} \ No newline at end of file + + /** + * Gets a connection to the database. + *

+ * This method can be overridden by subclasses to provide a shared connection. + * + * @return a new database connection + * @throws SQLException if the connection cannot be established + */ + protected Connection getConnection() throws SQLException { + return DriverManager.getConnection(jdbcUrl); + } +} diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteSchemaInitializationAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteSchemaInitializationAdapter.java index 2033277..57991e3 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteSchemaInitializationAdapter.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteSchemaInitializationAdapter.java @@ -1,16 +1,17 @@ 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.PersistenceSchemaInitializationPort; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; import java.sql.Statement; import java.util.Objects; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException; +import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitializationPort; + /** * SQLite implementation of {@link PersistenceSchemaInitializationPort}. *

diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteUnitOfWorkAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteUnitOfWorkAdapter.java new file mode 100644 index 0000000..3a367f7 --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteUnitOfWorkAdapter.java @@ -0,0 +1,133 @@ +package de.gecheckt.pdf.umbenenner.adapter.out.sqlite; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.Objects; +import java.util.function.Consumer; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +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.ProcessingAttempt; +import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort; + +/** + * SQLite implementation of {@link UnitOfWorkPort}. + *

+ * Provides transactional semantics for coordinated writes to both the document record + * and processing attempt repositories. + * + * @since M4-AP-006-fix + */ +public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort { + + private static final Logger logger = LogManager.getLogger(SqliteUnitOfWorkAdapter.class); + + private final String jdbcUrl; + + public SqliteUnitOfWorkAdapter(String jdbcUrl) { + Objects.requireNonNull(jdbcUrl, "jdbcUrl must not be null"); + if (jdbcUrl.isBlank()) { + throw new IllegalArgumentException("jdbcUrl must not be blank"); + } + this.jdbcUrl = jdbcUrl; + } + + @Override + public void executeInTransaction(Consumer operations) { + Objects.requireNonNull(operations, "operations must not be null"); + + Connection connection = null; + try { + connection = DriverManager.getConnection(jdbcUrl); + connection.setAutoCommit(false); + + TransactionOperationsImpl txOps = new TransactionOperationsImpl(connection); + operations.accept(txOps); + + connection.commit(); + logger.debug("Transaction committed successfully"); + + } catch (SQLException e) { + if (connection != null) { + try { + connection.rollback(); + logger.debug("Transaction rolled back due to error: {}", e.getMessage()); + } catch (SQLException rollbackEx) { + logger.error("Failed to rollback transaction: {}", rollbackEx.getMessage(), rollbackEx); + } + } + throw new DocumentPersistenceException("Transaction failed: " + e.getMessage(), e); + } finally { + if (connection != null) { + try { + connection.close(); + } catch (SQLException e) { + logger.warn("Failed to close connection: {}", e.getMessage(), e); + } + } + } + } + + private class TransactionOperationsImpl implements TransactionOperations { + private final Connection connection; + + TransactionOperationsImpl(Connection connection) { + this.connection = connection; + } + + @Override + public void saveProcessingAttempt(ProcessingAttempt attempt) { + try { + // Reuse the existing repository logic but with shared connection + SqliteProcessingAttemptRepositoryAdapter repo = + new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl) { + @Override + protected Connection getConnection() throws SQLException { + return connection; + } + }; + repo.save(attempt); + } catch (Exception e) { + throw new DocumentPersistenceException("Failed to save processing attempt: " + e.getMessage(), e); + } + } + + @Override + public void createDocumentRecord(DocumentRecord record) { + try { + // Reuse the existing repository logic but with shared connection + SqliteDocumentRecordRepositoryAdapter repo = + new SqliteDocumentRecordRepositoryAdapter(jdbcUrl) { + @Override + protected Connection getConnection() throws SQLException { + return connection; + } + }; + repo.create(record); + } catch (Exception e) { + throw new DocumentPersistenceException("Failed to create document record: " + e.getMessage(), e); + } + } + + @Override + public void updateDocumentRecord(DocumentRecord record) { + try { + // Reuse the existing repository logic but with shared connection + SqliteDocumentRecordRepositoryAdapter repo = + new SqliteDocumentRecordRepositoryAdapter(jdbcUrl) { + @Override + protected Connection getConnection() throws SQLException { + return connection; + } + }; + repo.update(record); + } catch (Exception e) { + throw new DocumentPersistenceException("Failed to update document record: " + e.getMessage(), e); + } + } + } +} \ No newline at end of file diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/PropertiesConfigurationPortAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/PropertiesConfigurationPortAdapterTest.java index d8881a1..52ffcff 100644 --- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/PropertiesConfigurationPortAdapterTest.java +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/PropertiesConfigurationPortAdapterTest.java @@ -1,17 +1,18 @@ package de.gecheckt.pdf.umbenenner.adapter.out.configuration; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import de.gecheckt.pdf.umbenenner.adapter.out.configuration.PropertiesConfigurationPortAdapter; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.FileWriter; import java.nio.file.Files; import java.nio.file.Path; import java.util.function.Function; -import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; /** * Unit tests for {@link PropertiesConfigurationPortAdapter}. diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/fingerprint/Sha256FingerprintAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/fingerprint/Sha256FingerprintAdapterTest.java index a9bae75..ac779b9 100644 --- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/fingerprint/Sha256FingerprintAdapterTest.java +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/fingerprint/Sha256FingerprintAdapterTest.java @@ -1,22 +1,22 @@ package de.gecheckt.pdf.umbenenner.adapter.out.fingerprint; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintResult; import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintSuccess; import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintTechnicalError; import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate; 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.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - /** * Unit tests for {@link Sha256FingerprintAdapter}. * diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/lock/FilesystemRunLockPortAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/lock/FilesystemRunLockPortAdapterTest.java index 3b5430e..f0d9dee 100644 --- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/lock/FilesystemRunLockPortAdapterTest.java +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/lock/FilesystemRunLockPortAdapterTest.java @@ -1,15 +1,18 @@ package de.gecheckt.pdf.umbenenner.adapter.out.lock; -import de.gecheckt.pdf.umbenenner.adapter.out.lock.FilesystemRunLockPortAdapter; -import de.gecheckt.pdf.umbenenner.application.port.out.RunLockUnavailableException; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.nio.file.Files; import java.nio.file.Path; -import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import de.gecheckt.pdf.umbenenner.application.port.out.RunLockUnavailableException; /** * Unit tests for {@link FilesystemRunLockPortAdapter}. diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/pdfextraction/PdfTextExtractionPortAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/pdfextraction/PdfTextExtractionPortAdapterTest.java index a340449..3b63805 100644 --- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/pdfextraction/PdfTextExtractionPortAdapterTest.java +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/pdfextraction/PdfTextExtractionPortAdapterTest.java @@ -1,23 +1,5 @@ package de.gecheckt.pdf.umbenenner.adapter.out.pdfextraction; -import de.gecheckt.pdf.umbenenner.adapter.out.pdfextraction.PdfTextExtractionPortAdapter; -import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionResult; -import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionSuccess; -import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionTechnicalError; -import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate; -import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator; -import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.pdmodel.PDPage; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.attribute.PosixFilePermission; -import java.util.HashSet; -import java.util.Set; - import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -25,6 +7,24 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.HashSet; +import java.util.Set; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionResult; +import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionSuccess; +import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionTechnicalError; +import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate; +import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator; + /** * Tests for {@link PdfTextExtractionPortAdapter}. *

diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sourcedocument/SourceDocumentCandidatesPortAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sourcedocument/SourceDocumentCandidatesPortAdapterTest.java index 0918fd3..d7e9a8f 100644 --- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sourcedocument/SourceDocumentCandidatesPortAdapterTest.java +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sourcedocument/SourceDocumentCandidatesPortAdapterTest.java @@ -1,18 +1,22 @@ package de.gecheckt.pdf.umbenenner.adapter.out.sourcedocument; -import de.gecheckt.pdf.umbenenner.adapter.out.sourcedocument.SourceDocumentCandidatesPortAdapter; -import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentAccessException; -import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; -import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentAccessException; +import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate; /** * Tests for {@link SourceDocumentCandidatesPortAdapter}. diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDocumentRecordRepositoryAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDocumentRecordRepositoryAdapterTest.java index 8016e23..582bad0 100644 --- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDocumentRecordRepositoryAdapterTest.java +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDocumentRecordRepositoryAdapterTest.java @@ -1,19 +1,26 @@ package de.gecheckt.pdf.umbenenner.adapter.out.sqlite; -import de.gecheckt.pdf.umbenenner.application.port.out.*; -import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; -import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus; -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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.nio.file.Path; import java.time.Instant; import java.time.temporal.ChronoUnit; -import static org.assertj.core.api.Assertions.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentKnownProcessable; +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.DocumentRecordLookupResult; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalSuccess; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown; +import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters; +import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; +import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus; +import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator; /** * Tests for {@link SqliteDocumentRecordRepositoryAdapter}. diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapterTest.java index 9fbdc85..21b1a02 100644 --- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapterTest.java +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapterTest.java @@ -1,23 +1,25 @@ package de.gecheckt.pdf.umbenenner.adapter.out.sqlite; -import de.gecheckt.pdf.umbenenner.application.port.out.*; -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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; 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 java.util.List; -import java.sql.Connection; -import java.sql.DriverManager; -import java.sql.SQLException; - -import static org.assertj.core.api.Assertions.*; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException; +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; /** * Tests for {@link SqliteProcessingAttemptRepositoryAdapter}. diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteSchemaInitializationAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteSchemaInitializationAdapterTest.java index ed5df09..032bbdc 100644 --- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteSchemaInitializationAdapterTest.java +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteSchemaInitializationAdapterTest.java @@ -1,8 +1,7 @@ package de.gecheckt.pdf.umbenenner.adapter.out.sqlite; -import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.nio.file.Path; import java.sql.Connection; @@ -13,8 +12,10 @@ import java.sql.SQLException; import java.util.HashSet; import java.util.Set; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException; /** * Unit tests for {@link SqliteSchemaInitializationAdapter}. diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/UnitOfWorkPort.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/UnitOfWorkPort.java new file mode 100644 index 0000000..118b400 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/UnitOfWorkPort.java @@ -0,0 +1,35 @@ +package de.gecheckt.pdf.umbenenner.application.port.out; + +import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; + +import java.util.function.Consumer; + +/** + * Port for executing multiple repository operations within a single unit of work. + *

+ * Ensures that related persistence operations (such as saving a processing attempt + * and updating a document record) are executed atomically. + * + * @since M4-AP-006-fix + */ +public interface UnitOfWorkPort { + + /** + * Executes the given operations within a single unit of work. + *

+ * If any operation fails, all changes are rolled back and the exception is propagated. + * + * @param operations the operations to execute; must not be null + * @throws DocumentPersistenceException if any operation fails + */ + void executeInTransaction(Consumer operations); + + /** + * Operations available within a transaction. + */ + interface TransactionOperations { + void saveProcessingAttempt(ProcessingAttempt attempt); + void createDocumentRecord(DocumentRecord record); + void updateDocumentRecord(DocumentRecord record); + } +} \ No newline at end of file diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/M4DocumentProcessor.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/M4DocumentProcessor.java index 0e0fab5..5de5e73 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/M4DocumentProcessor.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/M4DocumentProcessor.java @@ -12,6 +12,7 @@ import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters; import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceLookupTechnicalFailure; import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt; import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository; +import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort; import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext; import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; import de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome; @@ -66,12 +67,9 @@ import java.util.Objects; *

Persistence consistency

*

* For every identified document, both the processing attempt and the master record are - * written in sequence. If either write fails, the failure is logged and the batch run - * continues with the next candidate. No partial state is intentionally left; if the - * attempt write succeeds but the master record write fails, the inconsistency is bounded - * to that one document and is logged clearly. True transactionality across two separate - * repository calls is not available without a larger architectural change; this is - * documented as a known limitation of the M4 scope. + * written atomically using a unit of work pattern. If either write fails, both writes + * are rolled back and the failure is logged. The batch run continues with the next + * candidate. * *

Pre-fingerprint failures

*

@@ -87,6 +85,7 @@ public class M4DocumentProcessor { private final DocumentRecordRepository documentRecordRepository; private final ProcessingAttemptRepository processingAttemptRepository; + private final UnitOfWorkPort unitOfWorkPort; /** * Creates the M4 document processor with the required persistence ports. @@ -95,15 +94,20 @@ public class M4DocumentProcessor { * must not be null * @param processingAttemptRepository port for writing and reading the attempt history; * must not be null + * @param unitOfWorkPort port for executing operations atomically; + * must not be null * @throws NullPointerException if any parameter is null */ public M4DocumentProcessor( DocumentRecordRepository documentRecordRepository, - ProcessingAttemptRepository processingAttemptRepository) { + ProcessingAttemptRepository processingAttemptRepository, + UnitOfWorkPort unitOfWorkPort) { this.documentRecordRepository = Objects.requireNonNull(documentRecordRepository, "documentRecordRepository must not be null"); this.processingAttemptRepository = Objects.requireNonNull(processingAttemptRepository, "processingAttemptRepository must not be null"); + this.unitOfWorkPort = + Objects.requireNonNull(unitOfWorkPort, "unitOfWorkPort must not be null"); } /** @@ -329,22 +333,24 @@ public class M4DocumentProcessor { false // not retryable ); - // Write attempt first, then update master record - processingAttemptRepository.save(skipAttempt); + // Write attempt and master record atomically + unitOfWorkPort.executeInTransaction(txOps -> { + txOps.saveProcessingAttempt(skipAttempt); - // Update master record: only updatedAt changes; status and counters stay the same - DocumentRecord updatedRecord = new DocumentRecord( - existingRecord.fingerprint(), - new SourceDocumentLocator(candidate.locator().value()), - candidate.uniqueIdentifier(), - existingRecord.overallStatus(), // terminal status unchanged - existingRecord.failureCounters(), // counters unchanged for skip - existingRecord.lastFailureInstant(), - existingRecord.lastSuccessInstant(), - existingRecord.createdAt(), - now // updatedAt = now - ); - documentRecordRepository.update(updatedRecord); + // Update master record: only updatedAt changes; status and counters stay the same + DocumentRecord updatedRecord = new DocumentRecord( + existingRecord.fingerprint(), + new SourceDocumentLocator(candidate.locator().value()), + candidate.uniqueIdentifier(), + existingRecord.overallStatus(), // terminal status unchanged + existingRecord.failureCounters(), // counters unchanged for skip + existingRecord.lastFailureInstant(), + existingRecord.lastSuccessInstant(), + existingRecord.createdAt(), + now // updatedAt = now + ); + txOps.updateDocumentRecord(updatedRecord); + }); LOG.debug("Skip attempt #{} persisted for '{}' with status {}.", attemptNumber, candidate.uniqueIdentifier(), skipStatus); @@ -401,9 +407,11 @@ public class M4DocumentProcessor { now // updatedAt ); - // Persist attempt first, then master record - processingAttemptRepository.save(attempt); - documentRecordRepository.create(newRecord); + // Persist attempt and master record atomically + unitOfWorkPort.executeInTransaction(txOps -> { + txOps.saveProcessingAttempt(attempt); + txOps.createDocumentRecord(newRecord); + }); LOG.info("New document '{}' processed: status={}, contentErrors={}, transientErrors={}.", candidate.uniqueIdentifier(), @@ -466,9 +474,11 @@ public class M4DocumentProcessor { now // updatedAt ); - // Persist attempt first, then master record - processingAttemptRepository.save(attempt); - documentRecordRepository.update(updatedRecord); + // Persist attempt and master record atomically + unitOfWorkPort.executeInTransaction(txOps -> { + txOps.saveProcessingAttempt(attempt); + txOps.updateDocumentRecord(updatedRecord); + }); LOG.info("Known document '{}' processed: status={}, contentErrors={}, transientErrors={}.", candidate.uniqueIdentifier(), diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/M4DocumentProcessorTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/M4DocumentProcessorTest.java index 39761cf..bd5b33d 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/M4DocumentProcessorTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/M4DocumentProcessorTest.java @@ -12,6 +12,7 @@ import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters; import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceLookupTechnicalFailure; import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt; import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository; +import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort; import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext; import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; import de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome; @@ -32,6 +33,7 @@ import org.junit.jupiter.api.Test; import java.time.Instant; import java.util.ArrayList; import java.util.List; +import java.util.function.Consumer; import static org.junit.jupiter.api.Assertions.*; @@ -56,6 +58,7 @@ class M4DocumentProcessorTest { private CapturingDocumentRecordRepository recordRepo; private CapturingProcessingAttemptRepository attemptRepo; + private CapturingUnitOfWorkPort unitOfWorkPort; private M4DocumentProcessor processor; private SourceDocumentCandidate candidate; @@ -67,7 +70,8 @@ class M4DocumentProcessorTest { void setUp() { recordRepo = new CapturingDocumentRecordRepository(); attemptRepo = new CapturingProcessingAttemptRepository(); - processor = new M4DocumentProcessor(recordRepo, attemptRepo); + unitOfWorkPort = new CapturingUnitOfWorkPort(recordRepo, attemptRepo); + processor = new M4DocumentProcessor(recordRepo, attemptRepo, unitOfWorkPort); candidate = new SourceDocumentCandidate( "test.pdf", 1024L, new SourceDocumentLocator("/tmp/test.pdf")); @@ -321,8 +325,8 @@ class M4DocumentProcessorTest { @Test void process_persistenceWriteFailure_doesNotThrow_batchContinues() { recordRepo.setLookupResult(new DocumentUnknown()); - // Make the attempt save throw - attemptRepo.failOnSave = true; + // Make the unit of work throw + unitOfWorkPort.failOnExecute = true; DocumentProcessingOutcome m3Outcome = new PreCheckPassed( candidate, new PdfExtractionSuccess("text", new PdfPageCount(1))); @@ -422,4 +426,45 @@ class M4DocumentProcessorTest { return List.copyOf(savedAttempts); } } + + private static class CapturingUnitOfWorkPort implements UnitOfWorkPort { + private final CapturingDocumentRecordRepository recordRepo; + private final CapturingProcessingAttemptRepository attemptRepo; + boolean failOnExecute = false; + Consumer lastOperations = null; + + CapturingUnitOfWorkPort(CapturingDocumentRecordRepository recordRepo, + CapturingProcessingAttemptRepository attemptRepo) { + this.recordRepo = recordRepo; + this.attemptRepo = attemptRepo; + } + + @Override + public void executeInTransaction(Consumer operations) { + this.lastOperations = operations; + if (failOnExecute) { + throw new DocumentPersistenceException("Simulated transaction failure"); + } + + // Execute the operations with mock transaction operations that delegate to repos + TransactionOperations mockOps = new TransactionOperations() { + @Override + public void saveProcessingAttempt(ProcessingAttempt attempt) { + attemptRepo.savedAttempts.add(attempt); + } + + @Override + public void createDocumentRecord(DocumentRecord record) { + recordRepo.createdRecords.add(record); + } + + @Override + public void updateDocumentRecord(DocumentRecord record) { + recordRepo.updatedRecords.add(record); + } + }; + + operations.accept(mockOps); + } + } } diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java index 5fbc692..c079a7a 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java @@ -17,6 +17,7 @@ import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort; import de.gecheckt.pdf.umbenenner.application.port.out.RunLockUnavailableException; import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentAccessException; import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentCandidatesPort; +import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort; import de.gecheckt.pdf.umbenenner.application.service.M4DocumentProcessor; import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext; import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; @@ -39,6 +40,7 @@ import java.time.Instant; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.function.Consumer; import static org.junit.jupiter.api.Assertions.*; @@ -615,7 +617,7 @@ class BatchRunProcessingUseCaseTest { */ private static class NoOpM4DocumentProcessor extends M4DocumentProcessor { NoOpM4DocumentProcessor() { - super(new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository()); + super(new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpUnitOfWorkPort()); } } @@ -626,7 +628,7 @@ class BatchRunProcessingUseCaseTest { private int processCallCount = 0; TrackingM4DocumentProcessor() { - super(new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository()); + super(new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpUnitOfWorkPort()); } @Override @@ -692,4 +694,28 @@ class BatchRunProcessingUseCaseTest { return List.of(); } } + + /** No-op UnitOfWorkPort for use in test M4DocumentProcessor instances. */ + private static class NoOpUnitOfWorkPort implements UnitOfWorkPort { + @Override + public void executeInTransaction(Consumer operations) { + // No-op - just execute the operations directly without transaction + operations.accept(new TransactionOperations() { + @Override + public void saveProcessingAttempt(ProcessingAttempt attempt) { + // No-op + } + + @Override + public void createDocumentRecord(DocumentRecord record) { + // No-op + } + + @Override + public void updateDocumentRecord(DocumentRecord record) { + // No-op + } + }); + } + } } \ No newline at end of file diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java index 1fe653b..1596424 100644 --- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java @@ -1,5 +1,10 @@ package de.gecheckt.pdf.umbenenner.bootstrap; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Instant; +import java.util.UUID; + import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -12,6 +17,7 @@ import de.gecheckt.pdf.umbenenner.adapter.out.sourcedocument.SourceDocumentCandi import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteDocumentRecordRepositoryAdapter; import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteProcessingAttemptRepositoryAdapter; import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter; +import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteUnitOfWorkAdapter; import de.gecheckt.pdf.umbenenner.application.config.InvalidStartConfigurationException; import de.gecheckt.pdf.umbenenner.application.config.StartConfiguration; import de.gecheckt.pdf.umbenenner.application.config.StartConfigurationValidator; @@ -24,16 +30,12 @@ import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintPort; import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitializationPort; import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository; import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort; +import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort; import de.gecheckt.pdf.umbenenner.application.service.M4DocumentProcessor; import de.gecheckt.pdf.umbenenner.application.usecase.DefaultBatchRunProcessingUseCase; import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext; import de.gecheckt.pdf.umbenenner.domain.model.RunId; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.time.Instant; -import java.util.UUID; - /** * Manual bootstrap runner that constructs the object graph and drives the startup flow. *

@@ -65,6 +67,7 @@ import java.util.UUID; *

  • {@link SqliteSchemaInitializationAdapter} — schema initialisation at startup.
  • *
  • {@link SqliteDocumentRecordRepositoryAdapter} — document master record CRUD.
  • *
  • {@link SqliteProcessingAttemptRepositoryAdapter} — attempt history CRUD.
  • + *
  • {@link SqliteUnitOfWorkAdapter} — atomic persistence operations.
  • * * * @since M2 (extended in M4-AP-006) @@ -135,6 +138,7 @@ public class BootstrapRunner { *
  • {@link Sha256FingerprintAdapter} for SHA-256 content fingerprinting.
  • *
  • {@link SqliteDocumentRecordRepositoryAdapter} for document master record CRUD.
  • *
  • {@link SqliteProcessingAttemptRepositoryAdapter} for attempt history CRUD.
  • + *
  • {@link SqliteUnitOfWorkAdapter} for atomic persistence operations.
  • * *

    * Schema initialisation is performed in {@link #run()} before the use case is created, @@ -151,8 +155,10 @@ public class BootstrapRunner { new SqliteDocumentRecordRepositoryAdapter(jdbcUrl); ProcessingAttemptRepository processingAttemptRepository = new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl); + UnitOfWorkPort unitOfWorkPort = + new SqliteUnitOfWorkAdapter(jdbcUrl); M4DocumentProcessor m4Processor = - new M4DocumentProcessor(documentRecordRepository, processingAttemptRepository); + new M4DocumentProcessor(documentRecordRepository, processingAttemptRepository, unitOfWorkPort); return new DefaultBatchRunProcessingUseCase( config, lock,