From 65dd173d5ee4f8129526db00af13cefbdd3d3390 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 00:30:40 +0200 Subject: [PATCH 1/5] Add neutral DataProvider observation SPI --- .github/workflows/ci-lint.yml | 2 +- .../dataprovider/api/DataProviderAPI.java | 13 + .../observation/DataProviderObservation.java | 40 +++ .../api/observation/DataProviderObserver.java | 23 ++ .../DataProviderOperationContext.java | 40 +++ .../core/api/DataProviderObservations.java | 107 +++++++ .../core/api/DefaultDataProviderApi.java | 120 +++++++- .../core/api/DefaultDataProviderScope.java | 61 +++- .../api/IdentityBoundDatabaseProvider.java | 212 ++++++++++++-- .../core/api/ObservedOrmContext.java | 40 +++ .../core/api/DataProviderObservationTest.java | 264 ++++++++++++++++++ docs/OBSERVATION.md | 50 ++++ docs/README.md | 1 + pom.xml | 2 +- 14 files changed, 933 insertions(+), 42 deletions(-) create mode 100644 dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/observation/DataProviderObservation.java create mode 100644 dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/observation/DataProviderObserver.java create mode 100644 dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/observation/DataProviderOperationContext.java create mode 100644 dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DataProviderObservations.java create mode 100644 dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/ObservedOrmContext.java create mode 100644 dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/api/DataProviderObservationTest.java create mode 100644 docs/OBSERVATION.md diff --git a/.github/workflows/ci-lint.yml b/.github/workflows/ci-lint.yml index 278fb82d..7cf6b6e2 100644 --- a/.github/workflows/ci-lint.yml +++ b/.github/workflows/ci-lint.yml @@ -13,7 +13,7 @@ concurrency: jobs: maven-policy: name: Shared Maven policy - uses: HauntedMC/HauntedPlatform/.github/workflows/maven-ci.yml@v1.2.0 + uses: HauntedMC/HauntedPlatform/.github/workflows/maven-ci.yml@v1.3.0 with: maven-command: ./mvnw -U -B -ntp -DskipTests verify secrets: 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 1994d0b5..57e300bf 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,5 +1,6 @@ package nl.hauntedmc.dataprovider.api; +import nl.hauntedmc.dataprovider.api.observation.DataProviderObserver; import nl.hauntedmc.dataprovider.api.orm.ORMContext; import nl.hauntedmc.dataprovider.database.DatabaseProvider; import nl.hauntedmc.dataprovider.database.DatabaseType; @@ -19,6 +20,18 @@ default DataProviderAPI forPlugin(Object platformPlugin) { throw new UnsupportedOperationException("This DataProvider API does not support plugin binding."); } + /** + * Returns a facade that reports its operations to the supplied vendor-neutral observer. + * + *

The observer remains scoped to this facade, its child scopes, and handles obtained from it; + * it is never installed globally. Implementations predating observation support may return this + * facade unchanged.

+ */ + default DataProviderAPI withObserver(DataProviderObserver observer) { + Objects.requireNonNull(observer, "DataProvider observer cannot be null."); + return this; + } + /** * Creates an ORM context using the platform administrator's configured schema mode. * diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/observation/DataProviderObservation.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/observation/DataProviderObservation.java new file mode 100644 index 00000000..00a8bbe3 --- /dev/null +++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/observation/DataProviderObservation.java @@ -0,0 +1,40 @@ +package nl.hauntedmc.dataprovider.api.observation; + +/** + * One in-flight DataProvider operation observed through {@link DataProviderObserver}. + * + *

Exactly one terminal callback is issued by the DataProvider runtime for an observation it + * successfully starts. Implementations must be thread-safe because asynchronous operations may + * complete on a backend worker thread rather than the thread that started the observation.

+ */ +public interface DataProviderObservation { + + /** Called when the observed operation completes successfully. */ + void succeeded(); + + /** Called when the observed operation completes exceptionally or throws. */ + void failed(Throwable failure); + + /** Returns a reusable observation that ignores all terminal callbacks. */ + static DataProviderObservation noop() { + return NoopDataProviderObservation.INSTANCE; + } +} + +final class NoopDataProviderObservation implements DataProviderObservation { + + static final NoopDataProviderObservation INSTANCE = new NoopDataProviderObservation(); + + private NoopDataProviderObservation() { + } + + @Override + public void succeeded() { + // Intentionally empty. + } + + @Override + public void failed(Throwable failure) { + // Intentionally empty. + } +} diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/observation/DataProviderObserver.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/observation/DataProviderObserver.java new file mode 100644 index 00000000..4b012fd6 --- /dev/null +++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/observation/DataProviderObserver.java @@ -0,0 +1,23 @@ +package nl.hauntedmc.dataprovider.api.observation; + +/** + * Vendor-neutral hook for observing plugin-scoped DataProvider operations. + * + *

The runtime invokes observers synchronously when an operation starts. The returned observation + * receives its terminal callback when that operation completes; asynchronous operations may finish + * on a backend worker thread. Implementations should therefore be thread-safe and non-blocking.

+ * + *

Observer failures are isolated by the DataProvider runtime and never change the outcome of the + * underlying data operation.

+ */ +@FunctionalInterface +public interface DataProviderObserver { + + /** Starts one operation observation. Implementations should return a non-null handle. */ + DataProviderObservation start(DataProviderOperationContext context); + + /** Returns a reusable observer that performs no work. */ + static DataProviderObserver noop() { + return context -> DataProviderObservation.noop(); + } +} diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/observation/DataProviderOperationContext.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/observation/DataProviderOperationContext.java new file mode 100644 index 00000000..b4190236 --- /dev/null +++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/observation/DataProviderOperationContext.java @@ -0,0 +1,40 @@ +package nl.hauntedmc.dataprovider.api.observation; + +import nl.hauntedmc.dataprovider.api.OwnerScope; +import nl.hauntedmc.dataprovider.database.DatabaseType; + +import java.util.Objects; + +/** + * Stable, payload-free metadata describing one observed DataProvider operation. + * + *

The operation name comes from DataProvider's bounded public operation vocabulary, for example + * {@code database.register}, {@code relational.queryForSingle}, or {@code keyvalue.getKey}. + * Connection identifiers, SQL text, keys, destinations, payloads, credentials, and player data are + * deliberately excluded from this contract.

+ * + * @param pluginId platform-derived plugin identity that owns the API facade + * @param ownerScope public lifecycle owner scope; internal unique registration scopes are never exposed + * @param databaseType backend used by the operation + * @param operation stable DataProvider operation name + */ +public record DataProviderOperationContext( + String pluginId, + OwnerScope ownerScope, + DatabaseType databaseType, + String operation +) { + + public DataProviderOperationContext { + Objects.requireNonNull(pluginId, "Plugin id cannot be null."); + Objects.requireNonNull(ownerScope, "Owner scope cannot be null."); + Objects.requireNonNull(databaseType, "Database type cannot be null."); + Objects.requireNonNull(operation, "Operation cannot be null."); + if (pluginId.isBlank()) { + throw new IllegalArgumentException("Plugin id cannot be blank."); + } + if (operation.isBlank()) { + throw new IllegalArgumentException("Operation cannot be blank."); + } + } +} diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DataProviderObservations.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DataProviderObservations.java new file mode 100644 index 00000000..d0ed36ec --- /dev/null +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DataProviderObservations.java @@ -0,0 +1,107 @@ +package nl.hauntedmc.dataprovider.core.api; + +import nl.hauntedmc.dataprovider.api.observation.DataProviderObservation; +import nl.hauntedmc.dataprovider.api.observation.DataProviderObserver; +import nl.hauntedmc.dataprovider.api.observation.DataProviderOperationContext; + +import java.util.Objects; +import java.util.concurrent.CompletionStage; +import java.util.function.Supplier; + +/** Isolates optional observation callbacks from the data path. */ +final class DataProviderObservations { + + private DataProviderObservations() { + } + + static T observe( + DataProviderObserver observer, + DataProviderOperationContext context, + Supplier operation + ) { + Objects.requireNonNull(operation, "Operation cannot be null."); + DataProviderObservation observation = start(observer, context); + try { + T result = operation.get(); + succeeded(observation); + return result; + } catch (RuntimeException | Error failure) { + failed(observation, failure); + throw failure; + } + } + + static void observe( + DataProviderObserver observer, + DataProviderOperationContext context, + Runnable operation + ) { + observe(observer, context, () -> { + operation.run(); + return null; + }); + } + + static Object observeInvocation( + DataProviderObserver observer, + DataProviderOperationContext context, + ThrowingOperation operation + ) throws Throwable { + DataProviderObservation observation = start(observer, context); + final Object result; + try { + result = operation.execute(); + } catch (Throwable failure) { + failed(observation, failure); + throw failure; + } + if (result instanceof CompletionStage completionStage) { + completionStage.whenComplete((ignored, failure) -> { + if (failure == null) { + succeeded(observation); + } else { + failed(observation, failure); + } + }); + } else { + succeeded(observation); + } + return result; + } + + private static DataProviderObservation start( + DataProviderObserver observer, + DataProviderOperationContext context + ) { + if (observer == null) { + return DataProviderObservation.noop(); + } + try { + DataProviderObservation observation = observer.start(context); + return observation == null ? DataProviderObservation.noop() : observation; + } catch (RuntimeException ignored) { + return DataProviderObservation.noop(); + } + } + + private static void succeeded(DataProviderObservation observation) { + try { + observation.succeeded(); + } catch (RuntimeException ignored) { + // Observability must never change a successful data operation into a failure. + } + } + + private static void failed(DataProviderObservation observation, Throwable failure) { + try { + observation.failed(failure); + } catch (RuntimeException ignored) { + // Preserve the original data-operation failure. + } + } + + @FunctionalInterface + interface ThrowingOperation { + Object execute() throws Throwable; + } +} 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 03575850..1b4f1531 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 @@ -3,6 +3,8 @@ import nl.hauntedmc.dataprovider.api.DataProviderAPI; import nl.hauntedmc.dataprovider.api.DataProviderScope; import nl.hauntedmc.dataprovider.api.OwnerScope; +import nl.hauntedmc.dataprovider.api.observation.DataProviderObserver; +import nl.hauntedmc.dataprovider.api.observation.DataProviderOperationContext; import nl.hauntedmc.dataprovider.api.orm.ORMContext; import nl.hauntedmc.dataprovider.core.DataProviderHandler; import nl.hauntedmc.dataprovider.core.identity.PluginIdentity; @@ -18,20 +20,34 @@ public final class DefaultDataProviderApi implements DataProviderAPI { private final DataProviderHandler handler; private final PluginIdentity identity; + private final DataProviderObserver observer; public DefaultDataProviderApi(DataProviderHandler handler) { - this.handler = Objects.requireNonNull(handler, "DataProviderHandler cannot be null"); - this.identity = null; + this(handler, null, DataProviderObserver.noop()); } - private DefaultDataProviderApi(DataProviderHandler handler, PluginIdentity identity) { + private DefaultDataProviderApi( + DataProviderHandler handler, + PluginIdentity identity, + DataProviderObserver observer + ) { this.handler = Objects.requireNonNull(handler, "DataProviderHandler cannot be null"); - this.identity = Objects.requireNonNull(identity, "Plugin identity cannot be null"); + this.identity = identity; + this.observer = Objects.requireNonNull(observer, "DataProvider observer cannot be null."); } @Override public DataProviderAPI forPlugin(Object platformPlugin) { - return new DefaultDataProviderApi(handler, handler.issuePluginIdentity(platformPlugin)); + return new DefaultDataProviderApi(handler, handler.issuePluginIdentity(platformPlugin), observer); + } + + @Override + public DataProviderAPI withObserver(DataProviderObserver dataProviderObserver) { + return new DefaultDataProviderApi( + handler, + identity, + Objects.requireNonNull(dataProviderObserver, "DataProvider observer cannot be null.") + ); } @Override @@ -48,13 +64,24 @@ public ORMContext createOrmContext( } PluginIdentity boundIdentity = requireIdentity(); Class[] validatedEntities = validateEntityClasses(boundIdentity, entityClasses); - return new nl.hauntedmc.dataprovider.core.orm.ORMContext( - handler.getPluginId(boundIdentity), + String pluginId = handler.getPluginId(boundIdentity); + ORMContext context = new nl.hauntedmc.dataprovider.core.orm.ORMContext( + pluginId, dataSource, logger, handler.getConfiguredOrmSchemaMode(boundIdentity), validatedEntities ); + return new ObservedOrmContext( + context, + observer, + operationContext( + pluginId, + OwnerScope.of(pluginId), + IdentityBoundDatabaseProvider.boundDatabaseType(dataSource), + "orm.runInTransaction" + ) + ); } /** Package-visible for API-path regression tests. */ @@ -92,20 +119,45 @@ private static boolean isOwnedOrSharedClass(ClassLoader pluginLoader, ClassLoade @Override public DatabaseProvider registerDatabaseOrThrow(DatabaseType databaseType, String connectionIdentifier) { PluginIdentity boundIdentity = requireIdentity(); - return wrapProvider(handler, boundIdentity, - handler.registerDatabaseOrThrow(boundIdentity, databaseType, connectionIdentifier)); + String pluginId = boundIdentity.pluginId(); + OwnerScope ownerScope = OwnerScope.of(pluginId); + return DataProviderObservations.observe( + observer, + operationContext(pluginId, ownerScope, databaseType, "database.register"), + () -> wrapProvider( + handler, + boundIdentity, + handler.registerDatabaseOrThrow(boundIdentity, databaseType, connectionIdentifier), + observer, + pluginId, + ownerScope, + databaseType + ) + ); } @Override public DataProviderScope scope(OwnerScope ownerScope) { PluginIdentity boundIdentity = requireIdentity(); handler.requireIdentity(boundIdentity); - return new DefaultDataProviderScope(handler, ownerScope, boundIdentity); + return new DefaultDataProviderScope( + handler, + ownerScope, + boundIdentity, + observer, + boundIdentity.pluginId() + ); } @Override public void unregisterDatabase(DatabaseType databaseType, String connectionIdentifier) { - handler.unregisterDatabase(requireIdentity(), databaseType, connectionIdentifier); + PluginIdentity boundIdentity = requireIdentity(); + String pluginId = boundIdentity.pluginId(); + DataProviderObservations.observe( + observer, + operationContext(pluginId, OwnerScope.of(pluginId), databaseType, "database.unregister"), + () -> handler.unregisterDatabase(boundIdentity, databaseType, connectionIdentifier) + ); } @Override @@ -121,16 +173,56 @@ public void unregisterAllDatabasesForPlugin() { @Override public DatabaseProvider requireRegisteredDatabase(DatabaseType databaseType, String connectionIdentifier) { PluginIdentity boundIdentity = requireIdentity(); - return wrapProvider(handler, boundIdentity, - handler.requireRegisteredDatabase(boundIdentity, databaseType, connectionIdentifier)); + String pluginId = boundIdentity.pluginId(); + OwnerScope ownerScope = OwnerScope.of(pluginId); + return wrapProvider( + handler, + boundIdentity, + handler.requireRegisteredDatabase(boundIdentity, databaseType, connectionIdentifier), + observer, + pluginId, + ownerScope, + databaseType + ); } static DatabaseProvider wrapProvider( - DataProviderHandler handler, PluginIdentity identity, DatabaseProvider provider + DataProviderHandler handler, + PluginIdentity identity, + DatabaseProvider provider ) { return IdentityBoundDatabaseProvider.wrap(handler, identity, provider); } + static DatabaseProvider wrapProvider( + DataProviderHandler handler, + PluginIdentity identity, + DatabaseProvider provider, + DataProviderObserver observer, + String pluginId, + OwnerScope ownerScope, + DatabaseType databaseType + ) { + return IdentityBoundDatabaseProvider.wrap( + handler, + identity, + provider, + observer, + pluginId, + ownerScope, + databaseType + ); + } + + private static DataProviderOperationContext operationContext( + String pluginId, + OwnerScope ownerScope, + DatabaseType databaseType, + String operation + ) { + return new DataProviderOperationContext(pluginId, ownerScope, databaseType, operation); + } + private PluginIdentity requireIdentity() { if (identity == null) { throw new IllegalStateException("Bind DataProviderAPI with forPlugin(plugin) before use."); 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 b010fb2e..587ecff7 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,6 +2,8 @@ import nl.hauntedmc.dataprovider.api.DataProviderScope; import nl.hauntedmc.dataprovider.api.OwnerScope; +import nl.hauntedmc.dataprovider.api.observation.DataProviderObserver; +import nl.hauntedmc.dataprovider.api.observation.DataProviderOperationContext; import nl.hauntedmc.dataprovider.core.DataProviderHandler; import nl.hauntedmc.dataprovider.core.identity.PluginIdentity; import nl.hauntedmc.dataprovider.database.DatabaseProvider; @@ -24,14 +26,28 @@ public final class DefaultDataProviderScope implements DataProviderScope { private final OwnerScope ownerScope; private final OwnerScope registrationScope; private final PluginIdentity identity; + private final DataProviderObserver observer; + private final String pluginId; private final Object lifecycleMonitor = new Object(); private volatile LifecycleState lifecycleState = LifecycleState.OPEN; DefaultDataProviderScope(DataProviderHandler handler, OwnerScope ownerScope, PluginIdentity identity) { + this(handler, ownerScope, identity, DataProviderObserver.noop(), identity.pluginId()); + } + + DefaultDataProviderScope( + DataProviderHandler handler, + OwnerScope ownerScope, + PluginIdentity identity, + DataProviderObserver observer, + String pluginId + ) { this.handler = Objects.requireNonNull(handler, "DataProviderHandler cannot be null."); this.ownerScope = Objects.requireNonNull(ownerScope, "Owner scope cannot be null."); this.registrationScope = uniqueRegistrationScope(ownerScope); this.identity = Objects.requireNonNull(identity, "Plugin identity cannot be null."); + this.observer = Objects.requireNonNull(observer, "DataProvider observer cannot be null."); + this.pluginId = Objects.requireNonNull(pluginId, "Plugin id cannot be null."); } @Override @@ -50,12 +66,22 @@ public LifecycleState lifecycleState() { public DatabaseProvider registerDatabaseOrThrow(DatabaseType databaseType, String connectionIdentifier) { synchronized (lifecycleMonitor) { requireStructuredOpen("scope.registerDatabase"); - return DefaultDataProviderApi.wrapProvider(handler, identity, - handler.registerDatabaseForScopeOrThrow( + return DataProviderObservations.observe( + observer, + operationContext(databaseType, "database.register"), + () -> DefaultDataProviderApi.wrapProvider( + handler, identity, - registrationScope, - databaseType, - connectionIdentifier + handler.registerDatabaseForScopeOrThrow( + identity, + registrationScope, + databaseType, + connectionIdentifier + ), + observer, + pluginId, + ownerScope, + databaseType ) ); } @@ -65,7 +91,16 @@ public DatabaseProvider registerDatabaseOrThrow(DatabaseType databaseType, Strin public void unregisterDatabase(DatabaseType databaseType, String connectionIdentifier) { synchronized (lifecycleMonitor) { requireCleanupOpen("scope.unregisterDatabase"); - handler.unregisterDatabaseForScope(identity, registrationScope, databaseType, connectionIdentifier); + DataProviderObservations.observe( + observer, + operationContext(databaseType, "database.unregister"), + () -> handler.unregisterDatabaseForScope( + identity, + registrationScope, + databaseType, + connectionIdentifier + ) + ); } } @@ -81,13 +116,19 @@ public void unregisterAllDatabases() { public DatabaseProvider requireRegisteredDatabase(DatabaseType databaseType, String connectionIdentifier) { synchronized (lifecycleMonitor) { requireStructuredOpen("scope.requireRegisteredDatabase"); - return DefaultDataProviderApi.wrapProvider(handler, identity, + return DefaultDataProviderApi.wrapProvider( + handler, + identity, handler.requireRegisteredDatabaseForScope( identity, registrationScope, databaseType, connectionIdentifier - ) + ), + observer, + pluginId, + ownerScope, + databaseType ); } } @@ -110,6 +151,10 @@ public void close() { } } + private DataProviderOperationContext operationContext(DatabaseType databaseType, String operation) { + return new DataProviderOperationContext(pluginId, ownerScope, databaseType, operation); + } + private void requireStructuredOpen(String operation) { requireOwner(); requireLocallyOpen(operation); diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/IdentityBoundDatabaseProvider.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/IdentityBoundDatabaseProvider.java index 49252817..a7ceaf22 100644 --- a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/IdentityBoundDatabaseProvider.java +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/IdentityBoundDatabaseProvider.java @@ -1,11 +1,17 @@ package nl.hauntedmc.dataprovider.core.api; +import nl.hauntedmc.dataprovider.api.OwnerScope; +import nl.hauntedmc.dataprovider.api.observation.DataProviderObserver; +import nl.hauntedmc.dataprovider.api.observation.DataProviderOperationContext; import nl.hauntedmc.dataprovider.core.DataProviderHandler; import nl.hauntedmc.dataprovider.core.concurrent.ScopedDataSource; import nl.hauntedmc.dataprovider.core.identity.PluginIdentity; import nl.hauntedmc.dataprovider.database.DataAccess; 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; import nl.hauntedmc.dataprovider.database.keyvalue.KeyValueDatabaseProvider; import nl.hauntedmc.dataprovider.database.messaging.MessagingDataAccess; import nl.hauntedmc.dataprovider.database.messaging.MessagingDatabaseProvider; @@ -13,6 +19,7 @@ import nl.hauntedmc.dataprovider.database.messaging.durable.DurableDelivery; import nl.hauntedmc.dataprovider.database.messaging.durable.DurableMessagingDataAccess; import nl.hauntedmc.dataprovider.database.messaging.durable.DurableSubscription; +import nl.hauntedmc.dataprovider.database.relational.RelationalDataAccess; import nl.hauntedmc.dataprovider.database.relational.RelationalDatabaseProvider; import nl.hauntedmc.dataprovider.database.relational.schema.SchemaManager; @@ -42,13 +49,60 @@ static DatabaseProvider wrap(DataProviderHandler handler, PluginIdentity identit if (provider == null) { return null; } - return (DatabaseProvider) proxy(provider, handler, identity, providerInterfaces(provider)); + DatabaseType databaseType = inferDatabaseType(provider); + ObservationTarget target = new ObservationTarget( + DataProviderObserver.noop(), + identity.pluginId(), + OwnerScope.of(identity.pluginId()), + databaseType + ); + return (DatabaseProvider) proxy(provider, handler, identity, providerInterfaces(provider), target); + } + + static DatabaseProvider wrap( + DataProviderHandler handler, + PluginIdentity identity, + DatabaseProvider provider, + DataProviderObserver observer, + String pluginId, + OwnerScope ownerScope, + DatabaseType databaseType + ) { + if (provider == null) { + return null; + } + ObservationTarget target = new ObservationTarget(observer, pluginId, ownerScope, databaseType); + return (DatabaseProvider) proxy(provider, handler, identity, providerInterfaces(provider), target); } static boolean isBoundDataSource(DataSource dataSource) { return dataSource instanceof GuardedDataSource; } + static DatabaseType boundDatabaseType(DataSource dataSource) { + if (!(dataSource instanceof GuardedDataSource guardedDataSource)) { + throw new IllegalArgumentException("DataSource is not bound to a DataProvider registration."); + } + return guardedDataSource.target.databaseType(); + } + + private static DatabaseType inferDatabaseType(DatabaseProvider provider) { + if (provider instanceof RelationalDatabaseProvider) { + return DatabaseType.MYSQL; + } + if (provider instanceof DocumentDatabaseProvider) { + return DatabaseType.MONGODB; + } + if (provider instanceof KeyValueDatabaseProvider) { + return DatabaseType.REDIS; + } + if (provider instanceof MessagingDatabaseProvider) { + return DatabaseType.REDIS_MESSAGING; + } + throw new IllegalArgumentException("Unsupported DataProvider database-provider contract: " + + provider.getClass().getName()); + } + private static Class[] providerInterfaces(DatabaseProvider provider) { if (provider instanceof RelationalDatabaseProvider) { return new Class[] {RelationalDatabaseProvider.class}; @@ -65,12 +119,18 @@ private static Class[] providerInterfaces(DatabaseProvider provider) { return new Class[] {DatabaseProvider.class}; } - private static Object proxy(Object delegate, DataProviderHandler handler, PluginIdentity identity, Class[] interfaces) { + private static Object proxy( + Object delegate, + DataProviderHandler handler, + PluginIdentity identity, + Class[] interfaces, + ObservationTarget target + ) { Objects.requireNonNull(delegate, "Delegate cannot be null."); Objects.requireNonNull(handler, "Handler cannot be null."); return Proxy.newProxyInstance( IdentityBoundDatabaseProvider.class.getClassLoader(), interfaces, - new GuardedInvocation(delegate, handler, identity) + new GuardedInvocation(delegate, handler, identity, target) ); } @@ -78,11 +138,18 @@ private static final class GuardedInvocation implements InvocationHandler { private final Object delegate; private final DataProviderHandler handler; private final PluginIdentity identity; + private final ObservationTarget target; - private GuardedInvocation(Object delegate, DataProviderHandler handler, PluginIdentity identity) { + private GuardedInvocation( + Object delegate, + DataProviderHandler handler, + PluginIdentity identity, + ObservationTarget target + ) { this.delegate = delegate; this.handler = handler; this.identity = identity; + this.target = target; } @Override @@ -101,10 +168,29 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl if (method.getName().equals("isWrapperFor") && args != null && args.length == 1) { return ((Class) args[0]).isInstance(proxy); } + + Object[] invocationArguments = guardedDurableHandlerArguments( + method, + args, + handler, + identity, + target + ); + String operation = observationOperation(method); + if (operation == null) { + return invokeAndBind(method, invocationArguments); + } + return DataProviderObservations.observeInvocation( + target.observer(), + target.context(operation), + () -> invokeAndBind(method, invocationArguments) + ); + } + + private Object invokeAndBind(Method method, Object[] invocationArguments) throws Throwable { try { - Object[] invocationArguments = guardedDurableHandlerArguments(method, args, handler, identity); Object result = method.invoke(delegate, invocationArguments); - return bindResult(method.getReturnType(), result, handler, identity); + return bindResult(method.getReturnType(), result, handler, identity, target); } catch (InvocationTargetException exception) { throw exception.getCause(); } @@ -115,7 +201,8 @@ private static Object[] guardedDurableHandlerArguments( Method method, Object[] arguments, DataProviderHandler handler, - PluginIdentity identity + PluginIdentity identity, + ObservationTarget target ) { if (!method.getName().equals("consume") || arguments == null || arguments.length != 6 || !(arguments[5] instanceof Consumer originalHandler)) { @@ -123,38 +210,90 @@ private static Object[] guardedDurableHandlerArguments( } Object[] guarded = arguments.clone(); guarded[5] = (Consumer>) delivery -> originalHandler.accept( - (DurableDelivery) bindResult(DurableDelivery.class, delivery, handler, identity)); + (DurableDelivery) bindResult( + DurableDelivery.class, + delivery, + handler, + identity, + target + ) + ); return guarded; } } - private static Object bindResult(Class returnType, Object result, DataProviderHandler handler, PluginIdentity identity) { + private static Object bindResult( + Class returnType, + Object result, + DataProviderHandler handler, + PluginIdentity identity, + ObservationTarget target + ) { if (result == null) { return null; } if (result instanceof DataSource dataSource) { - return new GuardedDataSource(dataSource, handler, identity); + return new GuardedDataSource(dataSource, handler, identity, target); } if (result instanceof Subscription subscription) { - return proxy(subscription, handler, identity, new Class[] {Subscription.class}); + return proxy(subscription, handler, identity, new Class[] {Subscription.class}, target); } if (result instanceof DurableSubscription subscription) { - return proxy(subscription, handler, identity, new Class[] {DurableSubscription.class}); + return proxy(subscription, handler, identity, new Class[] {DurableSubscription.class}, target); } if (result instanceof DurableMessagingDataAccess access) { - return proxy(access, handler, identity, new Class[] {DurableMessagingDataAccess.class}); + return proxy(access, handler, identity, new Class[] {DurableMessagingDataAccess.class}, target); } if (result instanceof DurableDelivery delivery) { - return proxy(delivery, handler, identity, new Class[] {DurableDelivery.class}); + return proxy(delivery, handler, identity, new Class[] {DurableDelivery.class}, target); } if (returnType.isInterface() && (DataAccess.class.isAssignableFrom(returnType) || SchemaManager.class.isAssignableFrom(returnType) || returnType.getPackageName().startsWith("java.sql"))) { - return proxy(result, handler, identity, new Class[] {returnType}); + return proxy(result, handler, identity, new Class[] {returnType}, target); } return result; } + private static String observationOperation(Method method) { + Class declaringClass = method.getDeclaringClass(); + String methodName = method.getName(); + if (RelationalDataAccess.class.isAssignableFrom(declaringClass)) { + return "relational." + methodName; + } + if (DocumentDataAccess.class.isAssignableFrom(declaringClass)) { + return "document." + methodName; + } + if (KeyValueDataAccess.class.isAssignableFrom(declaringClass)) { + return "keyvalue." + methodName; + } + if (SchemaManager.class.isAssignableFrom(declaringClass)) { + return "schema." + methodName; + } + if (DurableMessagingDataAccess.class.isAssignableFrom(declaringClass)) { + return switch (methodName) { + case "publish", "consume", "shutdown" -> "messaging.durable." + methodName; + default -> null; + }; + } + if (MessagingDataAccess.class.isAssignableFrom(declaringClass)) { + return switch (methodName) { + case "publish", "subscribe", "shutdown" -> "messaging." + methodName; + default -> null; + }; + } + if (Subscription.class.isAssignableFrom(declaringClass) && methodName.equals("unsubscribe")) { + return "messaging.subscription.unsubscribe"; + } + if (DurableSubscription.class.isAssignableFrom(declaringClass) && methodName.equals("closeAsync")) { + return "messaging.durable.subscription.closeAsync"; + } + if (DurableDelivery.class.isAssignableFrom(declaringClass) && methodName.equals("acknowledge")) { + return "messaging.durable.acknowledge"; + } + return null; + } + private static boolean isCleanupMethod(Method method) { String name = method.getName(); Class declaringClass = method.getDeclaringClass(); @@ -180,11 +319,18 @@ private static final class GuardedDataSource implements ScopedDataSource { private final DataSource delegate; private final DataProviderHandler handler; private final PluginIdentity identity; + private final ObservationTarget target; - private GuardedDataSource(DataSource delegate, DataProviderHandler handler, PluginIdentity identity) { + private GuardedDataSource( + DataSource delegate, + DataProviderHandler handler, + PluginIdentity identity, + ObservationTarget target + ) { this.delegate = delegate; this.handler = handler; this.identity = identity; + this.target = target; } private void check() { @@ -193,11 +339,23 @@ private void check() { @Override public Connection getConnection() throws SQLException { check(); - return (Connection) bindResult(Connection.class, delegate.getConnection(), handler, identity); + return (Connection) bindResult( + Connection.class, + delegate.getConnection(), + handler, + identity, + target + ); } @Override public Connection getConnection(String user, String password) throws SQLException { check(); - return (Connection) bindResult(Connection.class, delegate.getConnection(user, password), handler, identity); + return (Connection) bindResult( + Connection.class, + delegate.getConnection(user, password), + handler, + identity, + target + ); } @Override public PrintWriter getLogWriter() throws SQLException { check(); return delegate.getLogWriter(); } @Override public void setLogWriter(PrintWriter writer) throws SQLException { check(); delegate.setLogWriter(writer); } @@ -222,4 +380,22 @@ private static void check(DataProviderHandler handler, PluginIdentity identity, handler.requireIdentity(boundIdentity); } } + + private record ObservationTarget( + DataProviderObserver observer, + String pluginId, + OwnerScope ownerScope, + DatabaseType databaseType + ) { + private ObservationTarget { + Objects.requireNonNull(observer, "DataProvider observer cannot be null."); + Objects.requireNonNull(pluginId, "Plugin id cannot be null."); + Objects.requireNonNull(ownerScope, "Owner scope cannot be null."); + Objects.requireNonNull(databaseType, "Database type cannot be null."); + } + + private DataProviderOperationContext context(String operation) { + return new DataProviderOperationContext(pluginId, ownerScope, databaseType, operation); + } + } } diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/ObservedOrmContext.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/ObservedOrmContext.java new file mode 100644 index 00000000..4481a6dc --- /dev/null +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/ObservedOrmContext.java @@ -0,0 +1,40 @@ +package nl.hauntedmc.dataprovider.core.api; + +import nl.hauntedmc.dataprovider.api.observation.DataProviderObserver; +import nl.hauntedmc.dataprovider.api.observation.DataProviderOperationContext; +import nl.hauntedmc.dataprovider.api.orm.ORMContext; + +import java.util.Objects; + +/** Keeps ORM transaction observation outside the public ORM contract. */ +final class ObservedOrmContext implements ORMContext { + + private final ORMContext delegate; + private final DataProviderObserver observer; + private final DataProviderOperationContext operationContext; + + ObservedOrmContext( + ORMContext delegate, + DataProviderObserver observer, + DataProviderOperationContext operationContext + ) { + this.delegate = Objects.requireNonNull(delegate, "ORM context cannot be null."); + this.observer = Objects.requireNonNull(observer, "DataProvider observer cannot be null."); + this.operationContext = Objects.requireNonNull(operationContext, "Operation context cannot be null."); + } + + @Override + public T runInTransaction(TransactionCallback callback) { + Objects.requireNonNull(callback, "Transaction callback cannot be null."); + return DataProviderObservations.observe( + observer, + operationContext, + () -> delegate.runInTransaction(callback) + ); + } + + @Override + public void shutdown() { + delegate.shutdown(); + } +} diff --git a/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/api/DataProviderObservationTest.java b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/api/DataProviderObservationTest.java new file mode 100644 index 00000000..987e6052 --- /dev/null +++ b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/api/DataProviderObservationTest.java @@ -0,0 +1,264 @@ +package nl.hauntedmc.dataprovider.core.api; + +import nl.hauntedmc.dataprovider.api.DataProviderAPI; +import nl.hauntedmc.dataprovider.api.DataProviderScope; +import nl.hauntedmc.dataprovider.api.OwnerScope; +import nl.hauntedmc.dataprovider.api.observation.DataProviderObservation; +import nl.hauntedmc.dataprovider.api.observation.DataProviderObserver; +import nl.hauntedmc.dataprovider.api.observation.DataProviderOperationContext; +import nl.hauntedmc.dataprovider.api.orm.ORMContext; +import nl.hauntedmc.dataprovider.core.DataProviderHandler; +import nl.hauntedmc.dataprovider.core.identity.PluginIdentity; +import nl.hauntedmc.dataprovider.core.identity.PluginIdentityRegistry; +import nl.hauntedmc.dataprovider.database.DatabaseProvider; +import nl.hauntedmc.dataprovider.database.DatabaseType; +import nl.hauntedmc.dataprovider.database.relational.RelationalDataAccess; +import nl.hauntedmc.dataprovider.database.relational.RelationalDatabaseProvider; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class DataProviderObservationTest { + + @Test + void facadeRegistrationReportsOnlyStablePluginScopeAndBackendMetadata() { + DataProviderHandler handler = mock(DataProviderHandler.class); + PluginIdentity identity = identity("serverfeatures"); + DatabaseProvider provider = mock(DatabaseProvider.class); + RecordingObserver observer = new RecordingObserver(); + when(handler.registerDatabaseOrThrow(identity, DatabaseType.MYSQL, "survival-primary")) + .thenReturn(provider); + + boundApi(handler, identity, observer) + .registerDatabaseOrThrow(DatabaseType.MYSQL, "survival-primary"); + + RecordingObservation observation = observer.single(); + assertEquals("serverfeatures", observation.context.pluginId()); + assertEquals(OwnerScope.of("serverfeatures"), observation.context.ownerScope()); + assertEquals(DatabaseType.MYSQL, observation.context.databaseType()); + assertEquals("database.register", observation.context.operation()); + assertEquals(1, observation.succeeded); + assertEquals(null, observation.failure); + } + + @Test + void scopedRegistrationExposesPublicScopeInsteadOfUniqueInternalRegistrationScope() { + DataProviderHandler handler = mock(DataProviderHandler.class); + PluginIdentity identity = identity("dataregistry"); + DatabaseProvider provider = mock(DatabaseProvider.class); + RecordingObserver observer = new RecordingObserver(); + OwnerScope publicScope = OwnerScope.of("profiles"); + when(handler.registerDatabaseForScopeOrThrow( + eq(identity), + any(OwnerScope.class), + eq(DatabaseType.REDIS), + eq("cache") + )).thenReturn(provider); + + DataProviderScope scope = boundApi(handler, identity, observer).scope(publicScope); + scope.registerDatabaseOrThrow(DatabaseType.REDIS, "cache"); + + ArgumentCaptor internalScope = ArgumentCaptor.forClass(OwnerScope.class); + verify(handler).registerDatabaseForScopeOrThrow( + eq(identity), + internalScope.capture(), + eq(DatabaseType.REDIS), + eq("cache") + ); + assertNotEquals(publicScope, internalScope.getValue()); + assertEquals(publicScope, observer.single().context.ownerScope()); + } + + @Test + void asynchronousDataAccessObservationFinishesWithTheReturnedFuture() { + DataProviderHandler handler = mock(DataProviderHandler.class); + PluginIdentity identity = identity("serverfeatures"); + RelationalDatabaseProvider provider = mock(RelationalDatabaseProvider.class); + RelationalDataAccess access = mock(RelationalDataAccess.class); + CompletableFuture> result = new CompletableFuture<>(); + RecordingObserver observer = new RecordingObserver(); + when(handler.requireRegisteredDatabase(identity, DatabaseType.MYSQL, "primary")) + .thenReturn(provider); + when(provider.getDataAccess()).thenReturn(access); + when(access.queryForSingle("SELECT 1")).thenReturn(result); + + RelationalDatabaseProvider boundProvider = boundApi(handler, identity, observer) + .requireRegisteredDatabase(DatabaseType.MYSQL, "primary", RelationalDatabaseProvider.class); + CompletableFuture> observedResult = boundProvider.getDataAccess() + .queryForSingle("SELECT 1"); + + RecordingObservation observation = observer.single(); + assertEquals("relational.queryForSingle", observation.context.operation()); + assertEquals(0, observation.succeeded); + assertEquals(null, observation.failure); + + result.complete(Map.of("value", 1)); + + assertSame(result, observedResult); + assertEquals(1, observation.succeeded); + assertEquals(null, observation.failure); + } + + @Test + void asynchronousFailureIsReportedWithoutReplacingTheOriginalFailure() { + DataProviderHandler handler = mock(DataProviderHandler.class); + PluginIdentity identity = identity("serverfeatures"); + RelationalDatabaseProvider provider = mock(RelationalDatabaseProvider.class); + RelationalDataAccess access = mock(RelationalDataAccess.class); + CompletableFuture result = new CompletableFuture<>(); + RecordingObserver observer = new RecordingObserver(); + IllegalStateException failure = new IllegalStateException("database unavailable"); + when(handler.requireRegisteredDatabase(identity, DatabaseType.MYSQL, "primary")) + .thenReturn(provider); + when(provider.getDataAccess()).thenReturn(access); + when(access.executeUpdate("UPDATE example SET value = 1")).thenReturn(result); + + RelationalDatabaseProvider boundProvider = boundApi(handler, identity, observer) + .requireRegisteredDatabase(DatabaseType.MYSQL, "primary", RelationalDatabaseProvider.class); + CompletableFuture observedResult = boundProvider.getDataAccess() + .executeUpdate("UPDATE example SET value = 1"); + result.completeExceptionally(failure); + + assertSame(result, observedResult); + assertSame(failure, observer.single().failure); + assertSame(failure, assertThrows(Exception.class, observedResult::join).getCause()); + } + + @Test + void observerStartFailureCannotChangeTheDataOperationOutcome() { + DataProviderHandler handler = mock(DataProviderHandler.class); + PluginIdentity identity = identity("serverfeatures"); + DatabaseProvider provider = mock(DatabaseProvider.class); + DataProviderObserver observer = context -> { + throw new IllegalStateException("observer failed"); + }; + when(handler.registerDatabaseOrThrow(identity, DatabaseType.REDIS, "cache")) + .thenReturn(provider); + + DatabaseProvider result = boundApi(handler, identity, observer) + .registerDatabaseOrThrow(DatabaseType.REDIS, "cache"); + + verify(handler).registerDatabaseOrThrow(identity, DatabaseType.REDIS, "cache"); + assertEquals(DatabaseProvider.class, result.getClass().getInterfaces()[0]); + } + + @Test + void observerTerminalFailureCannotChangeTheDataOperationOutcome() { + DataProviderHandler handler = mock(DataProviderHandler.class); + PluginIdentity identity = identity("serverfeatures"); + DatabaseProvider provider = mock(DatabaseProvider.class); + DataProviderObserver observer = context -> new DataProviderObservation() { + @Override + public void succeeded() { + throw new IllegalStateException("observer completion failed"); + } + + @Override + public void failed(Throwable failure) { + throw new IllegalStateException("observer completion failed"); + } + }; + when(handler.registerDatabaseOrThrow(identity, DatabaseType.MONGODB, "documents")) + .thenReturn(provider); + + boundApi(handler, identity, observer) + .registerDatabaseOrThrow(DatabaseType.MONGODB, "documents"); + + verify(handler).registerDatabaseOrThrow(identity, DatabaseType.MONGODB, "documents"); + } + + @Test + void ormWrapperReportsTransactionCompletion() { + ORMContext delegate = mock(ORMContext.class); + RecordingObserver observer = new RecordingObserver(); + DataProviderOperationContext context = new DataProviderOperationContext( + "dataregistry", + OwnerScope.of("dataregistry"), + DatabaseType.MYSQL, + "orm.runInTransaction" + ); + when(delegate.runInTransaction(any())).thenReturn("result"); + ORMContext observed = new ObservedOrmContext(delegate, observer, context); + + String result = observed.runInTransaction(session -> "ignored"); + + assertEquals("result", result); + assertEquals("orm.runInTransaction", observer.single().context.operation()); + assertEquals(1, observer.single().succeeded); + } + + @Test + void operationContextRejectsUnboundedIdentityFields() { + OwnerScope ownerScope = OwnerScope.of("scope"); + assertThrows(IllegalArgumentException.class, + () -> new DataProviderOperationContext(" ", ownerScope, DatabaseType.REDIS, "keyvalue.getKey")); + assertThrows(IllegalArgumentException.class, + () -> new DataProviderOperationContext("plugin", ownerScope, DatabaseType.REDIS, " ")); + } + + private PluginIdentity identity(String pluginId) { + return new PluginIdentityRegistry().register(pluginId, getClass().getClassLoader()); + } + + private static DataProviderAPI boundApi( + DataProviderHandler handler, + PluginIdentity identity, + DataProviderObserver observer + ) { + when(handler.issuePluginIdentity(any())).thenReturn(identity); + when(handler.getPluginId(identity)).thenReturn(identity.pluginId()); + return new DefaultDataProviderApi(handler) + .withObserver(observer) + .forPlugin(new Object()); + } + + private static final class RecordingObserver implements DataProviderObserver { + private final List observations = new ArrayList<>(); + + @Override + public synchronized DataProviderObservation start(DataProviderOperationContext context) { + RecordingObservation observation = new RecordingObservation(context); + observations.add(observation); + return observation; + } + + synchronized RecordingObservation single() { + assertEquals(1, observations.size()); + return observations.get(0); + } + } + + private static final class RecordingObservation implements DataProviderObservation { + private final DataProviderOperationContext context; + private int succeeded; + private Throwable failure; + + private RecordingObservation(DataProviderOperationContext context) { + this.context = context; + } + + @Override + public synchronized void succeeded() { + succeeded++; + } + + @Override + public synchronized void failed(Throwable throwable) { + failure = throwable; + } + } +} diff --git a/docs/OBSERVATION.md b/docs/OBSERVATION.md new file mode 100644 index 00000000..dc76f0d3 --- /dev/null +++ b/docs/OBSERVATION.md @@ -0,0 +1,50 @@ +# Operation observation + +DataProvider exposes an optional, vendor-neutral observation SPI for infrastructure integrations that need to measure or trace plugin-scoped data operations without coupling DataProvider to a telemetry implementation. + +## Attach an observer + +Attach an observer to a plugin-bound facade and retain that facade for the plugin lifecycle: + +```java +DataProviderAPI api = supplier.dataProviderApiFor(this) + .withObserver(observer); +``` + +The observer is local to that facade. It propagates to child `DataProviderScope` instances and handles obtained through that facade; it is never installed globally and does not affect other plugins using the same DataProvider runtime. + +The public SPI consists of: + +- `DataProviderObserver`, which starts one observation; +- `DataProviderObservation`, which receives exactly one success or failure terminal callback for an observation that was started successfully; +- `DataProviderOperationContext`, which contains stable plugin ownership, public owner scope, backend type, and operation name. + +## Metadata boundary + +`DataProviderOperationContext` intentionally contains only bounded operational metadata: + +- platform-derived plugin id; +- public lifecycle owner scope; +- `DatabaseType`; +- a DataProvider-owned operation name such as `database.register`, `relational.queryForSingle`, `keyvalue.getKey`, or `messaging.publish`. + +It deliberately does **not** expose connection identifiers, SQL/query text, query parameters, Redis keys or patterns, collection names, messaging destinations or streams, payloads, credentials, or player data. Internal UUID-backed registration scopes are also never exposed; scoped facades report their public `OwnerScope` instead. + +## Completion semantics + +Synchronous operations finish their observation before returning or throwing. Operations returning a `CompletionStage` finish only when that stage completes, so timing includes the actual asynchronous backend work rather than only task submission. + +Observer callbacks must be thread-safe and non-blocking. DataProvider isolates runtime exceptions raised by observer start/success/failure callbacks: instrumentation cannot turn a successful data operation into a failure or replace the original data-operation exception. + +## Current operation coverage + +The initial SPI observes meaningful API boundaries rather than every internal method: + +- database registration and single-registration removal; +- relational, document, key-value, schema, Pub/Sub, and durable-messaging data-access operations; +- subscription shutdown/acknowledgement operations; +- ORM transaction execution. + +Registry lookups, diagnostic getters, connection identifiers, raw JDBC calls through an exposed `DataSource`, bulk teardown without a single backend identity, and internal health/recovery mechanics are intentionally outside the initial contract. + +DataProvider has no OpenTelemetry dependency. HauntedObservability can implement this SPI later while DataProvider remains usable with the built-in no-op path. diff --git a/docs/README.md b/docs/README.md index d4ce64d7..bd9adc34 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ This directory contains developer and operational notes for DataProvider. - [Best Practices](BEST_PRACTICES.md) - [Scoped Lifecycle](SCOPED_LIFECYCLE.md) - [Structured Exceptions](EXCEPTIONS.md) +- [Operation Observation](OBSERVATION.md) - [Configuration](CONFIGURATION.md) - [Development](DEVELOPMENT.md) - [Testing and CI](TESTING_AND_CI.md) diff --git a/pom.xml b/pom.xml index c2b136c9..dbaced15 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ nl.hauntedmc.platform haunted-library-parent - 1.2.0 + 1.3.0 From c3b00a6a0249c0d795542895b556ce011198c71e Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 00:35:45 +0200 Subject: [PATCH 2/5] Optimize the no-op observation path --- .../api/observation/DataProviderObserver.java | 17 ++++++- .../core/api/DataProviderObservations.java | 31 +++++++---- .../core/api/DefaultDataProviderApi.java | 18 +++++++ .../core/api/DefaultDataProviderScope.java | 39 +++++++++----- .../api/IdentityBoundDatabaseProvider.java | 3 ++ .../core/api/DataProviderObservationTest.java | 51 +++++++++++++++++-- docs/OBSERVATION.md | 10 ++-- 7 files changed, 134 insertions(+), 35 deletions(-) diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/observation/DataProviderObserver.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/observation/DataProviderObserver.java index 4b012fd6..2b6627d0 100644 --- a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/observation/DataProviderObserver.java +++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/observation/DataProviderObserver.java @@ -16,8 +16,21 @@ public interface DataProviderObserver { /** Starts one operation observation. Implementations should return a non-null handle. */ DataProviderObservation start(DataProviderOperationContext context); - /** Returns a reusable observer that performs no work. */ + /** Returns the reusable no-op observer used by the uninstrumented fast path. */ static DataProviderObserver noop() { - return context -> DataProviderObservation.noop(); + return NoopDataProviderObserver.INSTANCE; + } +} + +final class NoopDataProviderObserver implements DataProviderObserver { + + static final NoopDataProviderObserver INSTANCE = new NoopDataProviderObserver(); + + private NoopDataProviderObserver() { + } + + @Override + public DataProviderObservation start(DataProviderOperationContext context) { + return DataProviderObservation.noop(); } } diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DataProviderObservations.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DataProviderObservations.java index d0ed36ec..da40a068 100644 --- a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DataProviderObservations.java +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DataProviderObservations.java @@ -14,12 +14,19 @@ final class DataProviderObservations { private DataProviderObservations() { } + static boolean isEnabled(DataProviderObserver observer) { + return observer != null && observer != DataProviderObserver.noop(); + } + static T observe( DataProviderObserver observer, DataProviderOperationContext context, Supplier operation ) { Objects.requireNonNull(operation, "Operation cannot be null."); + if (!isEnabled(observer)) { + return operation.get(); + } DataProviderObservation observation = start(observer, context); try { T result = operation.get(); @@ -47,6 +54,9 @@ static Object observeInvocation( DataProviderOperationContext context, ThrowingOperation operation ) throws Throwable { + if (!isEnabled(observer)) { + return operation.execute(); + } DataProviderObservation observation = start(observer, context); final Object result; try { @@ -56,13 +66,17 @@ static Object observeInvocation( throw failure; } if (result instanceof CompletionStage completionStage) { - completionStage.whenComplete((ignored, failure) -> { - if (failure == null) { - succeeded(observation); - } else { - failed(observation, failure); - } - }); + try { + completionStage.whenComplete((ignored, failure) -> { + if (failure == null) { + succeeded(observation); + } else { + failed(observation, failure); + } + }); + } catch (RuntimeException ignored) { + succeeded(observation); + } } else { succeeded(observation); } @@ -73,9 +87,6 @@ private static DataProviderObservation start( DataProviderObserver observer, DataProviderOperationContext context ) { - if (observer == null) { - return DataProviderObservation.noop(); - } try { DataProviderObservation observation = observer.start(context); return observation == null ? DataProviderObservation.noop() : observation; 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 1b4f1531..80f81334 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 @@ -72,6 +72,9 @@ public ORMContext createOrmContext( handler.getConfiguredOrmSchemaMode(boundIdentity), validatedEntities ); + if (!DataProviderObservations.isEnabled(observer)) { + return context; + } return new ObservedOrmContext( context, observer, @@ -121,6 +124,17 @@ public DatabaseProvider registerDatabaseOrThrow(DatabaseType databaseType, Strin PluginIdentity boundIdentity = requireIdentity(); String pluginId = boundIdentity.pluginId(); OwnerScope ownerScope = OwnerScope.of(pluginId); + if (!DataProviderObservations.isEnabled(observer)) { + return wrapProvider( + handler, + boundIdentity, + handler.registerDatabaseOrThrow(boundIdentity, databaseType, connectionIdentifier), + observer, + pluginId, + ownerScope, + databaseType + ); + } return DataProviderObservations.observe( observer, operationContext(pluginId, ownerScope, databaseType, "database.register"), @@ -152,6 +166,10 @@ public DataProviderScope scope(OwnerScope ownerScope) { @Override public void unregisterDatabase(DatabaseType databaseType, String connectionIdentifier) { PluginIdentity boundIdentity = requireIdentity(); + if (!DataProviderObservations.isEnabled(observer)) { + handler.unregisterDatabase(boundIdentity, databaseType, connectionIdentifier); + return; + } String pluginId = boundIdentity.pluginId(); DataProviderObservations.observe( observer, 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 587ecff7..6d19f68e 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 @@ -66,23 +66,13 @@ public LifecycleState lifecycleState() { public DatabaseProvider registerDatabaseOrThrow(DatabaseType databaseType, String connectionIdentifier) { synchronized (lifecycleMonitor) { requireStructuredOpen("scope.registerDatabase"); + if (!DataProviderObservations.isEnabled(observer)) { + return registerAndWrap(databaseType, connectionIdentifier); + } return DataProviderObservations.observe( observer, operationContext(databaseType, "database.register"), - () -> DefaultDataProviderApi.wrapProvider( - handler, - identity, - handler.registerDatabaseForScopeOrThrow( - identity, - registrationScope, - databaseType, - connectionIdentifier - ), - observer, - pluginId, - ownerScope, - databaseType - ) + () -> registerAndWrap(databaseType, connectionIdentifier) ); } } @@ -91,6 +81,10 @@ public DatabaseProvider registerDatabaseOrThrow(DatabaseType databaseType, Strin public void unregisterDatabase(DatabaseType databaseType, String connectionIdentifier) { synchronized (lifecycleMonitor) { requireCleanupOpen("scope.unregisterDatabase"); + if (!DataProviderObservations.isEnabled(observer)) { + handler.unregisterDatabaseForScope(identity, registrationScope, databaseType, connectionIdentifier); + return; + } DataProviderObservations.observe( observer, operationContext(databaseType, "database.unregister"), @@ -151,6 +145,23 @@ public void close() { } } + private DatabaseProvider registerAndWrap(DatabaseType databaseType, String connectionIdentifier) { + return DefaultDataProviderApi.wrapProvider( + handler, + identity, + handler.registerDatabaseForScopeOrThrow( + identity, + registrationScope, + databaseType, + connectionIdentifier + ), + observer, + pluginId, + ownerScope, + databaseType + ); + } + private DataProviderOperationContext operationContext(DatabaseType databaseType, String operation) { return new DataProviderOperationContext(pluginId, ownerScope, databaseType, operation); } diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/IdentityBoundDatabaseProvider.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/IdentityBoundDatabaseProvider.java index a7ceaf22..7d99afb1 100644 --- a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/IdentityBoundDatabaseProvider.java +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/IdentityBoundDatabaseProvider.java @@ -176,6 +176,9 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl identity, target ); + if (!DataProviderObservations.isEnabled(target.observer())) { + return invokeAndBind(method, invocationArguments); + } String operation = observationOperation(method); if (operation == null) { return invokeAndBind(method, invocationArguments); diff --git a/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/api/DataProviderObservationTest.java b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/api/DataProviderObservationTest.java index 987e6052..eed76765 100644 --- a/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/api/DataProviderObservationTest.java +++ b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/api/DataProviderObservationTest.java @@ -21,9 +21,13 @@ import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.function.BiConsumer; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; @@ -52,7 +56,7 @@ void facadeRegistrationReportsOnlyStablePluginScopeAndBackendMetadata() { assertEquals(DatabaseType.MYSQL, observation.context.databaseType()); assertEquals("database.register", observation.context.operation()); assertEquals(1, observation.succeeded); - assertEquals(null, observation.failure); + assertNull(observation.failure); } @Test @@ -104,13 +108,13 @@ void asynchronousDataAccessObservationFinishesWithTheReturnedFuture() { RecordingObservation observation = observer.single(); assertEquals("relational.queryForSingle", observation.context.operation()); assertEquals(0, observation.succeeded); - assertEquals(null, observation.failure); + assertNull(observation.failure); result.complete(Map.of("value", 1)); assertSame(result, observedResult); assertEquals(1, observation.succeeded); - assertEquals(null, observation.failure); + assertNull(observation.failure); } @Test @@ -135,7 +139,34 @@ void asynchronousFailureIsReportedWithoutReplacingTheOriginalFailure() { assertSame(result, observedResult); assertSame(failure, observer.single().failure); - assertSame(failure, assertThrows(Exception.class, observedResult::join).getCause()); + assertSame(failure, assertThrows(CompletionException.class, observedResult::join).getCause()); + } + + @Test + void defaultNoopPathDoesNotAttachAsyncCompletionCallbacks() { + DataProviderHandler handler = mock(DataProviderHandler.class); + PluginIdentity identity = identity("serverfeatures"); + RelationalDatabaseProvider provider = mock(RelationalDatabaseProvider.class); + RelationalDataAccess access = mock(RelationalDataAccess.class); + TrackingFuture> result = new TrackingFuture<>(); + when(handler.issuePluginIdentity(any())).thenReturn(identity); + when(handler.requireRegisteredDatabase(identity, DatabaseType.MYSQL, "primary")) + .thenReturn(provider); + when(provider.getDataAccess()).thenReturn(access); + when(access.queryForSingle("SELECT 1")).thenReturn(result); + + DataProviderAPI api = new DefaultDataProviderApi(handler).forPlugin(new Object()); + RelationalDatabaseProvider boundProvider = api.requireRegisteredDatabase( + DatabaseType.MYSQL, + "primary", + RelationalDatabaseProvider.class + ); + CompletableFuture> observedResult = boundProvider.getDataAccess() + .queryForSingle("SELECT 1"); + + assertSame(result, observedResult); + assertEquals(0, result.whenCompleteRegistrations); + assertSame(DataProviderObserver.noop(), DataProviderObserver.noop()); } @Test @@ -153,7 +184,7 @@ void observerStartFailureCannotChangeTheDataOperationOutcome() { .registerDatabaseOrThrow(DatabaseType.REDIS, "cache"); verify(handler).registerDatabaseOrThrow(identity, DatabaseType.REDIS, "cache"); - assertEquals(DatabaseProvider.class, result.getClass().getInterfaces()[0]); + assertNotNull(result); } @Test @@ -261,4 +292,14 @@ public synchronized void failed(Throwable throwable) { failure = throwable; } } + + private static final class TrackingFuture extends CompletableFuture { + private int whenCompleteRegistrations; + + @Override + public CompletableFuture whenComplete(BiConsumer action) { + whenCompleteRegistrations++; + return super.whenComplete(action); + } + } } diff --git a/docs/OBSERVATION.md b/docs/OBSERVATION.md index dc76f0d3..0aea1840 100644 --- a/docs/OBSERVATION.md +++ b/docs/OBSERVATION.md @@ -11,25 +11,27 @@ DataProviderAPI api = supplier.dataProviderApiFor(this) .withObserver(observer); ``` -The observer is local to that facade. It propagates to child `DataProviderScope` instances and handles obtained through that facade; it is never installed globally and does not affect other plugins using the same DataProvider runtime. +The observer is local to that facade. It propagates to child `DataProviderScope` instances and handles obtained through that facade; it is never installed globally and does not affect other plugins using the same DataProvider runtime. The built-in no-op observer uses a fast path: it does not create operation contexts or attach completion callbacks to asynchronous data operations. The public SPI consists of: - `DataProviderObserver`, which starts one observation; - `DataProviderObservation`, which receives exactly one success or failure terminal callback for an observation that was started successfully; -- `DataProviderOperationContext`, which contains stable plugin ownership, public owner scope, backend type, and operation name. +- `DataProviderOperationContext`, which contains plugin ownership, public owner scope, backend type, and a stable operation name. ## Metadata boundary -`DataProviderOperationContext` intentionally contains only bounded operational metadata: +`DataProviderOperationContext` intentionally contains only payload-free operational metadata: - platform-derived plugin id; - public lifecycle owner scope; - `DatabaseType`; -- a DataProvider-owned operation name such as `database.register`, `relational.queryForSingle`, `keyvalue.getKey`, or `messaging.publish`. +- a DataProvider-owned bounded operation name such as `database.register`, `relational.queryForSingle`, `keyvalue.getKey`, or `messaging.publish`. It deliberately does **not** expose connection identifiers, SQL/query text, query parameters, Redis keys or patterns, collection names, messaging destinations or streams, payloads, credentials, or player data. Internal UUID-backed registration scopes are also never exposed; scoped facades report their public `OwnerScope` instead. +A telemetry implementation remains responsible for its own cardinality policy. In particular, a public `OwnerScope` is useful trace context but is consumer-defined and should not automatically become a metric label. Likewise, the `Throwable` supplied for failed observations may contain backend-specific details in its message; exception text must never be used as a metric attribute and should be handled according to the telemetry implementation's logging/privacy policy. + ## Completion semantics Synchronous operations finish their observation before returning or throwing. Operations returning a `CompletionStage` finish only when that stage completes, so timing includes the actual asynchronous backend work rather than only task submission. From ac16e71e59cf9b768009714b742b6f01d7f3c3c3 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 01:13:04 +0200 Subject: [PATCH 3/5] Preserve scoped ORM observation ownership --- .../core/api/IdentityBoundDatabaseProvider.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/IdentityBoundDatabaseProvider.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/IdentityBoundDatabaseProvider.java index 7d99afb1..2a6832df 100644 --- a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/IdentityBoundDatabaseProvider.java +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/IdentityBoundDatabaseProvider.java @@ -80,10 +80,18 @@ static boolean isBoundDataSource(DataSource dataSource) { } static DatabaseType boundDatabaseType(DataSource dataSource) { + return boundObservationTarget(dataSource).databaseType(); + } + + static OwnerScope boundOwnerScope(DataSource dataSource) { + return boundObservationTarget(dataSource).ownerScope(); + } + + private static ObservationTarget boundObservationTarget(DataSource dataSource) { if (!(dataSource instanceof GuardedDataSource guardedDataSource)) { throw new IllegalArgumentException("DataSource is not bound to a DataProvider registration."); } - return guardedDataSource.target.databaseType(); + return guardedDataSource.target; } private static DatabaseType inferDatabaseType(DatabaseProvider provider) { From 45be25f1f3a30fc69bf6cec2ab0dba2cd5d49078 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 01:13:25 +0200 Subject: [PATCH 4/5] Use DataSource owner scope for ORM observation --- .../hauntedmc/dataprovider/core/api/DefaultDataProviderApi.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 80f81334..a84e9c87 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 @@ -80,7 +80,7 @@ public ORMContext createOrmContext( observer, operationContext( pluginId, - OwnerScope.of(pluginId), + IdentityBoundDatabaseProvider.boundOwnerScope(dataSource), IdentityBoundDatabaseProvider.boundDatabaseType(dataSource), "orm.runInTransaction" ) From d42952d0aaa440e03b445a8550bd808dae34dd45 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 01:13:39 +0200 Subject: [PATCH 5/5] Test scoped DataSource observation metadata --- ...oundDataSourceObservationMetadataTest.java | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/api/BoundDataSourceObservationMetadataTest.java diff --git a/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/api/BoundDataSourceObservationMetadataTest.java b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/api/BoundDataSourceObservationMetadataTest.java new file mode 100644 index 00000000..d5234e97 --- /dev/null +++ b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/api/BoundDataSourceObservationMetadataTest.java @@ -0,0 +1,47 @@ +package nl.hauntedmc.dataprovider.core.api; + +import nl.hauntedmc.dataprovider.api.OwnerScope; +import nl.hauntedmc.dataprovider.api.observation.DataProviderObserver; +import nl.hauntedmc.dataprovider.core.DataProviderHandler; +import nl.hauntedmc.dataprovider.core.identity.PluginIdentity; +import nl.hauntedmc.dataprovider.core.identity.PluginIdentityRegistry; +import nl.hauntedmc.dataprovider.database.DatabaseType; +import nl.hauntedmc.dataprovider.database.relational.RelationalDatabaseProvider; +import org.junit.jupiter.api.Test; + +import javax.sql.DataSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class BoundDataSourceObservationMetadataTest { + + @Test + void boundDataSourceRetainsItsPublicOwnerScopeForOrmObservation() { + DataProviderHandler handler = mock(DataProviderHandler.class); + PluginIdentity identity = new PluginIdentityRegistry().register( + "dataregistry", + getClass().getClassLoader() + ); + RelationalDatabaseProvider provider = mock(RelationalDatabaseProvider.class); + DataSource dataSource = mock(DataSource.class); + OwnerScope ownerScope = OwnerScope.of("profiles"); + when(provider.getDataSource()).thenReturn(dataSource); + + RelationalDatabaseProvider bound = (RelationalDatabaseProvider) IdentityBoundDatabaseProvider.wrap( + handler, + identity, + provider, + DataProviderObserver.noop(), + identity.pluginId(), + ownerScope, + DatabaseType.MYSQL + ); + + DataSource boundDataSource = bound.getDataSource(); + + assertEquals(ownerScope, IdentityBoundDatabaseProvider.boundOwnerScope(boundDataSource)); + assertEquals(DatabaseType.MYSQL, IdentityBoundDatabaseProvider.boundDatabaseType(boundDataSource)); + } +}