1
0

M4 AP-006 Persistenzkonsistenz und Bootstrap-Scope korrigieren

This commit is contained in:
2026-04-03 08:37:44 +02:00
parent fc30d1effd
commit d61299f892
22 changed files with 491 additions and 174 deletions

View File

@@ -1,11 +1,5 @@
package de.gecheckt.pdf.umbenenner.adapter.out.configuration; 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.IOException;
import java.io.StringReader; import java.io.StringReader;
import java.net.URI; import java.net.URI;
@@ -17,6 +11,12 @@ import java.nio.file.Paths;
import java.util.Properties; import java.util.Properties;
import java.util.function.Function; 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}. * Properties-based implementation of {@link ConfigurationPort}.
* AP-005: Loads configuration from config/application.properties with environment variable precedence. * AP-005: Loads configuration from config/application.properties with environment variable precedence.

View File

@@ -1,13 +1,5 @@
package de.gecheckt.pdf.umbenenner.adapter.out.fingerprint; 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.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.InvalidPathException; import java.nio.file.InvalidPathException;
@@ -19,6 +11,14 @@ import java.security.NoSuchAlgorithmException;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; 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}. * SHA-256-based implementation of {@link FingerprintPort}.
* <p> * <p>

View File

@@ -1,16 +1,16 @@
package de.gecheckt.pdf.umbenenner.adapter.out.lock; 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.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort; 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.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. * File-based implementation of {@link RunLockPort} that uses a lock file to prevent concurrent runs.
* <p> * <p>

View File

@@ -1,19 +1,20 @@
package de.gecheckt.pdf.umbenenner.adapter.out.pdfextraction; 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.application.port.out.PdfTextExtractionPort;
import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionResult; import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionResult;
import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionSuccess; import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionSuccess;
import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionTechnicalError; import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionTechnicalError;
import de.gecheckt.pdf.umbenenner.domain.model.PdfPageCount; import de.gecheckt.pdf.umbenenner.domain.model.PdfPageCount;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate; 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}. * PDFBox-based implementation of {@link PdfTextExtractionPort}.

View File

@@ -1,16 +1,16 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sourcedocument; 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.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List; import java.util.List;
import java.util.stream.Stream; 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}. * File-system based implementation of {@link SourceDocumentCandidatesPort}.
* <p> * <p>

View File

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

View File

@@ -1,19 +1,24 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite; 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.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt; 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.ProcessingAttemptRepository;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; 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}. * SQLite implementation of {@link ProcessingAttemptRepository}.
* <p> * <p>
@@ -72,7 +77,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
WHERE fingerprint = ? WHERE fingerprint = ?
"""; """;
try (Connection connection = DriverManager.getConnection(jdbcUrl); try (Connection connection = getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) { PreparedStatement statement = connection.prepareStatement(sql)) {
// Enable foreign key enforcement for this connection // Enable foreign key enforcement for this connection
@@ -129,7 +134,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
"""; """;
try (Connection connection = DriverManager.getConnection(jdbcUrl); try (Connection connection = getConnection();
Statement pragmaStmt = connection.createStatement(); Statement pragmaStmt = connection.createStatement();
PreparedStatement statement = connection.prepareStatement(sql)) { PreparedStatement statement = connection.prepareStatement(sql)) {
@@ -198,7 +203,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
ORDER BY attempt_number ASC ORDER BY attempt_number ASC
"""; """;
try (Connection connection = DriverManager.getConnection(jdbcUrl); try (Connection connection = getConnection();
Statement pragmaStmt = connection.createStatement(); Statement pragmaStmt = connection.createStatement();
PreparedStatement statement = connection.prepareStatement(sql)) { PreparedStatement statement = connection.prepareStatement(sql)) {
@@ -255,4 +260,16 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
public String getJdbcUrl() { public String getJdbcUrl() {
return jdbcUrl; return jdbcUrl;
} }
}
/**
* Gets a connection to the database.
* <p>
* 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);
}
}

View File

@@ -1,16 +1,17 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite; 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.Connection;
import java.sql.DriverManager; import java.sql.DriverManager;
import java.sql.SQLException; import java.sql.SQLException;
import java.sql.Statement; import java.sql.Statement;
import java.util.Objects; 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}. * SQLite implementation of {@link PersistenceSchemaInitializationPort}.
* <p> * <p>

View File

@@ -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}.
* <p>
* 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<TransactionOperations> 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);
}
}
}
}

View File

@@ -1,17 +1,18 @@
package de.gecheckt.pdf.umbenenner.adapter.out.configuration; package de.gecheckt.pdf.umbenenner.adapter.out.configuration;
import org.junit.jupiter.api.BeforeEach; import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertNotNull;
import org.junit.jupiter.api.io.TempDir; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import de.gecheckt.pdf.umbenenner.adapter.out.configuration.PropertiesConfigurationPortAdapter;
import java.io.FileWriter; import java.io.FileWriter;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.function.Function; 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}. * Unit tests for {@link PropertiesConfigurationPortAdapter}.

View File

@@ -1,22 +1,22 @@
package de.gecheckt.pdf.umbenenner.adapter.out.fingerprint; 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.FingerprintResult;
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintSuccess; import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintTechnicalError; import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintTechnicalError;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate; import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator; 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}. * Unit tests for {@link Sha256FingerprintAdapter}.
* *

View File

@@ -1,15 +1,18 @@
package de.gecheckt.pdf.umbenenner.adapter.out.lock; package de.gecheckt.pdf.umbenenner.adapter.out.lock;
import de.gecheckt.pdf.umbenenner.adapter.out.lock.FilesystemRunLockPortAdapter; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockUnavailableException; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.io.TempDir; import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; 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}. * Unit tests for {@link FilesystemRunLockPortAdapter}.

View File

@@ -1,23 +1,5 @@
package de.gecheckt.pdf.umbenenner.adapter.out.pdfextraction; 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf; 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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; 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}. * Tests for {@link PdfTextExtractionPortAdapter}.
* <p> * <p>

View File

@@ -1,18 +1,22 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sourcedocument; package de.gecheckt.pdf.umbenenner.adapter.out.sourcedocument;
import de.gecheckt.pdf.umbenenner.adapter.out.sourcedocument.SourceDocumentCandidatesPortAdapter; import static org.junit.jupiter.api.Assertions.assertEquals;
import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentAccessException; import static org.junit.jupiter.api.Assertions.assertFalse;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate; import static org.junit.jupiter.api.Assertions.assertNotNull;
import org.junit.jupiter.api.BeforeEach; import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List; 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}. * Tests for {@link SourceDocumentCandidatesPortAdapter}.

View File

@@ -1,19 +1,26 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite; package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import de.gecheckt.pdf.umbenenner.application.port.out.*; import static org.assertj.core.api.Assertions.assertThat;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; import static org.assertj.core.api.Assertions.assertThatThrownBy;
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 java.nio.file.Path; import java.nio.file.Path;
import java.time.Instant; import java.time.Instant;
import java.time.temporal.ChronoUnit; 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}. * Tests for {@link SqliteDocumentRecordRepositoryAdapter}.

View File

@@ -1,23 +1,25 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite; package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import de.gecheckt.pdf.umbenenner.application.port.out.*; import static org.assertj.core.api.Assertions.assertThat;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.RunId; 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.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path; import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import java.time.Instant; import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import java.time.temporal.ChronoUnit; import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import java.util.List; import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import java.sql.Connection; import de.gecheckt.pdf.umbenenner.domain.model.RunId;
import java.sql.DriverManager;
import java.sql.SQLException;
import static org.assertj.core.api.Assertions.*;
/** /**
* Tests for {@link SqliteProcessingAttemptRepositoryAdapter}. * Tests for {@link SqliteProcessingAttemptRepositoryAdapter}.

View File

@@ -1,8 +1,7 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite; package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException; import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path; import java.nio.file.Path;
import java.sql.Connection; import java.sql.Connection;
@@ -13,8 +12,10 @@ import java.sql.SQLException;
import java.util.HashSet; import java.util.HashSet;
import java.util.Set; import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import org.junit.jupiter.api.io.TempDir;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
/** /**
* Unit tests for {@link SqliteSchemaInitializationAdapter}. * Unit tests for {@link SqliteSchemaInitializationAdapter}.

View File

@@ -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.
* <p>
* 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.
* <p>
* 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<TransactionOperations> operations);
/**
* Operations available within a transaction.
*/
interface TransactionOperations {
void saveProcessingAttempt(ProcessingAttempt attempt);
void createDocumentRecord(DocumentRecord record);
void updateDocumentRecord(DocumentRecord record);
}
}

View File

@@ -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.PersistenceLookupTechnicalFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt; 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.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.BatchRunContext;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome; import de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome;
@@ -66,12 +67,9 @@ import java.util.Objects;
* <h2>Persistence consistency</h2> * <h2>Persistence consistency</h2>
* <p> * <p>
* For every identified document, both the processing attempt and the master record are * 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 * written atomically using a unit of work pattern. If either write fails, both writes
* continues with the next candidate. No partial state is intentionally left; if the * are rolled back and the failure is logged. The batch run continues with the next
* attempt write succeeds but the master record write fails, the inconsistency is bounded * candidate.
* 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.
* *
* <h2>Pre-fingerprint failures</h2> * <h2>Pre-fingerprint failures</h2>
* <p> * <p>
@@ -87,6 +85,7 @@ public class M4DocumentProcessor {
private final DocumentRecordRepository documentRecordRepository; private final DocumentRecordRepository documentRecordRepository;
private final ProcessingAttemptRepository processingAttemptRepository; private final ProcessingAttemptRepository processingAttemptRepository;
private final UnitOfWorkPort unitOfWorkPort;
/** /**
* Creates the M4 document processor with the required persistence ports. * Creates the M4 document processor with the required persistence ports.
@@ -95,15 +94,20 @@ public class M4DocumentProcessor {
* must not be null * must not be null
* @param processingAttemptRepository port for writing and reading the attempt history; * @param processingAttemptRepository port for writing and reading the attempt history;
* must not be null * must not be null
* @param unitOfWorkPort port for executing operations atomically;
* must not be null
* @throws NullPointerException if any parameter is null * @throws NullPointerException if any parameter is null
*/ */
public M4DocumentProcessor( public M4DocumentProcessor(
DocumentRecordRepository documentRecordRepository, DocumentRecordRepository documentRecordRepository,
ProcessingAttemptRepository processingAttemptRepository) { ProcessingAttemptRepository processingAttemptRepository,
UnitOfWorkPort unitOfWorkPort) {
this.documentRecordRepository = this.documentRecordRepository =
Objects.requireNonNull(documentRecordRepository, "documentRecordRepository must not be null"); Objects.requireNonNull(documentRecordRepository, "documentRecordRepository must not be null");
this.processingAttemptRepository = this.processingAttemptRepository =
Objects.requireNonNull(processingAttemptRepository, "processingAttemptRepository must not be null"); 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 false // not retryable
); );
// Write attempt first, then update master record // Write attempt and master record atomically
processingAttemptRepository.save(skipAttempt); unitOfWorkPort.executeInTransaction(txOps -> {
txOps.saveProcessingAttempt(skipAttempt);
// Update master record: only updatedAt changes; status and counters stay the same // Update master record: only updatedAt changes; status and counters stay the same
DocumentRecord updatedRecord = new DocumentRecord( DocumentRecord updatedRecord = new DocumentRecord(
existingRecord.fingerprint(), existingRecord.fingerprint(),
new SourceDocumentLocator(candidate.locator().value()), new SourceDocumentLocator(candidate.locator().value()),
candidate.uniqueIdentifier(), candidate.uniqueIdentifier(),
existingRecord.overallStatus(), // terminal status unchanged existingRecord.overallStatus(), // terminal status unchanged
existingRecord.failureCounters(), // counters unchanged for skip existingRecord.failureCounters(), // counters unchanged for skip
existingRecord.lastFailureInstant(), existingRecord.lastFailureInstant(),
existingRecord.lastSuccessInstant(), existingRecord.lastSuccessInstant(),
existingRecord.createdAt(), existingRecord.createdAt(),
now // updatedAt = now now // updatedAt = now
); );
documentRecordRepository.update(updatedRecord); txOps.updateDocumentRecord(updatedRecord);
});
LOG.debug("Skip attempt #{} persisted for '{}' with status {}.", LOG.debug("Skip attempt #{} persisted for '{}' with status {}.",
attemptNumber, candidate.uniqueIdentifier(), skipStatus); attemptNumber, candidate.uniqueIdentifier(), skipStatus);
@@ -401,9 +407,11 @@ public class M4DocumentProcessor {
now // updatedAt now // updatedAt
); );
// Persist attempt first, then master record // Persist attempt and master record atomically
processingAttemptRepository.save(attempt); unitOfWorkPort.executeInTransaction(txOps -> {
documentRecordRepository.create(newRecord); txOps.saveProcessingAttempt(attempt);
txOps.createDocumentRecord(newRecord);
});
LOG.info("New document '{}' processed: status={}, contentErrors={}, transientErrors={}.", LOG.info("New document '{}' processed: status={}, contentErrors={}, transientErrors={}.",
candidate.uniqueIdentifier(), candidate.uniqueIdentifier(),
@@ -466,9 +474,11 @@ public class M4DocumentProcessor {
now // updatedAt now // updatedAt
); );
// Persist attempt first, then master record // Persist attempt and master record atomically
processingAttemptRepository.save(attempt); unitOfWorkPort.executeInTransaction(txOps -> {
documentRecordRepository.update(updatedRecord); txOps.saveProcessingAttempt(attempt);
txOps.updateDocumentRecord(updatedRecord);
});
LOG.info("Known document '{}' processed: status={}, contentErrors={}, transientErrors={}.", LOG.info("Known document '{}' processed: status={}, contentErrors={}, transientErrors={}.",
candidate.uniqueIdentifier(), candidate.uniqueIdentifier(),

View File

@@ -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.PersistenceLookupTechnicalFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt; 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.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.BatchRunContext;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome; import de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome;
@@ -32,6 +33,7 @@ import org.junit.jupiter.api.Test;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
@@ -56,6 +58,7 @@ class M4DocumentProcessorTest {
private CapturingDocumentRecordRepository recordRepo; private CapturingDocumentRecordRepository recordRepo;
private CapturingProcessingAttemptRepository attemptRepo; private CapturingProcessingAttemptRepository attemptRepo;
private CapturingUnitOfWorkPort unitOfWorkPort;
private M4DocumentProcessor processor; private M4DocumentProcessor processor;
private SourceDocumentCandidate candidate; private SourceDocumentCandidate candidate;
@@ -67,7 +70,8 @@ class M4DocumentProcessorTest {
void setUp() { void setUp() {
recordRepo = new CapturingDocumentRecordRepository(); recordRepo = new CapturingDocumentRecordRepository();
attemptRepo = new CapturingProcessingAttemptRepository(); attemptRepo = new CapturingProcessingAttemptRepository();
processor = new M4DocumentProcessor(recordRepo, attemptRepo); unitOfWorkPort = new CapturingUnitOfWorkPort(recordRepo, attemptRepo);
processor = new M4DocumentProcessor(recordRepo, attemptRepo, unitOfWorkPort);
candidate = new SourceDocumentCandidate( candidate = new SourceDocumentCandidate(
"test.pdf", 1024L, new SourceDocumentLocator("/tmp/test.pdf")); "test.pdf", 1024L, new SourceDocumentLocator("/tmp/test.pdf"));
@@ -321,8 +325,8 @@ class M4DocumentProcessorTest {
@Test @Test
void process_persistenceWriteFailure_doesNotThrow_batchContinues() { void process_persistenceWriteFailure_doesNotThrow_batchContinues() {
recordRepo.setLookupResult(new DocumentUnknown()); recordRepo.setLookupResult(new DocumentUnknown());
// Make the attempt save throw // Make the unit of work throw
attemptRepo.failOnSave = true; unitOfWorkPort.failOnExecute = true;
DocumentProcessingOutcome m3Outcome = new PreCheckPassed( DocumentProcessingOutcome m3Outcome = new PreCheckPassed(
candidate, new PdfExtractionSuccess("text", new PdfPageCount(1))); candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));
@@ -422,4 +426,45 @@ class M4DocumentProcessorTest {
return List.copyOf(savedAttempts); return List.copyOf(savedAttempts);
} }
} }
private static class CapturingUnitOfWorkPort implements UnitOfWorkPort {
private final CapturingDocumentRecordRepository recordRepo;
private final CapturingProcessingAttemptRepository attemptRepo;
boolean failOnExecute = false;
Consumer<TransactionOperations> lastOperations = null;
CapturingUnitOfWorkPort(CapturingDocumentRecordRepository recordRepo,
CapturingProcessingAttemptRepository attemptRepo) {
this.recordRepo = recordRepo;
this.attemptRepo = attemptRepo;
}
@Override
public void executeInTransaction(Consumer<TransactionOperations> 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);
}
}
} }

View File

@@ -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.RunLockUnavailableException;
import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentAccessException; 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.SourceDocumentCandidatesPort;
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
import de.gecheckt.pdf.umbenenner.application.service.M4DocumentProcessor; import de.gecheckt.pdf.umbenenner.application.service.M4DocumentProcessor;
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext; import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
@@ -39,6 +40,7 @@ import java.time.Instant;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
@@ -615,7 +617,7 @@ class BatchRunProcessingUseCaseTest {
*/ */
private static class NoOpM4DocumentProcessor extends M4DocumentProcessor { private static class NoOpM4DocumentProcessor extends M4DocumentProcessor {
NoOpM4DocumentProcessor() { NoOpM4DocumentProcessor() {
super(new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository()); super(new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpUnitOfWorkPort());
} }
} }
@@ -626,7 +628,7 @@ class BatchRunProcessingUseCaseTest {
private int processCallCount = 0; private int processCallCount = 0;
TrackingM4DocumentProcessor() { TrackingM4DocumentProcessor() {
super(new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository()); super(new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpUnitOfWorkPort());
} }
@Override @Override
@@ -692,4 +694,28 @@ class BatchRunProcessingUseCaseTest {
return List.of(); return List.of();
} }
} }
/** No-op UnitOfWorkPort for use in test M4DocumentProcessor instances. */
private static class NoOpUnitOfWorkPort implements UnitOfWorkPort {
@Override
public void executeInTransaction(Consumer<TransactionOperations> 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
}
});
}
}
} }

View File

@@ -1,5 +1,10 @@
package de.gecheckt.pdf.umbenenner.bootstrap; 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.LogManager;
import org.apache.logging.log4j.Logger; 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.SqliteDocumentRecordRepositoryAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteProcessingAttemptRepositoryAdapter; 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.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.InvalidStartConfigurationException;
import de.gecheckt.pdf.umbenenner.application.config.StartConfiguration; import de.gecheckt.pdf.umbenenner.application.config.StartConfiguration;
import de.gecheckt.pdf.umbenenner.application.config.StartConfigurationValidator; 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.PersistenceSchemaInitializationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository; 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.RunLockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
import de.gecheckt.pdf.umbenenner.application.service.M4DocumentProcessor; import de.gecheckt.pdf.umbenenner.application.service.M4DocumentProcessor;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultBatchRunProcessingUseCase; import de.gecheckt.pdf.umbenenner.application.usecase.DefaultBatchRunProcessingUseCase;
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext; import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
import de.gecheckt.pdf.umbenenner.domain.model.RunId; 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. * Manual bootstrap runner that constructs the object graph and drives the startup flow.
* <p> * <p>
@@ -65,6 +67,7 @@ import java.util.UUID;
* <li>{@link SqliteSchemaInitializationAdapter} — schema initialisation at startup.</li> * <li>{@link SqliteSchemaInitializationAdapter} — schema initialisation at startup.</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>
* </ul> * </ul>
* *
* @since M2 (extended in M4-AP-006) * @since M2 (extended in M4-AP-006)
@@ -135,6 +138,7 @@ public class BootstrapRunner {
* <li>{@link Sha256FingerprintAdapter} for SHA-256 content fingerprinting.</li> * <li>{@link Sha256FingerprintAdapter} for SHA-256 content fingerprinting.</li>
* <li>{@link SqliteDocumentRecordRepositoryAdapter} for document master record CRUD.</li> * <li>{@link SqliteDocumentRecordRepositoryAdapter} for document master record CRUD.</li>
* <li>{@link SqliteProcessingAttemptRepositoryAdapter} for attempt history CRUD.</li> * <li>{@link SqliteProcessingAttemptRepositoryAdapter} for attempt history CRUD.</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 in {@link #run()} before the use case is created,
@@ -151,8 +155,10 @@ public class BootstrapRunner {
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl); new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
ProcessingAttemptRepository processingAttemptRepository = ProcessingAttemptRepository processingAttemptRepository =
new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl); new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl);
UnitOfWorkPort unitOfWorkPort =
new SqliteUnitOfWorkAdapter(jdbcUrl);
M4DocumentProcessor m4Processor = M4DocumentProcessor m4Processor =
new M4DocumentProcessor(documentRecordRepository, processingAttemptRepository); new M4DocumentProcessor(documentRecordRepository, processingAttemptRepository, unitOfWorkPort);
return new DefaultBatchRunProcessingUseCase( return new DefaultBatchRunProcessingUseCase(
config, config,
lock, lock,