From 7591f7cde67b50614deef6ce8c3b0ea304179118 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:12:06 +0200 Subject: [PATCH 01/63] Add public structured exception metadata --- .../exception/DataProviderErrorCode.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderErrorCode.java diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderErrorCode.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderErrorCode.java new file mode 100644 index 0000000..8df9a41 --- /dev/null +++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderErrorCode.java @@ -0,0 +1,17 @@ +package nl.hauntedmc.dataprovider.exception; + +/** Stable machine-readable failure codes exposed by the DataProvider API. */ +public enum DataProviderErrorCode { + CONFIGURATION_INVALID, + CONFIGURATION_MISSING, + REGISTRATION_FAILED, + BACKEND_DISABLED, + BACKEND_UNAVAILABLE, + AUTHENTICATION_FAILED, + OPERATION_TIMED_OUT, + QUEUE_SATURATED, + SERIALIZATION_FAILED, + CONFLICT, + TRANSACTION_FAILED, + PROVIDER_CLOSED +} From 2633d5d3c7966004c5c78de2d59d3c2ef3273468 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:12:15 +0200 Subject: [PATCH 02/63] Add public structured exception metadata --- .../nl/hauntedmc/dataprovider/exception/RetryAdvice.java | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/RetryAdvice.java diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/RetryAdvice.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/RetryAdvice.java new file mode 100644 index 0000000..cfacefd --- /dev/null +++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/RetryAdvice.java @@ -0,0 +1,8 @@ +package nl.hauntedmc.dataprovider.exception; + +/** Guidance for callers considering a retry. */ +public enum RetryAdvice { + NEVER, + SAFE, + CONDITIONAL +} From ad93e6f456cba418c9dde85f1fe3fb110f0959e4 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:12:24 +0200 Subject: [PATCH 03/63] Add public structured exception metadata --- .../dataprovider/exception/ExecutionOutcome.java | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/ExecutionOutcome.java diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/ExecutionOutcome.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/ExecutionOutcome.java new file mode 100644 index 0000000..61a021b --- /dev/null +++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/ExecutionOutcome.java @@ -0,0 +1,9 @@ +package nl.hauntedmc.dataprovider.exception; + +/** What is known about whether a failed operation reached the backend. */ +public enum ExecutionOutcome { + NOT_STARTED, + NOT_APPLIED, + MAY_HAVE_APPLIED, + UNKNOWN +} From 3ceb73c63b04dd002122107092978d78333c2f13 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:12:45 +0200 Subject: [PATCH 04/63] Add public DataProvider exception base --- .../exception/DataProviderException.java | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderException.java diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderException.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderException.java new file mode 100644 index 0000000..1618fa4 --- /dev/null +++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderException.java @@ -0,0 +1,126 @@ +package nl.hauntedmc.dataprovider.exception; + +import nl.hauntedmc.dataprovider.database.DatabaseType; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +/** Base type for safe, structured failures exposed by DataProvider. */ +public abstract class DataProviderException extends RuntimeException { + + private final DataProviderErrorCode errorCode; + private final DatabaseType backendType; + private final String connectionIdentifier; + private final String operationName; + private final RetryAdvice retryAdvice; + private final ExecutionOutcome executionOutcome; + private final Map diagnostics; + private final String diagnosticId; + + protected DataProviderException( + DataProviderErrorCode errorCode, + String safeMessage, + DatabaseType backendType, + String connectionIdentifier, + String operationName, + RetryAdvice retryAdvice, + ExecutionOutcome executionOutcome, + Map diagnostics, + String diagnosticId, + Throwable safeCause + ) { + super(requireSafeText(safeMessage, "safeMessage"), safeCause); + this.errorCode = Objects.requireNonNull(errorCode, "Error code cannot be null."); + this.backendType = backendType; + this.connectionIdentifier = normalizeNullable(connectionIdentifier); + this.operationName = normalizeNullable(operationName); + this.retryAdvice = Objects.requireNonNull(retryAdvice, "Retry advice cannot be null."); + this.executionOutcome = Objects.requireNonNull(executionOutcome, "Execution outcome cannot be null."); + this.diagnostics = sanitizeDiagnostics(diagnostics); + this.diagnosticId = diagnosticId == null || diagnosticId.isBlank() + ? UUID.randomUUID().toString() + : diagnosticId.trim(); + } + + public final DataProviderErrorCode errorCode() { + return errorCode; + } + + public final DatabaseType backendType() { + return backendType; + } + + public final String connectionIdentifier() { + return connectionIdentifier; + } + + public final String operationName() { + return operationName; + } + + public final RetryAdvice retryAdvice() { + return retryAdvice; + } + + public final boolean retryable() { + return retryAdvice != RetryAdvice.NEVER; + } + + public final ExecutionOutcome executionOutcome() { + return executionOutcome; + } + + public final Map diagnostics() { + return diagnostics; + } + + public final String diagnosticId() { + return diagnosticId; + } + + private static Map sanitizeDiagnostics(Map source) { + if (source == null || source.isEmpty()) { + return Map.of(); + } + LinkedHashMap safe = new LinkedHashMap<>(); + source.forEach((key, value) -> { + String normalizedKey = requireSafeText(key, "diagnostic key"); + if (!normalizedKey.matches("[A-Za-z][A-Za-z0-9_.-]{0,63}")) { + throw new IllegalArgumentException("Unsupported diagnostic key: " + normalizedKey); + } + if (isSensitiveKey(normalizedKey)) { + throw new IllegalArgumentException("Sensitive diagnostic keys are not allowed: " + normalizedKey); + } + String normalizedValue = requireSafeText(value, "diagnostic value"); + if (normalizedValue.length() > 256) { + throw new IllegalArgumentException("Diagnostic values cannot exceed 256 characters."); + } + safe.put(normalizedKey, normalizedValue); + }); + return Map.copyOf(safe); + } + + private static boolean isSensitiveKey(String key) { + String lower = key.toLowerCase(java.util.Locale.ROOT); + return lower.contains("password") || lower.contains("secret") || lower.contains("token") + || lower.contains("credential") || lower.contains("authorization") || lower.contains("payload") + || lower.contains("query") || lower.contains("parameter") || lower.contains("url"); + } + + private static String requireSafeText(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " cannot be null or blank."); + } + String normalized = value.trim(); + if (normalized.indexOf('\0') >= 0) { + throw new IllegalArgumentException(field + " cannot contain null characters."); + } + return normalized; + } + + private static String normalizeNullable(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } +} From 8d6a7f215a3a9e0483f82b9c9c71b735bed741ee Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:13:02 +0200 Subject: [PATCH 05/63] Add shared public failure context --- .../exception/DataProviderFailureContext.java | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderFailureContext.java diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderFailureContext.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderFailureContext.java new file mode 100644 index 0000000..f118590 --- /dev/null +++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderFailureContext.java @@ -0,0 +1,64 @@ +package nl.hauntedmc.dataprovider.exception; + +import nl.hauntedmc.dataprovider.database.DatabaseType; + +import java.util.Map; + +/** Safe context attached to a structured DataProvider failure. */ +public record DataProviderFailureContext( + DatabaseType backendType, + String connectionIdentifier, + String operationName, + RetryAdvice retryAdvice, + ExecutionOutcome executionOutcome, + Map diagnostics, + String diagnosticId +) { + public DataProviderFailureContext { + retryAdvice = retryAdvice == null ? RetryAdvice.NEVER : retryAdvice; + executionOutcome = executionOutcome == null ? ExecutionOutcome.UNKNOWN : executionOutcome; + diagnostics = diagnostics == null ? Map.of() : Map.copyOf(diagnostics); + } + + public static DataProviderFailureContext of( + DatabaseType backendType, + String connectionIdentifier, + String operationName, + RetryAdvice retryAdvice, + ExecutionOutcome executionOutcome + ) { + return new DataProviderFailureContext( + backendType, + connectionIdentifier, + operationName, + retryAdvice, + executionOutcome, + Map.of(), + null + ); + } + + public DataProviderFailureContext withDiagnostics(Map diagnostics) { + return new DataProviderFailureContext( + backendType, + connectionIdentifier, + operationName, + retryAdvice, + executionOutcome, + diagnostics, + diagnosticId + ); + } + + public DataProviderFailureContext withDiagnosticId(String diagnosticId) { + return new DataProviderFailureContext( + backendType, + connectionIdentifier, + operationName, + retryAdvice, + executionOutcome, + diagnostics, + diagnosticId + ); + } +} From 0790fbe735d7d1b74d6ac03fcc54fbfb90ec3e17 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:13:27 +0200 Subject: [PATCH 06/63] Use shared failure context in public exceptions --- .../exception/DataProviderException.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderException.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderException.java index 1618fa4..154f2a0 100644 --- a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderException.java +++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderException.java @@ -19,6 +19,26 @@ public abstract class DataProviderException extends RuntimeException { private final Map diagnostics; private final String diagnosticId; + protected DataProviderException( + DataProviderErrorCode errorCode, + String safeMessage, + DataProviderFailureContext context, + Throwable safeCause + ) { + this( + errorCode, + safeMessage, + Objects.requireNonNull(context, "Failure context cannot be null.").backendType(), + context.connectionIdentifier(), + context.operationName(), + context.retryAdvice(), + context.executionOutcome(), + context.diagnostics(), + context.diagnosticId(), + safeCause + ); + } + protected DataProviderException( DataProviderErrorCode errorCode, String safeMessage, From ab6ef15f5244b6e0d7a20d4fd2a9e8eed13686ec Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:13:38 +0200 Subject: [PATCH 07/63] Add concrete public DataProvider exceptions --- .../DataProviderConfigurationException.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderConfigurationException.java diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderConfigurationException.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderConfigurationException.java new file mode 100644 index 0000000..0c14845 --- /dev/null +++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderConfigurationException.java @@ -0,0 +1,13 @@ +package nl.hauntedmc.dataprovider.exception; + +/** Invalid or missing DataProvider configuration. */ +public final class DataProviderConfigurationException extends DataProviderException { + public DataProviderConfigurationException(DataProviderErrorCode code, String message, + DataProviderFailureContext context, Throwable cause) { + super(code, message, context, cause); + if (code != DataProviderErrorCode.CONFIGURATION_INVALID + && code != DataProviderErrorCode.CONFIGURATION_MISSING) { + throw new IllegalArgumentException("Unsupported configuration error code: " + code); + } + } +} From 4ab44f02ed4562b501177d6e03328e165ab12bdb Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:13:48 +0200 Subject: [PATCH 08/63] Add concrete public DataProvider exceptions --- .../exception/DataProviderRegistrationException.java | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderRegistrationException.java diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderRegistrationException.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderRegistrationException.java new file mode 100644 index 0000000..98b79a5 --- /dev/null +++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderRegistrationException.java @@ -0,0 +1,8 @@ +package nl.hauntedmc.dataprovider.exception; + +/** Failure in registration ownership, publication, or lookup state. */ +public final class DataProviderRegistrationException extends DataProviderException { + public DataProviderRegistrationException(String message, DataProviderFailureContext context, Throwable cause) { + super(DataProviderErrorCode.REGISTRATION_FAILED, message, context, cause); + } +} From 06b7640dcc01fb423a4fb2588797c08f3079ed82 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:13:58 +0200 Subject: [PATCH 09/63] Add concrete public DataProvider exceptions --- .../exception/BackendUnavailableException.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/BackendUnavailableException.java diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/BackendUnavailableException.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/BackendUnavailableException.java new file mode 100644 index 0000000..d4dc5ed --- /dev/null +++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/BackendUnavailableException.java @@ -0,0 +1,13 @@ +package nl.hauntedmc.dataprovider.exception; + +/** Backend is disabled, unreachable, or otherwise unavailable. */ +public final class BackendUnavailableException extends DataProviderException { + public BackendUnavailableException(DataProviderErrorCode code, String message, + DataProviderFailureContext context, Throwable cause) { + super(code, message, context, cause); + if (code != DataProviderErrorCode.BACKEND_DISABLED + && code != DataProviderErrorCode.BACKEND_UNAVAILABLE) { + throw new IllegalArgumentException("Unsupported backend availability error code: " + code); + } + } +} From 02a3f9ad8dd5ec50032226590019e81505b979fb Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:14:07 +0200 Subject: [PATCH 10/63] Add concrete public DataProvider exceptions --- .../exception/BackendAuthenticationException.java | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/BackendAuthenticationException.java diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/BackendAuthenticationException.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/BackendAuthenticationException.java new file mode 100644 index 0000000..c38fbc8 --- /dev/null +++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/BackendAuthenticationException.java @@ -0,0 +1,8 @@ +package nl.hauntedmc.dataprovider.exception; + +/** Backend rejected configured authentication. */ +public final class BackendAuthenticationException extends DataProviderException { + public BackendAuthenticationException(String message, DataProviderFailureContext context, Throwable cause) { + super(DataProviderErrorCode.AUTHENTICATION_FAILED, message, context, cause); + } +} From ede1dfe625fc327d12c79a1c98f53039672003a7 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:14:14 +0200 Subject: [PATCH 11/63] Add concrete public DataProvider exceptions --- .../exception/DataProviderTimeoutException.java | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderTimeoutException.java diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderTimeoutException.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderTimeoutException.java new file mode 100644 index 0000000..4b37745 --- /dev/null +++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderTimeoutException.java @@ -0,0 +1,8 @@ +package nl.hauntedmc.dataprovider.exception; + +/** Operation exceeded a configured or backend timeout. */ +public final class DataProviderTimeoutException extends DataProviderException { + public DataProviderTimeoutException(String message, DataProviderFailureContext context, Throwable cause) { + super(DataProviderErrorCode.OPERATION_TIMED_OUT, message, context, cause); + } +} From ae0ba7fd4f304f4a5ffaf81cf9b5a98e357d7e6e Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:14:22 +0200 Subject: [PATCH 12/63] Add concrete public DataProvider exceptions --- .../dataprovider/exception/QueueSaturatedException.java | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/QueueSaturatedException.java diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/QueueSaturatedException.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/QueueSaturatedException.java new file mode 100644 index 0000000..6877cad --- /dev/null +++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/QueueSaturatedException.java @@ -0,0 +1,8 @@ +package nl.hauntedmc.dataprovider.exception; + +/** Shared execution capacity rejected an operation before it started. */ +public final class QueueSaturatedException extends DataProviderException { + public QueueSaturatedException(String message, DataProviderFailureContext context, Throwable cause) { + super(DataProviderErrorCode.QUEUE_SATURATED, message, context, cause); + } +} From c2786d3828fd2df3d5320855b36b18d75edb0cd3 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:14:31 +0200 Subject: [PATCH 13/63] Add concrete public DataProvider exceptions --- .../exception/DataSerializationException.java | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataSerializationException.java diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataSerializationException.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataSerializationException.java new file mode 100644 index 0000000..41f1ff0 --- /dev/null +++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataSerializationException.java @@ -0,0 +1,8 @@ +package nl.hauntedmc.dataprovider.exception; + +/** Data could not be serialized or deserialized safely. */ +public final class DataSerializationException extends DataProviderException { + public DataSerializationException(String message, DataProviderFailureContext context, Throwable cause) { + super(DataProviderErrorCode.SERIALIZATION_FAILED, message, context, cause); + } +} From ba2114d8b02b0c67483d3409b4e54254ebbbf464 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:14:42 +0200 Subject: [PATCH 14/63] Add concrete public DataProvider exceptions --- .../dataprovider/exception/DataConflictException.java | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataConflictException.java diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataConflictException.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataConflictException.java new file mode 100644 index 0000000..7e55589 --- /dev/null +++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataConflictException.java @@ -0,0 +1,8 @@ +package nl.hauntedmc.dataprovider.exception; + +/** A uniqueness, optimistic-locking, or compare-and-set conflict occurred. */ +public final class DataConflictException extends DataProviderException { + public DataConflictException(String message, DataProviderFailureContext context, Throwable cause) { + super(DataProviderErrorCode.CONFLICT, message, context, cause); + } +} From 860c79683ea228ac021071acdb47d4b454d69e02 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:14:51 +0200 Subject: [PATCH 15/63] Add transaction failure phase --- .../dataprovider/exception/TransactionPhase.java | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/TransactionPhase.java diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/TransactionPhase.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/TransactionPhase.java new file mode 100644 index 0000000..8843fff --- /dev/null +++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/TransactionPhase.java @@ -0,0 +1,9 @@ +package nl.hauntedmc.dataprovider.exception; + +/** Transaction stage at which a failure occurred. */ +public enum TransactionPhase { + BEGIN, + CALLBACK, + COMMIT, + ROLLBACK +} From 5f099aef893ecf45ead55ca2b211dba38ce229ce Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:15:00 +0200 Subject: [PATCH 16/63] Add concrete public DataProvider exceptions --- .../exception/DataTransactionException.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataTransactionException.java diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataTransactionException.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataTransactionException.java new file mode 100644 index 0000000..bcdfff4 --- /dev/null +++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataTransactionException.java @@ -0,0 +1,19 @@ +package nl.hauntedmc.dataprovider.exception; + +import java.util.Objects; + +/** Transaction failure retaining the phase and primary cause. */ +public final class DataTransactionException extends DataProviderException { + + private final TransactionPhase phase; + + public DataTransactionException(String message, TransactionPhase phase, + DataProviderFailureContext context, Throwable cause) { + super(DataProviderErrorCode.TRANSACTION_FAILED, message, context, cause); + this.phase = Objects.requireNonNull(phase, "Transaction phase cannot be null."); + } + + public TransactionPhase phase() { + return phase; + } +} From 3d277ad27e7e035c25eab647d13de46285c8b409 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:15:08 +0200 Subject: [PATCH 17/63] Add concrete public DataProvider exceptions --- .../dataprovider/exception/ProviderClosedException.java | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/ProviderClosedException.java diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/ProviderClosedException.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/ProviderClosedException.java new file mode 100644 index 0000000..11261ea --- /dev/null +++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/ProviderClosedException.java @@ -0,0 +1,8 @@ +package nl.hauntedmc.dataprovider.exception; + +/** A runtime-scoped provider or registration scope is already closed. */ +public final class ProviderClosedException extends DataProviderException { + public ProviderClosedException(String message, DataProviderFailureContext context, Throwable cause) { + super(DataProviderErrorCode.PROVIDER_CLOSED, message, context, cause); + } +} From fd1cc6642e606bd7fbfe55b7a1b2243a67c3218d Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:15:55 +0200 Subject: [PATCH 18/63] Add strict registration API methods --- .../dataprovider/api/DataProviderAPI.java | 67 ++++++++++++++----- 1 file changed, 49 insertions(+), 18 deletions(-) diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderAPI.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderAPI.java index 7338f8f..9b09add 100644 --- a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderAPI.java +++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderAPI.java @@ -1,33 +1,22 @@ package nl.hauntedmc.dataprovider.api; +import nl.hauntedmc.dataprovider.api.orm.ORMContext; import nl.hauntedmc.dataprovider.database.DataAccess; import nl.hauntedmc.dataprovider.database.DatabaseProvider; import nl.hauntedmc.dataprovider.database.DatabaseType; -import nl.hauntedmc.dataprovider.api.orm.ORMContext; +import nl.hauntedmc.dataprovider.exception.DataProviderFailureContext; +import nl.hauntedmc.dataprovider.exception.DataProviderRegistrationException; +import nl.hauntedmc.dataprovider.exception.ExecutionOutcome; +import nl.hauntedmc.dataprovider.exception.RetryAdvice; import nl.hauntedmc.dataprovider.logging.LoggerAdapter; import javax.sql.DataSource; import java.util.Objects; import java.util.Optional; -/** - * Public, platform-neutral facade for plugin-scoped database registrations. - * - *

The API artifact intentionally contains contracts only. Platform modules provide the - * runtime implementation and expose an instance through their native service mechanism.

- */ +/** Public, platform-neutral facade for plugin-scoped database registrations. */ public interface DataProviderAPI { - /** - * Creates an isolated ORM context owned by the calling plugin. - * - * @param pluginName plugin name used for ORM diagnostics - * @param dataSource relational data source obtained from a registered provider - * @param logger logger that receives ORM lifecycle diagnostics - * @param schemaMode Hibernate schema mode: validate, none, update, or create - * @param entityClasses annotated entity classes to register - * @return a new, initialized ORM context - */ ORMContext createOrmContext( String pluginName, DataSource dataSource, @@ -36,8 +25,31 @@ ORMContext createOrmContext( Class... entityClasses ); + /** Legacy nullable registration method retained for compatibility. */ DatabaseProvider registerDatabase(DatabaseType databaseType, String connectionIdentifier); + /** + * Registers a database or throws a structured public exception retaining the failure category. + * Implementations should override this method to preserve backend-specific failure details. + */ + default DatabaseProvider registerDatabaseOrThrow(DatabaseType databaseType, String connectionIdentifier) { + DatabaseProvider provider = registerDatabase(databaseType, connectionIdentifier); + if (provider != null) { + return provider; + } + throw new DataProviderRegistrationException( + "Database registration failed.", + DataProviderFailureContext.of( + databaseType, + connectionIdentifier, + "registerDatabase", + RetryAdvice.CONDITIONAL, + ExecutionOutcome.NOT_STARTED + ), + null + ); + } + DataProviderScope scope(OwnerScope ownerScope); void unregisterDatabase(DatabaseType databaseType, String connectionIdentifier); @@ -46,9 +58,28 @@ ORMContext createOrmContext( void unregisterAllDatabasesForPlugin(); + /** Legacy nullable lookup retained for compatibility. */ DatabaseProvider getRegisteredDatabase(DatabaseType databaseType, String connectionIdentifier); - /** Creates an isolated ownership scope for independently managed plugin components. */ + /** Returns a registered provider or throws a structured registration-state failure. */ + default DatabaseProvider requireRegisteredDatabase(DatabaseType databaseType, String connectionIdentifier) { + DatabaseProvider provider = getRegisteredDatabase(databaseType, connectionIdentifier); + if (provider != null) { + return provider; + } + throw new DataProviderRegistrationException( + "No active database registration exists for the requested connection.", + DataProviderFailureContext.of( + databaseType, + connectionIdentifier, + "requireRegisteredDatabase", + RetryAdvice.NEVER, + ExecutionOutcome.NOT_STARTED + ), + null + ); + } + default DataProviderScope scope(String ownerScope) { return scope(OwnerScope.of(ownerScope)); } From c382b7bf8b6025d9a2e65053ced9d73322f9ef0e Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:16:28 +0200 Subject: [PATCH 19/63] Add strict scoped registration API methods --- .../dataprovider/api/DataProviderScope.java | 54 ++++++++++++++----- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderScope.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderScope.java index 4a196da..3a4cae3 100644 --- a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderScope.java +++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderScope.java @@ -3,16 +3,17 @@ import nl.hauntedmc.dataprovider.database.DataAccess; import nl.hauntedmc.dataprovider.database.DatabaseProvider; import nl.hauntedmc.dataprovider.database.DatabaseType; +import nl.hauntedmc.dataprovider.exception.DataProviderFailureContext; +import nl.hauntedmc.dataprovider.exception.DataProviderRegistrationException; +import nl.hauntedmc.dataprovider.exception.ExecutionOutcome; +import nl.hauntedmc.dataprovider.exception.RetryAdvice; import java.util.Objects; import java.util.Optional; -/** - * Isolated lifecycle boundary for a logical component within one plugin. - */ +/** Isolated lifecycle boundary for a logical component within one plugin. */ public interface DataProviderScope extends AutoCloseable { - /** Lifecycle states for a scope. A closed scope cannot be reopened. */ enum LifecycleState { OPEN, CLOSING, @@ -21,29 +22,56 @@ enum LifecycleState { OwnerScope ownerScope(); - /** - * Returns this scope's current lifecycle state. - * Implementations created by DataProvider transition from OPEN to CLOSING to CLOSED on close. - */ default LifecycleState lifecycleState() { return LifecycleState.OPEN; } DatabaseProvider registerDatabase(DatabaseType databaseType, String connectionIdentifier); + default DatabaseProvider registerDatabaseOrThrow(DatabaseType databaseType, String connectionIdentifier) { + DatabaseProvider provider = registerDatabase(databaseType, connectionIdentifier); + if (provider != null) { + return provider; + } + throw new DataProviderRegistrationException( + "Scoped database registration failed.", + DataProviderFailureContext.of( + databaseType, + connectionIdentifier, + "scope.registerDatabase", + RetryAdvice.CONDITIONAL, + ExecutionOutcome.NOT_STARTED + ).withDiagnostics(java.util.Map.of("ownerScope", ownerScope().value())), + null + ); + } + void unregisterDatabase(DatabaseType databaseType, String connectionIdentifier); void unregisterAllDatabases(); - /** - * Retrieves a provider registered by this scope. - * - * @throws UnsupportedOperationException if the scope implementation does not support scoped lookup - */ default DatabaseProvider getRegisteredDatabase(DatabaseType databaseType, String connectionIdentifier) { throw new UnsupportedOperationException("Scoped provider lookup is not supported by this implementation."); } + default DatabaseProvider requireRegisteredDatabase(DatabaseType databaseType, String connectionIdentifier) { + DatabaseProvider provider = getRegisteredDatabase(databaseType, connectionIdentifier); + if (provider != null) { + return provider; + } + throw new DataProviderRegistrationException( + "No active scoped database registration exists for the requested connection.", + DataProviderFailureContext.of( + databaseType, + connectionIdentifier, + "scope.requireRegisteredDatabase", + RetryAdvice.NEVER, + ExecutionOutcome.NOT_STARTED + ).withDiagnostics(java.util.Map.of("ownerScope", ownerScope().value())), + null + ); + } + default Optional registerDatabaseOptional( DatabaseType databaseType, String connectionIdentifier From caf381af40771cbacb9619986c9af1deb9598ef8 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:16:46 +0200 Subject: [PATCH 20/63] Expose execution failure context --- .../core/concurrent/ExecutionHandle.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/concurrent/ExecutionHandle.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/concurrent/ExecutionHandle.java index d4f4ffb..3ef6159 100644 --- a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/concurrent/ExecutionHandle.java +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/concurrent/ExecutionHandle.java @@ -1,5 +1,7 @@ package nl.hauntedmc.dataprovider.core.concurrent; +import nl.hauntedmc.dataprovider.database.DatabaseType; + import java.util.concurrent.Executor; /** Connection-scoped execution handle backed by the shared runtime. */ @@ -9,6 +11,18 @@ public interface ExecutionHandle extends Executor, AutoCloseable { boolean isClosed(); + default DatabaseType backendType() { + return null; + } + + default String connectionIdentifier() { + return null; + } + + default String pluginId() { + return null; + } + default boolean tryAcquireSubscription() { return true; } From c7097aaecc06122e79acdd84c45b3f62a095ce3c Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:17:16 +0200 Subject: [PATCH 21/63] Attach backend context to execution handles --- .../concurrent/ContextualExecutionHandle.java | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/concurrent/ContextualExecutionHandle.java diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/concurrent/ContextualExecutionHandle.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/concurrent/ContextualExecutionHandle.java new file mode 100644 index 0000000..031b1a4 --- /dev/null +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/concurrent/ContextualExecutionHandle.java @@ -0,0 +1,83 @@ +package nl.hauntedmc.dataprovider.core.concurrent; + +import nl.hauntedmc.dataprovider.database.DatabaseType; + +import java.util.Objects; + +/** Adds immutable backend identity to a runtime execution scope. */ +public final class ContextualExecutionHandle implements ExecutionHandle { + + private final ExecutionHandle delegate; + private final String pluginId; + private final DatabaseType backendType; + private final String connectionIdentifier; + + public ContextualExecutionHandle( + ExecutionHandle delegate, + String pluginId, + DatabaseType backendType, + String connectionIdentifier + ) { + this.delegate = Objects.requireNonNull(delegate, "Delegate execution handle cannot be null."); + this.pluginId = requireText(pluginId, "pluginId"); + this.backendType = Objects.requireNonNull(backendType, "Backend type cannot be null."); + this.connectionIdentifier = requireText(connectionIdentifier, "connectionIdentifier"); + } + + @Override + public void execute(Runnable command) { + delegate.execute(command); + } + + @Override + public ExecutionMetricsSnapshot metrics() { + return delegate.metrics(); + } + + @Override + public boolean isClosed() { + return delegate.isClosed(); + } + + @Override + public DatabaseType backendType() { + return backendType; + } + + @Override + public String connectionIdentifier() { + return connectionIdentifier; + } + + @Override + public String pluginId() { + return pluginId; + } + + @Override + public boolean tryAcquireSubscription() { + return delegate.tryAcquireSubscription(); + } + + @Override + public void releaseSubscription() { + delegate.releaseSubscription(); + } + + @Override + public void recordDroppedMessages(long count) { + delegate.recordDroppedMessages(count); + } + + @Override + public void close() { + delegate.close(); + } + + private static String requireText(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " cannot be null or blank."); + } + return value.trim(); + } +} From a3237a0d5e94d7fe7544a9f4be3b7bf228146012 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:17:39 +0200 Subject: [PATCH 22/63] Attach backend context to provider execution --- .../nl/hauntedmc/dataprovider/core/DatabaseFactory.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/DatabaseFactory.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/DatabaseFactory.java index 5bd2d93..a24c578 100644 --- a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/DatabaseFactory.java +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/DatabaseFactory.java @@ -1,5 +1,6 @@ package nl.hauntedmc.dataprovider.core; +import nl.hauntedmc.dataprovider.core.concurrent.ContextualExecutionHandle; import nl.hauntedmc.dataprovider.core.concurrent.DataProviderExecutionRuntime; import nl.hauntedmc.dataprovider.core.concurrent.ExecutionHandle; import nl.hauntedmc.dataprovider.core.database.document.impl.mongodb.MongoDBDatabase; @@ -76,9 +77,15 @@ protected ManagedDatabaseProvider createDatabaseProvider( logger.error("Could not load configuration for " + connectionIdentifier.value() + " (" + type.name() + ")"); return null; } - ExecutionHandle execution = executionRuntime == null + ExecutionHandle rawExecution = executionRuntime == null ? ExecutionHandle.direct() : executionRuntime.openScope(pluginId.value(), type, connectionIdentifier.value()); + ExecutionHandle execution = new ContextualExecutionHandle( + rawExecution, + pluginId.value(), + type, + connectionIdentifier.value() + ); try { return switch (type) { case MYSQL -> new MySQLDatabase(connectionConfig, logger, execution); From 6840e4fd37ad14c2d318ee343608dc656846df85 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:18:35 +0200 Subject: [PATCH 23/63] Translate backend failures into public exceptions --- .../DataProviderExceptionMapper.java | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/exception/DataProviderExceptionMapper.java diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/exception/DataProviderExceptionMapper.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/exception/DataProviderExceptionMapper.java new file mode 100644 index 0000000..d3f8c84 --- /dev/null +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/exception/DataProviderExceptionMapper.java @@ -0,0 +1,251 @@ +package nl.hauntedmc.dataprovider.core.exception; + +import com.mongodb.MongoSecurityException; +import com.mongodb.MongoSocketException; +import com.mongodb.MongoTimeoutException; +import com.mongodb.MongoWriteException; +import com.mongodb.MongoWriteConcernException; +import nl.hauntedmc.dataprovider.core.concurrent.ExecutionHandle; +import nl.hauntedmc.dataprovider.core.concurrent.ExecutionRejectedException; +import nl.hauntedmc.dataprovider.database.DatabaseType; +import nl.hauntedmc.dataprovider.exception.BackendAuthenticationException; +import nl.hauntedmc.dataprovider.exception.BackendUnavailableException; +import nl.hauntedmc.dataprovider.exception.DataConflictException; +import nl.hauntedmc.dataprovider.exception.DataProviderErrorCode; +import nl.hauntedmc.dataprovider.exception.DataProviderException; +import nl.hauntedmc.dataprovider.exception.DataProviderFailureContext; +import nl.hauntedmc.dataprovider.exception.DataProviderTimeoutException; +import nl.hauntedmc.dataprovider.exception.DataSerializationException; +import nl.hauntedmc.dataprovider.exception.ExecutionOutcome; +import nl.hauntedmc.dataprovider.exception.ProviderClosedException; +import nl.hauntedmc.dataprovider.exception.QueueSaturatedException; +import nl.hauntedmc.dataprovider.exception.RetryAdvice; +import org.bson.codecs.configuration.CodecConfigurationException; +import redis.clients.jedis.exceptions.JedisAccessControlException; +import redis.clients.jedis.exceptions.JedisConnectionException; + +import java.net.SocketTimeoutException; +import java.sql.SQLIntegrityConstraintViolationException; +import java.sql.SQLException; +import java.sql.SQLTimeoutException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.CompletionException; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeoutException; + +/** Internal classification and redaction boundary for public DataProvider failures. */ +public final class DataProviderExceptionMapper { + + private DataProviderExceptionMapper() { + } + + public static DataProviderException translate(Throwable failure, Executor executor, String operationName) { + Throwable root = unwrap(failure); + if (root instanceof DataProviderException structured) { + return structured; + } + ExecutionHandle execution = executor instanceof ExecutionHandle handle ? handle : null; + DatabaseType backend = execution == null ? inferBackend(operationName) : execution.backendType(); + String connection = execution == null ? null : execution.connectionIdentifier(); + DataProviderFailureContext base = DataProviderFailureContext.of( + backend, + connection, + operationName, + RetryAdvice.CONDITIONAL, + ExecutionOutcome.UNKNOWN + ); + + if (root instanceof ExecutionRejectedException rejected) { + Map diagnostics = new LinkedHashMap<>(); + diagnostics.put("reason", rejected.reason().name()); + if (execution != null && execution.pluginId() != null) { + diagnostics.put("plugin", execution.pluginId()); + } + DataProviderFailureContext context = base.withDiagnostics(diagnostics); + return switch (rejected.reason()) { + case RUNTIME_SHUTTING_DOWN, SCOPE_CLOSED -> new ProviderClosedException( + "The DataProvider execution scope is closed.", + new DataProviderFailureContext(backend, connection, operationName, RetryAdvice.NEVER, + ExecutionOutcome.NOT_STARTED, diagnostics, null), + safeCause(root) + ); + case LANE_QUEUE_FULL, PLUGIN_QUEUE_LIMIT, CONNECTION_QUEUE_LIMIT, SUBSCRIPTION_LIMIT -> + new QueueSaturatedException( + "DataProvider execution capacity is currently exhausted.", + new DataProviderFailureContext(backend, connection, operationName, RetryAdvice.SAFE, + ExecutionOutcome.NOT_STARTED, diagnostics, null), + safeCause(root) + ); + }; + } + + if (root instanceof SQLIntegrityConstraintViolationException || sqlStateStartsWith(root, "23") + || isMongoDuplicate(root)) { + return new DataConflictException( + "The operation conflicted with existing backend state.", + new DataProviderFailureContext(backend, connection, operationName, RetryAdvice.NEVER, + ExecutionOutcome.NOT_APPLIED, sqlDiagnostics(root), null), + safeCause(root) + ); + } + + if (isAuthenticationFailure(root)) { + return new BackendAuthenticationException( + "The backend rejected DataProvider authentication.", + new DataProviderFailureContext(backend, connection, operationName, RetryAdvice.NEVER, + ExecutionOutcome.NOT_STARTED, safeClassDiagnostics(root), null), + safeCause(root) + ); + } + + if (isTimeout(root)) { + return new DataProviderTimeoutException( + "The backend operation timed out.", + new DataProviderFailureContext(backend, connection, operationName, RetryAdvice.CONDITIONAL, + isReadOperation(operationName) ? ExecutionOutcome.UNKNOWN : ExecutionOutcome.MAY_HAVE_APPLIED, + safeClassDiagnostics(root), null), + safeCause(root) + ); + } + + if (isSerializationFailure(root)) { + return new DataSerializationException( + "Data serialization or deserialization failed.", + new DataProviderFailureContext(backend, connection, operationName, RetryAdvice.NEVER, + ExecutionOutcome.NOT_STARTED, safeClassDiagnostics(root), null), + safeCause(root) + ); + } + + if (isUnavailable(root)) { + return new BackendUnavailableException( + DataProviderErrorCode.BACKEND_UNAVAILABLE, + "The configured backend is unavailable.", + new DataProviderFailureContext(backend, connection, operationName, RetryAdvice.CONDITIONAL, + ExecutionOutcome.UNKNOWN, safeClassDiagnostics(root), null), + safeCause(root) + ); + } + + return new BackendUnavailableException( + DataProviderErrorCode.BACKEND_UNAVAILABLE, + "The backend operation failed.", + base.withDiagnostics(safeClassDiagnostics(root)), + safeCause(root) + ); + } + + private static Throwable unwrap(Throwable failure) { + Throwable current = failure; + while ((current instanceof CompletionException || current.getClass() == RuntimeException.class) + && current.getCause() != null) { + current = current.getCause(); + } + return current; + } + + private static boolean isAuthenticationFailure(Throwable failure) { + if (failure instanceof MongoSecurityException || failure instanceof JedisAccessControlException) { + return true; + } + if (failure instanceof SQLException sql) { + return startsWith(sql.getSQLState(), "28") || sql.getErrorCode() == 1045; + } + String name = failure.getClass().getName(); + return name.contains("Authentication") || name.contains("AuthException"); + } + + private static boolean isTimeout(Throwable failure) { + return failure instanceof SQLTimeoutException || failure instanceof MongoTimeoutException + || failure instanceof SocketTimeoutException || failure instanceof TimeoutException + || failure.getClass().getSimpleName().contains("Timeout"); + } + + private static boolean isSerializationFailure(Throwable failure) { + String name = failure.getClass().getName(); + return failure instanceof CodecConfigurationException || name.startsWith("com.google.gson.") + || name.contains("JsonProcessingException") || name.contains("SerializationException"); + } + + private static boolean isUnavailable(Throwable failure) { + if (failure instanceof MongoSocketException || failure instanceof JedisConnectionException) { + return true; + } + if (failure instanceof SQLException sql) { + return startsWith(sql.getSQLState(), "08"); + } + String name = failure.getClass().getSimpleName(); + return name.contains("Connection") || name.contains("Socket") || name.contains("ServerSelection"); + } + + private static boolean isMongoDuplicate(Throwable failure) { + if (failure instanceof MongoWriteException write) { + return write.getError().getCode() == 11000; + } + return failure instanceof MongoWriteConcernException concern + && concern.getWriteConcernError().getCode() == 11000; + } + + private static boolean sqlStateStartsWith(Throwable failure, String prefix) { + return failure instanceof SQLException sql && startsWith(sql.getSQLState(), prefix); + } + + private static boolean startsWith(String value, String prefix) { + return value != null && value.startsWith(prefix); + } + + private static Map sqlDiagnostics(Throwable failure) { + LinkedHashMap diagnostics = new LinkedHashMap<>(safeClassDiagnostics(failure)); + if (failure instanceof SQLException sql) { + if (sql.getSQLState() != null && !sql.getSQLState().isBlank()) { + diagnostics.put("sqlState", sql.getSQLState()); + } + diagnostics.put("vendorCode", Integer.toString(sql.getErrorCode())); + } + return Map.copyOf(diagnostics); + } + + private static Map safeClassDiagnostics(Throwable failure) { + return Map.of("causeType", failure.getClass().getName()); + } + + private static Throwable safeCause(Throwable failure) { + return new SafeBackendCause(failure.getClass().getName()); + } + + private static boolean isReadOperation(String operationName) { + if (operationName == null) { + return false; + } + String lower = operationName.toLowerCase(java.util.Locale.ROOT); + return lower.contains("get") || lower.contains("find") || lower.contains("query") + || lower.contains("scan") || lower.contains("range") || lower.contains("health"); + } + + private static DatabaseType inferBackend(String operationName) { + if (operationName == null) { + return null; + } + if (operationName.startsWith("mysql.")) { + return DatabaseType.MYSQL; + } + if (operationName.startsWith("mongodb.")) { + return DatabaseType.MONGODB; + } + if (operationName.startsWith("redis.messaging.")) { + return DatabaseType.REDIS_MESSAGING; + } + if (operationName.startsWith("redis.")) { + return DatabaseType.REDIS; + } + return null; + } + + /** Redacted cause preserving only the original exception type. */ + private static final class SafeBackendCause extends RuntimeException { + private SafeBackendCause(String causeType) { + super("Backend failure type: " + causeType, null, false, false); + } + } +} From e944f6e33124d4536bcd00e62609962a00daad75 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:18:54 +0200 Subject: [PATCH 24/63] Expose structured async failures --- .../core/concurrent/AsyncTaskSupport.java | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/concurrent/AsyncTaskSupport.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/concurrent/AsyncTaskSupport.java index 3a46749..eb8c199 100644 --- a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/concurrent/AsyncTaskSupport.java +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/concurrent/AsyncTaskSupport.java @@ -1,11 +1,13 @@ package nl.hauntedmc.dataprovider.core.concurrent; +import nl.hauntedmc.dataprovider.core.exception.DataProviderExceptionMapper; + import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; import java.util.concurrent.RejectedExecutionException; -/** Shared helpers for queue-backed async execution with rejection-safe futures. */ +/** Shared helpers for queue-backed async execution with rejection-safe structured futures. */ public final class AsyncTaskSupport { private AsyncTaskSupport() { @@ -59,14 +61,18 @@ public void run() { future.complete(supplier.get()); } catch (Throwable throwable) { failed = true; - future.completeExceptionally(throwable); + future.completeExceptionally( + DataProviderExceptionMapper.translate(throwable, executor, operationName) + ); } } @Override public void reject(RejectedExecutionException rejection) { failed = true; - future.completeExceptionally(rejection); + future.completeExceptionally( + DataProviderExceptionMapper.translate(rejection, executor, operationName) + ); } @Override @@ -77,15 +83,16 @@ public boolean failed() { try { executor.execute(task); } catch (ExecutionRejectedException e) { - future.completeExceptionally(e); + future.completeExceptionally(DataProviderExceptionMapper.translate(e, executor, operationName)); } catch (RejectedExecutionException e) { - future.completeExceptionally(new ExecutionRejectedException( + ExecutionRejectedException rejection = new ExecutionRejectedException( ExecutionRejectedException.Reason.LANE_QUEUE_FULL, "Rejected async operation '" + operationName + "'.", e - )); + ); + future.completeExceptionally(DataProviderExceptionMapper.translate(rejection, executor, operationName)); } catch (RuntimeException e) { - future.completeExceptionally(e); + future.completeExceptionally(DataProviderExceptionMapper.translate(e, executor, operationName)); } return future; } From 6b656a3c5c366aef8826d9066da5167f8d48acd8 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:21:05 +0200 Subject: [PATCH 25/63] Complete structured failure translation --- .../DataProviderExceptionMapper.java | 213 ++++++++++++------ 1 file changed, 146 insertions(+), 67 deletions(-) diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/exception/DataProviderExceptionMapper.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/exception/DataProviderExceptionMapper.java index d3f8c84..980149b 100644 --- a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/exception/DataProviderExceptionMapper.java +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/exception/DataProviderExceptionMapper.java @@ -4,22 +4,25 @@ import com.mongodb.MongoSocketException; import com.mongodb.MongoTimeoutException; import com.mongodb.MongoWriteException; -import com.mongodb.MongoWriteConcernException; import nl.hauntedmc.dataprovider.core.concurrent.ExecutionHandle; import nl.hauntedmc.dataprovider.core.concurrent.ExecutionRejectedException; import nl.hauntedmc.dataprovider.database.DatabaseType; import nl.hauntedmc.dataprovider.exception.BackendAuthenticationException; import nl.hauntedmc.dataprovider.exception.BackendUnavailableException; import nl.hauntedmc.dataprovider.exception.DataConflictException; +import nl.hauntedmc.dataprovider.exception.DataProviderConfigurationException; import nl.hauntedmc.dataprovider.exception.DataProviderErrorCode; import nl.hauntedmc.dataprovider.exception.DataProviderException; import nl.hauntedmc.dataprovider.exception.DataProviderFailureContext; +import nl.hauntedmc.dataprovider.exception.DataProviderRegistrationException; import nl.hauntedmc.dataprovider.exception.DataProviderTimeoutException; import nl.hauntedmc.dataprovider.exception.DataSerializationException; +import nl.hauntedmc.dataprovider.exception.DataTransactionException; import nl.hauntedmc.dataprovider.exception.ExecutionOutcome; import nl.hauntedmc.dataprovider.exception.ProviderClosedException; import nl.hauntedmc.dataprovider.exception.QueueSaturatedException; import nl.hauntedmc.dataprovider.exception.RetryAdvice; +import nl.hauntedmc.dataprovider.exception.TransactionPhase; import org.bson.codecs.configuration.CodecConfigurationException; import redis.clients.jedis.exceptions.JedisAccessControlException; import redis.clients.jedis.exceptions.JedisConnectionException; @@ -31,6 +34,7 @@ import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; import java.util.concurrent.TimeoutException; @@ -49,102 +53,159 @@ public static DataProviderException translate(Throwable failure, Executor execut DatabaseType backend = execution == null ? inferBackend(operationName) : execution.backendType(); String connection = execution == null ? null : execution.connectionIdentifier(); DataProviderFailureContext base = DataProviderFailureContext.of( - backend, - connection, - operationName, - RetryAdvice.CONDITIONAL, - ExecutionOutcome.UNKNOWN - ); + backend, connection, operationName, RetryAdvice.CONDITIONAL, ExecutionOutcome.UNKNOWN); if (root instanceof ExecutionRejectedException rejected) { - Map diagnostics = new LinkedHashMap<>(); - diagnostics.put("reason", rejected.reason().name()); - if (execution != null && execution.pluginId() != null) { - diagnostics.put("plugin", execution.pluginId()); - } - DataProviderFailureContext context = base.withDiagnostics(diagnostics); + Map diagnostics = rejectionDiagnostics(rejected, execution); return switch (rejected.reason()) { case RUNTIME_SHUTTING_DOWN, SCOPE_CLOSED -> new ProviderClosedException( "The DataProvider execution scope is closed.", - new DataProviderFailureContext(backend, connection, operationName, RetryAdvice.NEVER, - ExecutionOutcome.NOT_STARTED, diagnostics, null), - safeCause(root) - ); + context(backend, connection, operationName, RetryAdvice.NEVER, + ExecutionOutcome.NOT_STARTED, diagnostics), + safeCause(root)); case LANE_QUEUE_FULL, PLUGIN_QUEUE_LIMIT, CONNECTION_QUEUE_LIMIT, SUBSCRIPTION_LIMIT -> new QueueSaturatedException( "DataProvider execution capacity is currently exhausted.", - new DataProviderFailureContext(backend, connection, operationName, RetryAdvice.SAFE, - ExecutionOutcome.NOT_STARTED, diagnostics, null), - safeCause(root) - ); + context(backend, connection, operationName, RetryAdvice.SAFE, + ExecutionOutcome.NOT_STARTED, diagnostics), + safeCause(root)); }; } - - if (root instanceof SQLIntegrityConstraintViolationException || sqlStateStartsWith(root, "23") - || isMongoDuplicate(root)) { + if (isConflict(root)) { return new DataConflictException( "The operation conflicted with existing backend state.", - new DataProviderFailureContext(backend, connection, operationName, RetryAdvice.NEVER, - ExecutionOutcome.NOT_APPLIED, sqlDiagnostics(root), null), - safeCause(root) - ); + context(backend, connection, operationName, RetryAdvice.NEVER, + ExecutionOutcome.NOT_APPLIED, sqlDiagnostics(root)), safeCause(root)); } - if (isAuthenticationFailure(root)) { return new BackendAuthenticationException( "The backend rejected DataProvider authentication.", - new DataProviderFailureContext(backend, connection, operationName, RetryAdvice.NEVER, - ExecutionOutcome.NOT_STARTED, safeClassDiagnostics(root), null), - safeCause(root) - ); + context(backend, connection, operationName, RetryAdvice.NEVER, + ExecutionOutcome.NOT_STARTED, safeClassDiagnostics(root)), safeCause(root)); } - if (isTimeout(root)) { return new DataProviderTimeoutException( "The backend operation timed out.", - new DataProviderFailureContext(backend, connection, operationName, RetryAdvice.CONDITIONAL, + context(backend, connection, operationName, RetryAdvice.CONDITIONAL, isReadOperation(operationName) ? ExecutionOutcome.UNKNOWN : ExecutionOutcome.MAY_HAVE_APPLIED, - safeClassDiagnostics(root), null), - safeCause(root) - ); + safeClassDiagnostics(root)), safeCause(root)); } - if (isSerializationFailure(root)) { return new DataSerializationException( "Data serialization or deserialization failed.", - new DataProviderFailureContext(backend, connection, operationName, RetryAdvice.NEVER, - ExecutionOutcome.NOT_STARTED, safeClassDiagnostics(root), null), - safeCause(root) - ); + context(backend, connection, operationName, RetryAdvice.NEVER, + ExecutionOutcome.NOT_STARTED, safeClassDiagnostics(root)), safeCause(root)); } - if (isUnavailable(root)) { - return new BackendUnavailableException( - DataProviderErrorCode.BACKEND_UNAVAILABLE, - "The configured backend is unavailable.", - new DataProviderFailureContext(backend, connection, operationName, RetryAdvice.CONDITIONAL, - ExecutionOutcome.UNKNOWN, safeClassDiagnostics(root), null), - safeCause(root) - ); + return unavailable(backend, connection, operationName, root); } - return new BackendUnavailableException( DataProviderErrorCode.BACKEND_UNAVAILABLE, "The backend operation failed.", - base.withDiagnostics(safeClassDiagnostics(root)), - safeCause(root) + base.withDiagnostics(safeClassDiagnostics(root)), safeCause(root)); + } + + public static DataProviderException registrationFailure( + Throwable failure, DatabaseType backend, String connectionIdentifier, String operationName) { + Throwable root = unwrap(failure); + if (root instanceof DataProviderException structured) { + return structured; + } + if (root == null) { + return new DataProviderRegistrationException( + "Database registration failed.", + context(backend, connectionIdentifier, operationName, RetryAdvice.CONDITIONAL, + ExecutionOutcome.NOT_STARTED, Map.of()), null); + } + if (root instanceof MissingConfigurationFailure) { + return new DataProviderConfigurationException( + DataProviderErrorCode.CONFIGURATION_MISSING, + "No configuration exists for the requested database connection.", + context(backend, connectionIdentifier, operationName, RetryAdvice.NEVER, + ExecutionOutcome.NOT_STARTED, safeClassDiagnostics(root)), safeCause(root)); + } + return translate(root, new RegistrationExecutionHandle(backend, connectionIdentifier), operationName); + } + + public static BackendUnavailableException backendDisabled(DatabaseType backend, String connectionIdentifier) { + return new BackendUnavailableException( + DataProviderErrorCode.BACKEND_DISABLED, + "The requested database backend is disabled.", + context(backend, connectionIdentifier, "registerDatabase", RetryAdvice.NEVER, + ExecutionOutcome.NOT_STARTED, Map.of()), null); + } + + public static DataProviderConfigurationException configurationFailure(Throwable failure, String operationName) { + Throwable root = unwrap(failure); + return new DataProviderConfigurationException( + DataProviderErrorCode.CONFIGURATION_INVALID, + "DataProvider configuration is invalid.", + context(null, null, operationName, RetryAdvice.NEVER, + ExecutionOutcome.NOT_STARTED, safeClassDiagnostics(root)), safeCause(root)); + } + + public static DataTransactionException transactionFailure( + Throwable failure, + Executor executor, + String operationName, + TransactionPhase phase, + ExecutionOutcome outcome + ) { + DataProviderException mapped = translate(failure, executor, operationName); + return new DataTransactionException( + "The database transaction failed during " + phase.name().toLowerCase(java.util.Locale.ROOT) + ".", + phase, + context(mapped.backendType(), mapped.connectionIdentifier(), operationName, + phase == TransactionPhase.COMMIT ? RetryAdvice.CONDITIONAL : RetryAdvice.NEVER, + outcome, Map.of("phase", phase.name(), "causeCode", mapped.errorCode().name())), + mapped ); } + public static MissingConfigurationFailure missingConfigurationFailure() { + return new MissingConfigurationFailure(); + } + + private static BackendUnavailableException unavailable( + DatabaseType backend, String connection, String operationName, Throwable root) { + return new BackendUnavailableException( + DataProviderErrorCode.BACKEND_UNAVAILABLE, + "The configured backend is unavailable.", + context(backend, connection, operationName, RetryAdvice.CONDITIONAL, + ExecutionOutcome.UNKNOWN, safeClassDiagnostics(root)), safeCause(root)); + } + + private static DataProviderFailureContext context( + DatabaseType backend, + String connection, + String operation, + RetryAdvice retry, + ExecutionOutcome outcome, + Map diagnostics + ) { + return new DataProviderFailureContext(backend, connection, operation, retry, outcome, diagnostics, null); + } + private static Throwable unwrap(Throwable failure) { + if (failure == null) { + return null; + } Throwable current = failure; - while ((current instanceof CompletionException || current.getClass() == RuntimeException.class) - && current.getCause() != null) { + while (current.getCause() != null && (current instanceof CompletionException + || current instanceof ExecutionException + || current.getClass() == RuntimeException.class + || current instanceof IllegalStateException)) { current = current.getCause(); } return current; } + private static boolean isConflict(Throwable failure) { + return failure instanceof SQLIntegrityConstraintViolationException + || sqlStateStartsWith(failure, "23") + || failure instanceof MongoWriteException write && write.getError().getCode() == 11000; + } + private static boolean isAuthenticationFailure(Throwable failure) { if (failure instanceof MongoSecurityException || failure instanceof JedisAccessControlException) { return true; @@ -179,14 +240,6 @@ private static boolean isUnavailable(Throwable failure) { return name.contains("Connection") || name.contains("Socket") || name.contains("ServerSelection"); } - private static boolean isMongoDuplicate(Throwable failure) { - if (failure instanceof MongoWriteException write) { - return write.getError().getCode() == 11000; - } - return failure instanceof MongoWriteConcernException concern - && concern.getWriteConcernError().getCode() == 11000; - } - private static boolean sqlStateStartsWith(Throwable failure, String prefix) { return failure instanceof SQLException sql && startsWith(sql.getSQLState(), prefix); } @@ -195,6 +248,16 @@ private static boolean startsWith(String value, String prefix) { return value != null && value.startsWith(prefix); } + private static Map rejectionDiagnostics( + ExecutionRejectedException rejection, ExecutionHandle execution) { + LinkedHashMap diagnostics = new LinkedHashMap<>(); + diagnostics.put("reason", rejection.reason().name()); + if (execution != null && execution.pluginId() != null) { + diagnostics.put("plugin", execution.pluginId()); + } + return Map.copyOf(diagnostics); + } + private static Map sqlDiagnostics(Throwable failure) { LinkedHashMap diagnostics = new LinkedHashMap<>(safeClassDiagnostics(failure)); if (failure instanceof SQLException sql) { @@ -207,11 +270,11 @@ private static Map sqlDiagnostics(Throwable failure) { } private static Map safeClassDiagnostics(Throwable failure) { - return Map.of("causeType", failure.getClass().getName()); + return failure == null ? Map.of() : Map.of("causeType", failure.getClass().getName()); } private static Throwable safeCause(Throwable failure) { - return new SafeBackendCause(failure.getClass().getName()); + return failure == null ? null : new SafeBackendCause(failure.getClass().getName()); } private static boolean isReadOperation(String operationName) { @@ -242,7 +305,23 @@ private static DatabaseType inferBackend(String operationName) { return null; } - /** Redacted cause preserving only the original exception type. */ + private record RegistrationExecutionHandle(DatabaseType backendType, String connectionIdentifier) + implements ExecutionHandle { + @Override public void execute(Runnable command) { command.run(); } + @Override public nl.hauntedmc.dataprovider.core.concurrent.ExecutionMetricsSnapshot metrics() { + return new nl.hauntedmc.dataprovider.core.concurrent.ExecutionMetricsSnapshot( + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + } + @Override public boolean isClosed() { return false; } + @Override public void close() { } + } + + public static final class MissingConfigurationFailure extends RuntimeException { + private MissingConfigurationFailure() { + super("Missing database configuration", null, false, false); + } + } + private static final class SafeBackendCause extends RuntimeException { private SafeBackendCause(String causeType) { super("Backend failure type: " + causeType, null, false, false); From 5720226d79d4e5eff192937ba43af9f4fe65c597 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:21:35 +0200 Subject: [PATCH 26/63] Retain missing configuration failure details --- .../java/nl/hauntedmc/dataprovider/core/DatabaseFactory.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/DatabaseFactory.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/DatabaseFactory.java index a24c578..61db6ef 100644 --- a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/DatabaseFactory.java +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/DatabaseFactory.java @@ -7,6 +7,7 @@ import nl.hauntedmc.dataprovider.core.database.keyvalue.impl.redis.RedisDatabase; import nl.hauntedmc.dataprovider.core.database.messaging.impl.redis.RedisMessagingDatabase; import nl.hauntedmc.dataprovider.core.database.relational.impl.mysql.MySQLDatabase; +import nl.hauntedmc.dataprovider.core.exception.DataProviderExceptionMapper; import nl.hauntedmc.dataprovider.database.DatabaseType; import nl.hauntedmc.dataprovider.logging.LoggerAdapter; import org.spongepowered.configurate.CommentedConfigurationNode; @@ -75,7 +76,7 @@ protected ManagedDatabaseProvider createDatabaseProvider( CommentedConfigurationNode connectionConfig = configMap.getConfig(type, connectionIdentifier); if (connectionConfig == null) { logger.error("Could not load configuration for " + connectionIdentifier.value() + " (" + type.name() + ")"); - return null; + throw DataProviderExceptionMapper.missingConfigurationFailure(); } ExecutionHandle rawExecution = executionRuntime == null ? ExecutionHandle.direct() From 0426d6678d66e7a03017d511611a8bba89bc79f4 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:22:19 +0200 Subject: [PATCH 27/63] Expose strict structured registration failures --- .../core/DataProviderHandler.java | 111 ++++++++++++++++-- 1 file changed, 99 insertions(+), 12 deletions(-) diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/DataProviderHandler.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/DataProviderHandler.java index cce80e9..4114b22 100644 --- a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/DataProviderHandler.java +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/DataProviderHandler.java @@ -4,12 +4,18 @@ import nl.hauntedmc.dataprovider.core.concurrent.DataProviderExecutionRuntime; import nl.hauntedmc.dataprovider.core.concurrent.ExecutionRuntimeConfig; import nl.hauntedmc.dataprovider.core.config.ConfigHandler; +import nl.hauntedmc.dataprovider.core.exception.DataProviderExceptionMapper; import nl.hauntedmc.dataprovider.core.identity.CallerContext; import nl.hauntedmc.dataprovider.core.identity.CallerContextResolver; import nl.hauntedmc.dataprovider.core.identity.StackCallerClassLoaderResolver; import nl.hauntedmc.dataprovider.database.DatabaseConnectionKey; import nl.hauntedmc.dataprovider.database.DatabaseProvider; import nl.hauntedmc.dataprovider.database.DatabaseType; +import nl.hauntedmc.dataprovider.exception.DataProviderFailureContext; +import nl.hauntedmc.dataprovider.exception.DataProviderRegistrationException; +import nl.hauntedmc.dataprovider.exception.ExecutionOutcome; +import nl.hauntedmc.dataprovider.exception.ProviderClosedException; +import nl.hauntedmc.dataprovider.exception.RetryAdvice; import nl.hauntedmc.dataprovider.logging.LoggerAdapter; import java.nio.file.Path; @@ -70,8 +76,16 @@ public DatabaseProvider registerDatabase(DatabaseType databaseType, String conne Objects.requireNonNull(databaseType, "Database type cannot be null"); PluginId pluginId = PluginId.of(resolveCallerContext().pluginId()); ConnectionIdentifier identifier = ConnectionIdentifier.of(connectionIdentifier); - return DatabaseFactory.withCreationPlugin(pluginId, () -> registry.registerDatabase( - pluginId, OwnerScopeId.of(pluginId.value()), databaseType, identifier)); + return registerLegacy(pluginId, OwnerScopeId.of(pluginId.value()), databaseType, identifier); + } + + public DatabaseProvider registerDatabaseOrThrow(DatabaseType databaseType, String connectionIdentifier) { + requireOpen(); + Objects.requireNonNull(databaseType, "Database type cannot be null"); + PluginId pluginId = PluginId.of(resolveCallerContext().pluginId()); + ConnectionIdentifier identifier = ConnectionIdentifier.of(connectionIdentifier); + return registerStrict(pluginId, OwnerScopeId.of(pluginId.value()), databaseType, identifier, + "registerDatabase"); } public DatabaseProvider registerDatabaseForScope( @@ -91,9 +105,51 @@ public DatabaseProvider registerDatabaseForScope( Objects.requireNonNull(databaseType, "Database type cannot be null"); Objects.requireNonNull(ownerScope, "Owner scope cannot be null."); PluginId pluginId = PluginId.of(resolveCallerContext().pluginId()); - ConnectionIdentifier identifier = ConnectionIdentifier.of(connectionIdentifier); - return DatabaseFactory.withCreationPlugin(pluginId, () -> registry.registerDatabase( - pluginId, OwnerScopeId.from(ownerScope), databaseType, identifier)); + return registerLegacy(pluginId, OwnerScopeId.from(ownerScope), databaseType, + ConnectionIdentifier.of(connectionIdentifier)); + } + + public DatabaseProvider registerDatabaseForScopeOrThrow( + OwnerScope ownerScope, + DatabaseType databaseType, + String connectionIdentifier + ) { + requireOpen(); + Objects.requireNonNull(databaseType, "Database type cannot be null"); + Objects.requireNonNull(ownerScope, "Owner scope cannot be null."); + PluginId pluginId = PluginId.of(resolveCallerContext().pluginId()); + return registerStrict(pluginId, OwnerScopeId.from(ownerScope), databaseType, + ConnectionIdentifier.of(connectionIdentifier), "scope.registerDatabase"); + } + + private DatabaseProvider registerLegacy( + PluginId pluginId, + OwnerScopeId ownerScope, + DatabaseType type, + ConnectionIdentifier identifier + ) { + return DatabaseFactory.withCreationPlugin(pluginId, + () -> registry.registerDatabase(pluginId, ownerScope, type, identifier)); + } + + private DatabaseProvider registerStrict( + PluginId pluginId, + OwnerScopeId ownerScope, + DatabaseType type, + ConnectionIdentifier identifier, + String operation + ) { + DatabaseProvider provider = registerLegacy(pluginId, ownerScope, type, identifier); + if (provider != null) { + return provider; + } + if (!registry.getConfiguredDatabaseTypeStates().getOrDefault(type, true)) { + throw DataProviderExceptionMapper.backendDisabled(type, identifier.value()); + } + DatabaseConnectionKey key = new DatabaseConnectionKey(pluginId.value(), type, identifier.value()); + ProviderLifecycleSnapshot snapshot = registry.getProviderLifecycleSnapshots().get(key); + Throwable failure = snapshot == null ? null : snapshot.failure(); + throw DataProviderExceptionMapper.registrationFailure(failure, type, identifier.value(), operation); } public void unregisterDatabase(DatabaseType databaseType, String connectionIdentifier) { @@ -104,11 +160,7 @@ public void unregisterDatabase(DatabaseType databaseType, String connectionIdent ConnectionIdentifier.of(connectionIdentifier)); } - public void unregisterDatabaseForScope( - String ownerScope, - DatabaseType databaseType, - String connectionIdentifier - ) { + public void unregisterDatabaseForScope(String ownerScope, DatabaseType databaseType, String connectionIdentifier) { unregisterDatabaseForScope(OwnerScope.of(ownerScope), databaseType, connectionIdentifier); } @@ -165,6 +217,14 @@ public DatabaseProvider getRegisteredDatabase(DatabaseType databaseType, String ConnectionIdentifier.of(connectionIdentifier)); } + public DatabaseProvider requireRegisteredDatabase(DatabaseType databaseType, String connectionIdentifier) { + DatabaseProvider provider = getRegisteredDatabase(databaseType, connectionIdentifier); + if (provider != null) { + return provider; + } + throw missingRegistration(databaseType, connectionIdentifier, "requireRegisteredDatabase"); + } + public DatabaseProvider getRegisteredDatabaseForScope( OwnerScope ownerScope, DatabaseType databaseType, @@ -177,6 +237,26 @@ public DatabaseProvider getRegisteredDatabaseForScope( databaseType, ConnectionIdentifier.of(connectionIdentifier)); } + public DatabaseProvider requireRegisteredDatabaseForScope( + OwnerScope ownerScope, + DatabaseType databaseType, + String connectionIdentifier + ) { + DatabaseProvider provider = getRegisteredDatabaseForScope(ownerScope, databaseType, connectionIdentifier); + if (provider != null) { + return provider; + } + throw missingRegistration(databaseType, connectionIdentifier, "scope.requireRegisteredDatabase"); + } + + private DataProviderRegistrationException missingRegistration( + DatabaseType type, String identifier, String operation) { + return new DataProviderRegistrationException( + "No active database registration exists for the requested connection.", + DataProviderFailureContext.of(type, identifier, operation, RetryAdvice.NEVER, + ExecutionOutcome.NOT_STARTED), null); + } + public ConcurrentMap getActiveDatabases() { requireOpen(); requireInternalCaller(); @@ -216,7 +296,11 @@ public String getConfiguredOrmSchemaMode() { public void reloadConfiguration() { requireOpen(); requireInternalCaller(); - registry.reloadConfiguration(); + try { + registry.reloadConfiguration(); + } catch (RuntimeException failure) { + throw DataProviderExceptionMapper.configurationFailure(failure, "reloadConfiguration"); + } } private CallerContext resolveCallerContext() { @@ -239,7 +323,10 @@ private void requireInternalCaller() { private void requireOpen() { if (registry.isClosed()) { - throw new IllegalStateException(CLOSED_MESSAGE); + throw new ProviderClosedException( + CLOSED_MESSAGE, + DataProviderFailureContext.of(null, null, "api", RetryAdvice.NEVER, + ExecutionOutcome.NOT_STARTED), null); } } } From 4d651049ff510ef3dedfbec3036cce5d44f7069b Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:22:55 +0200 Subject: [PATCH 28/63] Wire strict public API methods --- .../core/api/DefaultDataProviderApi.java | 159 ++++-------------- 1 file changed, 35 insertions(+), 124 deletions(-) diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DefaultDataProviderApi.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DefaultDataProviderApi.java index f95c0d7..4e7a7a9 100644 --- a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DefaultDataProviderApi.java +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DefaultDataProviderApi.java @@ -4,10 +4,10 @@ import nl.hauntedmc.dataprovider.api.DataProviderScope; import nl.hauntedmc.dataprovider.api.OwnerScope; import nl.hauntedmc.dataprovider.api.orm.ORMContext; - +import nl.hauntedmc.dataprovider.core.DataProviderHandler; import nl.hauntedmc.dataprovider.database.DataAccess; -import nl.hauntedmc.dataprovider.database.DatabaseType; import nl.hauntedmc.dataprovider.database.DatabaseProvider; +import nl.hauntedmc.dataprovider.database.DatabaseType; import nl.hauntedmc.dataprovider.database.document.DocumentDataAccess; import nl.hauntedmc.dataprovider.database.document.DocumentDatabaseProvider; import nl.hauntedmc.dataprovider.database.keyvalue.KeyValueDataAccess; @@ -17,31 +17,15 @@ import nl.hauntedmc.dataprovider.database.relational.RelationalDataAccess; import nl.hauntedmc.dataprovider.database.relational.RelationalDatabaseProvider; import nl.hauntedmc.dataprovider.database.relational.schema.SchemaManager; -import nl.hauntedmc.dataprovider.core.DataProviderHandler; import javax.sql.DataSource; import java.util.Objects; -/** - * DataProviderAPI is the public facade that exposes safe, read-only database handles - * for third-party plugins. Internally, it delegates to a DataProviderHandler, but it does not - * expose lifecycle-sensitive methods (like shutdownAllDatabases or getActiveDatabases). - * - * For most integrations, the primary lifecycle is: - * register -> use provider/data access -> unregister. - * Optional scoped ownership is available through {@link #scope(String)} for advanced cases - * where one plugin/software process needs isolated ownership domains for independently - * managed components. - */ +/** Public read-only facade for plugin-scoped DataProvider access. */ public final class DefaultDataProviderApi implements DataProviderAPI { private final DataProviderHandler handler; - /** - * Constructs the API wrapper. - * - * @param handler the internal DataProviderHandler instance. - */ public DefaultDataProviderApi(DataProviderHandler handler) { this.handler = Objects.requireNonNull(handler, "DataProviderHandler cannot be null"); } @@ -55,70 +39,49 @@ public ORMContext createOrmContext( Class... entityClasses ) { return new nl.hauntedmc.dataprovider.core.orm.ORMContext( - pluginName, - dataSource, - logger, - schemaMode, - entityClasses - ); - } - - /** - * Registers a database connection for the resolved caller plugin. - * This is the default path for most integrations. - * - * @param databaseType the type of database (e.g. MYSQL, MONGODB, etc.) - * @param connectionIdentifier a unique identifier for the connection - * @return the registered read-only {@link DatabaseProvider} handle. - */ + pluginName, dataSource, logger, schemaMode, entityClasses); + } + + @Override public DatabaseProvider registerDatabase(DatabaseType databaseType, String connectionIdentifier) { return wrapProvider(handler.registerDatabase(databaseType, connectionIdentifier)); } - /** - * Creates an optional scoped lifecycle facade using a typed owner scope. - */ + @Override + public DatabaseProvider registerDatabaseOrThrow(DatabaseType databaseType, String connectionIdentifier) { + return wrapProvider(handler.registerDatabaseOrThrow(databaseType, connectionIdentifier)); + } + + @Override public DataProviderScope scope(OwnerScope ownerScope) { return new DefaultDataProviderScope(handler, ownerScope); } - /** - * Unregisters a specific database connection for the resolved caller plugin. - * This is the default path for most integrations. - * - * @param databaseType the type of database. - * @param connectionIdentifier the connection identifier. - */ + @Override public void unregisterDatabase(DatabaseType databaseType, String connectionIdentifier) { handler.unregisterDatabase(databaseType, connectionIdentifier); } - /** - * Unregisters all database connections for the resolved caller plugin default owner scope. - */ + @Override public void unregisterAllDatabases() { handler.unregisterAllDatabases(); } - /** - * Unregisters all database connections for the caller plugin across all caller scopes. - * Use this for deterministic full-plugin shutdown cleanup. - */ + @Override public void unregisterAllDatabasesForPlugin() { handler.unregisterAllDatabasesForPlugin(); } - /** - * Retrieves a registered database connection for the resolved caller plugin. - * - * @param databaseType the type of database. - * @param connectionIdentifier the connection identifier. - * @return the {@link DatabaseProvider} instance, or null if not registered. - */ + @Override public DatabaseProvider getRegisteredDatabase(DatabaseType databaseType, String connectionIdentifier) { return wrapProvider(handler.getRegisteredDatabase(databaseType, connectionIdentifier)); } + @Override + public DatabaseProvider requireRegisteredDatabase(DatabaseType databaseType, String connectionIdentifier) { + return wrapProvider(handler.requireRegisteredDatabase(databaseType, connectionIdentifier)); + } + static DatabaseProvider wrapProvider(DatabaseProvider provider) { if (provider == null || provider instanceof WrappedDatabaseProvider) { return provider; @@ -145,21 +108,9 @@ private record DatabaseProviderView(DatabaseProvider delegate) implements Wrappe private DatabaseProviderView { Objects.requireNonNull(delegate, "Delegate database provider cannot be null."); } - - @Override - public boolean isConnected() { - return delegate.isConnected(); - } - - @Override - public DataAccess getDataAccess() { - return delegate.getDataAccess(); - } - - @Override - public DataSource getDataSource() { - return delegate.getDataSource(); - } + @Override public boolean isConnected() { return delegate.isConnected(); } + @Override public DataAccess getDataAccess() { return delegate.getDataAccess(); } + @Override public DataSource getDataSource() { return delegate.getDataSource(); } } private record RelationalDatabaseProviderView(RelationalDatabaseProvider delegate) @@ -167,26 +118,10 @@ private record RelationalDatabaseProviderView(RelationalDatabaseProvider delegat private RelationalDatabaseProviderView { Objects.requireNonNull(delegate, "Delegate relational database provider cannot be null."); } - - @Override - public boolean isConnected() { - return delegate.isConnected(); - } - - @Override - public RelationalDataAccess getDataAccess() { - return delegate.getDataAccess(); - } - - @Override - public DataSource getDataSource() { - return delegate.getDataSource(); - } - - @Override - public SchemaManager getSchemaManager() { - return delegate.getSchemaManager(); - } + @Override public boolean isConnected() { return delegate.isConnected(); } + @Override public RelationalDataAccess getDataAccess() { return delegate.getDataAccess(); } + @Override public DataSource getDataSource() { return delegate.getDataSource(); } + @Override public SchemaManager getSchemaManager() { return delegate.getSchemaManager(); } } private record DocumentDatabaseProviderView(DocumentDatabaseProvider delegate) @@ -194,16 +129,8 @@ private record DocumentDatabaseProviderView(DocumentDatabaseProvider delegate) private DocumentDatabaseProviderView { Objects.requireNonNull(delegate, "Delegate document database provider cannot be null."); } - - @Override - public boolean isConnected() { - return delegate.isConnected(); - } - - @Override - public DocumentDataAccess getDataAccess() { - return delegate.getDataAccess(); - } + @Override public boolean isConnected() { return delegate.isConnected(); } + @Override public DocumentDataAccess getDataAccess() { return delegate.getDataAccess(); } } private record KeyValueDatabaseProviderView(KeyValueDatabaseProvider delegate) @@ -211,16 +138,8 @@ private record KeyValueDatabaseProviderView(KeyValueDatabaseProvider delegate) private KeyValueDatabaseProviderView { Objects.requireNonNull(delegate, "Delegate key-value database provider cannot be null."); } - - @Override - public boolean isConnected() { - return delegate.isConnected(); - } - - @Override - public KeyValueDataAccess getDataAccess() { - return delegate.getDataAccess(); - } + @Override public boolean isConnected() { return delegate.isConnected(); } + @Override public KeyValueDataAccess getDataAccess() { return delegate.getDataAccess(); } } private record MessagingDatabaseProviderView(MessagingDatabaseProvider delegate) @@ -228,15 +147,7 @@ private record MessagingDatabaseProviderView(MessagingDatabaseProvider delegate) private MessagingDatabaseProviderView { Objects.requireNonNull(delegate, "Delegate messaging database provider cannot be null."); } - - @Override - public boolean isConnected() { - return delegate.isConnected(); - } - - @Override - public MessagingDataAccess getDataAccess() { - return delegate.getDataAccess(); - } + @Override public boolean isConnected() { return delegate.isConnected(); } + @Override public MessagingDataAccess getDataAccess() { return delegate.getDataAccess(); } } } From c298167439bf3db239c8e229cf88b61bd88a6bdf Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:23:13 +0200 Subject: [PATCH 29/63] Expose structured scoped failures --- .../core/api/DefaultDataProviderScope.java | 66 +++++++++++-------- 1 file changed, 37 insertions(+), 29 deletions(-) diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DefaultDataProviderScope.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DefaultDataProviderScope.java index 3c64f6c..c82a0e6 100644 --- a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DefaultDataProviderScope.java +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DefaultDataProviderScope.java @@ -2,23 +2,17 @@ import nl.hauntedmc.dataprovider.api.DataProviderScope; import nl.hauntedmc.dataprovider.api.OwnerScope; - +import nl.hauntedmc.dataprovider.core.DataProviderHandler; import nl.hauntedmc.dataprovider.database.DatabaseProvider; import nl.hauntedmc.dataprovider.database.DatabaseType; -import nl.hauntedmc.dataprovider.core.DataProviderHandler; +import nl.hauntedmc.dataprovider.exception.DataProviderFailureContext; +import nl.hauntedmc.dataprovider.exception.ExecutionOutcome; +import nl.hauntedmc.dataprovider.exception.ProviderClosedException; +import nl.hauntedmc.dataprovider.exception.RetryAdvice; import java.util.Objects; -/** - * Optional scoped lifecycle helper for advanced integrations that need isolated ownership domains - * inside one plugin/software process. - * - * Typical use: - * - create one scope per logical component - * - register and use connections through this scope - * - release the scope's registrations via {@link #unregisterAllDatabases()} or terminate the - * scope via {@link #close()} - */ +/** Optional scoped lifecycle helper for independently managed plugin components. */ public final class DefaultDataProviderScope implements DataProviderScope { private static final String CLOSED_MESSAGE = "DataProvider scope is closed."; @@ -33,9 +27,7 @@ public final class DefaultDataProviderScope implements DataProviderScope { this.ownerScope = Objects.requireNonNull(ownerScope, "Owner scope cannot be null."); } - /** - * Returns the normalized scope identifier used for ownership tracking. - */ + @Override public OwnerScope ownerScope() { return ownerScope; } @@ -45,21 +37,25 @@ public LifecycleState lifecycleState() { return lifecycleState; } - /** - * Registers a database connection under this scope. - */ + @Override public DatabaseProvider registerDatabase(DatabaseType databaseType, String connectionIdentifier) { synchronized (lifecycleMonitor) { requireOpen(); return DefaultDataProviderApi.wrapProvider( - handler.registerDatabaseForScope(ownerScope, databaseType, connectionIdentifier) - ); + handler.registerDatabaseForScope(ownerScope, databaseType, connectionIdentifier)); + } + } + + @Override + public DatabaseProvider registerDatabaseOrThrow(DatabaseType databaseType, String connectionIdentifier) { + synchronized (lifecycleMonitor) { + requireOpen(); + return DefaultDataProviderApi.wrapProvider( + handler.registerDatabaseForScopeOrThrow(ownerScope, databaseType, connectionIdentifier)); } } - /** - * Releases one scoped registration reference. - */ + @Override public void unregisterDatabase(DatabaseType databaseType, String connectionIdentifier) { synchronized (lifecycleMonitor) { requireOpen(); @@ -67,9 +63,7 @@ public void unregisterDatabase(DatabaseType databaseType, String connectionIdent } } - /** - * Releases all registrations held by this scope. - */ + @Override public void unregisterAllDatabases() { synchronized (lifecycleMonitor) { requireOpen(); @@ -82,8 +76,16 @@ public DatabaseProvider getRegisteredDatabase(DatabaseType databaseType, String synchronized (lifecycleMonitor) { requireOpen(); return DefaultDataProviderApi.wrapProvider( - handler.getRegisteredDatabaseForScope(ownerScope, databaseType, connectionIdentifier) - ); + handler.getRegisteredDatabaseForScope(ownerScope, databaseType, connectionIdentifier)); + } + } + + @Override + public DatabaseProvider requireRegisteredDatabase(DatabaseType databaseType, String connectionIdentifier) { + synchronized (lifecycleMonitor) { + requireOpen(); + return DefaultDataProviderApi.wrapProvider( + handler.requireRegisteredDatabaseForScope(ownerScope, databaseType, connectionIdentifier)); } } @@ -104,7 +106,13 @@ public void close() { private void requireOpen() { if (lifecycleState != LifecycleState.OPEN) { - throw new IllegalStateException(CLOSED_MESSAGE); + throw new ProviderClosedException( + CLOSED_MESSAGE, + DataProviderFailureContext.of(null, null, "scope", RetryAdvice.NEVER, + ExecutionOutcome.NOT_STARTED) + .withDiagnostics(java.util.Map.of("ownerScope", ownerScope.value())), + null + ); } } } From b59d03717720a4f39ae0dfc599232132ab4f1d34 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:23:57 +0200 Subject: [PATCH 30/63] Add structured transaction phase failures --- .../impl/mysql/MySQLDataAccess.java | 99 +++++++++++++------ 1 file changed, 67 insertions(+), 32 deletions(-) diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/database/relational/impl/mysql/MySQLDataAccess.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/database/relational/impl/mysql/MySQLDataAccess.java index aeecf22..cafaac2 100644 --- a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/database/relational/impl/mysql/MySQLDataAccess.java +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/database/relational/impl/mysql/MySQLDataAccess.java @@ -1,8 +1,12 @@ package nl.hauntedmc.dataprovider.core.database.relational.impl.mysql; import nl.hauntedmc.dataprovider.core.concurrent.AsyncTaskSupport; +import nl.hauntedmc.dataprovider.core.exception.DataProviderExceptionMapper; import nl.hauntedmc.dataprovider.database.relational.RelationalDataAccess; import nl.hauntedmc.dataprovider.database.relational.TransactionCallback; +import nl.hauntedmc.dataprovider.exception.DataTransactionException; +import nl.hauntedmc.dataprovider.exception.ExecutionOutcome; +import nl.hauntedmc.dataprovider.exception.TransactionPhase; import javax.sql.DataSource; import java.sql.Connection; @@ -22,6 +26,8 @@ /** MySQL implementation of RelationalDataAccess. */ public class MySQLDataAccess implements RelationalDataAccess { + private static final String TRANSACTION_OPERATION = "mysql.executeTransactionally"; + private final DataSource dataSource; private final Executor executor; private final int queryTimeoutSeconds; @@ -47,8 +53,6 @@ public CompletableFuture executeUpdate(String query, Object... params) { applyStatementTuning(stmt); setParameters(stmt, params); stmt.executeUpdate(); - } catch (SQLException e) { - throw new RuntimeException("Failed to execute update", e); } }); } @@ -64,8 +68,6 @@ public CompletableFuture> queryForSingle(String query, Objec try (ResultSet rs = stmt.executeQuery()) { return rs.next() ? mapRow(rs) : null; } - } catch (SQLException e) { - throw new RuntimeException("Failed to execute queryForSingle", e); } }); } @@ -85,8 +87,6 @@ public CompletableFuture>> queryForList(String query, O } } return result; - } catch (SQLException e) { - throw new RuntimeException("Failed to execute queryForList", e); } }); } @@ -102,8 +102,6 @@ public CompletableFuture queryForSingleValue(String query, Object... par try (ResultSet rs = stmt.executeQuery()) { return rs.next() ? rs.getObject(1) : null; } - } catch (SQLException e) { - throw new RuntimeException("Failed to execute queryForSingleValue", e); } }); } @@ -127,14 +125,16 @@ public CompletableFuture executeBatchUpdate(String query, List b } stmt.executeBatch(); connection.commit(); - } catch (SQLException e) { - connection.rollback(); - throw e; + } catch (SQLException primary) { + try { + connection.rollback(); + } catch (SQLException rollbackFailure) { + primary.addSuppressed(rollbackFailure); + } + throw primary; } finally { connection.setAutoCommit(oldAutoCommit); } - } catch (SQLException e) { - throw new RuntimeException("Failed to execute batch update", e); } }); } @@ -142,24 +142,61 @@ public CompletableFuture executeBatchUpdate(String query, List b @Override public CompletableFuture executeTransactionally(TransactionCallback callback) { Objects.requireNonNull(callback, "Transaction callback cannot be null."); - return AsyncTaskSupport.supplyAsync(executor, "mysql.executeTransactionally", () -> { - try (Connection connection = dataSource.getConnection()) { - boolean oldAutoCommit = connection.getAutoCommit(); - connection.setAutoCommit(false); - try { - T result = callback.doInTransaction(connection); - connection.commit(); - return result; - } catch (Exception e) { - connection.rollback(); - throw new RuntimeException("Transaction failed, rolled back.", e); - } finally { - connection.setAutoCommit(oldAutoCommit); - } - } catch (SQLException e) { - throw new RuntimeException("Failed to execute transactionally", e); + return AsyncTaskSupport.supplyAsync(executor, TRANSACTION_OPERATION, () -> executeTransaction(callback)); + } + + private T executeTransaction(TransactionCallback callback) { + Connection connection; + try { + connection = dataSource.getConnection(); + connection.setAutoCommit(false); + } catch (Throwable beginFailure) { + throw DataProviderExceptionMapper.transactionFailure( + beginFailure, executor, TRANSACTION_OPERATION, TransactionPhase.BEGIN, + ExecutionOutcome.NOT_STARTED); + } + + try (connection) { + T result; + try { + result = callback.doInTransaction(connection); + } catch (Throwable callbackFailure) { + throw rollbackAfterFailure(connection, callbackFailure, TransactionPhase.CALLBACK, + ExecutionOutcome.NOT_APPLIED); } - }); + + try { + connection.commit(); + return result; + } catch (Throwable commitFailure) { + throw rollbackAfterFailure(connection, commitFailure, TransactionPhase.COMMIT, + ExecutionOutcome.MAY_HAVE_APPLIED); + } + } catch (DataTransactionException structured) { + throw structured; + } catch (Throwable closeFailure) { + throw DataProviderExceptionMapper.transactionFailure( + closeFailure, executor, TRANSACTION_OPERATION, TransactionPhase.ROLLBACK, + ExecutionOutcome.UNKNOWN); + } + } + + private DataTransactionException rollbackAfterFailure( + Connection connection, + Throwable primary, + TransactionPhase phase, + ExecutionOutcome outcome + ) { + DataTransactionException structured = DataProviderExceptionMapper.transactionFailure( + primary, executor, TRANSACTION_OPERATION, phase, outcome); + try { + connection.rollback(); + } catch (Throwable rollbackFailure) { + structured.addSuppressed(DataProviderExceptionMapper.transactionFailure( + rollbackFailure, executor, TRANSACTION_OPERATION, TransactionPhase.ROLLBACK, + ExecutionOutcome.UNKNOWN)); + } + return structured; } @Override @@ -179,8 +216,6 @@ public CompletableFuture executeInsert(String query, Object... params) { } throw new SQLException("Insert succeeded but no generated key was returned."); } - } catch (SQLException e) { - throw new RuntimeException("Failed to execute insert", e); } }); } From 8a1702be548170f551a43e28a138a7cf1396cc62 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:24:43 +0200 Subject: [PATCH 31/63] Add structured exception regression tests --- .../DataProviderExceptionMapperTest.java | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/exception/DataProviderExceptionMapperTest.java diff --git a/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/exception/DataProviderExceptionMapperTest.java b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/exception/DataProviderExceptionMapperTest.java new file mode 100644 index 0000000..647bea0 --- /dev/null +++ b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/exception/DataProviderExceptionMapperTest.java @@ -0,0 +1,100 @@ +package nl.hauntedmc.dataprovider.core.exception; + +import nl.hauntedmc.dataprovider.core.concurrent.ContextualExecutionHandle; +import nl.hauntedmc.dataprovider.core.concurrent.ExecutionHandle; +import nl.hauntedmc.dataprovider.core.concurrent.ExecutionRejectedException; +import nl.hauntedmc.dataprovider.database.DatabaseType; +import nl.hauntedmc.dataprovider.exception.BackendAuthenticationException; +import nl.hauntedmc.dataprovider.exception.DataConflictException; +import nl.hauntedmc.dataprovider.exception.DataProviderException; +import nl.hauntedmc.dataprovider.exception.DataProviderTimeoutException; +import nl.hauntedmc.dataprovider.exception.ExecutionOutcome; +import nl.hauntedmc.dataprovider.exception.ProviderClosedException; +import nl.hauntedmc.dataprovider.exception.QueueSaturatedException; +import nl.hauntedmc.dataprovider.exception.RetryAdvice; +import org.junit.jupiter.api.Test; + +import java.sql.SQLIntegrityConstraintViolationException; +import java.sql.SQLException; +import java.sql.SQLTimeoutException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DataProviderExceptionMapperTest { + + private final ExecutionHandle execution = new ContextualExecutionHandle( + ExecutionHandle.direct(), "test-plugin", DatabaseType.MYSQL, "main"); + + @Test + void queueRejectionIsSafeAndRetryable() { + DataProviderException mapped = DataProviderExceptionMapper.translate( + new ExecutionRejectedException( + ExecutionRejectedException.Reason.PLUGIN_QUEUE_LIMIT, + "internal detail"), + execution, + "mysql.queryForList" + ); + + QueueSaturatedException saturated = assertInstanceOf(QueueSaturatedException.class, mapped); + assertEquals(DatabaseType.MYSQL, saturated.backendType()); + assertEquals("main", saturated.connectionIdentifier()); + assertEquals("mysql.queryForList", saturated.operationName()); + assertEquals(RetryAdvice.SAFE, saturated.retryAdvice()); + assertEquals(ExecutionOutcome.NOT_STARTED, saturated.executionOutcome()); + assertEquals("PLUGIN_QUEUE_LIMIT", saturated.diagnostics().get("reason")); + assertNotNull(saturated.diagnosticId()); + assertFalse(saturated.getMessage().contains("internal detail")); + } + + @Test + void closureMapsToNonRetryableProviderFailure() { + ProviderClosedException closed = assertInstanceOf( + ProviderClosedException.class, + DataProviderExceptionMapper.translate( + new ExecutionRejectedException( + ExecutionRejectedException.Reason.SCOPE_CLOSED, + "scope detail"), + execution, + "mysql.executeUpdate") + ); + assertEquals(RetryAdvice.NEVER, closed.retryAdvice()); + assertEquals(ExecutionOutcome.NOT_STARTED, closed.executionOutcome()); + } + + @Test + void sqlFailuresAreClassifiedWithoutLeakingMessages() { + SQLException authentication = new SQLException( + "password=top-secret jdbc:mysql://secret-host", "28000", 1045); + BackendAuthenticationException auth = assertInstanceOf( + BackendAuthenticationException.class, + DataProviderExceptionMapper.translate(authentication, execution, "mysql.connect") + ); + assertFalse(auth.getMessage().contains("top-secret")); + assertFalse(auth.getCause().getMessage().contains("top-secret")); + assertEquals("28000", auth.diagnostics().get("sqlState"), + "Authentication diagnostics should retain safe SQL state when available."); + + DataConflictException conflict = assertInstanceOf( + DataConflictException.class, + DataProviderExceptionMapper.translate( + new SQLIntegrityConstraintViolationException("duplicate secret", "23000", 1062), + execution, + "mysql.executeInsert") + ); + assertEquals(ExecutionOutcome.NOT_APPLIED, conflict.executionOutcome()); + + DataProviderTimeoutException timeout = assertInstanceOf( + DataProviderTimeoutException.class, + DataProviderExceptionMapper.translate( + new SQLTimeoutException("write timed out", "HYT00"), + execution, + "mysql.executeUpdate") + ); + assertEquals(ExecutionOutcome.MAY_HAVE_APPLIED, timeout.executionOutcome()); + assertTrue(timeout.retryable()); + } +} From 453668cc15a286e194ee8dcdb5d3fb245a15c2e0 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:24:59 +0200 Subject: [PATCH 32/63] Test public exception safety contract --- .../exception/DataProviderExceptionTest.java | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 dataprovider-api/src/test/java/nl/hauntedmc/dataprovider/exception/DataProviderExceptionTest.java diff --git a/dataprovider-api/src/test/java/nl/hauntedmc/dataprovider/exception/DataProviderExceptionTest.java b/dataprovider-api/src/test/java/nl/hauntedmc/dataprovider/exception/DataProviderExceptionTest.java new file mode 100644 index 0000000..d46df29 --- /dev/null +++ b/dataprovider-api/src/test/java/nl/hauntedmc/dataprovider/exception/DataProviderExceptionTest.java @@ -0,0 +1,56 @@ +package nl.hauntedmc.dataprovider.exception; + +import nl.hauntedmc.dataprovider.database.DatabaseType; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class DataProviderExceptionTest { + + @Test + void contextIsImmutableAndMetadataIsStable() { + Map diagnostics = new HashMap<>(); + diagnostics.put("sqlState", "23000"); + DataProviderRegistrationException exception = new DataProviderRegistrationException( + "Registration failed safely.", + new DataProviderFailureContext( + DatabaseType.MYSQL, + "main", + "registerDatabase", + RetryAdvice.CONDITIONAL, + ExecutionOutcome.NOT_STARTED, + diagnostics, + null + ), + null + ); + diagnostics.put("sqlState", "changed"); + + assertEquals(DataProviderErrorCode.REGISTRATION_FAILED, exception.errorCode()); + assertEquals("23000", exception.diagnostics().get("sqlState")); + assertThrows(UnsupportedOperationException.class, + () -> exception.diagnostics().put("other", "value")); + assertNotNull(exception.diagnosticId()); + } + + @Test + void sensitiveDiagnosticKeysAreRejected() { + DataProviderFailureContext context = new DataProviderFailureContext( + DatabaseType.REDIS, + "cache", + "redis.getKey", + RetryAdvice.NEVER, + ExecutionOutcome.NOT_STARTED, + Map.of("password", "must-not-appear"), + null + ); + + assertThrows(IllegalArgumentException.class, + () -> new DataProviderRegistrationException("Safe message.", context, null)); + } +} From 784cc8b71be439bd2fcca14236b43819eb300012 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:26:30 +0200 Subject: [PATCH 33/63] Update async tests for structured failures --- .../core/concurrent/AsyncTaskSupportTest.java | 48 +++++++++---------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/concurrent/AsyncTaskSupportTest.java b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/concurrent/AsyncTaskSupportTest.java index dc7166b..4023533 100644 --- a/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/concurrent/AsyncTaskSupportTest.java +++ b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/concurrent/AsyncTaskSupportTest.java @@ -1,5 +1,9 @@ package nl.hauntedmc.dataprovider.core.concurrent; +import nl.hauntedmc.dataprovider.exception.BackendUnavailableException; +import nl.hauntedmc.dataprovider.exception.ExecutionOutcome; +import nl.hauntedmc.dataprovider.exception.QueueSaturatedException; +import nl.hauntedmc.dataprovider.exception.RetryAdvice; import org.junit.jupiter.api.Test; import java.util.concurrent.CompletableFuture; @@ -8,56 +12,50 @@ import java.util.concurrent.RejectedExecutionException; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; class AsyncTaskSupportTest { @Test void supplyAsyncRunsOnExecutorAndReturnsValue() { Executor directExecutor = Runnable::run; - CompletableFuture result = AsyncTaskSupport.supplyAsync( - directExecutor, - "unit.supply", - () -> 42 - ); - + directExecutor, "unit.supply", () -> 42); assertEquals(42, result.join()); } @Test - void runAsyncReturnsFailedFutureWhenExecutorRejects() { + void runAsyncReturnsStructuredFailureWhenExecutorRejects() { Executor rejectingExecutor = command -> { - throw new RejectedExecutionException("full"); + throw new RejectedExecutionException("full internal queue detail"); }; - CompletableFuture future = AsyncTaskSupport.runAsync( - rejectingExecutor, - "unit.reject", - () -> { - } - ); - - CompletionException ex = assertThrows(CompletionException.class, future::join); - assertTrue(ex.getCause() instanceof RejectedExecutionException); - assertTrue(ex.getCause().getMessage().contains("unit.reject")); + rejectingExecutor, "unit.reject", () -> { }); + + CompletionException completion = assertThrows(CompletionException.class, future::join); + QueueSaturatedException rejection = assertInstanceOf( + QueueSaturatedException.class, completion.getCause()); + assertEquals("unit.reject", rejection.operationName()); + assertEquals(RetryAdvice.SAFE, rejection.retryAdvice()); + assertEquals(ExecutionOutcome.NOT_STARTED, rejection.executionOutcome()); } @Test - void runAsyncPropagatesTaskFailures() { + void runAsyncRedactsAndStructuresTaskFailures() { Executor directExecutor = Runnable::run; - CompletableFuture future = AsyncTaskSupport.runAsync( directExecutor, "unit.failure", () -> { - throw new IllegalStateException("boom"); + throw new IllegalStateException("password=boom"); } ); - CompletionException ex = assertThrows(CompletionException.class, future::join); - assertTrue(ex.getCause() instanceof IllegalStateException); - assertEquals("boom", ex.getCause().getMessage()); + CompletionException completion = assertThrows(CompletionException.class, future::join); + BackendUnavailableException failure = assertInstanceOf( + BackendUnavailableException.class, completion.getCause()); + assertEquals("unit.failure", failure.operationName()); + assertEquals("java.lang.IllegalStateException", failure.diagnostics().get("causeType")); } } From 94668e818777540e505a570534395c7132c10a66 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:27:13 +0200 Subject: [PATCH 34/63] Update runtime rejection tests for public exceptions --- ...ProviderExecutionRuntimeHardeningTest.java | 34 +++++-------------- 1 file changed, 8 insertions(+), 26 deletions(-) diff --git a/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/concurrent/DataProviderExecutionRuntimeHardeningTest.java b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/concurrent/DataProviderExecutionRuntimeHardeningTest.java index 9ef674d..cd78281 100644 --- a/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/concurrent/DataProviderExecutionRuntimeHardeningTest.java +++ b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/concurrent/DataProviderExecutionRuntimeHardeningTest.java @@ -1,6 +1,7 @@ package nl.hauntedmc.dataprovider.core.concurrent; import nl.hauntedmc.dataprovider.database.DatabaseType; +import nl.hauntedmc.dataprovider.exception.QueueSaturatedException; import org.junit.jupiter.api.Test; import java.time.Duration; @@ -70,14 +71,10 @@ void exceptionalFuturePreservesStructuredRejectionReason() throws Exception { CompletableFuture rejected = AsyncTaskSupport.runAsync(scope, "rejected", () -> { }); CompletionException completion = org.junit.jupiter.api.Assertions.assertThrows( - CompletionException.class, - rejected::join - ); - ExecutionRejectedException rejection = assertInstanceOf( - ExecutionRejectedException.class, - completion.getCause() - ); - assertEquals(ExecutionRejectedException.Reason.CONNECTION_QUEUE_LIMIT, rejection.reason()); + CompletionException.class, rejected::join); + QueueSaturatedException rejection = assertInstanceOf( + QueueSaturatedException.class, completion.getCause()); + assertEquals("CONNECTION_QUEUE_LIMIT", rejection.diagnostics().get("reason")); release.countDown(); } } @@ -126,10 +123,7 @@ void interruptedWorkerDoesNotLeakInterruptFlagToNextTask() throws Exception { first.close(); CompletableFuture next = AsyncTaskSupport.supplyAsync( - second, - "check-interrupt", - () -> Thread.currentThread().isInterrupted() - ); + second, "check-interrupt", () -> Thread.currentThread().isInterrupted()); assertFalse(next.get(2, TimeUnit.SECONDS)); } } @@ -144,24 +138,12 @@ private static DataProviderExecutionRuntime runtime( long scopeGraceMs ) { ExecutionRuntimeConfig.LaneConfig lane = new ExecutionRuntimeConfig.LaneConfig( - workers, - queueCapacity, - pluginActive, - pluginQueue, - connectionActive, - connectionQueue - ); + workers, queueCapacity, pluginActive, pluginQueue, connectionActive, connectionQueue); EnumMap lanes = new EnumMap<>(ExecutionLane.class); for (ExecutionLane executionLane : ExecutionLane.values()) { lanes.put(executionLane, lane); } return new DataProviderExecutionRuntime(new ExecutionRuntimeConfig( - Map.copyOf(lanes), - Duration.ofMillis(scopeGraceMs), - Duration.ofMillis(250), - 16, - 8, - 4 - )); + Map.copyOf(lanes), Duration.ofMillis(scopeGraceMs), Duration.ofMillis(250), 16, 8, 4)); } } From 84708a7920df7dc99b8921592702681e41b711e0 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:27:50 +0200 Subject: [PATCH 35/63] Document structured exception handling --- docs/EXCEPTIONS.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 docs/EXCEPTIONS.md diff --git a/docs/EXCEPTIONS.md b/docs/EXCEPTIONS.md new file mode 100644 index 0000000..f63743c --- /dev/null +++ b/docs/EXCEPTIONS.md @@ -0,0 +1,48 @@ +# Structured exceptions + +DataProvider exposes unchecked structured failures from `nl.hauntedmc.dataprovider.exception`. + +## Strict and compatibility APIs + +Use `registerDatabaseOrThrow(...)` when startup must distinguish missing configuration, disabled backends, authentication failure, timeout, or backend unavailability. Use `requireRegisteredDatabase(...)` when absence is exceptional. + +The original `registerDatabase(...)` and optional helpers remain available for compatibility. They continue returning `null` or `Optional.empty()` and intentionally discard the failure category after DataProvider records it internally. + +## Common handling + +```java +try { + DatabaseProvider provider = api.registerDatabaseOrThrow(DatabaseType.MYSQL, "main"); +} catch (BackendAuthenticationException exception) { + // Configuration intervention is required; retrying unchanged credentials is not useful. +} catch (BackendUnavailableException exception) { + // Consult retryAdvice() and executionOutcome() before retrying. +} +``` + +All structured exceptions expose: + +- `errorCode()` — stable machine-readable category +- `backendType()` — backend involved, when applicable +- `connectionIdentifier()` — safe logical identifier, never a connection URL +- `operationName()` — stable operation identifier +- `retryAdvice()` — `NEVER`, `SAFE`, or `CONDITIONAL` +- `executionOutcome()` — whether the operation started or may already have applied +- `diagnostics()` — immutable allowlisted metadata +- `diagnosticId()` — correlation identifier for operational support + +## Retry safety + +`retryable()` is a convenience method. Prefer `retryAdvice()` and `executionOutcome()` for writes: + +- `SAFE` + `NOT_STARTED`: retrying is normally safe. +- `CONDITIONAL` + `MAY_HAVE_APPLIED`: do not retry blindly; use an idempotency key or verify backend state. +- `NEVER`: correct configuration, ownership, authentication, or lifecycle state first. + +A transaction commit timeout can mean the commit succeeded but its acknowledgement was lost. DataProvider reports this as `DataTransactionException` with phase `COMMIT` and outcome `MAY_HAVE_APPLIED`. + +## Redaction + +Public exception messages, diagnostics, and causes never include passwords, tokens, payloads, query parameter values, raw configuration, or credential-bearing URLs. Public causes preserve the original failure type through a redacted surrogate. Raw backend failures remain confined to DataProvider lifecycle diagnostics and internal logging. + +Rollback failures are attached as suppressed structured exceptions without replacing the primary transaction failure. From 89c5f3abe153307796fa1b0a47cc3a285fb910d2 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:28:30 +0200 Subject: [PATCH 36/63] Update factory missing configuration test --- .../dataprovider/core/DatabaseFactoryTest.java | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/DatabaseFactoryTest.java b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/DatabaseFactoryTest.java index f3706ae..9a31283 100644 --- a/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/DatabaseFactoryTest.java +++ b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/DatabaseFactoryTest.java @@ -1,17 +1,16 @@ package nl.hauntedmc.dataprovider.core; -import nl.hauntedmc.dataprovider.database.DatabaseProvider; -import nl.hauntedmc.dataprovider.database.DatabaseType; import nl.hauntedmc.dataprovider.core.database.document.impl.mongodb.MongoDBDatabase; import nl.hauntedmc.dataprovider.core.database.keyvalue.impl.redis.RedisDatabase; import nl.hauntedmc.dataprovider.core.database.messaging.impl.redis.RedisMessagingDatabase; import nl.hauntedmc.dataprovider.core.database.relational.impl.mysql.MySQLDatabase; +import nl.hauntedmc.dataprovider.core.exception.DataProviderExceptionMapper; import nl.hauntedmc.dataprovider.core.testutil.RecordingLoggerAdapter; +import nl.hauntedmc.dataprovider.database.DatabaseType; import org.junit.jupiter.api.Test; import org.spongepowered.configurate.CommentedConfigurationNode; import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; @@ -29,15 +28,14 @@ void constructorValidatesArguments() { } @Test - void returnsNullAndLogsWhenConfigurationIsMissing() { + void retainsTypedFailureAndLogsWhenConfigurationIsMissing() { RecordingLoggerAdapter logger = new RecordingLoggerAdapter(); DatabaseConfigMap configMap = mock(DatabaseConfigMap.class); when(configMap.getConfig(DatabaseType.MYSQL, ConnectionIdentifier.of("missing"))).thenReturn(null); DatabaseFactory factory = new DatabaseFactory(configMap, logger); - DatabaseProvider provider = factory.createDatabaseProvider(DatabaseType.MYSQL, "missing"); - - assertNull(provider); + assertThrows(DataProviderExceptionMapper.MissingConfigurationFailure.class, + () -> factory.createDatabaseProvider(DatabaseType.MYSQL, "missing")); assertTrue(logger.errorMessages().stream().anyMatch(m -> m.contains("Could not load configuration"))); } @@ -56,6 +54,7 @@ void createsProviderImplementationForEachDatabaseType() { assertInstanceOf(MySQLDatabase.class, factory.createDatabaseProvider(DatabaseType.MYSQL, "default")); assertInstanceOf(MongoDBDatabase.class, factory.createDatabaseProvider(DatabaseType.MONGODB, "default")); assertInstanceOf(RedisDatabase.class, factory.createDatabaseProvider(DatabaseType.REDIS, "default")); - assertInstanceOf(RedisMessagingDatabase.class, factory.createDatabaseProvider(DatabaseType.REDIS_MESSAGING, "default")); + assertInstanceOf(RedisMessagingDatabase.class, + factory.createDatabaseProvider(DatabaseType.REDIS_MESSAGING, "default")); } } From be744a080f2e4c8e2035b383d9557fece305e269 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:29:40 +0200 Subject: [PATCH 37/63] Restore transaction connection state --- .../impl/mysql/MySQLDataAccess.java | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/database/relational/impl/mysql/MySQLDataAccess.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/database/relational/impl/mysql/MySQLDataAccess.java index cafaac2..2aa9e79 100644 --- a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/database/relational/impl/mysql/MySQLDataAccess.java +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/database/relational/impl/mysql/MySQLDataAccess.java @@ -147,8 +147,10 @@ public CompletableFuture executeTransactionally(TransactionCallback ca private T executeTransaction(TransactionCallback callback) { Connection connection; + boolean oldAutoCommit; try { connection = dataSource.getConnection(); + oldAutoCommit = connection.getAutoCommit(); connection.setAutoCommit(false); } catch (Throwable beginFailure) { throw DataProviderExceptionMapper.transactionFailure( @@ -161,17 +163,29 @@ private T executeTransaction(TransactionCallback callback) { try { result = callback.doInTransaction(connection); } catch (Throwable callbackFailure) { - throw rollbackAfterFailure(connection, callbackFailure, TransactionPhase.CALLBACK, - ExecutionOutcome.NOT_APPLIED); + DataTransactionException structured = rollbackAfterFailure( + connection, callbackFailure, TransactionPhase.CALLBACK, ExecutionOutcome.NOT_APPLIED); + restoreAutoCommit(connection, oldAutoCommit, structured); + throw structured; } try { connection.commit(); - return result; } catch (Throwable commitFailure) { - throw rollbackAfterFailure(connection, commitFailure, TransactionPhase.COMMIT, - ExecutionOutcome.MAY_HAVE_APPLIED); + DataTransactionException structured = rollbackAfterFailure( + connection, commitFailure, TransactionPhase.COMMIT, ExecutionOutcome.MAY_HAVE_APPLIED); + restoreAutoCommit(connection, oldAutoCommit, structured); + throw structured; + } + + try { + connection.setAutoCommit(oldAutoCommit); + } catch (Throwable restoreFailure) { + throw DataProviderExceptionMapper.transactionFailure( + restoreFailure, executor, TRANSACTION_OPERATION, TransactionPhase.ROLLBACK, + ExecutionOutcome.UNKNOWN); } + return result; } catch (DataTransactionException structured) { throw structured; } catch (Throwable closeFailure) { @@ -199,6 +213,20 @@ private DataTransactionException rollbackAfterFailure( return structured; } + private void restoreAutoCommit( + Connection connection, + boolean oldAutoCommit, + DataTransactionException primary + ) { + try { + connection.setAutoCommit(oldAutoCommit); + } catch (Throwable restoreFailure) { + primary.addSuppressed(DataProviderExceptionMapper.transactionFailure( + restoreFailure, executor, TRANSACTION_OPERATION, TransactionPhase.ROLLBACK, + ExecutionOutcome.UNKNOWN)); + } + } + @Override public CompletableFuture executeInsert(String query, Object... params) { String sql = requireQuery(query); From cf4d20d1d1c3385eb08f7e424aef88dc0af47871 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:31:05 +0200 Subject: [PATCH 38/63] Add synchronous structured failure helpers --- .../core/exception/StructuredFailures.java | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/exception/StructuredFailures.java diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/exception/StructuredFailures.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/exception/StructuredFailures.java new file mode 100644 index 0000000..7eb2073 --- /dev/null +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/exception/StructuredFailures.java @@ -0,0 +1,77 @@ +package nl.hauntedmc.dataprovider.core.exception; + +import nl.hauntedmc.dataprovider.core.concurrent.ExecutionHandle; +import nl.hauntedmc.dataprovider.database.DatabaseType; +import nl.hauntedmc.dataprovider.exception.DataProviderFailureContext; +import nl.hauntedmc.dataprovider.exception.DataSerializationException; +import nl.hauntedmc.dataprovider.exception.ExecutionOutcome; +import nl.hauntedmc.dataprovider.exception.ProviderClosedException; +import nl.hauntedmc.dataprovider.exception.RetryAdvice; + +import java.util.Map; +import java.util.concurrent.Executor; + +/** Constructors for structured failures that occur before async submission. */ +public final class StructuredFailures { + + private StructuredFailures() { + } + + public static DataSerializationException serialization( + Throwable failure, + Executor executor, + String operationName + ) { + Context context = context(executor); + return new DataSerializationException( + "Data serialization or deserialization failed.", + new DataProviderFailureContext( + context.backendType, + context.connectionIdentifier, + operationName, + RetryAdvice.NEVER, + ExecutionOutcome.NOT_STARTED, + Map.of("causeType", failure.getClass().getName()), + null + ), + redactedCause(failure) + ); + } + + public static ProviderClosedException closed(Executor executor, String operationName) { + Context context = context(executor); + return new ProviderClosedException( + "The DataProvider provider is closed.", + new DataProviderFailureContext( + context.backendType, + context.connectionIdentifier, + operationName, + RetryAdvice.NEVER, + ExecutionOutcome.NOT_STARTED, + Map.of(), + null + ), + null + ); + } + + private static Context context(Executor executor) { + if (executor instanceof ExecutionHandle handle) { + return new Context(handle.backendType(), handle.connectionIdentifier()); + } + return new Context(null, null); + } + + private static Throwable redactedCause(Throwable failure) { + return new RedactedCause(failure.getClass().getName()); + } + + private record Context(DatabaseType backendType, String connectionIdentifier) { + } + + private static final class RedactedCause extends RuntimeException { + private RedactedCause(String type) { + super("Backend failure type: " + type, null, false, false); + } + } +} From 245f26aba25d947117da5729f709efaa0ddfe3eb Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:31:56 +0200 Subject: [PATCH 39/63] Structure MongoDB serialization failures --- .../impl/mongodb/MongoDBDataAccess.java | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/database/document/impl/mongodb/MongoDBDataAccess.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/database/document/impl/mongodb/MongoDBDataAccess.java index d88974f..480303a 100644 --- a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/database/document/impl/mongodb/MongoDBDataAccess.java +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/database/document/impl/mongodb/MongoDBDataAccess.java @@ -6,10 +6,12 @@ import com.mongodb.client.model.IndexOptions; import com.mongodb.client.model.UpdateOptions; import nl.hauntedmc.dataprovider.core.concurrent.AsyncTaskSupport; +import nl.hauntedmc.dataprovider.core.exception.StructuredFailures; import nl.hauntedmc.dataprovider.database.document.DocumentDataAccess; import nl.hauntedmc.dataprovider.database.document.model.DocumentQuery; import nl.hauntedmc.dataprovider.database.document.model.DocumentUpdate; import nl.hauntedmc.dataprovider.database.document.model.DocumentUpdateOptions; +import nl.hauntedmc.dataprovider.exception.DataSerializationException; import org.bson.Document; import org.bson.conversions.Bson; import org.bson.types.Binary; @@ -65,11 +67,23 @@ private UpdateOptions toMongoUpdateOptions(DocumentUpdateOptions options) { } private Document toMongoDocument(Map document) { - return copyDocument(document, "document"); + try { + return copyDocument(document, "document"); + } catch (DataSerializationException structured) { + throw structured; + } catch (RuntimeException failure) { + throw StructuredFailures.serialization(failure, executor, "mongodb.serialize"); + } } private Map documentToMap(Document document) { - return new LinkedHashMap<>(copyDocument(document, "document")); + try { + return new LinkedHashMap<>(copyDocument(document, "document")); + } catch (DataSerializationException structured) { + throw structured; + } catch (RuntimeException failure) { + throw StructuredFailures.serialization(failure, executor, "mongodb.deserialize"); + } } @Override From 255690718e9f0036f6f7906f55a5b6c0370c22b4 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:33:14 +0200 Subject: [PATCH 40/63] Structure Redis messaging failures --- .../impl/redis/RedisMessagingDataAccess.java | 39 +++++++++++++++---- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/database/messaging/impl/redis/RedisMessagingDataAccess.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/database/messaging/impl/redis/RedisMessagingDataAccess.java index 31a3d50..afba33e 100644 --- a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/database/messaging/impl/redis/RedisMessagingDataAccess.java +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/database/messaging/impl/redis/RedisMessagingDataAccess.java @@ -2,6 +2,9 @@ import nl.hauntedmc.dataprovider.core.concurrent.AsyncTaskSupport; import nl.hauntedmc.dataprovider.core.concurrent.ExecutionHandle; +import nl.hauntedmc.dataprovider.core.concurrent.ExecutionRejectedException; +import nl.hauntedmc.dataprovider.core.exception.DataProviderExceptionMapper; +import nl.hauntedmc.dataprovider.core.exception.StructuredFailures; import nl.hauntedmc.dataprovider.database.messaging.MessagingDataAccess; import nl.hauntedmc.dataprovider.database.messaging.api.EventMessage; import nl.hauntedmc.dataprovider.database.messaging.api.MessageRegistry; @@ -96,12 +99,21 @@ public CompletableFuture publish(String destinati String validatedDestination = validateDestination(destination); Objects.requireNonNull(message, "Message cannot be null"); if (shuttingDown.get()) { - return CompletableFuture.failedFuture(new IllegalStateException("Messaging provider is shutting down")); + return CompletableFuture.failedFuture(StructuredFailures.closed(workers, "redis.messaging.publish")); + } + final String json; + try { + json = messageRegistry.toJson(message); + } catch (RuntimeException failure) { + return CompletableFuture.failedFuture( + StructuredFailures.serialization(failure, workers, "redis.messaging.serialize")); } - String json = messageRegistry.toJson(message); if (json.length() > maxPayloadChars) { - return CompletableFuture.failedFuture(new IllegalArgumentException( - "Message payload exceeds maxPayloadChars (" + maxPayloadChars + ")")); + return CompletableFuture.failedFuture(StructuredFailures.serialization( + new IllegalArgumentException("Serialized message exceeds configured size limit."), + workers, + "redis.messaging.serialize" + )); } return AsyncTaskSupport.runAsync(workers, "redis.messaging.publish", () -> { try (Jedis jedis = pool.getResource()) { @@ -120,7 +132,7 @@ public Subscription subscribe( Objects.requireNonNull(type, "Type cannot be null"); Objects.requireNonNull(handler, "Handler cannot be null"); if (shuttingDown.get()) { - throw new IllegalStateException("Messaging provider is shutting down"); + throw StructuredFailures.closed(workers, "redis.messaging.subscribe"); } ChannelSubscription channelSubscription; @@ -129,11 +141,22 @@ public Subscription subscribe( channelSubscription = channelSubscriptions.get(validatedDestination); if (channelSubscription == null) { if (channelSubscriptions.size() >= maxSubscriptions) { - throw new IllegalStateException( - "Maximum active Redis subscriptions reached (" + maxSubscriptions + ")"); + throw DataProviderExceptionMapper.translate( + new ExecutionRejectedException( + ExecutionRejectedException.Reason.SUBSCRIPTION_LIMIT, + "Connection subscription limit reached."), + workers, + "redis.messaging.subscribe" + ); } if (executionBudget != null && !executionBudget.tryAcquireSubscription()) { - throw new IllegalStateException("DataProvider messaging subscription budget exhausted"); + throw DataProviderExceptionMapper.translate( + new ExecutionRejectedException( + ExecutionRejectedException.Reason.SUBSCRIPTION_LIMIT, + "Runtime subscription limit reached."), + workers, + "redis.messaging.subscribe" + ); } channelSubscription = new ChannelSubscription(validatedDestination, executionBudget != null); channelSubscriptions.put(validatedDestination, channelSubscription); From 0b4a227254d52f8c092cee228dcb3782ffa238be Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:35:00 +0200 Subject: [PATCH 41/63] Test structured MySQL failures --- .../impl/mysql/MySQLDataAccessTest.java | 133 ++++++++++-------- 1 file changed, 74 insertions(+), 59 deletions(-) diff --git a/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/database/relational/impl/mysql/MySQLDataAccessTest.java b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/database/relational/impl/mysql/MySQLDataAccessTest.java index 2fd89f0..f7fc6d6 100644 --- a/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/database/relational/impl/mysql/MySQLDataAccessTest.java +++ b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/database/relational/impl/mysql/MySQLDataAccessTest.java @@ -1,6 +1,10 @@ package nl.hauntedmc.dataprovider.core.database.relational.impl.mysql; import nl.hauntedmc.dataprovider.core.testutil.DirectExecutorService; +import nl.hauntedmc.dataprovider.exception.BackendUnavailableException; +import nl.hauntedmc.dataprovider.exception.DataTransactionException; +import nl.hauntedmc.dataprovider.exception.ExecutionOutcome; +import nl.hauntedmc.dataprovider.exception.TransactionPhase; import org.junit.jupiter.api.Test; import javax.sql.DataSource; @@ -12,7 +16,6 @@ import java.sql.Statement; import java.util.List; import java.util.Map; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -51,7 +54,6 @@ void queryForSingleMapsFirstRow() throws Exception { PreparedStatement statement = mock(PreparedStatement.class); ResultSet resultSet = mock(ResultSet.class); ResultSetMetaData metaData = mock(ResultSetMetaData.class); - when(dataSource.getConnection()).thenReturn(connection); when(connection.prepareStatement(anyString())).thenReturn(statement); when(statement.executeQuery()).thenReturn(resultSet); @@ -63,9 +65,8 @@ void queryForSingleMapsFirstRow() throws Exception { when(resultSet.getObject(1)).thenReturn(7); when(resultSet.getObject(2)).thenReturn("Remy"); - MySQLDataAccess access = new MySQLDataAccess(dataSource, new DirectExecutorService()); - Map row = access.queryForSingle("SELECT * FROM players WHERE id=?", 7).join(); - + Map row = new MySQLDataAccess(dataSource, new DirectExecutorService()) + .queryForSingle("SELECT * FROM players WHERE id=?", 7).join(); assertEquals(7, row.get("id")); assertEquals("Remy", row.get("name")); } @@ -80,9 +81,8 @@ void queryForSingleReturnsNullWhenNoRowsFound() throws Exception { when(connection.prepareStatement(anyString())).thenReturn(statement); when(statement.executeQuery()).thenReturn(resultSet); when(resultSet.next()).thenReturn(false); - - MySQLDataAccess access = new MySQLDataAccess(dataSource, new DirectExecutorService()); - assertNull(access.queryForSingle("SELECT 1").join()); + assertNull(new MySQLDataAccess(dataSource, new DirectExecutorService()) + .queryForSingle("SELECT 1").join()); } @Test @@ -92,7 +92,6 @@ void queryForListMapsAllRows() throws Exception { PreparedStatement statement = mock(PreparedStatement.class); ResultSet resultSet = mock(ResultSet.class); ResultSetMetaData metaData = mock(ResultSetMetaData.class); - when(dataSource.getConnection()).thenReturn(connection); when(connection.prepareStatement(anyString())).thenReturn(statement); when(statement.executeQuery()).thenReturn(resultSet); @@ -104,11 +103,9 @@ void queryForListMapsAllRows() throws Exception { when(resultSet.getObject(1)).thenReturn(1, 2); when(resultSet.getObject(2)).thenReturn("a", "b"); - MySQLDataAccess access = new MySQLDataAccess(dataSource, new DirectExecutorService()); - List> rows = access.queryForList("SELECT * FROM players").join(); - + List> rows = new MySQLDataAccess(dataSource, new DirectExecutorService()) + .queryForList("SELECT * FROM players").join(); assertEquals(2, rows.size()); - assertEquals(1, rows.get(0).get("id")); assertEquals("b", rows.get(1).get("name")); } @@ -118,7 +115,6 @@ void queryForSingleValueReturnsFirstColumnAndHandlesEmptyResult() throws Excepti Connection connection = mock(Connection.class); PreparedStatement statement = mock(PreparedStatement.class); ResultSet resultSet = mock(ResultSet.class); - when(dataSource.getConnection()).thenReturn(connection); when(connection.prepareStatement(anyString())).thenReturn(statement); when(statement.executeQuery()).thenReturn(resultSet); @@ -126,13 +122,9 @@ void queryForSingleValueReturnsFirstColumnAndHandlesEmptyResult() throws Excepti when(resultSet.getObject(1)).thenReturn("value"); MySQLDataAccess access = new MySQLDataAccess(dataSource, new DirectExecutorService()); - - Object value = access.queryForSingleValue("SELECT value FROM test").join(); - assertEquals("value", value); - + assertEquals("value", access.queryForSingleValue("SELECT value FROM test").join()); when(resultSet.next()).thenReturn(false); - Object missing = access.queryForSingleValue("SELECT value FROM test WHERE id=999").join(); - assertNull(missing); + assertNull(access.queryForSingleValue("SELECT value FROM test WHERE id=999").join()); } @Test @@ -143,8 +135,7 @@ void executeBatchUpdateAddsEachBatchEntry() throws Exception { when(dataSource.getConnection()).thenReturn(connection); when(connection.prepareStatement(anyString())).thenReturn(statement); - MySQLDataAccess access = new MySQLDataAccess(dataSource, new DirectExecutorService()); - access.executeBatchUpdate( + new MySQLDataAccess(dataSource, new DirectExecutorService()).executeBatchUpdate( "INSERT INTO test(a,b) VALUES (?,?)", List.of(new Object[]{1, "x"}, new Object[]{2, "y"}) ).join(); @@ -157,39 +148,66 @@ void executeBatchUpdateAddsEachBatchEntry() throws Exception { } @Test - void executeTransactionallyCommitsOnSuccessAndRollsBackOnFailure() throws Exception { + void executeTransactionallyCommitsAndRestoresConnectionState() throws Exception { DataSource dataSource = mock(DataSource.class); Connection connection = mock(Connection.class); when(dataSource.getConnection()).thenReturn(connection); when(connection.getAutoCommit()).thenReturn(true); - MySQLDataAccess access = new MySQLDataAccess(dataSource, new DirectExecutorService()); - String result = access.executeTransactionally(conn -> "done").join(); + String result = new MySQLDataAccess(dataSource, new DirectExecutorService()) + .executeTransactionally(conn -> "done").join(); assertEquals("done", result); verify(connection).setAutoCommit(false); verify(connection).commit(); verify(connection).setAutoCommit(true); + } - DataSource failingDataSource = mock(DataSource.class); - Connection failingConnection = mock(Connection.class); - when(failingDataSource.getConnection()).thenReturn(failingConnection); - when(failingConnection.getAutoCommit()).thenReturn(false); - MySQLDataAccess failingAccess = new MySQLDataAccess(failingDataSource, new DirectExecutorService()); - - CompletionException ex = assertThrows( - CompletionException.class, - () -> failingAccess.executeTransactionally(conn -> { - throw new IllegalStateException("boom"); - }).join() - ); - - assertInstanceOf(RuntimeException.class, ex.getCause()); - verify(failingConnection).rollback(); - verify(failingConnection, times(2)).setAutoCommit(false); + @Test + void callbackAndRollbackFailuresRetainPrimaryTransactionFailure() throws Exception { + DataSource dataSource = mock(DataSource.class); + Connection connection = mock(Connection.class); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.getAutoCommit()).thenReturn(false); + org.mockito.Mockito.doThrow(new SQLException("rollback secret")).when(connection).rollback(); + + CompletionException completion = assertThrows(CompletionException.class, + () -> new MySQLDataAccess(dataSource, new DirectExecutorService()) + .executeTransactionally(conn -> { + throw new IllegalStateException("callback secret"); + }).join()); + DataTransactionException transaction = assertInstanceOf( + DataTransactionException.class, completion.getCause()); + assertEquals(TransactionPhase.CALLBACK, transaction.phase()); + assertEquals(ExecutionOutcome.NOT_APPLIED, transaction.executionOutcome()); + assertEquals(1, transaction.getSuppressed().length); + assertEquals(TransactionPhase.ROLLBACK, + assertInstanceOf(DataTransactionException.class, transaction.getSuppressed()[0]).phase()); + verify(connection).rollback(); + verify(connection, times(2)).setAutoCommit(false); + } + + @Test + void commitFailureReportsUnknownWriteOutcome() throws Exception { + DataSource dataSource = mock(DataSource.class); + Connection connection = mock(Connection.class); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.getAutoCommit()).thenReturn(true); + org.mockito.Mockito.doThrow(new SQLException("commit lost", "08006")).when(connection).commit(); + + CompletionException completion = assertThrows(CompletionException.class, + () -> new MySQLDataAccess(dataSource, new DirectExecutorService()) + .executeTransactionally(conn -> "done").join()); + DataTransactionException transaction = assertInstanceOf( + DataTransactionException.class, completion.getCause()); + assertEquals(TransactionPhase.COMMIT, transaction.phase()); + assertEquals(ExecutionOutcome.MAY_HAVE_APPLIED, transaction.executionOutcome()); + assertTrue(transaction.retryable()); + verify(connection).rollback(); + verify(connection).setAutoCommit(true); } @Test - void executeInsertReturnsGeneratedKeyAndFailsWhenNoneReturned() throws Exception { + void executeInsertReturnsGeneratedKeyAndStructuresFailure() throws Exception { DataSource dataSource = mock(DataSource.class); Connection connection = mock(Connection.class); PreparedStatement statement = mock(PreparedStatement.class); @@ -203,28 +221,25 @@ void executeInsertReturnsGeneratedKeyAndFailsWhenNoneReturned() throws Exception when(generatedKeys.getObject(1)).thenReturn(42L); MySQLDataAccess access = new MySQLDataAccess(dataSource, new DirectExecutorService()); - Object key = access.executeInsert("INSERT INTO players(name) VALUES (?)", "test").join(); - assertEquals(42L, key); + assertEquals(42L, access.executeInsert("INSERT INTO players(name) VALUES (?)", "test").join()); when(statement.executeUpdate()).thenReturn(0); - CompletionException ex = assertThrows( - CompletionException.class, - () -> access.executeInsert("INSERT INTO players(name) VALUES (?)", "test").join() - ); - assertTrue(ex.getCause().getMessage().contains("Failed to execute insert")); + CompletionException completion = assertThrows(CompletionException.class, + () -> access.executeInsert("INSERT INTO players(name) VALUES (?)", "test").join()); + assertInstanceOf(BackendUnavailableException.class, completion.getCause()); } @Test - void wrapsSqlExceptionsInRuntimeExceptions() throws Exception { + void sqlExceptionsCompleteWithStructuredBackendFailure() throws Exception { DataSource dataSource = mock(DataSource.class); - when(dataSource.getConnection()).thenThrow(new SQLException("no connection")); - MySQLDataAccess access = new MySQLDataAccess(dataSource, new DirectExecutorService()); - - CompletionException ex = assertThrows( - CompletionException.class, - () -> access.executeUpdate("UPDATE test SET value=1").join() - ); - assertInstanceOf(RuntimeException.class, ex.getCause()); - assertTrue(ex.getCause().getMessage().contains("Failed to execute update")); + when(dataSource.getConnection()).thenThrow(new SQLException("password=secret", "08001")); + + CompletionException completion = assertThrows(CompletionException.class, + () -> new MySQLDataAccess(dataSource, new DirectExecutorService()) + .executeUpdate("UPDATE test SET value=1").join()); + BackendUnavailableException failure = assertInstanceOf( + BackendUnavailableException.class, completion.getCause()); + assertEquals("08001", failure.diagnostics().get("sqlState")); + assertTrue(!failure.getMessage().contains("secret")); } } From 91ffeeadfe22bff663647866c84c48522b1081ca Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 21 Jul 2026 23:35:57 +0200 Subject: [PATCH 42/63] Retain safe SQL diagnostics consistently --- .../DataProviderExceptionMapper.java | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/exception/DataProviderExceptionMapper.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/exception/DataProviderExceptionMapper.java index 980149b..90fd2b8 100644 --- a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/exception/DataProviderExceptionMapper.java +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/exception/DataProviderExceptionMapper.java @@ -61,40 +61,38 @@ public static DataProviderException translate(Throwable failure, Executor execut case RUNTIME_SHUTTING_DOWN, SCOPE_CLOSED -> new ProviderClosedException( "The DataProvider execution scope is closed.", context(backend, connection, operationName, RetryAdvice.NEVER, - ExecutionOutcome.NOT_STARTED, diagnostics), - safeCause(root)); + ExecutionOutcome.NOT_STARTED, diagnostics), safeCause(root)); case LANE_QUEUE_FULL, PLUGIN_QUEUE_LIMIT, CONNECTION_QUEUE_LIMIT, SUBSCRIPTION_LIMIT -> new QueueSaturatedException( "DataProvider execution capacity is currently exhausted.", context(backend, connection, operationName, RetryAdvice.SAFE, - ExecutionOutcome.NOT_STARTED, diagnostics), - safeCause(root)); + ExecutionOutcome.NOT_STARTED, diagnostics), safeCause(root)); }; } if (isConflict(root)) { return new DataConflictException( "The operation conflicted with existing backend state.", context(backend, connection, operationName, RetryAdvice.NEVER, - ExecutionOutcome.NOT_APPLIED, sqlDiagnostics(root)), safeCause(root)); + ExecutionOutcome.NOT_APPLIED, diagnosticsFor(root)), safeCause(root)); } if (isAuthenticationFailure(root)) { return new BackendAuthenticationException( "The backend rejected DataProvider authentication.", context(backend, connection, operationName, RetryAdvice.NEVER, - ExecutionOutcome.NOT_STARTED, safeClassDiagnostics(root)), safeCause(root)); + ExecutionOutcome.NOT_STARTED, diagnosticsFor(root)), safeCause(root)); } if (isTimeout(root)) { return new DataProviderTimeoutException( "The backend operation timed out.", context(backend, connection, operationName, RetryAdvice.CONDITIONAL, isReadOperation(operationName) ? ExecutionOutcome.UNKNOWN : ExecutionOutcome.MAY_HAVE_APPLIED, - safeClassDiagnostics(root)), safeCause(root)); + diagnosticsFor(root)), safeCause(root)); } if (isSerializationFailure(root)) { return new DataSerializationException( "Data serialization or deserialization failed.", context(backend, connection, operationName, RetryAdvice.NEVER, - ExecutionOutcome.NOT_STARTED, safeClassDiagnostics(root)), safeCause(root)); + ExecutionOutcome.NOT_STARTED, diagnosticsFor(root)), safeCause(root)); } if (isUnavailable(root)) { return unavailable(backend, connection, operationName, root); @@ -102,7 +100,7 @@ public static DataProviderException translate(Throwable failure, Executor execut return new BackendUnavailableException( DataProviderErrorCode.BACKEND_UNAVAILABLE, "The backend operation failed.", - base.withDiagnostics(safeClassDiagnostics(root)), safeCause(root)); + base.withDiagnostics(diagnosticsFor(root)), safeCause(root)); } public static DataProviderException registrationFailure( @@ -122,7 +120,7 @@ public static DataProviderException registrationFailure( DataProviderErrorCode.CONFIGURATION_MISSING, "No configuration exists for the requested database connection.", context(backend, connectionIdentifier, operationName, RetryAdvice.NEVER, - ExecutionOutcome.NOT_STARTED, safeClassDiagnostics(root)), safeCause(root)); + ExecutionOutcome.NOT_STARTED, diagnosticsFor(root)), safeCause(root)); } return translate(root, new RegistrationExecutionHandle(backend, connectionIdentifier), operationName); } @@ -141,7 +139,7 @@ public static DataProviderConfigurationException configurationFailure(Throwable DataProviderErrorCode.CONFIGURATION_INVALID, "DataProvider configuration is invalid.", context(null, null, operationName, RetryAdvice.NEVER, - ExecutionOutcome.NOT_STARTED, safeClassDiagnostics(root)), safeCause(root)); + ExecutionOutcome.NOT_STARTED, diagnosticsFor(root)), safeCause(root)); } public static DataTransactionException transactionFailure( @@ -172,7 +170,7 @@ private static BackendUnavailableException unavailable( DataProviderErrorCode.BACKEND_UNAVAILABLE, "The configured backend is unavailable.", context(backend, connection, operationName, RetryAdvice.CONDITIONAL, - ExecutionOutcome.UNKNOWN, safeClassDiagnostics(root)), safeCause(root)); + ExecutionOutcome.UNKNOWN, diagnosticsFor(root)), safeCause(root)); } private static DataProviderFailureContext context( @@ -258,8 +256,11 @@ private static Map rejectionDiagnostics( return Map.copyOf(diagnostics); } - private static Map sqlDiagnostics(Throwable failure) { - LinkedHashMap diagnostics = new LinkedHashMap<>(safeClassDiagnostics(failure)); + private static Map diagnosticsFor(Throwable failure) { + LinkedHashMap diagnostics = new LinkedHashMap<>(); + if (failure != null) { + diagnostics.put("causeType", failure.getClass().getName()); + } if (failure instanceof SQLException sql) { if (sql.getSQLState() != null && !sql.getSQLState().isBlank()) { diagnostics.put("sqlState", sql.getSQLState()); @@ -269,10 +270,6 @@ private static Map sqlDiagnostics(Throwable failure) { return Map.copyOf(diagnostics); } - private static Map safeClassDiagnostics(Throwable failure) { - return failure == null ? Map.of() : Map.of("causeType", failure.getClass().getName()); - } - private static Throwable safeCause(Throwable failure) { return failure == null ? null : new SafeBackendCause(failure.getClass().getName()); } From f20dbfb9dd9b38d84a0ae02db50522d823ef1d8e Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 11:29:08 +0200 Subject: [PATCH 43/63] Improve CI test failure diagnostics --- .github/workflows/ci-tests-and-coverage.yml | 26 +-------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/.github/workflows/ci-tests-and-coverage.yml b/.github/workflows/ci-tests-and-coverage.yml index f91d7d3..1eb3ea8 100644 --- a/.github/workflows/ci-tests-and-coverage.yml +++ b/.github/workflows/ci-tests-and-coverage.yml @@ -22,28 +22,4 @@ jobs: - name: Checkout uses: actions/checkout@v7 - - name: Set up JDK 25 - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: "25" - cache: maven - - - name: Run Unit and Backend Integration Tests - run: mvn -U -B -ntp -Pintegration-tests verify - - - name: Upload JaCoCo Report - if: always() - uses: actions/upload-artifact@v7 - with: - name: jacoco-report - if-no-files-found: error - path: "**/target/site/jacoco" - - - name: Upload Integration Test Reports - if: always() - uses: actions/upload-artifact@v7 - with: - name: integration-test-reports - if-no-files-found: error - path: "**/target/failsafe-reports" + - name: Set up JDK 25 \ No newline at end of file From 1a7e6b90d1147e63c1891893732286a47b5149b4 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 11:30:14 +0200 Subject: [PATCH 44/63] Restore complete test workflow with failure diagnostics --- .github/workflows/ci-tests-and-coverage.yml | 37 ++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-tests-and-coverage.yml b/.github/workflows/ci-tests-and-coverage.yml index 1eb3ea8..6c6691b 100644 --- a/.github/workflows/ci-tests-and-coverage.yml +++ b/.github/workflows/ci-tests-and-coverage.yml @@ -22,4 +22,39 @@ jobs: - name: Checkout uses: actions/checkout@v7 - - name: Set up JDK 25 \ No newline at end of file + - name: Set up JDK 25 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "25" + cache: maven + + - name: Run Unit and Backend Integration Tests + id: tests + run: mvn -U -B -ntp -Pintegration-tests verify + + - name: Print failing test reports + if: failure() && steps.tests.outcome == 'failure' + shell: bash + run: | + find . -type f \( -path '*/target/surefire-reports/*.txt' -o -path '*/target/failsafe-reports/*.txt' \) -print0 \ + | sort -z \ + | xargs -0 -r -n1 sh -c 'echo "::group::$0"; cat "$0"; echo "::endgroup::"' + + - name: Upload JaCoCo Report + if: always() + uses: actions/upload-artifact@v7 + with: + name: jacoco-report + if-no-files-found: warn + path: "**/target/site/jacoco" + + - name: Upload Test Reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-reports + if-no-files-found: warn + path: | + **/target/surefire-reports + **/target/failsafe-reports From d44b83c7c9a4190795e211d9e8ffaeff5c8a3bba Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 11:33:33 +0200 Subject: [PATCH 45/63] Preserve caller validation failures in async operations --- .../core/concurrent/AsyncTaskSupport.java | 51 ++++++++++++++----- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/concurrent/AsyncTaskSupport.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/concurrent/AsyncTaskSupport.java index eb8c199..9d4283d 100644 --- a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/concurrent/AsyncTaskSupport.java +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/concurrent/AsyncTaskSupport.java @@ -1,8 +1,10 @@ package nl.hauntedmc.dataprovider.core.concurrent; import nl.hauntedmc.dataprovider.core.exception.DataProviderExceptionMapper; +import nl.hauntedmc.dataprovider.exception.DataProviderException; import java.util.Objects; +import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; import java.util.concurrent.RejectedExecutionException; @@ -61,18 +63,18 @@ public void run() { future.complete(supplier.get()); } catch (Throwable throwable) { failed = true; - future.completeExceptionally( - DataProviderExceptionMapper.translate(throwable, executor, operationName) - ); + future.completeExceptionally(mapFailure(throwable, executor, operationName)); } } @Override public void reject(RejectedExecutionException rejection) { failed = true; - future.completeExceptionally( - DataProviderExceptionMapper.translate(rejection, executor, operationName) - ); + future.completeExceptionally(DataProviderExceptionMapper.translate( + rejection, + executor, + operationName + )); } @Override @@ -82,18 +84,39 @@ public boolean failed() { }; try { executor.execute(task); - } catch (ExecutionRejectedException e) { - future.completeExceptionally(DataProviderExceptionMapper.translate(e, executor, operationName)); - } catch (RejectedExecutionException e) { - ExecutionRejectedException rejection = new ExecutionRejectedException( + } catch (ExecutionRejectedException rejection) { + future.completeExceptionally(DataProviderExceptionMapper.translate( + rejection, + executor, + operationName + )); + } catch (RejectedExecutionException rejection) { + ExecutionRejectedException structuredRejection = new ExecutionRejectedException( ExecutionRejectedException.Reason.LANE_QUEUE_FULL, "Rejected async operation '" + operationName + "'.", - e + rejection ); - future.completeExceptionally(DataProviderExceptionMapper.translate(rejection, executor, operationName)); - } catch (RuntimeException e) { - future.completeExceptionally(DataProviderExceptionMapper.translate(e, executor, operationName)); + future.completeExceptionally(DataProviderExceptionMapper.translate( + structuredRejection, + executor, + operationName + )); + } catch (RuntimeException failure) { + future.completeExceptionally(mapFailure(failure, executor, operationName)); } return future; } + + private static Throwable mapFailure(Throwable failure, Executor executor, String operationName) { + if (failure instanceof DataProviderException + || failure instanceof IllegalArgumentException + || failure instanceof NullPointerException + || failure instanceof UnsupportedOperationException + || failure instanceof SecurityException + || failure instanceof CancellationException + || failure instanceof Error) { + return failure; + } + return DataProviderExceptionMapper.translate(failure, executor, operationName); + } } From 1667f8bd6818268f5582df1bab5fa9d477abdbe3 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 11:35:58 +0200 Subject: [PATCH 46/63] Preserve legacy lifecycle behavior and simplify handler paths --- .../core/DataProviderHandler.java | 289 +++++++++++------- 1 file changed, 181 insertions(+), 108 deletions(-) diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/DataProviderHandler.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/DataProviderHandler.java index 4114b22..6990d5b 100644 --- a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/DataProviderHandler.java +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/DataProviderHandler.java @@ -48,8 +48,10 @@ public DataProviderHandler( Objects.requireNonNull(resourceClassLoader, "Resource class loader cannot be null."); Objects.requireNonNull(configHandler, "Config handler cannot be null."); this.logger = Objects.requireNonNull(logger, "Logger cannot be null."); - this.callerContextResolver = Objects.requireNonNull(callerContextResolver, - "Caller context resolver cannot be null."); + this.callerContextResolver = Objects.requireNonNull( + callerContextResolver, + "Caller context resolver cannot be null." + ); ownClassLoader = resourceClassLoader; DatabaseConfigMap configMap = new DatabaseConfigMap(dataPath, this.logger, resourceClassLoader); executionRuntime = new DataProviderExecutionRuntime(ExecutionRuntimeConfig.from(configHandler.getConfig())); @@ -65,27 +67,35 @@ public DataProviderHandler( ) { this.logger = Objects.requireNonNull(logger, "Logger cannot be null."); this.registry = Objects.requireNonNull(registry, "Registry cannot be null."); - this.callerContextResolver = Objects.requireNonNull(callerContextResolver, - "Caller context resolver cannot be null."); + this.callerContextResolver = Objects.requireNonNull( + callerContextResolver, + "Caller context resolver cannot be null." + ); this.ownClassLoader = Objects.requireNonNull(ownClassLoader, "Own class loader cannot be null."); executionRuntime = null; } public DatabaseProvider registerDatabase(DatabaseType databaseType, String connectionIdentifier) { - requireOpen(); - Objects.requireNonNull(databaseType, "Database type cannot be null"); - PluginId pluginId = PluginId.of(resolveCallerContext().pluginId()); - ConnectionIdentifier identifier = ConnectionIdentifier.of(connectionIdentifier); - return registerLegacy(pluginId, OwnerScopeId.of(pluginId.value()), databaseType, identifier); + requireLegacyOpen(); + PluginId pluginId = resolvePluginId(); + return registerLegacy( + pluginId, + OwnerScopeId.of(pluginId.value()), + requireType(databaseType), + ConnectionIdentifier.of(connectionIdentifier) + ); } public DatabaseProvider registerDatabaseOrThrow(DatabaseType databaseType, String connectionIdentifier) { - requireOpen(); - Objects.requireNonNull(databaseType, "Database type cannot be null"); - PluginId pluginId = PluginId.of(resolveCallerContext().pluginId()); - ConnectionIdentifier identifier = ConnectionIdentifier.of(connectionIdentifier); - return registerStrict(pluginId, OwnerScopeId.of(pluginId.value()), databaseType, identifier, - "registerDatabase"); + requireStructuredOpen("registerDatabase"); + PluginId pluginId = resolvePluginId(); + return registerStrict( + pluginId, + OwnerScopeId.of(pluginId.value()), + requireType(databaseType), + ConnectionIdentifier.of(connectionIdentifier), + "registerDatabase" + ); } public DatabaseProvider registerDatabaseForScope( @@ -101,12 +111,14 @@ public DatabaseProvider registerDatabaseForScope( DatabaseType databaseType, String connectionIdentifier ) { - requireOpen(); - Objects.requireNonNull(databaseType, "Database type cannot be null"); - Objects.requireNonNull(ownerScope, "Owner scope cannot be null."); - PluginId pluginId = PluginId.of(resolveCallerContext().pluginId()); - return registerLegacy(pluginId, OwnerScopeId.from(ownerScope), databaseType, - ConnectionIdentifier.of(connectionIdentifier)); + requireLegacyOpen(); + PluginId pluginId = resolvePluginId(); + return registerLegacy( + pluginId, + OwnerScopeId.from(Objects.requireNonNull(ownerScope, "Owner scope cannot be null.")), + requireType(databaseType), + ConnectionIdentifier.of(connectionIdentifier) + ); } public DatabaseProvider registerDatabaseForScopeOrThrow( @@ -114,50 +126,26 @@ public DatabaseProvider registerDatabaseForScopeOrThrow( DatabaseType databaseType, String connectionIdentifier ) { - requireOpen(); - Objects.requireNonNull(databaseType, "Database type cannot be null"); - Objects.requireNonNull(ownerScope, "Owner scope cannot be null."); - PluginId pluginId = PluginId.of(resolveCallerContext().pluginId()); - return registerStrict(pluginId, OwnerScopeId.from(ownerScope), databaseType, - ConnectionIdentifier.of(connectionIdentifier), "scope.registerDatabase"); - } - - private DatabaseProvider registerLegacy( - PluginId pluginId, - OwnerScopeId ownerScope, - DatabaseType type, - ConnectionIdentifier identifier - ) { - return DatabaseFactory.withCreationPlugin(pluginId, - () -> registry.registerDatabase(pluginId, ownerScope, type, identifier)); - } - - private DatabaseProvider registerStrict( - PluginId pluginId, - OwnerScopeId ownerScope, - DatabaseType type, - ConnectionIdentifier identifier, - String operation - ) { - DatabaseProvider provider = registerLegacy(pluginId, ownerScope, type, identifier); - if (provider != null) { - return provider; - } - if (!registry.getConfiguredDatabaseTypeStates().getOrDefault(type, true)) { - throw DataProviderExceptionMapper.backendDisabled(type, identifier.value()); - } - DatabaseConnectionKey key = new DatabaseConnectionKey(pluginId.value(), type, identifier.value()); - ProviderLifecycleSnapshot snapshot = registry.getProviderLifecycleSnapshots().get(key); - Throwable failure = snapshot == null ? null : snapshot.failure(); - throw DataProviderExceptionMapper.registrationFailure(failure, type, identifier.value(), operation); + requireStructuredOpen("scope.registerDatabase"); + PluginId pluginId = resolvePluginId(); + return registerStrict( + pluginId, + OwnerScopeId.from(Objects.requireNonNull(ownerScope, "Owner scope cannot be null.")), + requireType(databaseType), + ConnectionIdentifier.of(connectionIdentifier), + "scope.registerDatabase" + ); } public void unregisterDatabase(DatabaseType databaseType, String connectionIdentifier) { - requireOpen(); - Objects.requireNonNull(databaseType, "Database type cannot be null"); - PluginId pluginId = PluginId.of(resolveCallerContext().pluginId()); - registry.unregisterDatabase(pluginId, OwnerScopeId.of(pluginId.value()), databaseType, - ConnectionIdentifier.of(connectionIdentifier)); + requireLegacyOpen(); + PluginId pluginId = resolvePluginId(); + registry.unregisterDatabase( + pluginId, + OwnerScopeId.of(pluginId.value()), + requireType(databaseType), + ConnectionIdentifier.of(connectionIdentifier) + ); } public void unregisterDatabaseForScope(String ownerScope, DatabaseType databaseType, String connectionIdentifier) { @@ -169,17 +157,18 @@ public void unregisterDatabaseForScope( DatabaseType databaseType, String connectionIdentifier ) { - requireOpen(); - Objects.requireNonNull(databaseType, "Database type cannot be null"); - Objects.requireNonNull(ownerScope, "Owner scope cannot be null."); - PluginId pluginId = PluginId.of(resolveCallerContext().pluginId()); - registry.unregisterDatabase(pluginId, OwnerScopeId.from(ownerScope), databaseType, - ConnectionIdentifier.of(connectionIdentifier)); + requireLegacyOpen(); + registry.unregisterDatabase( + resolvePluginId(), + OwnerScopeId.from(Objects.requireNonNull(ownerScope, "Owner scope cannot be null.")), + requireType(databaseType), + ConnectionIdentifier.of(connectionIdentifier) + ); } public void unregisterAllDatabases() { - requireOpen(); - PluginId pluginId = PluginId.of(resolveCallerContext().pluginId()); + requireLegacyOpen(); + PluginId pluginId = resolvePluginId(); registry.unregisterAllDatabases(pluginId, OwnerScopeId.of(pluginId.value())); } @@ -188,15 +177,16 @@ public void unregisterAllDatabasesForScope(String ownerScope) { } public void unregisterAllDatabasesForScope(OwnerScope ownerScope) { - requireOpen(); - Objects.requireNonNull(ownerScope, "Owner scope cannot be null."); - PluginId pluginId = PluginId.of(resolveCallerContext().pluginId()); - registry.unregisterAllDatabases(pluginId, OwnerScopeId.from(ownerScope)); + requireLegacyOpen(); + registry.unregisterAllDatabases( + resolvePluginId(), + OwnerScopeId.from(Objects.requireNonNull(ownerScope, "Owner scope cannot be null.")) + ); } public void unregisterAllDatabasesForPlugin() { - requireOpen(); - registry.unregisterAllDatabasesForPlugin(PluginId.of(resolveCallerContext().pluginId())); + requireLegacyOpen(); + registry.unregisterAllDatabasesForPlugin(resolvePluginId()); } public void shutdownAllDatabases() { @@ -211,18 +201,27 @@ public void shutdownAllDatabases() { } public DatabaseProvider getRegisteredDatabase(DatabaseType databaseType, String connectionIdentifier) { - requireOpen(); - Objects.requireNonNull(databaseType, "Database type cannot be null"); - return registry.getDatabase(PluginId.of(resolveCallerContext().pluginId()), databaseType, - ConnectionIdentifier.of(connectionIdentifier)); + requireLegacyOpen(); + return registry.getDatabase( + resolvePluginId(), + requireType(databaseType), + ConnectionIdentifier.of(connectionIdentifier) + ); } public DatabaseProvider requireRegisteredDatabase(DatabaseType databaseType, String connectionIdentifier) { - DatabaseProvider provider = getRegisteredDatabase(databaseType, connectionIdentifier); + requireStructuredOpen("requireRegisteredDatabase"); + DatabaseType type = requireType(databaseType); + String identifier = ConnectionIdentifier.of(connectionIdentifier).value(); + DatabaseProvider provider = registry.getDatabase( + resolvePluginId(), + type, + ConnectionIdentifier.of(identifier) + ); if (provider != null) { return provider; } - throw missingRegistration(databaseType, connectionIdentifier, "requireRegisteredDatabase"); + throw missingRegistration(type, identifier, "requireRegisteredDatabase"); } public DatabaseProvider getRegisteredDatabaseForScope( @@ -230,11 +229,13 @@ public DatabaseProvider getRegisteredDatabaseForScope( DatabaseType databaseType, String connectionIdentifier ) { - requireOpen(); - Objects.requireNonNull(ownerScope, "Owner scope cannot be null."); - Objects.requireNonNull(databaseType, "Database type cannot be null"); - return registry.getDatabase(PluginId.of(resolveCallerContext().pluginId()), OwnerScopeId.from(ownerScope), - databaseType, ConnectionIdentifier.of(connectionIdentifier)); + requireLegacyOpen(); + return registry.getDatabase( + resolvePluginId(), + OwnerScopeId.from(Objects.requireNonNull(ownerScope, "Owner scope cannot be null.")), + requireType(databaseType), + ConnectionIdentifier.of(connectionIdentifier) + ); } public DatabaseProvider requireRegisteredDatabaseForScope( @@ -242,59 +243,59 @@ public DatabaseProvider requireRegisteredDatabaseForScope( DatabaseType databaseType, String connectionIdentifier ) { - DatabaseProvider provider = getRegisteredDatabaseForScope(ownerScope, databaseType, connectionIdentifier); + requireStructuredOpen("scope.requireRegisteredDatabase"); + DatabaseType type = requireType(databaseType); + ConnectionIdentifier identifier = ConnectionIdentifier.of(connectionIdentifier); + DatabaseProvider provider = registry.getDatabase( + resolvePluginId(), + OwnerScopeId.from(Objects.requireNonNull(ownerScope, "Owner scope cannot be null.")), + type, + identifier + ); if (provider != null) { return provider; } - throw missingRegistration(databaseType, connectionIdentifier, "scope.requireRegisteredDatabase"); - } - - private DataProviderRegistrationException missingRegistration( - DatabaseType type, String identifier, String operation) { - return new DataProviderRegistrationException( - "No active database registration exists for the requested connection.", - DataProviderFailureContext.of(type, identifier, operation, RetryAdvice.NEVER, - ExecutionOutcome.NOT_STARTED), null); + throw missingRegistration(type, identifier.value(), "scope.requireRegisteredDatabase"); } public ConcurrentMap getActiveDatabases() { - requireOpen(); + requireLegacyOpen(); requireInternalCaller(); return registry.getActiveDatabases(); } public Map getActiveDatabaseReferenceCounts() { - requireOpen(); + requireLegacyOpen(); requireInternalCaller(); return registry.getActiveDatabaseReferenceCounts(); } public Map getCachedDatabaseHealth() { - requireOpen(); + requireLegacyOpen(); requireInternalCaller(); return registry.getCachedHealthSnapshots(); } public CompletableFuture probeDatabaseHealthAsync() { - requireOpen(); + requireLegacyOpen(); requireInternalCaller(); return registry.probeRemoteHealthAsync(); } public Map getConfiguredDatabaseTypeStates() { - requireOpen(); + requireLegacyOpen(); requireInternalCaller(); return registry.getConfiguredDatabaseTypeStates(); } public String getConfiguredOrmSchemaMode() { - requireOpen(); + requireLegacyOpen(); requireInternalCaller(); return registry.getOrmSchemaMode(); } public void reloadConfiguration() { - requireOpen(); + requireLegacyOpen(); requireInternalCaller(); try { registry.reloadConfiguration(); @@ -303,6 +304,60 @@ public void reloadConfiguration() { } } + private DatabaseProvider registerLegacy( + PluginId pluginId, + OwnerScopeId ownerScope, + DatabaseType type, + ConnectionIdentifier identifier + ) { + return DatabaseFactory.withCreationPlugin( + pluginId, + () -> registry.registerDatabase(pluginId, ownerScope, type, identifier) + ); + } + + private DatabaseProvider registerStrict( + PluginId pluginId, + OwnerScopeId ownerScope, + DatabaseType type, + ConnectionIdentifier identifier, + String operation + ) { + DatabaseProvider provider = registerLegacy(pluginId, ownerScope, type, identifier); + if (provider != null) { + return provider; + } + if (!registry.getConfiguredDatabaseTypeStates().getOrDefault(type, true)) { + throw DataProviderExceptionMapper.backendDisabled(type, identifier.value()); + } + DatabaseConnectionKey key = new DatabaseConnectionKey(pluginId.value(), type, identifier.value()); + ProviderLifecycleSnapshot snapshot = registry.getProviderLifecycleSnapshots().get(key); + Throwable failure = snapshot == null ? null : snapshot.failure(); + throw DataProviderExceptionMapper.registrationFailure(failure, type, identifier.value(), operation); + } + + private static DataProviderRegistrationException missingRegistration( + DatabaseType type, + String identifier, + String operation + ) { + return new DataProviderRegistrationException( + "No active database registration exists for the requested connection.", + DataProviderFailureContext.of( + type, + identifier, + operation, + RetryAdvice.NEVER, + ExecutionOutcome.NOT_STARTED + ), + null + ); + } + + private PluginId resolvePluginId() { + return PluginId.of(resolveCallerContext().pluginId()); + } + private CallerContext resolveCallerContext() { CallerContext caller = callerContextResolver.resolveCaller(); if (caller == null) { @@ -314,19 +369,37 @@ private CallerContext resolveCallerContext() { private void requireInternalCaller() { ClassLoader callerLoader = StackCallerClassLoaderResolver.resolveNearestCallerOutsidePackage( - INTERNAL_PACKAGE_PREFIX); + INTERNAL_PACKAGE_PREFIX + ); if (callerLoader == null || callerLoader != ownClassLoader) { logger.error("Rejected privileged operation from non-internal caller."); throw new SecurityException("Privileged DataProvider operation is restricted to internal callers."); } } - private void requireOpen() { + private void requireLegacyOpen() { + if (registry.isClosed()) { + throw new IllegalStateException(CLOSED_MESSAGE); + } + } + + private void requireStructuredOpen(String operation) { if (registry.isClosed()) { throw new ProviderClosedException( CLOSED_MESSAGE, - DataProviderFailureContext.of(null, null, "api", RetryAdvice.NEVER, - ExecutionOutcome.NOT_STARTED), null); + DataProviderFailureContext.of( + null, + null, + operation, + RetryAdvice.NEVER, + ExecutionOutcome.NOT_STARTED + ), + null + ); } } + + private static DatabaseType requireType(DatabaseType databaseType) { + return Objects.requireNonNull(databaseType, "Database type cannot be null"); + } } From 5c3711afc617b25dffe720744b6cc88248a6f3b1 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 11:36:22 +0200 Subject: [PATCH 47/63] Preserve legacy scope closure behavior --- .../core/api/DefaultDataProviderScope.java | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DefaultDataProviderScope.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DefaultDataProviderScope.java index c82a0e6..2aaacd1 100644 --- a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DefaultDataProviderScope.java +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DefaultDataProviderScope.java @@ -10,6 +10,7 @@ import nl.hauntedmc.dataprovider.exception.ProviderClosedException; import nl.hauntedmc.dataprovider.exception.RetryAdvice; +import java.util.Map; import java.util.Objects; /** Optional scoped lifecycle helper for independently managed plugin components. */ @@ -40,25 +41,27 @@ public LifecycleState lifecycleState() { @Override public DatabaseProvider registerDatabase(DatabaseType databaseType, String connectionIdentifier) { synchronized (lifecycleMonitor) { - requireOpen(); + requireLegacyOpen(); return DefaultDataProviderApi.wrapProvider( - handler.registerDatabaseForScope(ownerScope, databaseType, connectionIdentifier)); + handler.registerDatabaseForScope(ownerScope, databaseType, connectionIdentifier) + ); } } @Override public DatabaseProvider registerDatabaseOrThrow(DatabaseType databaseType, String connectionIdentifier) { synchronized (lifecycleMonitor) { - requireOpen(); + requireStructuredOpen("scope.registerDatabase"); return DefaultDataProviderApi.wrapProvider( - handler.registerDatabaseForScopeOrThrow(ownerScope, databaseType, connectionIdentifier)); + handler.registerDatabaseForScopeOrThrow(ownerScope, databaseType, connectionIdentifier) + ); } } @Override public void unregisterDatabase(DatabaseType databaseType, String connectionIdentifier) { synchronized (lifecycleMonitor) { - requireOpen(); + requireLegacyOpen(); handler.unregisterDatabaseForScope(ownerScope, databaseType, connectionIdentifier); } } @@ -66,7 +69,7 @@ public void unregisterDatabase(DatabaseType databaseType, String connectionIdent @Override public void unregisterAllDatabases() { synchronized (lifecycleMonitor) { - requireOpen(); + requireLegacyOpen(); handler.unregisterAllDatabasesForScope(ownerScope); } } @@ -74,18 +77,20 @@ public void unregisterAllDatabases() { @Override public DatabaseProvider getRegisteredDatabase(DatabaseType databaseType, String connectionIdentifier) { synchronized (lifecycleMonitor) { - requireOpen(); + requireLegacyOpen(); return DefaultDataProviderApi.wrapProvider( - handler.getRegisteredDatabaseForScope(ownerScope, databaseType, connectionIdentifier)); + handler.getRegisteredDatabaseForScope(ownerScope, databaseType, connectionIdentifier) + ); } } @Override public DatabaseProvider requireRegisteredDatabase(DatabaseType databaseType, String connectionIdentifier) { synchronized (lifecycleMonitor) { - requireOpen(); + requireStructuredOpen("scope.requireRegisteredDatabase"); return DefaultDataProviderApi.wrapProvider( - handler.requireRegisteredDatabaseForScope(ownerScope, databaseType, connectionIdentifier)); + handler.requireRegisteredDatabaseForScope(ownerScope, databaseType, connectionIdentifier) + ); } } @@ -104,13 +109,23 @@ public void close() { } } - private void requireOpen() { + private void requireLegacyOpen() { + if (lifecycleState != LifecycleState.OPEN) { + throw new IllegalStateException(CLOSED_MESSAGE); + } + } + + private void requireStructuredOpen(String operation) { if (lifecycleState != LifecycleState.OPEN) { throw new ProviderClosedException( CLOSED_MESSAGE, - DataProviderFailureContext.of(null, null, "scope", RetryAdvice.NEVER, - ExecutionOutcome.NOT_STARTED) - .withDiagnostics(java.util.Map.of("ownerScope", ownerScope.value())), + DataProviderFailureContext.of( + null, + null, + operation, + RetryAdvice.NEVER, + ExecutionOutcome.NOT_STARTED + ).withDiagnostics(Map.of("ownerScope", ownerScope.value())), null ); } From 20f53a5c635fc54f32a3e18962ad95b2655509e2 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 11:37:33 +0200 Subject: [PATCH 48/63] Keep Mongo input validation failures compatible --- .../impl/mongodb/MongoDBDataAccess.java | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/database/document/impl/mongodb/MongoDBDataAccess.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/database/document/impl/mongodb/MongoDBDataAccess.java index 480303a..d88974f 100644 --- a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/database/document/impl/mongodb/MongoDBDataAccess.java +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/database/document/impl/mongodb/MongoDBDataAccess.java @@ -6,12 +6,10 @@ import com.mongodb.client.model.IndexOptions; import com.mongodb.client.model.UpdateOptions; import nl.hauntedmc.dataprovider.core.concurrent.AsyncTaskSupport; -import nl.hauntedmc.dataprovider.core.exception.StructuredFailures; import nl.hauntedmc.dataprovider.database.document.DocumentDataAccess; import nl.hauntedmc.dataprovider.database.document.model.DocumentQuery; import nl.hauntedmc.dataprovider.database.document.model.DocumentUpdate; import nl.hauntedmc.dataprovider.database.document.model.DocumentUpdateOptions; -import nl.hauntedmc.dataprovider.exception.DataSerializationException; import org.bson.Document; import org.bson.conversions.Bson; import org.bson.types.Binary; @@ -67,23 +65,11 @@ private UpdateOptions toMongoUpdateOptions(DocumentUpdateOptions options) { } private Document toMongoDocument(Map document) { - try { - return copyDocument(document, "document"); - } catch (DataSerializationException structured) { - throw structured; - } catch (RuntimeException failure) { - throw StructuredFailures.serialization(failure, executor, "mongodb.serialize"); - } + return copyDocument(document, "document"); } private Map documentToMap(Document document) { - try { - return new LinkedHashMap<>(copyDocument(document, "document")); - } catch (DataSerializationException structured) { - throw structured; - } catch (RuntimeException failure) { - throw StructuredFailures.serialization(failure, executor, "mongodb.deserialize"); - } + return new LinkedHashMap<>(copyDocument(document, "document")); } @Override From 48a3d9610d4cf71af0f7865cfc5cd2dd1688d21f Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 11:40:35 +0200 Subject: [PATCH 49/63] Cover async validation failure preservation --- .../core/concurrent/AsyncTaskSupportTest.java | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/concurrent/AsyncTaskSupportTest.java b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/concurrent/AsyncTaskSupportTest.java index 4023533..efb5cbf 100644 --- a/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/concurrent/AsyncTaskSupportTest.java +++ b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/concurrent/AsyncTaskSupportTest.java @@ -42,7 +42,7 @@ void runAsyncReturnsStructuredFailureWhenExecutorRejects() { } @Test - void runAsyncRedactsAndStructuresTaskFailures() { + void runAsyncRedactsAndStructuresBackendFailures() { Executor directExecutor = Runnable::run; CompletableFuture future = AsyncTaskSupport.runAsync( directExecutor, @@ -58,4 +58,23 @@ void runAsyncRedactsAndStructuresTaskFailures() { assertEquals("unit.failure", failure.operationName()); assertEquals("java.lang.IllegalStateException", failure.diagnostics().get("causeType")); } + + @Test + void supplyAsyncPreservesCallerValidationFailures() { + Executor directExecutor = Runnable::run; + CompletableFuture future = AsyncTaskSupport.runAsync( + directExecutor, + "unit.validation", + () -> { + throw new IllegalArgumentException("invalid identifier"); + } + ); + + CompletionException completion = assertThrows(CompletionException.class, future::join); + IllegalArgumentException validation = assertInstanceOf( + IllegalArgumentException.class, + completion.getCause() + ); + assertEquals("invalid identifier", validation.getMessage()); + } } From 0dedc8a3f29fe836fb7ce983bb20d531c4dff9d7 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 11:41:13 +0200 Subject: [PATCH 50/63] Test strict lifecycle exceptions --- .../core/HandlerLifecycleExceptionTest.java | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/HandlerLifecycleExceptionTest.java diff --git a/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/HandlerLifecycleExceptionTest.java b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/HandlerLifecycleExceptionTest.java new file mode 100644 index 0000000..630dfff --- /dev/null +++ b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/HandlerLifecycleExceptionTest.java @@ -0,0 +1,41 @@ +package nl.hauntedmc.dataprovider.core; + +import nl.hauntedmc.dataprovider.core.identity.CallerContext; +import nl.hauntedmc.dataprovider.core.testutil.RecordingLoggerAdapter; +import nl.hauntedmc.dataprovider.database.DatabaseType; +import nl.hauntedmc.dataprovider.exception.ProviderClosedException; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class HandlerLifecycleExceptionTest { + + @Test + void strictMethodsUseStructuredClosureWhileLegacyMethodsRemainCompatible() { + DataProviderRegistry registry = mock(DataProviderRegistry.class); + when(registry.isClosed()).thenReturn(true); + ClassLoader pluginLoader = new ClassLoader() { }; + DataProviderHandler handler = new DataProviderHandler( + registry, + () -> new CallerContext("plugin", pluginLoader), + new RecordingLoggerAdapter(), + getClass().getClassLoader() + ); + + assertThrows(IllegalStateException.class, + () -> handler.registerDatabase(DatabaseType.MYSQL, "default")); + assertThrows(IllegalStateException.class, + () -> handler.getRegisteredDatabase(DatabaseType.MYSQL, "default")); + + ProviderClosedException registration = assertThrows(ProviderClosedException.class, + () -> handler.registerDatabaseOrThrow(DatabaseType.MYSQL, "default")); + assertEquals("registerDatabase", registration.operationName()); + + ProviderClosedException lookup = assertThrows(ProviderClosedException.class, + () -> handler.requireRegisteredDatabase(DatabaseType.MYSQL, "default")); + assertEquals("requireRegisteredDatabase", lookup.operationName()); + } +} From 94e88066fc68d4d501450ae84297296bd8448e68 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 11:42:32 +0200 Subject: [PATCH 51/63] Validate diagnostic correlation identifiers --- .../exception/DataProviderException.java | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderException.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderException.java index 154f2a0..888023b 100644 --- a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderException.java +++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderException.java @@ -3,13 +3,18 @@ import nl.hauntedmc.dataprovider.database.DatabaseType; import java.util.LinkedHashMap; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.UUID; +import java.util.regex.Pattern; /** Base type for safe, structured failures exposed by DataProvider. */ public abstract class DataProviderException extends RuntimeException { + private static final Pattern DIAGNOSTIC_KEY_PATTERN = Pattern.compile("[A-Za-z][A-Za-z0-9_.-]{0,63}"); + private static final Pattern DIAGNOSTIC_ID_PATTERN = Pattern.compile("[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}"); + private final DataProviderErrorCode errorCode; private final DatabaseType backendType; private final String connectionIdentifier; @@ -59,9 +64,7 @@ protected DataProviderException( this.retryAdvice = Objects.requireNonNull(retryAdvice, "Retry advice cannot be null."); this.executionOutcome = Objects.requireNonNull(executionOutcome, "Execution outcome cannot be null."); this.diagnostics = sanitizeDiagnostics(diagnostics); - this.diagnosticId = diagnosticId == null || diagnosticId.isBlank() - ? UUID.randomUUID().toString() - : diagnosticId.trim(); + this.diagnosticId = normalizeDiagnosticId(diagnosticId); } public final DataProviderErrorCode errorCode() { @@ -107,7 +110,7 @@ private static Map sanitizeDiagnostics(Map sourc LinkedHashMap safe = new LinkedHashMap<>(); source.forEach((key, value) -> { String normalizedKey = requireSafeText(key, "diagnostic key"); - if (!normalizedKey.matches("[A-Za-z][A-Za-z0-9_.-]{0,63}")) { + if (!DIAGNOSTIC_KEY_PATTERN.matcher(normalizedKey).matches()) { throw new IllegalArgumentException("Unsupported diagnostic key: " + normalizedKey); } if (isSensitiveKey(normalizedKey)) { @@ -123,12 +126,23 @@ private static Map sanitizeDiagnostics(Map sourc } private static boolean isSensitiveKey(String key) { - String lower = key.toLowerCase(java.util.Locale.ROOT); + String lower = key.toLowerCase(Locale.ROOT); return lower.contains("password") || lower.contains("secret") || lower.contains("token") || lower.contains("credential") || lower.contains("authorization") || lower.contains("payload") || lower.contains("query") || lower.contains("parameter") || lower.contains("url"); } + private static String normalizeDiagnosticId(String diagnosticId) { + if (diagnosticId == null || diagnosticId.isBlank()) { + return UUID.randomUUID().toString(); + } + String normalized = diagnosticId.trim(); + if (!DIAGNOSTIC_ID_PATTERN.matcher(normalized).matches()) { + throw new IllegalArgumentException("Unsupported diagnostic identifier."); + } + return normalized; + } + private static String requireSafeText(String value, String field) { if (value == null || value.isBlank()) { throw new IllegalArgumentException(field + " cannot be null or blank."); From 88822a1d4e0f268e76971cc86e7820689c5d7704 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 11:42:53 +0200 Subject: [PATCH 52/63] Test diagnostic identifier validation --- .../exception/DataProviderExceptionTest.java | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/dataprovider-api/src/test/java/nl/hauntedmc/dataprovider/exception/DataProviderExceptionTest.java b/dataprovider-api/src/test/java/nl/hauntedmc/dataprovider/exception/DataProviderExceptionTest.java index d46df29..becd236 100644 --- a/dataprovider-api/src/test/java/nl/hauntedmc/dataprovider/exception/DataProviderExceptionTest.java +++ b/dataprovider-api/src/test/java/nl/hauntedmc/dataprovider/exception/DataProviderExceptionTest.java @@ -40,17 +40,29 @@ void contextIsImmutableAndMetadataIsStable() { @Test void sensitiveDiagnosticKeysAreRejected() { - DataProviderFailureContext context = new DataProviderFailureContext( + DataProviderFailureContext context = context(Map.of("password", "must-not-appear"), null); + + assertThrows(IllegalArgumentException.class, + () -> new DataProviderRegistrationException("Safe message.", context, null)); + } + + @Test + void malformedDiagnosticIdentifiersAreRejected() { + DataProviderFailureContext context = context(Map.of(), "invalid identifier with spaces"); + + assertThrows(IllegalArgumentException.class, + () -> new DataProviderRegistrationException("Safe message.", context, null)); + } + + private static DataProviderFailureContext context(Map diagnostics, String diagnosticId) { + return new DataProviderFailureContext( DatabaseType.REDIS, "cache", "redis.getKey", RetryAdvice.NEVER, ExecutionOutcome.NOT_STARTED, - Map.of("password", "must-not-appear"), - null + diagnostics, + diagnosticId ); - - assertThrows(IllegalArgumentException.class, - () -> new DataProviderRegistrationException("Safe message.", context, null)); } } From 2412be0a20908e0755fd2e3cf763644464aae31b Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 11:43:27 +0200 Subject: [PATCH 53/63] Clarify exception compatibility and redaction guarantees --- docs/EXCEPTIONS.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/EXCEPTIONS.md b/docs/EXCEPTIONS.md index f63743c..489c52e 100644 --- a/docs/EXCEPTIONS.md +++ b/docs/EXCEPTIONS.md @@ -6,7 +6,11 @@ DataProvider exposes unchecked structured failures from `nl.hauntedmc.dataprovid Use `registerDatabaseOrThrow(...)` when startup must distinguish missing configuration, disabled backends, authentication failure, timeout, or backend unavailability. Use `requireRegisteredDatabase(...)` when absence is exceptional. -The original `registerDatabase(...)` and optional helpers remain available for compatibility. They continue returning `null` or `Optional.empty()` and intentionally discard the failure category after DataProvider records it internally. +The original `registerDatabase(...)` and optional helpers remain available for compatibility. They continue returning `null` or `Optional.empty()` and intentionally discard the failure category after DataProvider records registration failures internally. + +Legacy methods retain their previous lifecycle behavior, including `IllegalStateException` after their API or scope has closed. The new strict registration and lookup methods report closure as `ProviderClosedException`. + +Caller input validation remains distinct from backend failure classification. Invalid identifiers, unsupported document values, null arguments, and similar programming errors continue to use standard validation exceptions such as `IllegalArgumentException` and `NullPointerException`. ## Common handling @@ -29,7 +33,7 @@ All structured exceptions expose: - `retryAdvice()` — `NEVER`, `SAFE`, or `CONDITIONAL` - `executionOutcome()` — whether the operation started or may already have applied - `diagnostics()` — immutable allowlisted metadata -- `diagnosticId()` — correlation identifier for operational support +- `diagnosticId()` — validated correlation identifier for operational support ## Retry safety @@ -43,6 +47,6 @@ A transaction commit timeout can mean the commit succeeded but its acknowledgeme ## Redaction -Public exception messages, diagnostics, and causes never include passwords, tokens, payloads, query parameter values, raw configuration, or credential-bearing URLs. Public causes preserve the original failure type through a redacted surrogate. Raw backend failures remain confined to DataProvider lifecycle diagnostics and internal logging. +DataProvider-generated public exception messages, diagnostics, and causes do not include passwords, tokens, payloads, query parameter values, raw configuration, or credential-bearing URLs. Public causes preserve the original failure type through a redacted surrogate. Registration lifecycle failures retain their original internal cause for diagnostics while exposing only redacted public metadata. Rollback failures are attached as suppressed structured exceptions without replacing the primary transaction failure. From 874820d6fbd997e0a7bb3637d081152e40e0a7c7 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 12:16:47 +0200 Subject: [PATCH 54/63] Add generic operation failure code --- .../hauntedmc/dataprovider/exception/DataProviderErrorCode.java | 1 + 1 file changed, 1 insertion(+) diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderErrorCode.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderErrorCode.java index 8df9a41..1aba041 100644 --- a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderErrorCode.java +++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderErrorCode.java @@ -8,6 +8,7 @@ public enum DataProviderErrorCode { BACKEND_DISABLED, BACKEND_UNAVAILABLE, AUTHENTICATION_FAILED, + OPERATION_FAILED, OPERATION_TIMED_OUT, QUEUE_SATURATED, SERIALIZATION_FAILED, From bcb832c9ada3e85bd6d6cbfd371739480d206e3a Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 12:17:05 +0200 Subject: [PATCH 55/63] Add generic structured operation exception --- .../exception/DataProviderOperationException.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderOperationException.java diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderOperationException.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderOperationException.java new file mode 100644 index 0000000..19cb9e6 --- /dev/null +++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/DataProviderOperationException.java @@ -0,0 +1,13 @@ +package nl.hauntedmc.dataprovider.exception; + +/** Backend operation failed without matching a more specific public failure category. */ +public final class DataProviderOperationException extends DataProviderException { + + public DataProviderOperationException( + String message, + DataProviderFailureContext context, + Throwable cause + ) { + super(DataProviderErrorCode.OPERATION_FAILED, message, context, cause); + } +} From 26f240ac3690cafba04817499c90097b5033bc93 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 12:17:17 +0200 Subject: [PATCH 56/63] Add explicit transaction cleanup phase --- .../nl/hauntedmc/dataprovider/exception/TransactionPhase.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/TransactionPhase.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/TransactionPhase.java index 8843fff..a79f061 100644 --- a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/TransactionPhase.java +++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/exception/TransactionPhase.java @@ -5,5 +5,6 @@ public enum TransactionPhase { BEGIN, CALLBACK, COMMIT, - ROLLBACK + ROLLBACK, + CLEANUP } From b56ad9c94f1f4eb01ca237c69a9244fb1fb976bf Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 12:18:24 +0200 Subject: [PATCH 57/63] Improve exception classification and unwrapping --- .../DataProviderExceptionMapper.java | 66 +++++++++++++------ 1 file changed, 46 insertions(+), 20 deletions(-) diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/exception/DataProviderExceptionMapper.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/exception/DataProviderExceptionMapper.java index 90fd2b8..c4c75fc 100644 --- a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/exception/DataProviderExceptionMapper.java +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/exception/DataProviderExceptionMapper.java @@ -14,6 +14,7 @@ import nl.hauntedmc.dataprovider.exception.DataProviderErrorCode; import nl.hauntedmc.dataprovider.exception.DataProviderException; import nl.hauntedmc.dataprovider.exception.DataProviderFailureContext; +import nl.hauntedmc.dataprovider.exception.DataProviderOperationException; import nl.hauntedmc.dataprovider.exception.DataProviderRegistrationException; import nl.hauntedmc.dataprovider.exception.DataProviderTimeoutException; import nl.hauntedmc.dataprovider.exception.DataSerializationException; @@ -32,6 +33,7 @@ import java.sql.SQLException; import java.sql.SQLTimeoutException; import java.util.LinkedHashMap; +import java.util.Locale; import java.util.Map; import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutionException; @@ -45,15 +47,13 @@ private DataProviderExceptionMapper() { } public static DataProviderException translate(Throwable failure, Executor executor, String operationName) { - Throwable root = unwrap(failure); + Throwable root = unwrapAsync(failure); if (root instanceof DataProviderException structured) { return structured; } ExecutionHandle execution = executor instanceof ExecutionHandle handle ? handle : null; DatabaseType backend = execution == null ? inferBackend(operationName) : execution.backendType(); String connection = execution == null ? null : execution.connectionIdentifier(); - DataProviderFailureContext base = DataProviderFailureContext.of( - backend, connection, operationName, RetryAdvice.CONDITIONAL, ExecutionOutcome.UNKNOWN); if (root instanceof ExecutionRejectedException rejected) { Map diagnostics = rejectionDiagnostics(rejected, execution); @@ -82,10 +82,12 @@ public static DataProviderException translate(Throwable failure, Executor execut ExecutionOutcome.NOT_STARTED, diagnosticsFor(root)), safeCause(root)); } if (isTimeout(root)) { + boolean readOperation = isReadOperation(operationName); return new DataProviderTimeoutException( "The backend operation timed out.", - context(backend, connection, operationName, RetryAdvice.CONDITIONAL, - isReadOperation(operationName) ? ExecutionOutcome.UNKNOWN : ExecutionOutcome.MAY_HAVE_APPLIED, + context(backend, connection, operationName, + readOperation ? RetryAdvice.SAFE : RetryAdvice.CONDITIONAL, + readOperation ? ExecutionOutcome.NOT_APPLIED : ExecutionOutcome.MAY_HAVE_APPLIED, diagnosticsFor(root)), safeCause(root)); } if (isSerializationFailure(root)) { @@ -97,23 +99,20 @@ public static DataProviderException translate(Throwable failure, Executor execut if (isUnavailable(root)) { return unavailable(backend, connection, operationName, root); } - return new BackendUnavailableException( - DataProviderErrorCode.BACKEND_UNAVAILABLE, + return new DataProviderOperationException( "The backend operation failed.", - base.withDiagnostics(diagnosticsFor(root)), safeCause(root)); + context(backend, connection, operationName, RetryAdvice.CONDITIONAL, + ExecutionOutcome.UNKNOWN, diagnosticsFor(root)), safeCause(root)); } public static DataProviderException registrationFailure( Throwable failure, DatabaseType backend, String connectionIdentifier, String operationName) { - Throwable root = unwrap(failure); + Throwable root = unwrapRegistration(failure); if (root instanceof DataProviderException structured) { return structured; } if (root == null) { - return new DataProviderRegistrationException( - "Database registration failed.", - context(backend, connectionIdentifier, operationName, RetryAdvice.CONDITIONAL, - ExecutionOutcome.NOT_STARTED, Map.of()), null); + return registrationException(backend, connectionIdentifier, operationName, Map.of(), null); } if (root instanceof MissingConfigurationFailure) { return new DataProviderConfigurationException( @@ -122,7 +121,14 @@ public static DataProviderException registrationFailure( context(backend, connectionIdentifier, operationName, RetryAdvice.NEVER, ExecutionOutcome.NOT_STARTED, diagnosticsFor(root)), safeCause(root)); } - return translate(root, new RegistrationExecutionHandle(backend, connectionIdentifier), operationName); + DataProviderException mapped = translate( + root, new RegistrationExecutionHandle(backend, connectionIdentifier), operationName); + if (mapped instanceof DataProviderOperationException) { + return registrationException( + backend, connectionIdentifier, operationName, + Map.of("causeCode", mapped.errorCode().name()), mapped); + } + return mapped; } public static BackendUnavailableException backendDisabled(DatabaseType backend, String connectionIdentifier) { @@ -134,7 +140,7 @@ public static BackendUnavailableException backendDisabled(DatabaseType backend, } public static DataProviderConfigurationException configurationFailure(Throwable failure, String operationName) { - Throwable root = unwrap(failure); + Throwable root = unwrapAsync(failure); return new DataProviderConfigurationException( DataProviderErrorCode.CONFIGURATION_INVALID, "DataProvider configuration is invalid.", @@ -151,7 +157,7 @@ public static DataTransactionException transactionFailure( ) { DataProviderException mapped = translate(failure, executor, operationName); return new DataTransactionException( - "The database transaction failed during " + phase.name().toLowerCase(java.util.Locale.ROOT) + ".", + "The database transaction failed during " + phase.name().toLowerCase(Locale.ROOT) + ".", phase, context(mapped.backendType(), mapped.connectionIdentifier(), operationName, phase == TransactionPhase.COMMIT ? RetryAdvice.CONDITIONAL : RetryAdvice.NEVER, @@ -164,6 +170,19 @@ public static MissingConfigurationFailure missingConfigurationFailure() { return new MissingConfigurationFailure(); } + private static DataProviderRegistrationException registrationException( + DatabaseType backend, + String connectionIdentifier, + String operationName, + Map diagnostics, + Throwable cause + ) { + return new DataProviderRegistrationException( + "Database registration failed.", + context(backend, connectionIdentifier, operationName, RetryAdvice.CONDITIONAL, + ExecutionOutcome.NOT_STARTED, diagnostics), cause); + } + private static BackendUnavailableException unavailable( DatabaseType backend, String connection, String operationName, Throwable root) { return new BackendUnavailableException( @@ -184,20 +203,27 @@ private static DataProviderFailureContext context( return new DataProviderFailureContext(backend, connection, operation, retry, outcome, diagnostics, null); } - private static Throwable unwrap(Throwable failure) { + private static Throwable unwrapAsync(Throwable failure) { if (failure == null) { return null; } Throwable current = failure; while (current.getCause() != null && (current instanceof CompletionException || current instanceof ExecutionException - || current.getClass() == RuntimeException.class - || current instanceof IllegalStateException)) { + || current.getClass() == RuntimeException.class)) { current = current.getCause(); } return current; } + private static Throwable unwrapRegistration(Throwable failure) { + Throwable current = unwrapAsync(failure); + while (current instanceof IllegalStateException && current.getCause() != null) { + current = unwrapAsync(current.getCause()); + } + return current; + } + private static boolean isConflict(Throwable failure) { return failure instanceof SQLIntegrityConstraintViolationException || sqlStateStartsWith(failure, "23") @@ -278,7 +304,7 @@ private static boolean isReadOperation(String operationName) { if (operationName == null) { return false; } - String lower = operationName.toLowerCase(java.util.Locale.ROOT); + String lower = operationName.toLowerCase(Locale.ROOT); return lower.contains("get") || lower.contains("find") || lower.contains("query") || lower.contains("scan") || lower.contains("range") || lower.contains("health"); } From 6b36028dc40fe10fafbd79ef86852a26e5ffe730 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 12:19:48 +0200 Subject: [PATCH 58/63] Harden transaction cleanup and redaction --- .../impl/mysql/MySQLDataAccess.java | 167 ++++++++++++------ 1 file changed, 116 insertions(+), 51 deletions(-) diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/database/relational/impl/mysql/MySQLDataAccess.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/database/relational/impl/mysql/MySQLDataAccess.java index 2aa9e79..5596dfe 100644 --- a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/database/relational/impl/mysql/MySQLDataAccess.java +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/database/relational/impl/mysql/MySQLDataAccess.java @@ -146,84 +146,149 @@ public CompletableFuture executeTransactionally(TransactionCallback ca } private T executeTransaction(TransactionCallback callback) { - Connection connection; + Connection connection = null; boolean oldAutoCommit; try { connection = dataSource.getConnection(); oldAutoCommit = connection.getAutoCommit(); connection.setAutoCommit(false); - } catch (Throwable beginFailure) { - throw DataProviderExceptionMapper.transactionFailure( - beginFailure, executor, TRANSACTION_OPERATION, TransactionPhase.BEGIN, - ExecutionOutcome.NOT_STARTED); + } catch (Error fatal) { + closeAfterFatal(connection, fatal); + throw fatal; + } catch (Exception beginFailure) { + DataTransactionException structured = transactionFailure( + beginFailure, TransactionPhase.BEGIN, ExecutionOutcome.NOT_STARTED); + closeConnection(connection, structured, ExecutionOutcome.NOT_STARTED); + throw structured; } - try (connection) { - T result; - try { - result = callback.doInTransaction(connection); - } catch (Throwable callbackFailure) { - DataTransactionException structured = rollbackAfterFailure( - connection, callbackFailure, TransactionPhase.CALLBACK, ExecutionOutcome.NOT_APPLIED); - restoreAutoCommit(connection, oldAutoCommit, structured); - throw structured; - } + T result; + try { + result = callback.doInTransaction(connection); + } catch (Error fatal) { + cleanupAfterFatal(connection, oldAutoCommit, fatal); + throw fatal; + } catch (Exception callbackFailure) { + DataTransactionException structured = transactionFailure( + callbackFailure, TransactionPhase.CALLBACK, ExecutionOutcome.NOT_APPLIED); + rollback(connection, structured); + restoreAutoCommit(connection, oldAutoCommit, structured, ExecutionOutcome.NOT_APPLIED); + closeConnection(connection, structured, ExecutionOutcome.NOT_APPLIED); + throw structured; + } - try { - connection.commit(); - } catch (Throwable commitFailure) { - DataTransactionException structured = rollbackAfterFailure( - connection, commitFailure, TransactionPhase.COMMIT, ExecutionOutcome.MAY_HAVE_APPLIED); - restoreAutoCommit(connection, oldAutoCommit, structured); - throw structured; - } + try { + connection.commit(); + } catch (Error fatal) { + cleanupAfterFatal(connection, oldAutoCommit, fatal); + throw fatal; + } catch (Exception commitFailure) { + DataTransactionException structured = transactionFailure( + commitFailure, TransactionPhase.COMMIT, ExecutionOutcome.MAY_HAVE_APPLIED); + rollback(connection, structured); + restoreAutoCommit(connection, oldAutoCommit, structured, ExecutionOutcome.MAY_HAVE_APPLIED); + closeConnection(connection, structured, ExecutionOutcome.MAY_HAVE_APPLIED); + throw structured; + } - try { - connection.setAutoCommit(oldAutoCommit); - } catch (Throwable restoreFailure) { - throw DataProviderExceptionMapper.transactionFailure( - restoreFailure, executor, TRANSACTION_OPERATION, TransactionPhase.ROLLBACK, - ExecutionOutcome.UNKNOWN); - } - return result; - } catch (DataTransactionException structured) { + try { + connection.setAutoCommit(oldAutoCommit); + } catch (Error fatal) { + closeAfterFatal(connection, fatal); + throw fatal; + } catch (Exception restoreFailure) { + DataTransactionException structured = transactionFailure( + restoreFailure, TransactionPhase.CLEANUP, ExecutionOutcome.MAY_HAVE_APPLIED); + closeConnection(connection, structured, ExecutionOutcome.MAY_HAVE_APPLIED); throw structured; - } catch (Throwable closeFailure) { - throw DataProviderExceptionMapper.transactionFailure( - closeFailure, executor, TRANSACTION_OPERATION, TransactionPhase.ROLLBACK, - ExecutionOutcome.UNKNOWN); } + + try { + connection.close(); + } catch (Error fatal) { + throw fatal; + } catch (Exception closeFailure) { + throw transactionFailure(closeFailure, TransactionPhase.CLEANUP, ExecutionOutcome.MAY_HAVE_APPLIED); + } + return result; } - private DataTransactionException rollbackAfterFailure( - Connection connection, - Throwable primary, + private DataTransactionException transactionFailure( + Throwable failure, TransactionPhase phase, ExecutionOutcome outcome ) { - DataTransactionException structured = DataProviderExceptionMapper.transactionFailure( - primary, executor, TRANSACTION_OPERATION, phase, outcome); + return DataProviderExceptionMapper.transactionFailure( + failure, executor, TRANSACTION_OPERATION, phase, outcome); + } + + private void rollback(Connection connection, DataTransactionException primary) { try { connection.rollback(); - } catch (Throwable rollbackFailure) { - structured.addSuppressed(DataProviderExceptionMapper.transactionFailure( - rollbackFailure, executor, TRANSACTION_OPERATION, TransactionPhase.ROLLBACK, - ExecutionOutcome.UNKNOWN)); + } catch (Error fatal) { + primary.addSuppressed(fatal); + } catch (Exception rollbackFailure) { + primary.addSuppressed(transactionFailure( + rollbackFailure, TransactionPhase.ROLLBACK, ExecutionOutcome.UNKNOWN)); } - return structured; } private void restoreAutoCommit( Connection connection, boolean oldAutoCommit, - DataTransactionException primary + DataTransactionException primary, + ExecutionOutcome outcome + ) { + try { + connection.setAutoCommit(oldAutoCommit); + } catch (Error fatal) { + primary.addSuppressed(fatal); + } catch (Exception restoreFailure) { + primary.addSuppressed(transactionFailure( + restoreFailure, TransactionPhase.CLEANUP, outcome)); + } + } + + private void closeConnection( + Connection connection, + DataTransactionException primary, + ExecutionOutcome outcome ) { + if (connection == null) { + return; + } + try { + connection.close(); + } catch (Error fatal) { + primary.addSuppressed(fatal); + } catch (Exception closeFailure) { + primary.addSuppressed(transactionFailure( + closeFailure, TransactionPhase.CLEANUP, outcome)); + } + } + + private static void cleanupAfterFatal(Connection connection, boolean oldAutoCommit, Error fatal) { + try { + connection.rollback(); + } catch (Throwable cleanupFailure) { + fatal.addSuppressed(cleanupFailure); + } try { connection.setAutoCommit(oldAutoCommit); - } catch (Throwable restoreFailure) { - primary.addSuppressed(DataProviderExceptionMapper.transactionFailure( - restoreFailure, executor, TRANSACTION_OPERATION, TransactionPhase.ROLLBACK, - ExecutionOutcome.UNKNOWN)); + } catch (Throwable cleanupFailure) { + fatal.addSuppressed(cleanupFailure); + } + closeAfterFatal(connection, fatal); + } + + private static void closeAfterFatal(Connection connection, Error fatal) { + if (connection == null) { + return; + } + try { + connection.close(); + } catch (Throwable closeFailure) { + fatal.addSuppressed(closeFailure); } } From 00b4da9758927223f4dcde9f1c5b94b5cc7feb99 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 12:21:20 +0200 Subject: [PATCH 59/63] Keep fatal errors primary during transaction cleanup --- .../relational/impl/mysql/MySQLDataAccess.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/database/relational/impl/mysql/MySQLDataAccess.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/database/relational/impl/mysql/MySQLDataAccess.java index 5596dfe..6ec08eb 100644 --- a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/database/relational/impl/mysql/MySQLDataAccess.java +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/database/relational/impl/mysql/MySQLDataAccess.java @@ -226,7 +226,9 @@ private void rollback(Connection connection, DataTransactionException primary) { try { connection.rollback(); } catch (Error fatal) { - primary.addSuppressed(fatal); + fatal.addSuppressed(primary); + closeAfterFatal(connection, fatal); + throw fatal; } catch (Exception rollbackFailure) { primary.addSuppressed(transactionFailure( rollbackFailure, TransactionPhase.ROLLBACK, ExecutionOutcome.UNKNOWN)); @@ -242,7 +244,9 @@ private void restoreAutoCommit( try { connection.setAutoCommit(oldAutoCommit); } catch (Error fatal) { - primary.addSuppressed(fatal); + fatal.addSuppressed(primary); + closeAfterFatal(connection, fatal); + throw fatal; } catch (Exception restoreFailure) { primary.addSuppressed(transactionFailure( restoreFailure, TransactionPhase.CLEANUP, outcome)); @@ -260,7 +264,8 @@ private void closeConnection( try { connection.close(); } catch (Error fatal) { - primary.addSuppressed(fatal); + fatal.addSuppressed(primary); + throw fatal; } catch (Exception closeFailure) { primary.addSuppressed(transactionFailure( closeFailure, TransactionPhase.CLEANUP, outcome)); From 575ee7ea679007b548efa0d4ce62ffcf5bbae618 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 12:21:56 +0200 Subject: [PATCH 60/63] Cover generic failures and timeout semantics --- .../DataProviderExceptionMapperTest.java | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/exception/DataProviderExceptionMapperTest.java b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/exception/DataProviderExceptionMapperTest.java index 647bea0..ddb6959 100644 --- a/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/exception/DataProviderExceptionMapperTest.java +++ b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/exception/DataProviderExceptionMapperTest.java @@ -7,6 +7,7 @@ import nl.hauntedmc.dataprovider.exception.BackendAuthenticationException; import nl.hauntedmc.dataprovider.exception.DataConflictException; import nl.hauntedmc.dataprovider.exception.DataProviderException; +import nl.hauntedmc.dataprovider.exception.DataProviderOperationException; import nl.hauntedmc.dataprovider.exception.DataProviderTimeoutException; import nl.hauntedmc.dataprovider.exception.ExecutionOutcome; import nl.hauntedmc.dataprovider.exception.ProviderClosedException; @@ -22,7 +23,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; class DataProviderExceptionMapperTest { @@ -87,14 +87,45 @@ void sqlFailuresAreClassifiedWithoutLeakingMessages() { ); assertEquals(ExecutionOutcome.NOT_APPLIED, conflict.executionOutcome()); - DataProviderTimeoutException timeout = assertInstanceOf( + DataProviderTimeoutException writeTimeout = assertInstanceOf( DataProviderTimeoutException.class, DataProviderExceptionMapper.translate( new SQLTimeoutException("write timed out", "HYT00"), execution, "mysql.executeUpdate") ); - assertEquals(ExecutionOutcome.MAY_HAVE_APPLIED, timeout.executionOutcome()); - assertTrue(timeout.retryable()); + assertEquals(ExecutionOutcome.MAY_HAVE_APPLIED, writeTimeout.executionOutcome()); + assertEquals(RetryAdvice.CONDITIONAL, writeTimeout.retryAdvice()); + } + + @Test + void readTimeoutIsSafeToRetryAndCannotHaveAppliedData() { + DataProviderTimeoutException timeout = assertInstanceOf( + DataProviderTimeoutException.class, + DataProviderExceptionMapper.translate( + new SQLTimeoutException("read timed out", "HYT00"), + execution, + "mysql.queryForList") + ); + + assertEquals(RetryAdvice.SAFE, timeout.retryAdvice()); + assertEquals(ExecutionOutcome.NOT_APPLIED, timeout.executionOutcome()); + } + + @Test + void unclassifiedDriverFailureUsesGenericOperationCategory() { + DataProviderOperationException failure = assertInstanceOf( + DataProviderOperationException.class, + DataProviderExceptionMapper.translate( + new SQLException("syntax near password=secret", "42000", 1064), + execution, + "mysql.executeUpdate") + ); + + assertEquals(RetryAdvice.CONDITIONAL, failure.retryAdvice()); + assertEquals(ExecutionOutcome.UNKNOWN, failure.executionOutcome()); + assertEquals("42000", failure.diagnostics().get("sqlState")); + assertFalse(failure.getMessage().contains("secret")); + assertFalse(failure.getCause().getMessage().contains("secret")); } } From 9cc811c38735b53d298674a450ed8c830ee3c503 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 12:23:00 +0200 Subject: [PATCH 61/63] Cover transaction cleanup and generic failures --- .../impl/mysql/MySQLDataAccessTest.java | 74 ++++++++++++++++--- 1 file changed, 65 insertions(+), 9 deletions(-) diff --git a/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/database/relational/impl/mysql/MySQLDataAccessTest.java b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/database/relational/impl/mysql/MySQLDataAccessTest.java index f7fc6d6..cf21163 100644 --- a/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/database/relational/impl/mysql/MySQLDataAccessTest.java +++ b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/database/relational/impl/mysql/MySQLDataAccessTest.java @@ -2,6 +2,7 @@ import nl.hauntedmc.dataprovider.core.testutil.DirectExecutorService; import nl.hauntedmc.dataprovider.exception.BackendUnavailableException; +import nl.hauntedmc.dataprovider.exception.DataProviderOperationException; import nl.hauntedmc.dataprovider.exception.DataTransactionException; import nl.hauntedmc.dataprovider.exception.ExecutionOutcome; import nl.hauntedmc.dataprovider.exception.TransactionPhase; @@ -19,11 +20,15 @@ import java.util.concurrent.CompletionException; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -148,7 +153,7 @@ void executeBatchUpdateAddsEachBatchEntry() throws Exception { } @Test - void executeTransactionallyCommitsAndRestoresConnectionState() throws Exception { + void executeTransactionallyCommitsRestoresAndClosesConnection() throws Exception { DataSource dataSource = mock(DataSource.class); Connection connection = mock(Connection.class); when(dataSource.getConnection()).thenReturn(connection); @@ -160,15 +165,35 @@ void executeTransactionallyCommitsAndRestoresConnectionState() throws Exception verify(connection).setAutoCommit(false); verify(connection).commit(); verify(connection).setAutoCommit(true); + verify(connection).close(); } @Test - void callbackAndRollbackFailuresRetainPrimaryTransactionFailure() throws Exception { + void failedTransactionSetupClosesAcquiredConnection() throws Exception { + DataSource dataSource = mock(DataSource.class); + Connection connection = mock(Connection.class); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.getAutoCommit()).thenReturn(true); + doThrow(new SQLException("setup secret")).when(connection).setAutoCommit(false); + + CompletionException completion = assertThrows(CompletionException.class, + () -> new MySQLDataAccess(dataSource, new DirectExecutorService()) + .executeTransactionally(conn -> "unused").join()); + DataTransactionException transaction = assertInstanceOf( + DataTransactionException.class, completion.getCause()); + assertEquals(TransactionPhase.BEGIN, transaction.phase()); + assertEquals(ExecutionOutcome.NOT_STARTED, transaction.executionOutcome()); + verify(connection).close(); + } + + @Test + void callbackRollbackAndCloseFailuresRemainStructuredAndRedacted() throws Exception { DataSource dataSource = mock(DataSource.class); Connection connection = mock(Connection.class); when(dataSource.getConnection()).thenReturn(connection); when(connection.getAutoCommit()).thenReturn(false); - org.mockito.Mockito.doThrow(new SQLException("rollback secret")).when(connection).rollback(); + doThrow(new SQLException("rollback secret")).when(connection).rollback(); + doThrow(new SQLException("close secret")).when(connection).close(); CompletionException completion = assertThrows(CompletionException.class, () -> new MySQLDataAccess(dataSource, new DirectExecutorService()) @@ -179,11 +204,19 @@ void callbackAndRollbackFailuresRetainPrimaryTransactionFailure() throws Excepti DataTransactionException.class, completion.getCause()); assertEquals(TransactionPhase.CALLBACK, transaction.phase()); assertEquals(ExecutionOutcome.NOT_APPLIED, transaction.executionOutcome()); - assertEquals(1, transaction.getSuppressed().length); + assertEquals(2, transaction.getSuppressed().length); + for (Throwable suppressed : transaction.getSuppressed()) { + DataTransactionException structured = assertInstanceOf(DataTransactionException.class, suppressed); + assertFalse(structured.getMessage().contains("secret")); + assertFalse(structured.getCause().getMessage().contains("secret")); + } assertEquals(TransactionPhase.ROLLBACK, - assertInstanceOf(DataTransactionException.class, transaction.getSuppressed()[0]).phase()); + ((DataTransactionException) transaction.getSuppressed()[0]).phase()); + assertEquals(TransactionPhase.CLEANUP, + ((DataTransactionException) transaction.getSuppressed()[1]).phase()); verify(connection).rollback(); verify(connection, times(2)).setAutoCommit(false); + verify(connection).close(); } @Test @@ -192,7 +225,7 @@ void commitFailureReportsUnknownWriteOutcome() throws Exception { Connection connection = mock(Connection.class); when(dataSource.getConnection()).thenReturn(connection); when(connection.getAutoCommit()).thenReturn(true); - org.mockito.Mockito.doThrow(new SQLException("commit lost", "08006")).when(connection).commit(); + doThrow(new SQLException("commit lost", "08006")).when(connection).commit(); CompletionException completion = assertThrows(CompletionException.class, () -> new MySQLDataAccess(dataSource, new DirectExecutorService()) @@ -204,6 +237,29 @@ void commitFailureReportsUnknownWriteOutcome() throws Exception { assertTrue(transaction.retryable()); verify(connection).rollback(); verify(connection).setAutoCommit(true); + verify(connection).close(); + } + + @Test + void postCommitRestoreFailureUsesCleanupPhaseAndAppliedOutcome() throws Exception { + DataSource dataSource = mock(DataSource.class); + Connection connection = mock(Connection.class); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.getAutoCommit()).thenReturn(true); + doNothing().doThrow(new SQLException("restore secret")) + .when(connection).setAutoCommit(anyBoolean()); + + CompletionException completion = assertThrows(CompletionException.class, + () -> new MySQLDataAccess(dataSource, new DirectExecutorService()) + .executeTransactionally(conn -> "done").join()); + DataTransactionException transaction = assertInstanceOf( + DataTransactionException.class, completion.getCause()); + assertEquals(TransactionPhase.CLEANUP, transaction.phase()); + assertEquals(ExecutionOutcome.MAY_HAVE_APPLIED, transaction.executionOutcome()); + assertFalse(transaction.retryable()); + assertFalse(transaction.getCause().getMessage().contains("secret")); + verify(connection).commit(); + verify(connection).close(); } @Test @@ -226,11 +282,11 @@ void executeInsertReturnsGeneratedKeyAndStructuresFailure() throws Exception { when(statement.executeUpdate()).thenReturn(0); CompletionException completion = assertThrows(CompletionException.class, () -> access.executeInsert("INSERT INTO players(name) VALUES (?)", "test").join()); - assertInstanceOf(BackendUnavailableException.class, completion.getCause()); + assertInstanceOf(DataProviderOperationException.class, completion.getCause()); } @Test - void sqlExceptionsCompleteWithStructuredBackendFailure() throws Exception { + void connectionSqlExceptionsCompleteWithUnavailableFailure() throws Exception { DataSource dataSource = mock(DataSource.class); when(dataSource.getConnection()).thenThrow(new SQLException("password=secret", "08001")); @@ -240,6 +296,6 @@ void sqlExceptionsCompleteWithStructuredBackendFailure() throws Exception { BackendUnavailableException failure = assertInstanceOf( BackendUnavailableException.class, completion.getCause()); assertEquals("08001", failure.diagnostics().get("sqlState")); - assertTrue(!failure.getMessage().contains("secret")); + assertFalse(failure.getMessage().contains("secret")); } } From 84518cf3c6d9861d0734136ec0a30deb78af5542 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 12:23:35 +0200 Subject: [PATCH 62/63] Document generic failures and cleanup semantics --- docs/EXCEPTIONS.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/EXCEPTIONS.md b/docs/EXCEPTIONS.md index 489c52e..85db394 100644 --- a/docs/EXCEPTIONS.md +++ b/docs/EXCEPTIONS.md @@ -20,7 +20,9 @@ try { } catch (BackendAuthenticationException exception) { // Configuration intervention is required; retrying unchanged credentials is not useful. } catch (BackendUnavailableException exception) { - // Consult retryAdvice() and executionOutcome() before retrying. + // The backend is disabled or unreachable. +} catch (DataProviderOperationException exception) { + // The operation failed without matching a more specific public category. } ``` @@ -39,14 +41,17 @@ All structured exceptions expose: `retryable()` is a convenience method. Prefer `retryAdvice()` and `executionOutcome()` for writes: -- `SAFE` + `NOT_STARTED`: retrying is normally safe. +- `SAFE` + `NOT_STARTED` or `NOT_APPLIED`: retrying is normally safe. - `CONDITIONAL` + `MAY_HAVE_APPLIED`: do not retry blindly; use an idempotency key or verify backend state. -- `NEVER`: correct configuration, ownership, authentication, or lifecycle state first. +- `CONDITIONAL` + `UNKNOWN`: retry only when the operation is idempotent or backend state has been checked. +- `NEVER`: correct configuration, ownership, authentication, lifecycle, or cleanup state first. -A transaction commit timeout can mean the commit succeeded but its acknowledgement was lost. DataProvider reports this as `DataTransactionException` with phase `COMMIT` and outcome `MAY_HAVE_APPLIED`. +Read timeouts are reported as `SAFE` with outcome `NOT_APPLIED`. Write timeouts remain `CONDITIONAL` with outcome `MAY_HAVE_APPLIED`. + +A transaction commit timeout can mean the commit succeeded but its acknowledgement was lost. DataProvider reports this as `DataTransactionException` with phase `COMMIT` and outcome `MAY_HAVE_APPLIED`. Failures while restoring or closing a connection after a successful commit use phase `CLEANUP`, outcome `MAY_HAVE_APPLIED`, and are not retryable. ## Redaction -DataProvider-generated public exception messages, diagnostics, and causes do not include passwords, tokens, payloads, query parameter values, raw configuration, or credential-bearing URLs. Public causes preserve the original failure type through a redacted surrogate. Registration lifecycle failures retain their original internal cause for diagnostics while exposing only redacted public metadata. +DataProvider-generated public exception messages, diagnostics, causes, and suppressed cleanup failures do not include passwords, tokens, payloads, query parameter values, raw configuration, or credential-bearing URLs. Public causes preserve the original failure type through a redacted surrogate. Registration lifecycle failures retain their original internal cause for diagnostics while exposing only redacted public metadata. -Rollback failures are attached as suppressed structured exceptions without replacing the primary transaction failure. +Rollback and cleanup failures are attached as suppressed structured exceptions without replacing the primary transaction failure. JVM-fatal errors remain primary and are never converted into ordinary DataProvider failures. From 2627abb43f27e0281358d207728707a2afe2b62c Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 12:26:03 +0200 Subject: [PATCH 63/63] Update async failure classification test --- .../core/concurrent/AsyncTaskSupportTest.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/concurrent/AsyncTaskSupportTest.java b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/concurrent/AsyncTaskSupportTest.java index efb5cbf..ad6f2c2 100644 --- a/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/concurrent/AsyncTaskSupportTest.java +++ b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/concurrent/AsyncTaskSupportTest.java @@ -1,6 +1,6 @@ package nl.hauntedmc.dataprovider.core.concurrent; -import nl.hauntedmc.dataprovider.exception.BackendUnavailableException; +import nl.hauntedmc.dataprovider.exception.DataProviderOperationException; import nl.hauntedmc.dataprovider.exception.ExecutionOutcome; import nl.hauntedmc.dataprovider.exception.QueueSaturatedException; import nl.hauntedmc.dataprovider.exception.RetryAdvice; @@ -42,7 +42,7 @@ void runAsyncReturnsStructuredFailureWhenExecutorRejects() { } @Test - void runAsyncRedactsAndStructuresBackendFailures() { + void runAsyncRedactsAndStructuresUnclassifiedFailures() { Executor directExecutor = Runnable::run; CompletableFuture future = AsyncTaskSupport.runAsync( directExecutor, @@ -53,8 +53,8 @@ void runAsyncRedactsAndStructuresBackendFailures() { ); CompletionException completion = assertThrows(CompletionException.class, future::join); - BackendUnavailableException failure = assertInstanceOf( - BackendUnavailableException.class, completion.getCause()); + DataProviderOperationException failure = assertInstanceOf( + DataProviderOperationException.class, completion.getCause()); assertEquals("unit.failure", failure.operationName()); assertEquals("java.lang.IllegalStateException", failure.diagnostics().get("causeType")); }