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..2b6627d0 --- /dev/null +++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/observation/DataProviderObserver.java @@ -0,0 +1,36 @@ +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 the reusable no-op observer used by the uninstrumented fast path. */ + static DataProviderObserver 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-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..da40a068 --- /dev/null +++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DataProviderObservations.java @@ -0,0 +1,118 @@ +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 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(); + 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 { + if (!isEnabled(observer)) { + return operation.execute(); + } + 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) { + try { + completionStage.whenComplete((ignored, failure) -> { + if (failure == null) { + succeeded(observation); + } else { + failed(observation, failure); + } + }); + } catch (RuntimeException ignored) { + succeeded(observation); + } + } else { + succeeded(observation); + } + return result; + } + + private static DataProviderObservation start( + DataProviderObserver observer, + DataProviderOperationContext context + ) { + 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..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 @@ -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,27 @@ 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 ); + if (!DataProviderObservations.isEnabled(observer)) { + return context; + } + return new ObservedOrmContext( + context, + observer, + operationContext( + pluginId, + IdentityBoundDatabaseProvider.boundOwnerScope(dataSource), + IdentityBoundDatabaseProvider.boundDatabaseType(dataSource), + "orm.runInTransaction" + ) + ); } /** Package-visible for API-path regression tests. */ @@ -92,20 +122,60 @@ 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); + 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"), + () -> 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(); + if (!DataProviderObservations.isEnabled(observer)) { + handler.unregisterDatabase(boundIdentity, databaseType, connectionIdentifier); + return; + } + String pluginId = boundIdentity.pluginId(); + DataProviderObservations.observe( + observer, + operationContext(pluginId, OwnerScope.of(pluginId), databaseType, "database.unregister"), + () -> handler.unregisterDatabase(boundIdentity, databaseType, connectionIdentifier) + ); } @Override @@ -121,16 +191,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..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 @@ -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,13 +66,13 @@ public LifecycleState lifecycleState() { public DatabaseProvider registerDatabaseOrThrow(DatabaseType databaseType, String connectionIdentifier) { synchronized (lifecycleMonitor) { requireStructuredOpen("scope.registerDatabase"); - return DefaultDataProviderApi.wrapProvider(handler, identity, - handler.registerDatabaseForScopeOrThrow( - identity, - registrationScope, - databaseType, - connectionIdentifier - ) + if (!DataProviderObservations.isEnabled(observer)) { + return registerAndWrap(databaseType, connectionIdentifier); + } + return DataProviderObservations.observe( + observer, + operationContext(databaseType, "database.register"), + () -> registerAndWrap(databaseType, connectionIdentifier) ); } } @@ -65,7 +81,20 @@ public DatabaseProvider registerDatabaseOrThrow(DatabaseType databaseType, Strin public void unregisterDatabase(DatabaseType databaseType, String connectionIdentifier) { synchronized (lifecycleMonitor) { requireCleanupOpen("scope.unregisterDatabase"); - handler.unregisterDatabaseForScope(identity, registrationScope, databaseType, connectionIdentifier); + if (!DataProviderObservations.isEnabled(observer)) { + handler.unregisterDatabaseForScope(identity, registrationScope, databaseType, connectionIdentifier); + return; + } + DataProviderObservations.observe( + observer, + operationContext(databaseType, "database.unregister"), + () -> handler.unregisterDatabaseForScope( + identity, + registrationScope, + databaseType, + connectionIdentifier + ) + ); } } @@ -81,13 +110,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 +145,27 @@ 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); + } + 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..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 @@ -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,68 @@ 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) { + 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; + } + + 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 +127,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 +146,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 +176,32 @@ 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 + ); + if (!DataProviderObservations.isEnabled(target.observer())) { + return invokeAndBind(method, invocationArguments); + } + 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 +212,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 +221,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 +330,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 +350,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 +391,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/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)); + } +} 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..eed76765 --- /dev/null +++ b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/api/DataProviderObservationTest.java @@ -0,0 +1,305 @@ +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 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; +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); + assertNull(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); + assertNull(observation.failure); + + result.complete(Map.of("value", 1)); + + assertSame(result, observedResult); + assertEquals(1, observation.succeeded); + assertNull(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(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 + 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"); + assertNotNull(result); + } + + @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; + } + } + + 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 new file mode 100644 index 00000000..0aea1840 --- /dev/null +++ b/docs/OBSERVATION.md @@ -0,0 +1,52 @@ +# 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 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 plugin ownership, public owner scope, backend type, and a stable operation name. + +## Metadata boundary + +`DataProviderOperationContext` intentionally contains only payload-free operational metadata: + +- platform-derived plugin id; +- public lifecycle owner scope; +- `DatabaseType`; +- 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. + +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