1
0

M4 AP-005 Repository für Versuchshistorie implementieren

This commit is contained in:
2026-04-02 20:37:21 +02:00
parent 29ea56d2cf
commit 8ee4041feb
2 changed files with 692 additions and 0 deletions

View File

@@ -0,0 +1,258 @@
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.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}.
* <p>
* Provides CRUD operations for the processing attempt history (Versuchshistorie)
* with explicit mapping between application types and the SQLite schema.
* <p>
* <strong>Architecture boundary:</strong> All JDBC and SQLite details are strictly
* confined to this class. No JDBC types appear in the port interface or in any
* application/domain type.
*
* @since M4-AP-005
*/
public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttemptRepository {
private static final Logger logger = LogManager.getLogger(SqliteProcessingAttemptRepositoryAdapter.class);
private final String jdbcUrl;
private static final String PRAGMA_FOREIGN_KEYS_ON = "PRAGMA foreign_keys = ON";
/**
* Constructs the adapter with the JDBC URL of the SQLite database file.
*
* @param jdbcUrl the JDBC URL of the SQLite database; must not be null or blank
* @throws NullPointerException if {@code jdbcUrl} is null
* @throws IllegalArgumentException if {@code jdbcUrl} is blank
*/
public SqliteProcessingAttemptRepositoryAdapter(String jdbcUrl) {
Objects.requireNonNull(jdbcUrl, "jdbcUrl must not be null");
if (jdbcUrl.isBlank()) {
throw new IllegalArgumentException("jdbcUrl must not be blank");
}
this.jdbcUrl = jdbcUrl;
}
/**
* Returns the attempt number to assign to the <em>next</em> attempt for the given
* fingerprint.
* <p>
* If no prior attempts exist for the fingerprint, returns&nbsp;1.
* Otherwise returns the current maximum attempt number plus&nbsp;1.
*
* @param fingerprint the document identity; must not be null
* @return the next monotonic attempt number; always &gt;= 1
* @throws DocumentPersistenceException if the query fails due to a technical error
*/
@Override
public int loadNextAttemptNumber(DocumentFingerprint fingerprint) {
if (fingerprint == null) {
throw new NullPointerException("fingerprint must not be null");
}
String sql = """
SELECT COALESCE(MAX(attempt_number), 0) + 1 AS next_attempt_number
FROM processing_attempt
WHERE fingerprint = ?
""";
try (Connection connection = DriverManager.getConnection(jdbcUrl);
PreparedStatement statement = connection.prepareStatement(sql)) {
// Enable foreign key enforcement for this connection
try (Statement pragmaStmt = connection.createStatement()) {
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
}
statement.setString(1, fingerprint.sha256Hex());
try (ResultSet rs = statement.executeQuery()) {
if (rs.next()) {
return rs.getInt("next_attempt_number");
} else {
// This should not happen, but fallback to 1
return 1;
}
}
} catch (SQLException e) {
String message = "Failed to load next attempt number for fingerprint '" +
fingerprint.sha256Hex() + "': " + e.getMessage();
logger.error(message, e);
throw new DocumentPersistenceException(message, e);
}
}
/**
* Persists exactly one processing attempt record.
* <p>
* The {@link ProcessingAttempt#attemptNumber()} must have been obtained from
* {@link #loadNextAttemptNumber(DocumentFingerprint)} in the same run to guarantee
* monotonic ordering.
*
* @param attempt the attempt to persist; must not be null
* @throws DocumentPersistenceException if the insert fails due to a technical error
*/
@Override
public void save(ProcessingAttempt attempt) {
if (attempt == null) {
throw new NullPointerException("attempt must not be null");
}
String sql = """
INSERT INTO processing_attempt (
fingerprint,
run_id,
attempt_number,
started_at,
ended_at,
status,
failure_class,
failure_message,
retryable
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (Connection connection = DriverManager.getConnection(jdbcUrl);
Statement pragmaStmt = connection.createStatement();
PreparedStatement statement = connection.prepareStatement(sql)) {
// Enable foreign key enforcement for this connection
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
statement.setString(1, attempt.fingerprint().sha256Hex());
statement.setString(2, attempt.runId().value());
statement.setInt(3, attempt.attemptNumber());
statement.setString(4, attempt.startedAt().toString());
statement.setString(5, attempt.endedAt().toString());
statement.setString(6, attempt.status().name());
// Handle nullable fields
statement.setString(7, attempt.failureClass());
statement.setString(8, attempt.failureMessage());
statement.setBoolean(9, attempt.retryable());
int rowsAffected = statement.executeUpdate();
if (rowsAffected != 1) {
throw new DocumentPersistenceException(
"Expected to insert 1 row but affected " + rowsAffected + " rows");
}
logger.debug("Saved processing attempt #{} for fingerprint: {}",
attempt.attemptNumber(), attempt.fingerprint().sha256Hex());
} catch (SQLException e) {
String message = "Failed to save processing attempt #" + attempt.attemptNumber() +
" for fingerprint '" + attempt.fingerprint().sha256Hex() + "': " + e.getMessage();
logger.error(message, e);
throw new DocumentPersistenceException(message, e);
}
}
/**
* Returns all historised attempts for the given fingerprint, ordered by
* {@link ProcessingAttempt#attemptNumber()} ascending.
* <p>
* Returns an empty list if no attempts have been recorded yet.
* Intended for use in tests and diagnostics; not required on the primary batch path.
*
* @param fingerprint the document identity; must not be null
* @return immutable list of attempts, ordered by attempt number; never null
* @throws DocumentPersistenceException if the query fails due to a technical error
*/
@Override
public List<ProcessingAttempt> findAllByFingerprint(DocumentFingerprint fingerprint) {
if (fingerprint == null) {
throw new NullPointerException("fingerprint must not be null");
}
String sql = """
SELECT
fingerprint,
run_id,
attempt_number,
started_at,
ended_at,
status,
failure_class,
failure_message,
retryable
FROM processing_attempt
WHERE fingerprint = ?
ORDER BY attempt_number ASC
""";
try (Connection connection = DriverManager.getConnection(jdbcUrl);
Statement pragmaStmt = connection.createStatement();
PreparedStatement statement = connection.prepareStatement(sql)) {
// Enable foreign key enforcement for this connection
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
statement.setString(1, fingerprint.sha256Hex());
try (ResultSet rs = statement.executeQuery()) {
List<ProcessingAttempt> attempts = new ArrayList<>();
while (rs.next()) {
ProcessingAttempt attempt = mapResultSetToProcessingAttempt(rs);
attempts.add(attempt);
}
return List.copyOf(attempts); // Return immutable copy
}
} catch (SQLException e) {
String message = "Failed to find processing attempts for fingerprint '" +
fingerprint.sha256Hex() + "': " + e.getMessage();
logger.error(message, e);
throw new DocumentPersistenceException(message, e);
}
}
/**
* Maps a ResultSet row to a ProcessingAttempt.
*
* @param rs the ResultSet positioned at the current row
* @return the mapped ProcessingAttempt
* @throws SQLException if reading from the ResultSet fails
*/
private ProcessingAttempt mapResultSetToProcessingAttempt(ResultSet rs) throws SQLException {
return new ProcessingAttempt(
new DocumentFingerprint(rs.getString("fingerprint")),
new de.gecheckt.pdf.umbenenner.domain.model.RunId(rs.getString("run_id")),
rs.getInt("attempt_number"),
Instant.parse(rs.getString("started_at")),
Instant.parse(rs.getString("ended_at")),
de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus.valueOf(rs.getString("status")),
rs.getString("failure_class"),
rs.getString("failure_message"),
rs.getBoolean("retryable")
);
}
/**
* Returns the JDBC URL this adapter uses to connect to the SQLite database.
* <p>
* Intended for logging and diagnostics only.
*
* @return the JDBC URL; never null or blank
*/
public String getJdbcUrl() {
return jdbcUrl;
}
}

View File

@@ -0,0 +1,434 @@
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 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.*;
/**
* Tests for {@link SqliteProcessingAttemptRepositoryAdapter}.
*
* @since M4-AP-005
*/
class SqliteProcessingAttemptRepositoryAdapterTest {
private SqliteProcessingAttemptRepositoryAdapter repository;
private String jdbcUrl;
@TempDir
Path tempDir;
@BeforeEach
void setUp() {
Path dbFile = tempDir.resolve("test.db");
jdbcUrl = "jdbc:sqlite:" + dbFile.toAbsolutePath();
// Initialize schema first
SqliteSchemaInitializationAdapter schemaInitializer =
new SqliteSchemaInitializationAdapter(jdbcUrl);
schemaInitializer.initializeSchema();
repository = new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl);
}
// -------------------------------------------------------------------------
// Construction
// -------------------------------------------------------------------------
@Test
void constructor_rejectsNullJdbcUrl() {
assertThatThrownBy(() -> new SqliteProcessingAttemptRepositoryAdapter(null))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("jdbcUrl");
}
@Test
void constructor_rejectsBlankJdbcUrl() {
assertThatThrownBy(() -> new SqliteProcessingAttemptRepositoryAdapter(" "))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("jdbcUrl");
}
@Test
void getJdbcUrl_returnsConfiguredUrl() {
String url = "jdbc:sqlite:/some/path/test.db";
SqliteProcessingAttemptRepositoryAdapter adapter = new SqliteProcessingAttemptRepositoryAdapter(url);
assertThat(adapter.getJdbcUrl()).isEqualTo(url);
}
// -------------------------------------------------------------------------
// loadNextAttemptNumber
// -------------------------------------------------------------------------
@Test
void loadNextAttemptNumber_shouldReturnOne_whenNoAttemptsExist() {
// Given
DocumentFingerprint fingerprint = new DocumentFingerprint(
"0000000000000000000000000000000000000000000000000000000000000000");
// When
int nextAttemptNumber = repository.loadNextAttemptNumber(fingerprint);
// Then
assertThat(nextAttemptNumber).isEqualTo(1);
}
@Test
void loadNextAttemptNumber_shouldReturnNextNumber_whenAttemptsExist() {
// Given
DocumentFingerprint fingerprint = new DocumentFingerprint(
"1111111111111111111111111111111111111111111111111111111111111111");
RunId runId = new RunId("test-run-1");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
// Insert a document record first (FK constraint)
insertDocumentRecord(fingerprint);
// Insert first attempt
ProcessingAttempt firstAttempt = new ProcessingAttempt(
fingerprint,
runId,
1,
now,
now.plusSeconds(10),
ProcessingStatus.FAILED_RETRYABLE,
"IOException",
"File not found",
true
);
repository.save(firstAttempt);
// When
int nextAttemptNumber = repository.loadNextAttemptNumber(fingerprint);
// Then
assertThat(nextAttemptNumber).isEqualTo(2);
}
@Test
void loadNextAttemptNumber_shouldBeMonotonicAcrossMultipleCalls() {
// Given
DocumentFingerprint fingerprint = new DocumentFingerprint(
"2222222222222222222222222222222222222222222222222222222222222222");
RunId runId = new RunId("test-run-2");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
// Insert a document record first (FK constraint)
insertDocumentRecord(fingerprint);
// Insert multiple attempts
for (int i = 1; i <= 5; i++) {
ProcessingAttempt attempt = new ProcessingAttempt(
fingerprint,
runId,
i,
now.plusSeconds(i * 10),
now.plusSeconds(i * 10 + 5),
ProcessingStatus.FAILED_RETRYABLE,
"IOException",
"File not found",
true
);
repository.save(attempt);
}
// When
int nextAttemptNumber = repository.loadNextAttemptNumber(fingerprint);
// Then
assertThat(nextAttemptNumber).isEqualTo(6);
}
@Test
void loadNextAttemptNumber_shouldRejectNullFingerprint() {
assertThatThrownBy(() -> repository.loadNextAttemptNumber(null))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("fingerprint");
}
// -------------------------------------------------------------------------
// save
// -------------------------------------------------------------------------
@Test
void save_shouldPersistProcessingAttempt_withAllFields() {
// Given
DocumentFingerprint fingerprint = new DocumentFingerprint(
"3333333333333333333333333333333333333333333333333333333333333333");
RunId runId = new RunId("test-run-3");
Instant startedAt = Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MICROS);
Instant endedAt = Instant.now().truncatedTo(ChronoUnit.MICROS);
// Insert a document record first (FK constraint)
insertDocumentRecord(fingerprint);
ProcessingAttempt attempt = new ProcessingAttempt(
fingerprint,
runId,
1,
startedAt,
endedAt,
ProcessingStatus.FAILED_RETRYABLE,
"IOException",
"File not found",
true
);
// When
repository.save(attempt);
// Then
List<ProcessingAttempt> attempts = repository.findAllByFingerprint(fingerprint);
assertThat(attempts).hasSize(1);
ProcessingAttempt saved = attempts.get(0);
assertThat(saved.fingerprint()).isEqualTo(fingerprint);
assertThat(saved.runId()).isEqualTo(runId);
assertThat(saved.attemptNumber()).isEqualTo(1);
assertThat(saved.startedAt()).isEqualTo(startedAt);
assertThat(saved.endedAt()).isEqualTo(endedAt);
assertThat(saved.status()).isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
assertThat(saved.failureClass()).isEqualTo("IOException");
assertThat(saved.failureMessage()).isEqualTo("File not found");
assertThat(saved.retryable()).isTrue();
}
@Test
void save_shouldPersistProcessingAttempt_withNullFailureFields() {
// Given
DocumentFingerprint fingerprint = new DocumentFingerprint(
"4444444444444444444444444444444444444444444444444444444444444444");
RunId runId = new RunId("test-run-4");
Instant startedAt = Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MICROS);
Instant endedAt = Instant.now().truncatedTo(ChronoUnit.MICROS);
// Insert a document record first (FK constraint)
insertDocumentRecord(fingerprint);
ProcessingAttempt attempt = new ProcessingAttempt(
fingerprint,
runId,
1,
startedAt,
endedAt,
ProcessingStatus.SUCCESS,
null, // null failure class
null, // null failure message
false // not retryable
);
// When
repository.save(attempt);
// Then
List<ProcessingAttempt> attempts = repository.findAllByFingerprint(fingerprint);
assertThat(attempts).hasSize(1);
ProcessingAttempt saved = attempts.get(0);
assertThat(saved.failureClass()).isNull();
assertThat(saved.failureMessage()).isNull();
assertThat(saved.retryable()).isFalse();
}
@Test
void save_shouldRejectNullAttempt() {
assertThatThrownBy(() -> repository.save(null))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("attempt");
}
// -------------------------------------------------------------------------
// findAllByFingerprint
// -------------------------------------------------------------------------
@Test
void findAllByFingerprint_shouldReturnEmptyList_whenNoAttemptsExist() {
// Given
DocumentFingerprint fingerprint = new DocumentFingerprint(
"5555555555555555555555555555555555555555555555555555555555555555");
// When
List<ProcessingAttempt> attempts = repository.findAllByFingerprint(fingerprint);
// Then
assertThat(attempts).isEmpty();
}
@Test
void findAllByFingerprint_shouldReturnAllAttemptsOrderedByNumber() {
// Given
DocumentFingerprint fingerprint = new DocumentFingerprint(
"6666666666666666666666666666666666666666666666666666666666666666");
RunId runId1 = new RunId("test-run-5");
RunId runId2 = new RunId("test-run-6");
Instant baseTime = Instant.now().truncatedTo(ChronoUnit.MICROS);
// Insert a document record first (FK constraint)
insertDocumentRecord(fingerprint);
// Insert attempts out of order to verify sorting
ProcessingAttempt attempt3 = new ProcessingAttempt(
fingerprint,
runId2,
3,
baseTime.plusSeconds(20),
baseTime.plusSeconds(25),
ProcessingStatus.FAILED_FINAL,
"ContentError",
"No text extractable",
false
);
repository.save(attempt3);
ProcessingAttempt attempt1 = new ProcessingAttempt(
fingerprint,
runId1,
1,
baseTime,
baseTime.plusSeconds(5),
ProcessingStatus.FAILED_RETRYABLE,
"IOException",
"File not found",
true
);
repository.save(attempt1);
ProcessingAttempt attempt2 = new ProcessingAttempt(
fingerprint,
runId1,
2,
baseTime.plusSeconds(10),
baseTime.plusSeconds(15),
ProcessingStatus.SKIPPED_ALREADY_PROCESSED,
null,
null,
false
);
repository.save(attempt2);
// When
List<ProcessingAttempt> attempts = repository.findAllByFingerprint(fingerprint);
// Then
assertThat(attempts).hasSize(3);
assertThat(attempts.get(0).attemptNumber()).isEqualTo(1);
assertThat(attempts.get(1).attemptNumber()).isEqualTo(2);
assertThat(attempts.get(2).attemptNumber()).isEqualTo(3);
// Verify all fields
ProcessingAttempt first = attempts.get(0);
assertThat(first.runId()).isEqualTo(runId1);
assertThat(first.status()).isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
assertThat(first.failureClass()).isEqualTo("IOException");
ProcessingAttempt second = attempts.get(1);
assertThat(second.status()).isEqualTo(ProcessingStatus.SKIPPED_ALREADY_PROCESSED);
assertThat(second.failureClass()).isNull();
ProcessingAttempt third = attempts.get(2);
assertThat(third.status()).isEqualTo(ProcessingStatus.FAILED_FINAL);
assertThat(third.retryable()).isFalse();
}
@Test
void findAllByFingerprint_shouldReturnImmutableList() {
// Given
DocumentFingerprint fingerprint = new DocumentFingerprint(
"7777777777777777777777777777777777777777777777777777777777777777");
// When
List<ProcessingAttempt> attempts = repository.findAllByFingerprint(fingerprint);
// Then
assertThat(attempts).isEmpty();
assertThatThrownBy(() -> attempts.add(null))
.isInstanceOf(UnsupportedOperationException.class);
}
@Test
void findAllByFingerprint_shouldRejectNullFingerprint() {
assertThatThrownBy(() -> repository.findAllByFingerprint(null))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("fingerprint");
}
// -------------------------------------------------------------------------
// Integration with document records (FK constraints)
// -------------------------------------------------------------------------
@Test
void save_shouldFail_whenFingerprintDoesNotExistInDocumentRecord() {
// Given
DocumentFingerprint fingerprint = new DocumentFingerprint(
"8888888888888888888888888888888888888888888888888888888888888888");
RunId runId = new RunId("test-run-7");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
ProcessingAttempt attempt = new ProcessingAttempt(
fingerprint,
runId,
1,
now,
now.plusSeconds(10),
ProcessingStatus.FAILED_RETRYABLE,
"IOException",
"File not found",
true
);
// When / Then
assertThatThrownBy(() -> repository.save(attempt))
.isInstanceOf(DocumentPersistenceException.class);
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private void insertDocumentRecord(DocumentFingerprint fingerprint) {
String sql = """
INSERT INTO document_record (
fingerprint,
last_known_source_locator,
last_known_source_file_name,
overall_status,
content_error_count,
transient_error_count,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""";
try (Connection connection = DriverManager.getConnection(jdbcUrl);
var statement = connection.prepareStatement(sql)) {
statement.setString(1, fingerprint.sha256Hex());
statement.setString(2, "/test/path/document.pdf");
statement.setString(3, "document.pdf");
statement.setString(4, ProcessingStatus.PROCESSING.name());
statement.setInt(5, 0);
statement.setInt(6, 0);
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
statement.setString(7, now.toString());
statement.setString(8, now.toString());
statement.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException("Failed to insert document record for testing", e);
}
}
}