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
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
* 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.
*
*
@@ -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
@@ -65,6 +67,7 @@ import java.util.UUID;
*
* 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,
Persistence consistency
* Pre-fingerprint failures
*