From 071b60eebb84b6d3933c2182631f97a02169ce18 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 25 Mar 2026 11:56:43 +0100 Subject: [PATCH 01/17] Fix version console logging --- pom.xml | 2 +- .../velocity/VelocityDataProvider.java | 14 ++++-- .../velocity/VelocityDataProviderTest.java | 48 +++++++++++++++++++ 3 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 src/test/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProviderTest.java diff --git a/pom.xml b/pom.xml index 50b34b7..512ffac 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ nl.hauntedmc.dataprovider dataprovider DataProvider - 1.21.0 + 2.0.0 jar Plugin-scoped data access layer for Velocity and Bukkit/Paper plugins. https://github.com/HauntedMC/DataProvider diff --git a/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProvider.java b/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProvider.java index 26f231b..72b9ade 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProvider.java +++ b/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProvider.java @@ -21,7 +21,7 @@ @Plugin( id = "dataprovider", name = "DataProvider", - version = "1.21.0", + version = "2.0.0", description = "A cross-platform data provider plugin.", authors = {"HauntedMC"} ) @@ -54,10 +54,7 @@ public void onProxyInitialize(ProxyInitializeEvent event) { .build(); commandManager.register(meta, new DataProviderCommand(dataProvider.getDataProviderHandler())); - String pluginVersion = proxyServer.getPluginManager() - .fromInstance(this) - .map(container -> container.getDescription().getVersion().toString()) - .orElse("unknown"); + String pluginVersion = resolvePluginVersion(proxyServer, this); logger.info("DataProvider plugin enabled on Velocity (v{}).", pluginVersion); } @@ -77,4 +74,11 @@ public static DataProviderAPI getDataProviderAPI() { return new DataProviderAPI(dataProvider.getDataProviderHandler()); } // END EXTERNALLY ACCESSIBLE + + static String resolvePluginVersion(ProxyServer proxyServer, Object pluginInstance) { + return proxyServer.getPluginManager() + .fromInstance(pluginInstance) + .flatMap(container -> container.getDescription().getVersion()) + .orElse("unknown"); + } } diff --git a/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProviderTest.java b/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProviderTest.java new file mode 100644 index 0000000..78751a7 --- /dev/null +++ b/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProviderTest.java @@ -0,0 +1,48 @@ +package nl.hauntedmc.dataprovider.platform.velocity; + +import com.velocitypowered.api.plugin.PluginContainer; +import com.velocitypowered.api.plugin.PluginDescription; +import com.velocitypowered.api.plugin.PluginManager; +import com.velocitypowered.api.proxy.ProxyServer; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class VelocityDataProviderTest { + + @Test + void resolvePluginVersionReturnsDescriptionVersionValue() { + ProxyServer proxyServer = mock(ProxyServer.class); + PluginManager pluginManager = mock(PluginManager.class); + PluginContainer pluginContainer = mock(PluginContainer.class); + PluginDescription pluginDescription = mock(PluginDescription.class); + Object pluginInstance = new Object(); + + when(proxyServer.getPluginManager()).thenReturn(pluginManager); + when(pluginManager.fromInstance(pluginInstance)).thenReturn(Optional.of(pluginContainer)); + when(pluginContainer.getDescription()).thenReturn(pluginDescription); + when(pluginDescription.getVersion()).thenReturn(Optional.of("1.20.4")); + + assertEquals("1.20.4", VelocityDataProvider.resolvePluginVersion(proxyServer, pluginInstance)); + } + + @Test + void resolvePluginVersionFallsBackToUnknownWhenVersionMissing() { + ProxyServer proxyServer = mock(ProxyServer.class); + PluginManager pluginManager = mock(PluginManager.class); + PluginContainer pluginContainer = mock(PluginContainer.class); + PluginDescription pluginDescription = mock(PluginDescription.class); + Object pluginInstance = new Object(); + + when(proxyServer.getPluginManager()).thenReturn(pluginManager); + when(pluginManager.fromInstance(pluginInstance)).thenReturn(Optional.of(pluginContainer)); + when(pluginContainer.getDescription()).thenReturn(pluginDescription); + when(pluginDescription.getVersion()).thenReturn(Optional.empty()); + + assertEquals("unknown", VelocityDataProvider.resolvePluginVersion(proxyServer, pluginInstance)); + } +} From 37be6db9983fe7b72fd4ce5fca863c1137257a5c Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 25 Mar 2026 12:42:59 +0100 Subject: [PATCH 02/17] Refactor public provider API into read-only handles --- docs/BEST_PRACTICES.md | 1 + docs/USAGE_GUIDE.md | 3 + .../dataprovider/api/DataProviderAPI.java | 143 +++++++++++++++++- .../database/DatabaseProvider.java | 14 +- .../impl/mongodb/MongoDBDatabase.java | 3 +- .../keyvalue/impl/redis/RedisDatabase.java | 3 +- .../impl/redis/RedisMessagingDatabase.java | 3 +- .../relational/impl/mysql/MySQLDatabase.java | 3 +- .../internal/DataProviderRegistry.java | 16 +- .../internal/DatabaseFactory.java | 3 +- .../internal/ManagedDatabaseProvider.java | 19 +++ .../dataprovider/api/DataProviderAPITest.java | 69 +++++++-- .../DatabaseProviderDefaultsTest.java | 8 - ...DatabaseProviderInterfaceContractTest.java | 24 --- .../internal/DataProviderRegistryTest.java | 3 +- 15 files changed, 238 insertions(+), 77 deletions(-) create mode 100644 src/main/java/nl/hauntedmc/dataprovider/internal/ManagedDatabaseProvider.java diff --git a/docs/BEST_PRACTICES.md b/docs/BEST_PRACTICES.md index 1b0e9e4..6569c0f 100644 --- a/docs/BEST_PRACTICES.md +++ b/docs/BEST_PRACTICES.md @@ -3,6 +3,7 @@ ## API usage - Prefer `registerDatabaseOptional`, `registerDatabaseAs`, and `registerDataAccess` over raw nullable APIs. +- Treat returned `DatabaseProvider` instances as read-only handles; lifecycle is managed through the API. - Prefer `getDataAccessOptional(...)` instead of manual casts. - Treat database registration as startup wiring, not ad-hoc runtime behavior in hot paths. diff --git a/docs/USAGE_GUIDE.md b/docs/USAGE_GUIDE.md index b17b446..f22cf07 100644 --- a/docs/USAGE_GUIDE.md +++ b/docs/USAGE_GUIDE.md @@ -60,6 +60,9 @@ Identifier guidance: ## 3. Use the provider safely +`DatabaseProvider` is a read-only handle. Connection lifecycle stays owned by `DataProviderAPI`, +so acquire and release connections through `registerDatabase*` / `unregisterDatabase*`. + `DatabaseProvider` has helper methods to avoid raw casts: ```java diff --git a/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderAPI.java b/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderAPI.java index 24ab7fa..fffc876 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderAPI.java +++ b/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderAPI.java @@ -3,15 +3,25 @@ import nl.hauntedmc.dataprovider.database.DataAccess; import nl.hauntedmc.dataprovider.database.DatabaseType; import nl.hauntedmc.dataprovider.database.DatabaseProvider; +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; +import nl.hauntedmc.dataprovider.database.relational.RelationalDataAccess; +import nl.hauntedmc.dataprovider.database.relational.RelationalDatabaseProvider; +import nl.hauntedmc.dataprovider.database.relational.schema.SchemaManager; import nl.hauntedmc.dataprovider.internal.DataProviderHandler; +import javax.sql.DataSource; import java.util.Objects; import java.util.Optional; /** - * DataProviderAPI is the public facade that exposes only the safe methods for - * third-party plugins. Internally, it delegates to a DataProviderHandler, but it does not - * expose sensitive methods (like shutdownAllDatabases or getActiveDatabases). + * DataProviderAPI is the public facade that exposes safe, read-only database handles + * for third-party plugins. Internally, it delegates to a DataProviderHandler, but it does not + * expose lifecycle-sensitive methods (like shutdownAllDatabases or getActiveDatabases). */ public class DataProviderAPI { @@ -31,10 +41,10 @@ public DataProviderAPI(DataProviderHandler handler) { * * @param databaseType the type of database (e.g. MYSQL, MONGODB, etc.) * @param connectionIdentifier a unique identifier for the connection - * @return the registered {@link DatabaseProvider} instance. + * @return the registered read-only {@link DatabaseProvider} handle. */ public DatabaseProvider registerDatabase(DatabaseType databaseType, String connectionIdentifier) { - return handler.registerDatabase(databaseType, connectionIdentifier); + return wrapProvider(handler.registerDatabase(databaseType, connectionIdentifier)); } /** @@ -93,7 +103,7 @@ public void unregisterAllDatabases() { * @return the {@link DatabaseProvider} instance, or null if not registered. */ public DatabaseProvider getRegisteredDatabase(DatabaseType databaseType, String connectionIdentifier) { - return handler.getRegisteredDatabase(databaseType, connectionIdentifier); + return wrapProvider(handler.getRegisteredDatabase(databaseType, connectionIdentifier)); } /** @@ -137,4 +147,125 @@ private static Optional castProvider( } return Optional.of(expectedProviderType.cast(provider)); } + + private static DatabaseProvider wrapProvider(DatabaseProvider provider) { + if (provider == null || provider instanceof WrappedDatabaseProvider) { + return provider; + } + if (provider instanceof RelationalDatabaseProvider relationalProvider) { + return new RelationalDatabaseProviderView(relationalProvider); + } + if (provider instanceof DocumentDatabaseProvider documentProvider) { + return new DocumentDatabaseProviderView(documentProvider); + } + if (provider instanceof KeyValueDatabaseProvider keyValueProvider) { + return new KeyValueDatabaseProviderView(keyValueProvider); + } + if (provider instanceof MessagingDatabaseProvider messagingProvider) { + return new MessagingDatabaseProviderView(messagingProvider); + } + return new DatabaseProviderView(provider); + } + + private interface WrappedDatabaseProvider extends DatabaseProvider { + } + + private record DatabaseProviderView(DatabaseProvider delegate) implements WrappedDatabaseProvider { + private DatabaseProviderView { + Objects.requireNonNull(delegate, "Delegate database provider cannot be null."); + } + + @Override + public boolean isConnected() { + return delegate.isConnected(); + } + + @Override + public DataAccess getDataAccess() { + return delegate.getDataAccess(); + } + + @Override + public DataSource getDataSource() { + return delegate.getDataSource(); + } + } + + private record RelationalDatabaseProviderView(RelationalDatabaseProvider delegate) + implements RelationalDatabaseProvider, WrappedDatabaseProvider { + private RelationalDatabaseProviderView { + Objects.requireNonNull(delegate, "Delegate relational database provider cannot be null."); + } + + @Override + public boolean isConnected() { + return delegate.isConnected(); + } + + @Override + public RelationalDataAccess getDataAccess() { + return delegate.getDataAccess(); + } + + @Override + public DataSource getDataSource() { + return delegate.getDataSource(); + } + + @Override + public SchemaManager getSchemaManager() { + return delegate.getSchemaManager(); + } + } + + private record DocumentDatabaseProviderView(DocumentDatabaseProvider delegate) + implements DocumentDatabaseProvider, WrappedDatabaseProvider { + private DocumentDatabaseProviderView { + Objects.requireNonNull(delegate, "Delegate document database provider cannot be null."); + } + + @Override + public boolean isConnected() { + return delegate.isConnected(); + } + + @Override + public DocumentDataAccess getDataAccess() { + return delegate.getDataAccess(); + } + } + + private record KeyValueDatabaseProviderView(KeyValueDatabaseProvider delegate) + implements KeyValueDatabaseProvider, WrappedDatabaseProvider { + private KeyValueDatabaseProviderView { + Objects.requireNonNull(delegate, "Delegate key-value database provider cannot be null."); + } + + @Override + public boolean isConnected() { + return delegate.isConnected(); + } + + @Override + public KeyValueDataAccess getDataAccess() { + return delegate.getDataAccess(); + } + } + + private record MessagingDatabaseProviderView(MessagingDatabaseProvider delegate) + implements MessagingDatabaseProvider, WrappedDatabaseProvider { + private MessagingDatabaseProviderView { + Objects.requireNonNull(delegate, "Delegate messaging database provider cannot be null."); + } + + @Override + public boolean isConnected() { + return delegate.isConnected(); + } + + @Override + public MessagingDataAccess getDataAccess() { + return delegate.getDataAccess(); + } + } } diff --git a/src/main/java/nl/hauntedmc/dataprovider/database/DatabaseProvider.java b/src/main/java/nl/hauntedmc/dataprovider/database/DatabaseProvider.java index 01b96aa..94e92f3 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/database/DatabaseProvider.java +++ b/src/main/java/nl/hauntedmc/dataprovider/database/DatabaseProvider.java @@ -5,21 +5,11 @@ import java.util.Optional; /** - * The minimal shared parent for all database providers (relational or NoSQL). - * Allows storing them in a common map. + * Read-only database handle exposed to plugin consumers. + * Lifecycle operations are owned by DataProvider internals. */ public interface DatabaseProvider { - /** - * Establish a connection to the database. - */ - void connect(); - - /** - * Close the database connection. - */ - void disconnect(); - /** * Check if the database is currently connected. * diff --git a/src/main/java/nl/hauntedmc/dataprovider/database/document/impl/mongodb/MongoDBDatabase.java b/src/main/java/nl/hauntedmc/dataprovider/database/document/impl/mongodb/MongoDBDatabase.java index d9db187..da9122a 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/database/document/impl/mongodb/MongoDBDatabase.java +++ b/src/main/java/nl/hauntedmc/dataprovider/database/document/impl/mongodb/MongoDBDatabase.java @@ -5,6 +5,7 @@ import com.mongodb.client.MongoClient; import com.mongodb.client.MongoClients; import nl.hauntedmc.dataprovider.internal.concurrent.BoundedExecutorFactory; +import nl.hauntedmc.dataprovider.internal.ManagedDatabaseProvider; import nl.hauntedmc.dataprovider.database.document.DocumentDataAccess; import nl.hauntedmc.dataprovider.database.document.DocumentDatabaseProvider; import nl.hauntedmc.dataprovider.database.security.TlsSupport; @@ -23,7 +24,7 @@ * MongoDBDatabase implements DocumentDatabaseProvider for MongoDB. * This version uses Configurate to load configuration values from a YAML file. */ -public class MongoDBDatabase implements DocumentDatabaseProvider { +public class MongoDBDatabase implements DocumentDatabaseProvider, ManagedDatabaseProvider { private final CommentedConfigurationNode config; private final ILoggerAdapter logger; diff --git a/src/main/java/nl/hauntedmc/dataprovider/database/keyvalue/impl/redis/RedisDatabase.java b/src/main/java/nl/hauntedmc/dataprovider/database/keyvalue/impl/redis/RedisDatabase.java index 431e799..20eeb43 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/database/keyvalue/impl/redis/RedisDatabase.java +++ b/src/main/java/nl/hauntedmc/dataprovider/database/keyvalue/impl/redis/RedisDatabase.java @@ -1,6 +1,7 @@ package nl.hauntedmc.dataprovider.database.keyvalue.impl.redis; import nl.hauntedmc.dataprovider.internal.concurrent.BoundedExecutorFactory; +import nl.hauntedmc.dataprovider.internal.ManagedDatabaseProvider; import nl.hauntedmc.dataprovider.database.keyvalue.KeyValueDataAccess; import nl.hauntedmc.dataprovider.database.keyvalue.KeyValueDatabaseProvider; import nl.hauntedmc.dataprovider.database.security.TlsSupport; @@ -19,7 +20,7 @@ /** * RedisDatabase implements KeyValueDatabaseProvider, managing a JedisPool and an ExecutorService. */ -public class RedisDatabase implements KeyValueDatabaseProvider { +public class RedisDatabase implements KeyValueDatabaseProvider, ManagedDatabaseProvider { private final CommentedConfigurationNode config; private final ILoggerAdapter logger; diff --git a/src/main/java/nl/hauntedmc/dataprovider/database/messaging/impl/redis/RedisMessagingDatabase.java b/src/main/java/nl/hauntedmc/dataprovider/database/messaging/impl/redis/RedisMessagingDatabase.java index ed2f0f8..79b1f2b 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/database/messaging/impl/redis/RedisMessagingDatabase.java +++ b/src/main/java/nl/hauntedmc/dataprovider/database/messaging/impl/redis/RedisMessagingDatabase.java @@ -1,6 +1,7 @@ package nl.hauntedmc.dataprovider.database.messaging.impl.redis; import nl.hauntedmc.dataprovider.internal.concurrent.BoundedExecutorFactory; +import nl.hauntedmc.dataprovider.internal.ManagedDatabaseProvider; import nl.hauntedmc.dataprovider.database.messaging.MessagingDataAccess; import nl.hauntedmc.dataprovider.database.messaging.MessagingDatabaseProvider; import nl.hauntedmc.dataprovider.database.messaging.api.MessageRegistry; @@ -17,7 +18,7 @@ /** * Production‑ready Redis back‑end using ACL user/password and Pub/Sub. */ -public final class RedisMessagingDatabase implements MessagingDatabaseProvider { +public final class RedisMessagingDatabase implements MessagingDatabaseProvider, ManagedDatabaseProvider { private final CommentedConfigurationNode cfg; private final ILoggerAdapter logger; diff --git a/src/main/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDatabase.java b/src/main/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDatabase.java index a6d4c5a..1d80f33 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDatabase.java +++ b/src/main/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDatabase.java @@ -3,6 +3,7 @@ import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; import nl.hauntedmc.dataprovider.internal.concurrent.BoundedExecutorFactory; +import nl.hauntedmc.dataprovider.internal.ManagedDatabaseProvider; import nl.hauntedmc.dataprovider.database.relational.RelationalDataAccess; import nl.hauntedmc.dataprovider.database.relational.RelationalDatabaseProvider; import nl.hauntedmc.dataprovider.database.relational.schema.SchemaManager; @@ -19,7 +20,7 @@ /** * MySQL implementation of RelationalDatabaseProvider. */ -public class MySQLDatabase implements RelationalDatabaseProvider { +public class MySQLDatabase implements RelationalDatabaseProvider, ManagedDatabaseProvider { private static final Set SECURE_SSL_MODES = Set.of("REQUIRED", "VERIFY_CA", "VERIFY_IDENTITY"); diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistry.java b/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistry.java index 8069eab..909eea0 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistry.java +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistry.java @@ -32,7 +32,7 @@ protected DatabaseProvider registerDatabase(String pluginName, DatabaseType data while (true) { ActiveDatabaseRegistration existingRegistration = activeDatabases.get(key); if (existingRegistration != null) { - DatabaseProvider existingProvider = existingRegistration.provider(); + ManagedDatabaseProvider existingProvider = existingRegistration.provider(); if (isProviderHealthy(existingProvider, key) && existingRegistration.tryAcquireReference()) { int references = existingRegistration.referenceCount(); logger.info(pluginName + " reused " + databaseType.name() + " connection (" + connectionIdentifier @@ -52,7 +52,7 @@ protected DatabaseProvider registerDatabase(String pluginName, DatabaseType data return null; } - DatabaseProvider createdProvider = null; + ManagedDatabaseProvider createdProvider = null; try { createdProvider = factory.createDatabaseProvider(databaseType, connectionIdentifier); if (createdProvider == null) { @@ -83,7 +83,7 @@ protected DatabaseProvider registerDatabase(String pluginName, DatabaseType data logger.error("Failed to clean up duplicate connection for " + key, e); } - DatabaseProvider raceWinnerProvider = raceWinner.provider(); + ManagedDatabaseProvider raceWinnerProvider = raceWinner.provider(); if (isProviderHealthy(raceWinnerProvider, key) && raceWinner.tryAcquireReference()) { int references = raceWinner.referenceCount(); logger.info(pluginName + " already has " + databaseType.name() + " connection (" + connectionIdentifier @@ -117,7 +117,7 @@ private boolean isProviderHealthy(DatabaseProvider provider, DatabaseConnectionK } } - private void disconnectQuietly(DatabaseProvider provider, DatabaseConnectionKey key, String reason) { + private void disconnectQuietly(ManagedDatabaseProvider provider, DatabaseConnectionKey key, String reason) { try { provider.disconnect(); } catch (Exception e) { @@ -132,7 +132,7 @@ protected DatabaseProvider getDatabase(String pluginName, DatabaseType databaseT return null; } - DatabaseProvider provider = registration.provider(); + ManagedDatabaseProvider provider = registration.provider(); if (isProviderHealthy(provider, key)) { return provider; } @@ -221,15 +221,15 @@ protected Map getActiveDatabaseReferenceCounts() } private static final class ActiveDatabaseRegistration { - private final DatabaseProvider provider; + private final ManagedDatabaseProvider provider; private final AtomicInteger referenceCount; - private ActiveDatabaseRegistration(DatabaseProvider provider) { + private ActiveDatabaseRegistration(ManagedDatabaseProvider provider) { this.provider = Objects.requireNonNull(provider, "Database provider cannot be null."); this.referenceCount = new AtomicInteger(1); } - private DatabaseProvider provider() { + private ManagedDatabaseProvider provider() { return provider; } diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/DatabaseFactory.java b/src/main/java/nl/hauntedmc/dataprovider/internal/DatabaseFactory.java index 004102d..4672ed0 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/internal/DatabaseFactory.java +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/DatabaseFactory.java @@ -1,7 +1,6 @@ package nl.hauntedmc.dataprovider.internal; import nl.hauntedmc.dataprovider.database.DatabaseType; -import nl.hauntedmc.dataprovider.database.DatabaseProvider; import nl.hauntedmc.dataprovider.database.document.impl.mongodb.MongoDBDatabase; import nl.hauntedmc.dataprovider.database.keyvalue.impl.redis.RedisDatabase; import nl.hauntedmc.dataprovider.database.messaging.impl.redis.RedisMessagingDatabase; @@ -21,7 +20,7 @@ protected DatabaseFactory(DatabaseConfigMap configMap, ILoggerAdapter logger) { this.logger = Objects.requireNonNull(logger, "Logger cannot be null."); } - protected DatabaseProvider createDatabaseProvider(DatabaseType type, String connectionIdentifier) { + protected ManagedDatabaseProvider createDatabaseProvider(DatabaseType type, String connectionIdentifier) { CommentedConfigurationNode connectionConfig = configMap.getConfig(type, connectionIdentifier); if (connectionConfig == null) { logger.error("Could not load configuration for " + connectionIdentifier + " (" + type.name() + ")"); diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/ManagedDatabaseProvider.java b/src/main/java/nl/hauntedmc/dataprovider/internal/ManagedDatabaseProvider.java new file mode 100644 index 0000000..6b228a0 --- /dev/null +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/ManagedDatabaseProvider.java @@ -0,0 +1,19 @@ +package nl.hauntedmc.dataprovider.internal; + +import nl.hauntedmc.dataprovider.database.DatabaseProvider; + +/** + * Internal provider contract that includes lifecycle ownership. + */ +public interface ManagedDatabaseProvider extends DatabaseProvider { + + /** + * Establish a connection to the database. + */ + void connect(); + + /** + * Close the database connection. + */ + void disconnect(); +} diff --git a/src/test/java/nl/hauntedmc/dataprovider/api/DataProviderAPITest.java b/src/test/java/nl/hauntedmc/dataprovider/api/DataProviderAPITest.java index 0ba07b5..3b8e6fd 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/api/DataProviderAPITest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/api/DataProviderAPITest.java @@ -3,15 +3,23 @@ import nl.hauntedmc.dataprovider.database.DataAccess; import nl.hauntedmc.dataprovider.database.DatabaseProvider; import nl.hauntedmc.dataprovider.database.DatabaseType; +import nl.hauntedmc.dataprovider.database.messaging.MessagingDataAccess; +import nl.hauntedmc.dataprovider.database.messaging.MessagingDatabaseProvider; +import nl.hauntedmc.dataprovider.database.messaging.api.EventMessage; +import nl.hauntedmc.dataprovider.database.messaging.api.Subscription; +import nl.hauntedmc.dataprovider.internal.ManagedDatabaseProvider; import nl.hauntedmc.dataprovider.internal.DataProviderHandler; import org.junit.jupiter.api.Test; import javax.sql.DataSource; import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; @@ -40,43 +48,45 @@ void registerAndLookupOptionalApisHandleNullProvider() { @Test void providerCastingAndDataAccessViewsReturnExpectedOptionalResults() { DataProviderHandler handler = mock(DataProviderHandler.class); - StubDatabaseProvider provider = new StubDatabaseProvider(new StubDataAccess()); + StubMessagingDatabaseProvider provider = new StubMessagingDatabaseProvider(new StubMessagingDataAccess()); when(handler.registerDatabase(DatabaseType.REDIS, "cache")).thenReturn(provider); when(handler.getRegisteredDatabase(DatabaseType.REDIS, "cache")).thenReturn(provider); DataProviderAPI api = new DataProviderAPI(handler); - Optional registerAs = api.registerDatabaseAs( + Optional registerAs = api.registerDatabaseAs( DatabaseType.REDIS, "cache", - StubDatabaseProvider.class + MessagingDatabaseProvider.class ); - Optional lookupAs = api.getRegisteredDatabaseAs( + Optional lookupAs = api.getRegisteredDatabaseAs( DatabaseType.REDIS, "cache", - StubDatabaseProvider.class + MessagingDatabaseProvider.class ); - Optional registerAccess = api.registerDataAccess( + Optional registerAccess = api.registerDataAccess( DatabaseType.REDIS, "cache", - StubDataAccess.class + StubMessagingDataAccess.class ); - Optional lookupAccess = api.getRegisteredDataAccess( + Optional lookupAccess = api.getRegisteredDataAccess( DatabaseType.REDIS, "cache", - StubDataAccess.class + StubMessagingDataAccess.class ); assertTrue(registerAs.isPresent()); assertTrue(lookupAs.isPresent()); assertTrue(registerAccess.isPresent()); assertTrue(lookupAccess.isPresent()); + assertNotSame(provider, registerAs.get()); + assertNotSame(provider, lookupAs.get()); } @Test void providerCastingAndDataAccessViewsReturnEmptyWhenTypeMismatches() { DataProviderHandler handler = mock(DataProviderHandler.class); - StubDatabaseProvider provider = new StubDatabaseProvider(new StubDataAccess()); + StubMessagingDatabaseProvider provider = new StubMessagingDatabaseProvider(new StubMessagingDataAccess()); when(handler.registerDatabase(DatabaseType.REDIS, "cache")).thenReturn(provider); when(handler.getRegisteredDatabase(DatabaseType.REDIS, "cache")).thenReturn(provider); @@ -87,6 +97,11 @@ void providerCastingAndDataAccessViewsReturnEmptyWhenTypeMismatches() { "cache", OtherDatabaseProvider.class ); + Optional managedView = api.registerDatabaseAs( + DatabaseType.REDIS, + "cache", + StubDatabaseProvider.class + ); Optional dataAccessView = api.getRegisteredDataAccess( DatabaseType.REDIS, "cache", @@ -94,6 +109,7 @@ void providerCastingAndDataAccessViewsReturnEmptyWhenTypeMismatches() { ); assertFalse(providerView.isPresent()); + assertFalse(managedView.isPresent()); assertFalse(dataAccessView.isPresent()); } @@ -124,13 +140,30 @@ void typedMethodsRejectNullExpectedTypes() { api.getRegisteredDataAccess(DatabaseType.MYSQL, "default", null)); } - private static final class StubDataAccess implements DataAccess { + private static class StubDataAccess implements DataAccess { + } + + private static final class StubMessagingDataAccess extends StubDataAccess implements MessagingDataAccess { + @Override + public CompletableFuture publish(String destination, T message) { + return CompletableFuture.completedFuture(null); + } + + @Override + public Subscription subscribe(String destination, Class type, Consumer handler) { + return () -> CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture shutdown() { + return CompletableFuture.completedFuture(null); + } } private static final class OtherDataAccess implements DataAccess { } - private static class StubDatabaseProvider implements DatabaseProvider { + private static class StubDatabaseProvider implements DatabaseProvider, ManagedDatabaseProvider { private final DataAccess dataAccess; private StubDatabaseProvider(DataAccess dataAccess) { @@ -166,4 +199,16 @@ private OtherDatabaseProvider() { super(new OtherDataAccess()); } } + + private static final class StubMessagingDatabaseProvider extends StubDatabaseProvider + implements MessagingDatabaseProvider { + private StubMessagingDatabaseProvider(StubMessagingDataAccess dataAccess) { + super(dataAccess); + } + + @Override + public MessagingDataAccess getDataAccess() { + return (MessagingDataAccess) super.getDataAccess(); + } + } } diff --git a/src/test/java/nl/hauntedmc/dataprovider/database/DatabaseProviderDefaultsTest.java b/src/test/java/nl/hauntedmc/dataprovider/database/DatabaseProviderDefaultsTest.java index 182489c..6922a6a 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/database/DatabaseProviderDefaultsTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/database/DatabaseProviderDefaultsTest.java @@ -127,14 +127,6 @@ private StubDatabaseProvider( this.dataSourceSupplier = dataSourceSupplier; } - @Override - public void connect() { - } - - @Override - public void disconnect() { - } - @Override public boolean isConnected() { return true; diff --git a/src/test/java/nl/hauntedmc/dataprovider/database/DatabaseProviderInterfaceContractTest.java b/src/test/java/nl/hauntedmc/dataprovider/database/DatabaseProviderInterfaceContractTest.java index 0106105..94e3386 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/database/DatabaseProviderInterfaceContractTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/database/DatabaseProviderInterfaceContractTest.java @@ -15,14 +15,6 @@ class DatabaseProviderInterfaceContractTest { @Test void documentProviderDoesNotExposeDataSource() { DocumentDatabaseProvider provider = new DocumentDatabaseProvider() { - @Override - public void connect() { - } - - @Override - public void disconnect() { - } - @Override public boolean isConnected() { return true; @@ -40,14 +32,6 @@ public DocumentDataAccess getDataAccess() { @Test void keyValueProviderDoesNotExposeDataSource() { KeyValueDatabaseProvider provider = new KeyValueDatabaseProvider() { - @Override - public void connect() { - } - - @Override - public void disconnect() { - } - @Override public boolean isConnected() { return true; @@ -65,14 +49,6 @@ public KeyValueDataAccess getDataAccess() { @Test void messagingProviderDoesNotExposeDataSource() { MessagingDatabaseProvider provider = new MessagingDatabaseProvider() { - @Override - public void connect() { - } - - @Override - public void disconnect() { - } - @Override public boolean isConnected() { return true; diff --git a/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistryTest.java b/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistryTest.java index 5ae1403..7d2e6a7 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistryTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistryTest.java @@ -5,6 +5,7 @@ import nl.hauntedmc.dataprovider.database.DatabaseConnectionKey; import nl.hauntedmc.dataprovider.database.DatabaseProvider; import nl.hauntedmc.dataprovider.database.DatabaseType; +import nl.hauntedmc.dataprovider.internal.ManagedDatabaseProvider; import nl.hauntedmc.dataprovider.testutil.RecordingLoggerAdapter; import org.junit.jupiter.api.Test; @@ -243,7 +244,7 @@ void getActiveSnapshotsExposeCurrentRegistryState() { assertEquals(1, refs.get(key)); } - private static final class RecordingProvider implements DatabaseProvider { + private static final class RecordingProvider implements ManagedDatabaseProvider { private boolean connected; private int connectCalls; private int disconnectCalls; From d9c473a56434cf95ee8b0efc0f941a645711be54 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 25 Mar 2026 13:14:02 +0100 Subject: [PATCH 03/17] Make call stack verification less strict --- .../identity/PluginCallerChainResolver.java | 38 ++++++++ .../identity/BukkitCallerContextResolver.java | 36 +++----- .../VelocityCallerContextResolver.java | 49 +++++------ .../PluginCallerChainResolverTest.java | 86 +++++++++++++++++++ .../VelocityCallerContextResolverTest.java | 63 ++++++++++++++ 5 files changed, 221 insertions(+), 51 deletions(-) create mode 100644 src/main/java/nl/hauntedmc/dataprovider/internal/identity/PluginCallerChainResolver.java create mode 100644 src/test/java/nl/hauntedmc/dataprovider/internal/identity/PluginCallerChainResolverTest.java create mode 100644 src/test/java/nl/hauntedmc/dataprovider/platform/velocity/identity/VelocityCallerContextResolverTest.java diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/identity/PluginCallerChainResolver.java b/src/main/java/nl/hauntedmc/dataprovider/internal/identity/PluginCallerChainResolver.java new file mode 100644 index 0000000..9a543f9 --- /dev/null +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/identity/PluginCallerChainResolver.java @@ -0,0 +1,38 @@ +package nl.hauntedmc.dataprovider.internal.identity; + +import java.util.List; +import java.util.Objects; +import java.util.function.Function; + +/** + * Resolves the nearest plugin-backed caller from a stack-derived class loader chain. + */ +public final class PluginCallerChainResolver { + + private PluginCallerChainResolver() { + } + + public static CallerContext resolveNearestMappedCaller( + List callerChain, + Function pluginIdResolver, + String missingCallerMessage + ) { + Objects.requireNonNull(callerChain, "Caller chain cannot be null."); + Objects.requireNonNull(pluginIdResolver, "Plugin ID resolver cannot be null."); + Objects.requireNonNull(missingCallerMessage, "Missing caller message cannot be null."); + + for (ClassLoader callerLoader : callerChain) { + if (callerLoader == null) { + continue; + } + + String pluginId = pluginIdResolver.apply(callerLoader); + if (pluginId == null || pluginId.isBlank()) { + continue; + } + return new CallerContext(pluginId, callerLoader); + } + + throw new SecurityException(missingCallerMessage); + } +} diff --git a/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/identity/BukkitCallerContextResolver.java b/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/identity/BukkitCallerContextResolver.java index c3a41ae..53e271b 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/identity/BukkitCallerContextResolver.java +++ b/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/identity/BukkitCallerContextResolver.java @@ -2,6 +2,7 @@ import nl.hauntedmc.dataprovider.internal.identity.CallerContext; import nl.hauntedmc.dataprovider.internal.identity.CallerContextResolver; +import nl.hauntedmc.dataprovider.internal.identity.PluginCallerChainResolver; import nl.hauntedmc.dataprovider.internal.identity.StackCallerClassLoaderResolver; import org.bukkit.Bukkit; import org.bukkit.plugin.Plugin; @@ -22,29 +23,20 @@ public BukkitCallerContextResolver(ClassLoader ownClassLoader) { @Override public CallerContext resolveCaller() { - List callerChain = StackCallerClassLoaderResolver.resolveExternalCallerChain(ownClassLoader); - Plugin resolvedPlugin = null; - ClassLoader resolvedLoader = null; - - for (ClassLoader callerLoader : callerChain) { - Plugin plugin = findPluginByClassLoader(callerLoader); - if (plugin == null) { - continue; - } - if (resolvedPlugin == null) { - resolvedPlugin = plugin; - resolvedLoader = callerLoader; - continue; - } - if (callerLoader != resolvedLoader) { - throw new SecurityException("Ambiguous caller plugin chain detected."); - } - } + return resolveCaller(StackCallerClassLoaderResolver.resolveExternalCallerChain(ownClassLoader)); + } - if (resolvedPlugin == null || resolvedLoader == null) { - throw new SecurityException("Caller class loader is not mapped to a Bukkit plugin."); - } - return new CallerContext(resolvedPlugin.getName(), resolvedLoader); + CallerContext resolveCaller(List callerChain) { + return PluginCallerChainResolver.resolveNearestMappedCaller( + callerChain, + this::resolvePluginName, + "Caller class loader is not mapped to a Bukkit plugin." + ); + } + + private String resolvePluginName(ClassLoader callerLoader) { + Plugin plugin = findPluginByClassLoader(callerLoader); + return plugin == null ? null : plugin.getName(); } private static Plugin findPluginByClassLoader(ClassLoader callerLoader) { diff --git a/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/identity/VelocityCallerContextResolver.java b/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/identity/VelocityCallerContextResolver.java index ff42e72..684b934 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/identity/VelocityCallerContextResolver.java +++ b/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/identity/VelocityCallerContextResolver.java @@ -3,6 +3,7 @@ import com.velocitypowered.api.proxy.ProxyServer; import nl.hauntedmc.dataprovider.internal.identity.CallerContext; import nl.hauntedmc.dataprovider.internal.identity.CallerContextResolver; +import nl.hauntedmc.dataprovider.internal.identity.PluginCallerChainResolver; import nl.hauntedmc.dataprovider.internal.identity.StackCallerClassLoaderResolver; import java.util.List; @@ -23,34 +24,24 @@ public VelocityCallerContextResolver(ProxyServer proxyServer, ClassLoader ownCla @Override public CallerContext resolveCaller() { - List callerChain = StackCallerClassLoaderResolver.resolveExternalCallerChain(ownClassLoader); - String resolvedPluginId = null; - ClassLoader resolvedLoader = null; - - for (ClassLoader callerLoader : callerChain) { - String pluginId = proxyServer.getPluginManager().getPlugins().stream() - .filter(container -> container.getInstance() - .map(instance -> instance.getClass().getClassLoader() == callerLoader) - .orElse(false)) - .findFirst() - .map(container -> container.getDescription().getId()) - .orElse(null); - if (pluginId == null) { - continue; - } - if (resolvedLoader == null) { - resolvedLoader = callerLoader; - resolvedPluginId = pluginId; - continue; - } - if (resolvedLoader != callerLoader) { - throw new SecurityException("Ambiguous caller plugin chain detected."); - } - } - - if (resolvedPluginId == null || resolvedLoader == null) { - throw new SecurityException("Caller class loader is not mapped to a Velocity plugin."); - } - return new CallerContext(resolvedPluginId, resolvedLoader); + return resolveCaller(StackCallerClassLoaderResolver.resolveExternalCallerChain(ownClassLoader)); + } + + CallerContext resolveCaller(List callerChain) { + return PluginCallerChainResolver.resolveNearestMappedCaller( + callerChain, + this::resolvePluginId, + "Caller class loader is not mapped to a Velocity plugin." + ); + } + + private String resolvePluginId(ClassLoader callerLoader) { + return proxyServer.getPluginManager().getPlugins().stream() + .filter(container -> container.getInstance() + .map(instance -> instance.getClass().getClassLoader() == callerLoader) + .orElse(false)) + .findFirst() + .map(container -> container.getDescription().getId()) + .orElse(null); } } diff --git a/src/test/java/nl/hauntedmc/dataprovider/internal/identity/PluginCallerChainResolverTest.java b/src/test/java/nl/hauntedmc/dataprovider/internal/identity/PluginCallerChainResolverTest.java new file mode 100644 index 0000000..f43d064 --- /dev/null +++ b/src/test/java/nl/hauntedmc/dataprovider/internal/identity/PluginCallerChainResolverTest.java @@ -0,0 +1,86 @@ +package nl.hauntedmc.dataprovider.internal.identity; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class PluginCallerChainResolverTest { + + @Test + void validatesArguments() { + assertThrows(NullPointerException.class, () -> PluginCallerChainResolver.resolveNearestMappedCaller( + null, + callerLoader -> "plugin", + "missing" + )); + assertThrows(NullPointerException.class, () -> PluginCallerChainResolver.resolveNearestMappedCaller( + List.of(), + null, + "missing" + )); + assertThrows(NullPointerException.class, () -> PluginCallerChainResolver.resolveNearestMappedCaller( + List.of(), + callerLoader -> "plugin", + null + )); + } + + @Test + void returnsNearestMappedPluginCaller() { + ClassLoader nearestPluginLoader = new ClassLoader() { + }; + ClassLoader outerPluginLoader = new ClassLoader() { + }; + + CallerContext callerContext = PluginCallerChainResolver.resolveNearestMappedCaller( + List.of(nearestPluginLoader, outerPluginLoader), + callerLoader -> { + if (callerLoader == nearestPluginLoader) { + return "proxyfeatures"; + } + if (callerLoader == outerPluginLoader) { + return "shared-wrapper"; + } + return null; + }, + "missing" + ); + + assertEquals("proxyfeatures", callerContext.pluginId()); + assertSame(nearestPluginLoader, callerContext.classLoader()); + } + + @Test + void skipsUnmappedLoadersUntilPluginCallerIsFound() { + ClassLoader libraryLoader = new ClassLoader() { + }; + ClassLoader pluginLoader = new ClassLoader() { + }; + + CallerContext callerContext = PluginCallerChainResolver.resolveNearestMappedCaller( + List.of(libraryLoader, pluginLoader), + callerLoader -> callerLoader == pluginLoader ? "dataregistry" : null, + "missing" + ); + + assertEquals("dataregistry", callerContext.pluginId()); + assertSame(pluginLoader, callerContext.classLoader()); + } + + @Test + void throwsWhenNoPluginCallerIsMapped() { + SecurityException exception = assertThrows(SecurityException.class, () -> + PluginCallerChainResolver.resolveNearestMappedCaller( + List.of(new ClassLoader() { + }), + callerLoader -> null, + "missing" + )); + + assertEquals("missing", exception.getMessage()); + } +} diff --git a/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/identity/VelocityCallerContextResolverTest.java b/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/identity/VelocityCallerContextResolverTest.java new file mode 100644 index 0000000..cfb404c --- /dev/null +++ b/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/identity/VelocityCallerContextResolverTest.java @@ -0,0 +1,63 @@ +package nl.hauntedmc.dataprovider.platform.velocity.identity; + +import com.velocitypowered.api.plugin.PluginContainer; +import com.velocitypowered.api.plugin.PluginDescription; +import com.velocitypowered.api.plugin.PluginManager; +import com.velocitypowered.api.proxy.ProxyServer; +import nl.hauntedmc.dataprovider.internal.identity.CallerContext; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Proxy; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class VelocityCallerContextResolverTest { + + @Test + void resolvesNearestPluginFromCallerChain() { + ClassLoader nearestLoader = new ClassLoader() { + }; + ClassLoader outerLoader = new ClassLoader() { + }; + + VelocityCallerContextResolver resolver = new VelocityCallerContextResolver( + createProxyServer( + createPluginContainer("proxyfeatures", createPluginInstance(nearestLoader)), + createPluginContainer("wrapperplugin", createPluginInstance(outerLoader)) + ), + getClass().getClassLoader() + ); + + CallerContext callerContext = resolver.resolveCaller(List.of(nearestLoader, outerLoader)); + + assertEquals("proxyfeatures", callerContext.pluginId()); + assertSame(nearestLoader, callerContext.classLoader()); + } + + private static ProxyServer createProxyServer(PluginContainer... pluginContainers) { + ProxyServer proxyServer = mock(ProxyServer.class); + PluginManager pluginManager = mock(PluginManager.class); + when(proxyServer.getPluginManager()).thenReturn(pluginManager); + when(pluginManager.getPlugins()).thenReturn(List.of(pluginContainers)); + return proxyServer; + } + + private static PluginContainer createPluginContainer(String pluginId, Object pluginInstance) { + PluginContainer container = mock(PluginContainer.class); + PluginDescription description = mock(PluginDescription.class); + doReturn(Optional.of(pluginInstance)).when(container).getInstance(); + when(container.getDescription()).thenReturn(description); + when(description.getId()).thenReturn(pluginId); + return container; + } + + private static Object createPluginInstance(ClassLoader classLoader) { + return Proxy.newProxyInstance(classLoader, new Class[]{Runnable.class}, (proxy, method, args) -> null); + } +} From 33983b67ea5e87b26d89eb0c05c288f9361d428f Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 25 Mar 2026 13:25:39 +0100 Subject: [PATCH 04/17] Fix missing mysql driver error --- .../relational/impl/mysql/MySQLDatabase.java | 8 +++++- .../impl/mysql/MySQLDatabaseTest.java | 25 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/main/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDatabase.java b/src/main/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDatabase.java index 1d80f33..f4832fa 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDatabase.java +++ b/src/main/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDatabase.java @@ -22,6 +22,7 @@ */ public class MySQLDatabase implements RelationalDatabaseProvider, ManagedDatabaseProvider { + static final String MYSQL_DRIVER_CLASS_NAME = "com.mysql.cj.jdbc.Driver"; private static final Set SECURE_SSL_MODES = Set.of("REQUIRED", "VERIFY_CA", "VERIFY_IDENTITY"); private final CommentedConfigurationNode config; @@ -76,6 +77,7 @@ public void connect() { allowPublicKeyRetrieval ); hikariConfig.setJdbcUrl(jdbcUrl); + hikariConfig.setDriverClassName(MYSQL_DRIVER_CLASS_NAME); hikariConfig.setUsername(user); hikariConfig.setPassword(password); @@ -87,7 +89,7 @@ public void connect() { hikariConfig.setMaxLifetime(1800000); hikariConfig.setLeakDetectionThreshold(2000); - createdDataSource = new HikariDataSource(hikariConfig); + createdDataSource = createDataSource(hikariConfig); createdExecutor = BoundedExecutorFactory.create("dataprovider-mysql", poolSize, queueCapacity); try (var connection = createdDataSource.getConnection()) { @@ -179,4 +181,8 @@ public RelationalDataAccess getDataAccess() { public DataSource getDataSource() { return dataSource; } + + HikariDataSource createDataSource(HikariConfig hikariConfig) { + return new HikariDataSource(hikariConfig); + } } diff --git a/src/test/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDatabaseTest.java b/src/test/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDatabaseTest.java index d4eab4b..b5e866d 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDatabaseTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDatabaseTest.java @@ -1,9 +1,14 @@ package nl.hauntedmc.dataprovider.database.relational.impl.mysql; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; import nl.hauntedmc.dataprovider.testutil.RecordingLoggerAdapter; import org.junit.jupiter.api.Test; import org.spongepowered.configurate.CommentedConfigurationNode; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -41,4 +46,24 @@ void disconnectIsSafeWhenNeverConnected() { assertFalse(database.isConnected()); assertNull(database.getDataSource()); } + + @Test + void connectConfiguresExplicitMySqlDriverClass() { + CommentedConfigurationNode config = CommentedConfigurationNode.root(); + RecordingLoggerAdapter logger = new RecordingLoggerAdapter(); + AtomicReference capturedConfig = new AtomicReference<>(); + MySQLDatabase database = new MySQLDatabase(config, logger) { + @Override + HikariDataSource createDataSource(HikariConfig hikariConfig) { + capturedConfig.set(hikariConfig); + throw new RuntimeException("stop after hikari config capture"); + } + }; + + database.connect(); + + assertEquals(MySQLDatabase.MYSQL_DRIVER_CLASS_NAME, capturedConfig.get().getDriverClassName()); + assertTrue(logger.errorMessages().stream().anyMatch(message -> + message.contains("[MySQLDatabase] Connection failed!"))); + } } From 7f0c24e7650fdc4a7b786e524a3fb90247684baf Mon Sep 17 00:00:00 2001 From: remdui Date: Wed, 25 Mar 2026 21:18:36 +0100 Subject: [PATCH 05/17] Harden DataProvider lifecycle: block post-shutdown API use and ensure clean re-enable --- docs/USAGE_GUIDE.md | 8 + .../internal/DataProviderHandler.java | 14 + .../internal/DataProviderRegistry.java | 320 +++++++++++------- .../platform/bukkit/BukkitDataProvider.java | 23 +- .../velocity/VelocityDataProvider.java | 23 +- .../dataprovider/DataProviderTest.java | 25 ++ .../internal/DataProviderHandlerTest.java | 27 ++ .../internal/DataProviderRegistryTest.java | 41 ++- .../bukkit/BukkitDataProviderTest.java | 52 +++ .../velocity/VelocityDataProviderTest.java | 42 +++ 10 files changed, 443 insertions(+), 132 deletions(-) create mode 100644 src/test/java/nl/hauntedmc/dataprovider/platform/bukkit/BukkitDataProviderTest.java diff --git a/docs/USAGE_GUIDE.md b/docs/USAGE_GUIDE.md index f22cf07..74d341b 100644 --- a/docs/USAGE_GUIDE.md +++ b/docs/USAGE_GUIDE.md @@ -16,6 +16,14 @@ DataProviderAPI api = BukkitDataProvider.getDataProviderAPI(); Caller identity is resolved automatically from the plugin runtime context. +## 1.1 API lifecycle across reloads + +Treat `DataProviderAPI` as runtime-scoped, not permanent. + +- Acquire the API during your plugin enable/start phase. +- Do not keep API references across plugin reloads or disable/enable cycles. +- After DataProvider shuts down, old API handles throw `IllegalStateException`; reacquire a fresh API after DataProvider is enabled again. + ## 2. Register a connection Basic: diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderHandler.java b/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderHandler.java index d727111..12d0877 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderHandler.java +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderHandler.java @@ -23,6 +23,8 @@ public class DataProviderHandler { private static final String INTERNAL_PACKAGE_PREFIX = "nl.hauntedmc.dataprovider.internal"; private static final Pattern CONNECTION_IDENTIFIER_PATTERN = Pattern.compile("[A-Za-z0-9_.:-]{1,128}"); + private static final String CLOSED_MESSAGE = + "DataProvider API is no longer available. Obtain a fresh API instance after plugin enable."; private final DataProviderRegistry registry; private final CallerContextResolver callerContextResolver; @@ -64,6 +66,7 @@ public DataProviderHandler( * Registers a database connection for the resolved caller plugin. */ public DatabaseProvider registerDatabase(DatabaseType databaseType, String connectionIdentifier) { + requireOpen(); Objects.requireNonNull(databaseType, "Database type cannot be null"); requireConnectionIdentifier(connectionIdentifier); CallerContext caller = resolveCallerContext(); @@ -74,6 +77,7 @@ public DatabaseProvider registerDatabase(DatabaseType databaseType, String conne * Unregisters a specific database connection for the resolved caller plugin. */ public void unregisterDatabase(DatabaseType databaseType, String connectionIdentifier) { + requireOpen(); Objects.requireNonNull(databaseType, "Database type cannot be null"); requireConnectionIdentifier(connectionIdentifier); CallerContext caller = resolveCallerContext(); @@ -84,6 +88,7 @@ public void unregisterDatabase(DatabaseType databaseType, String connectionIdent * Unregisters all database connections for the resolved caller plugin. */ public void unregisterAllDatabases() { + requireOpen(); CallerContext caller = resolveCallerContext(); registry.unregisterAllDatabases(caller.pluginId()); } @@ -100,6 +105,7 @@ public void shutdownAllDatabases() { * Retrieves a registered database connection for the resolved caller plugin. */ public DatabaseProvider getRegisteredDatabase(DatabaseType databaseType, String connectionIdentifier) { + requireOpen(); Objects.requireNonNull(databaseType, "Database type cannot be null"); requireConnectionIdentifier(connectionIdentifier); CallerContext caller = resolveCallerContext(); @@ -110,6 +116,7 @@ public DatabaseProvider getRegisteredDatabase(DatabaseType databaseType, String * Returns a snapshot of active database connections. */ public ConcurrentMap getActiveDatabases() { + requireOpen(); requireInternalCaller(); return registry.getActiveDatabases(); } @@ -118,6 +125,7 @@ public ConcurrentMap getActiveDatabases * Returns active connection reference counts per database key. */ public Map getActiveDatabaseReferenceCounts() { + requireOpen(); requireInternalCaller(); return registry.getActiveDatabaseReferenceCounts(); } @@ -147,4 +155,10 @@ private void requireInternalCaller() { throw new SecurityException("Privileged DataProvider operation is restricted to internal callers."); } } + + private void requireOpen() { + if (registry.isClosed()) { + throw new IllegalStateException(CLOSED_MESSAGE); + } + } } diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistry.java b/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistry.java index 909eea0..fe35c1f 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistry.java +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistry.java @@ -12,13 +12,21 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; class DataProviderRegistry { + private static final String SHUTDOWN_MESSAGE = + "DataProvider is shut down. Obtain a fresh API instance after plugin enable."; + private final ConcurrentMap activeDatabases = new ConcurrentHashMap<>(); + private final ReadWriteLock lifecycleLock = new ReentrantReadWriteLock(true); private final DatabaseFactory factory; private final ConfigHandler configHandler; private final ILoggerAdapter logger; + private volatile boolean closed; public DataProviderRegistry(DatabaseFactory factory, ConfigHandler configHandler, ILoggerAdapter logger) { this.factory = Objects.requireNonNull(factory, "Factory cannot be null."); @@ -27,84 +35,93 @@ public DataProviderRegistry(DatabaseFactory factory, ConfigHandler configHandler } protected DatabaseProvider registerDatabase(String pluginName, DatabaseType databaseType, String connectionIdentifier) { - DatabaseConnectionKey key = new DatabaseConnectionKey(pluginName, databaseType, connectionIdentifier); - - while (true) { - ActiveDatabaseRegistration existingRegistration = activeDatabases.get(key); - if (existingRegistration != null) { - ManagedDatabaseProvider existingProvider = existingRegistration.provider(); - if (isProviderHealthy(existingProvider, key) && existingRegistration.tryAcquireReference()) { - int references = existingRegistration.referenceCount(); - logger.info(pluginName + " reused " + databaseType.name() + " connection (" + connectionIdentifier - + "), active references=" + references); - return existingProvider; - } - if (!activeDatabases.remove(key, existingRegistration)) { - continue; - } - disconnectQuietly(existingProvider, key, "stale existing connection"); - logger.warn("Removed stale " + databaseType.name() + " connection for " + pluginName - + " (" + connectionIdentifier + ") before re-registering."); - } - - if (!configHandler.isDatabaseTypeEnabled(databaseType)) { - logger.error("Failed to establish connection for " + pluginName + " with " + databaseType.name() + ": This database type is disabled in the main config."); - return null; - } + Lock readLock = lifecycleLock.readLock(); + readLock.lock(); + try { + ensureOpen(); + DatabaseConnectionKey key = new DatabaseConnectionKey(pluginName, databaseType, connectionIdentifier); - ManagedDatabaseProvider createdProvider = null; - try { - createdProvider = factory.createDatabaseProvider(databaseType, connectionIdentifier); - if (createdProvider == null) { - return null; - } - createdProvider.connect(); - if (!createdProvider.isConnected()) { - try { - createdProvider.disconnect(); - } catch (Exception e) { - logger.error("Failed to clean up failed connection for " + key, e); + while (true) { + ActiveDatabaseRegistration existingRegistration = activeDatabases.get(key); + if (existingRegistration != null) { + ManagedDatabaseProvider existingProvider = existingRegistration.provider(); + if (isProviderHealthy(existingProvider, key) && existingRegistration.tryAcquireReference()) { + int references = existingRegistration.referenceCount(); + logger.info(pluginName + " reused " + databaseType.name() + " connection (" + connectionIdentifier + + "), active references=" + references); + return existingProvider; } - logger.error("Failed to establish connection for " + pluginName + " with " + databaseType.name() + " (" + connectionIdentifier + ")"); - return null; + if (!activeDatabases.remove(key, existingRegistration)) { + continue; + } + disconnectQuietly(existingProvider, key, "stale existing connection"); + logger.warn("Removed stale " + databaseType.name() + " connection for " + pluginName + + " (" + connectionIdentifier + ") before re-registering."); } - ActiveDatabaseRegistration createdRegistration = new ActiveDatabaseRegistration(createdProvider); - ActiveDatabaseRegistration raceWinner = activeDatabases.putIfAbsent(key, createdRegistration); - if (raceWinner == null) { - logger.info(pluginName + " registered " + databaseType.name() + " connection (" + connectionIdentifier - + "), active references=1"); - return createdProvider; + if (!configHandler.isDatabaseTypeEnabled(databaseType)) { + logger.error("Failed to establish connection for " + pluginName + " with " + databaseType.name() + + ": This database type is disabled in the main config."); + return null; } + ManagedDatabaseProvider createdProvider = null; try { - createdProvider.disconnect(); - } catch (Exception e) { - logger.error("Failed to clean up duplicate connection for " + key, e); - } + createdProvider = factory.createDatabaseProvider(databaseType, connectionIdentifier); + if (createdProvider == null) { + return null; + } + createdProvider.connect(); + if (!createdProvider.isConnected()) { + try { + createdProvider.disconnect(); + } catch (Exception e) { + logger.error("Failed to clean up failed connection for " + key, e); + } + logger.error("Failed to establish connection for " + pluginName + " with " + databaseType.name() + + " (" + connectionIdentifier + ")"); + return null; + } - ManagedDatabaseProvider raceWinnerProvider = raceWinner.provider(); - if (isProviderHealthy(raceWinnerProvider, key) && raceWinner.tryAcquireReference()) { - int references = raceWinner.referenceCount(); - logger.info(pluginName + " already has " + databaseType.name() + " connection (" + connectionIdentifier - + "), active references=" + references); - return raceWinnerProvider; - } + ActiveDatabaseRegistration createdRegistration = new ActiveDatabaseRegistration(createdProvider); + ActiveDatabaseRegistration raceWinner = activeDatabases.putIfAbsent(key, createdRegistration); + if (raceWinner == null) { + logger.info(pluginName + " registered " + databaseType.name() + " connection (" + connectionIdentifier + + "), active references=1"); + return createdProvider; + } - if (activeDatabases.remove(key, raceWinner)) { - disconnectQuietly(raceWinnerProvider, key, "stale raced connection"); - } - } catch (Exception e) { - if (createdProvider != null) { try { createdProvider.disconnect(); - } catch (Exception disconnectException) { - logger.error("Failed to clean up errored connection for " + key, disconnectException); + } catch (Exception e) { + logger.error("Failed to clean up duplicate connection for " + key, e); + } + + ManagedDatabaseProvider raceWinnerProvider = raceWinner.provider(); + if (isProviderHealthy(raceWinnerProvider, key) && raceWinner.tryAcquireReference()) { + int references = raceWinner.referenceCount(); + logger.info(pluginName + " already has " + databaseType.name() + " connection (" + connectionIdentifier + + "), active references=" + references); + return raceWinnerProvider; + } + + if (activeDatabases.remove(key, raceWinner)) { + disconnectQuietly(raceWinnerProvider, key, "stale raced connection"); + } + } catch (Exception e) { + if (createdProvider != null) { + try { + createdProvider.disconnect(); + } catch (Exception disconnectException) { + logger.error("Failed to clean up errored connection for " + key, disconnectException); + } } + logger.error("Failed to register database for " + pluginName, e); + return null; } - logger.error("Failed to register database for " + pluginName, e); - return null; } + } finally { + readLock.unlock(); } } @@ -126,98 +143,153 @@ private void disconnectQuietly(ManagedDatabaseProvider provider, DatabaseConnect } protected DatabaseProvider getDatabase(String pluginName, DatabaseType databaseType, String connectionIdentifier) { - DatabaseConnectionKey key = new DatabaseConnectionKey(pluginName, databaseType, connectionIdentifier); - ActiveDatabaseRegistration registration = activeDatabases.get(key); - if (registration == null) { - return null; - } + Lock readLock = lifecycleLock.readLock(); + readLock.lock(); + try { + ensureOpen(); + DatabaseConnectionKey key = new DatabaseConnectionKey(pluginName, databaseType, connectionIdentifier); + ActiveDatabaseRegistration registration = activeDatabases.get(key); + if (registration == null) { + return null; + } - ManagedDatabaseProvider provider = registration.provider(); - if (isProviderHealthy(provider, key)) { - return provider; - } + ManagedDatabaseProvider provider = registration.provider(); + if (isProviderHealthy(provider, key)) { + return provider; + } - if (activeDatabases.remove(key, registration)) { - disconnectQuietly(provider, key, "stale connection during lookup"); - logger.warn("Removed stale " + databaseType.name() + " connection for " + pluginName - + " (" + connectionIdentifier + ") while retrieving the provider."); + if (activeDatabases.remove(key, registration)) { + disconnectQuietly(provider, key, "stale connection during lookup"); + logger.warn("Removed stale " + databaseType.name() + " connection for " + pluginName + + " (" + connectionIdentifier + ") while retrieving the provider."); + } + return null; + } finally { + readLock.unlock(); } - return null; } protected void unregisterDatabase(String pluginName, DatabaseType databaseType, String connectionIdentifier) { - DatabaseConnectionKey key = new DatabaseConnectionKey(pluginName, databaseType, connectionIdentifier); - ActiveDatabaseRegistration registration = activeDatabases.get(key); - if (registration == null) { - return; - } - - int references = registration.releaseReference(); - if (references > 0) { - logger.info(pluginName + " released " + databaseType.name() + " connection (" + connectionIdentifier - + "), remaining references=" + references); - return; - } - - if (!activeDatabases.remove(key, registration)) { - return; - } - + Lock readLock = lifecycleLock.readLock(); + readLock.lock(); try { - registration.provider().disconnect(); - } catch (Exception e) { - logger.error("Error disconnecting " + key, e); - } - logger.info(pluginName + " unregistered " + databaseType.name() + " connection (" + connectionIdentifier + ")"); - } + ensureOpen(); + DatabaseConnectionKey key = new DatabaseConnectionKey(pluginName, databaseType, connectionIdentifier); + ActiveDatabaseRegistration registration = activeDatabases.get(key); + if (registration == null) { + return; + } - protected void unregisterAllDatabases(String pluginName) { - for (Map.Entry entry : activeDatabases.entrySet()) { - DatabaseConnectionKey key = entry.getKey(); - if (!key.pluginName().equals(pluginName)) { - continue; + int references = registration.releaseReference(); + if (references > 0) { + logger.info(pluginName + " released " + databaseType.name() + " connection (" + connectionIdentifier + + "), remaining references=" + references); + return; } - ActiveDatabaseRegistration registration = entry.getValue(); if (!activeDatabases.remove(key, registration)) { - continue; + return; } - registration.forceReleaseAll(); try { registration.provider().disconnect(); } catch (Exception e) { logger.error("Error disconnecting " + key, e); } + logger.info(pluginName + " unregistered " + databaseType.name() + " connection (" + connectionIdentifier + ")"); + } finally { + readLock.unlock(); + } + } + + protected void unregisterAllDatabases(String pluginName) { + Lock readLock = lifecycleLock.readLock(); + readLock.lock(); + try { + ensureOpen(); + for (Map.Entry entry : activeDatabases.entrySet()) { + DatabaseConnectionKey key = entry.getKey(); + if (!key.pluginName().equals(pluginName)) { + continue; + } + + ActiveDatabaseRegistration registration = entry.getValue(); + if (!activeDatabases.remove(key, registration)) { + continue; + } + + registration.forceReleaseAll(); + try { + registration.provider().disconnect(); + } catch (Exception e) { + logger.error("Error disconnecting " + key, e); + } + } + } finally { + readLock.unlock(); } } protected void shutdownAllDatabases() { - for (Map.Entry entry : activeDatabases.entrySet()) { - try { - entry.getValue().provider().disconnect(); - } catch (Exception e) { - logger.error("Error disconnecting " + entry.getKey(), e); + Lock writeLock = lifecycleLock.writeLock(); + writeLock.lock(); + try { + if (closed) { + return; + } + closed = true; + for (Map.Entry entry : activeDatabases.entrySet()) { + try { + entry.getValue().provider().disconnect(); + } catch (Exception e) { + logger.error("Error disconnecting " + entry.getKey(), e); + } } + activeDatabases.clear(); + logger.info("All database connections have been closed."); + } finally { + writeLock.unlock(); } - activeDatabases.clear(); - logger.info("All database connections have been closed."); } protected ConcurrentMap getActiveDatabases() { - ConcurrentMap snapshot = new ConcurrentHashMap<>(); - for (Map.Entry entry : activeDatabases.entrySet()) { - snapshot.put(entry.getKey(), entry.getValue().provider()); + Lock readLock = lifecycleLock.readLock(); + readLock.lock(); + try { + ensureOpen(); + ConcurrentMap snapshot = new ConcurrentHashMap<>(); + for (Map.Entry entry : activeDatabases.entrySet()) { + snapshot.put(entry.getKey(), entry.getValue().provider()); + } + return snapshot; + } finally { + readLock.unlock(); } - return snapshot; } protected Map getActiveDatabaseReferenceCounts() { - Map snapshot = new HashMap<>(); - for (Map.Entry entry : activeDatabases.entrySet()) { - snapshot.put(entry.getKey(), entry.getValue().referenceCount()); + Lock readLock = lifecycleLock.readLock(); + readLock.lock(); + try { + ensureOpen(); + Map snapshot = new HashMap<>(); + for (Map.Entry entry : activeDatabases.entrySet()) { + snapshot.put(entry.getKey(), entry.getValue().referenceCount()); + } + return snapshot; + } finally { + readLock.unlock(); + } + } + + protected boolean isClosed() { + return closed; + } + + private void ensureOpen() { + if (closed) { + throw new IllegalStateException(SHUTDOWN_MESSAGE); } - return snapshot; } private static final class ActiveDatabaseRegistration { diff --git a/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/BukkitDataProvider.java b/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/BukkitDataProvider.java index 0bd9846..03cb95c 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/BukkitDataProvider.java +++ b/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/BukkitDataProvider.java @@ -8,14 +8,25 @@ import org.bukkit.plugin.java.JavaPlugin; import java.util.Objects; +import java.util.logging.Level; public class BukkitDataProvider extends JavaPlugin { - private static DataProvider dataProvider; + private static volatile DataProvider dataProvider; @Override public void onEnable() { + DataProvider previousProvider = dataProvider; + if (previousProvider != null) { + getLogger().warning("Detected leftover DataProvider instance during enable; forcing cleanup first."); + dataProvider = null; + try { + previousProvider.shutdownAllDatabases(); + } catch (Exception e) { + getLogger().log(Level.SEVERE, "Failed to shut down leftover DataProvider instance.", e); + } + } BukkitLoggerAdapter logInstance = new BukkitLoggerAdapter(getLogger()); dataProvider = new DataProvider( @@ -35,8 +46,14 @@ public void onEnable() { @Override public void onDisable() { - if (dataProvider != null) { - dataProvider.shutdownAllDatabases(); + DataProvider providerToShutdown = dataProvider; + dataProvider = null; + if (providerToShutdown != null) { + try { + providerToShutdown.shutdownAllDatabases(); + } catch (Exception e) { + getLogger().log(Level.SEVERE, "Failed to shut down DataProvider cleanly.", e); + } } getLogger().info("Disabled."); } diff --git a/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProvider.java b/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProvider.java index 72b9ade..6d13d0f 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProvider.java +++ b/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProvider.java @@ -30,7 +30,7 @@ public class VelocityDataProvider { private final ProxyServer proxyServer; private final Logger logger; private final Path dataDirectory; - private static DataProvider dataProvider; + private static volatile DataProvider dataProvider; @Inject public VelocityDataProvider(ProxyServer proxyServer, Logger logger, @DataDirectory Path dataDirectory) { @@ -41,6 +41,17 @@ public VelocityDataProvider(ProxyServer proxyServer, Logger logger, @DataDirecto @Subscribe public void onProxyInitialize(ProxyInitializeEvent event) { + DataProvider previousProvider = dataProvider; + if (previousProvider != null) { + logger.warn("Detected leftover DataProvider instance during enable; forcing cleanup first."); + dataProvider = null; + try { + previousProvider.shutdownAllDatabases(); + } catch (Exception e) { + logger.error("Failed to shut down leftover DataProvider instance cleanly.", e); + } + } + SLF4JLoggerAdapter logInstance = new SLF4JLoggerAdapter(logger); dataProvider = new DataProvider( logInstance, @@ -60,8 +71,14 @@ public void onProxyInitialize(ProxyInitializeEvent event) { @Subscribe public void onProxyShutdown(ProxyShutdownEvent event) { - if (dataProvider != null) { - dataProvider.shutdownAllDatabases(); + DataProvider providerToShutdown = dataProvider; + dataProvider = null; + if (providerToShutdown != null) { + try { + providerToShutdown.shutdownAllDatabases(); + } catch (Exception e) { + logger.error("Failed to shut down DataProvider cleanly.", e); + } } logger.info("DataProvider plugin disabled on Velocity."); } diff --git a/src/test/java/nl/hauntedmc/dataprovider/DataProviderTest.java b/src/test/java/nl/hauntedmc/dataprovider/DataProviderTest.java index 931b991..10a123e 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/DataProviderTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/DataProviderTest.java @@ -1,5 +1,7 @@ package nl.hauntedmc.dataprovider; +import nl.hauntedmc.dataprovider.api.DataProviderAPI; +import nl.hauntedmc.dataprovider.database.DatabaseType; import nl.hauntedmc.dataprovider.internal.identity.CallerContext; import nl.hauntedmc.dataprovider.internal.identity.CallerContextResolver; import nl.hauntedmc.dataprovider.testutil.RecordingLoggerAdapter; @@ -64,4 +66,27 @@ void exposesCoreComponentsAndLoadsResourcesFromClassLoader() throws IOException assertThrows(IllegalArgumentException.class, () -> provider.getResource(null)); } } + + @Test + void staleApiReferenceFailsAfterShutdownAndNewProviderStartsClean() { + RecordingLoggerAdapter logger = new RecordingLoggerAdapter(); + ClassLoader classLoader = getClass().getClassLoader(); + CallerContextResolver resolver = () -> new CallerContext("plugin", classLoader); + + DataProvider firstProvider = new DataProvider(logger, tempDir.resolve("data-first"), classLoader, resolver); + DataProviderAPI staleApi = new DataProviderAPI(firstProvider.getDataProviderHandler()); + + firstProvider.shutdownAllDatabases(); + + IllegalStateException staleFailure = assertThrows( + IllegalStateException.class, + () -> staleApi.registerDatabase(DatabaseType.MYSQL, "default") + ); + assertTrue(staleFailure.getMessage().contains("no longer available")); + + DataProvider secondProvider = new DataProvider(logger, tempDir.resolve("data-second"), classLoader, resolver); + DataProviderAPI freshApi = new DataProviderAPI(secondProvider.getDataProviderHandler()); + assertNull(freshApi.getRegisteredDatabase(DatabaseType.MYSQL, "default")); + secondProvider.shutdownAllDatabases(); + } } diff --git a/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderHandlerTest.java b/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderHandlerTest.java index 2b3c795..ee81f75 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderHandlerTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderHandlerTest.java @@ -16,6 +16,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -131,4 +132,30 @@ void privilegedOperationsReturnRegistrySnapshotsForInternalCaller() { verify(registry).shutdownAllDatabases(); } + + @Test + void operationsFailFastWhenRegistryIsClosed() { + DataProviderRegistry registry = mock(DataProviderRegistry.class); + when(registry.isClosed()).thenReturn(true); + + CallerContextResolver resolver = () -> new CallerContext("plugin", getClass().getClassLoader()); + DataProviderHandler handler = new DataProviderHandler( + registry, + resolver, + new RecordingLoggerAdapter(), + getClass().getClassLoader() + ); + + assertThrows(IllegalStateException.class, () -> handler.registerDatabase(DatabaseType.MYSQL, "default")); + assertThrows(IllegalStateException.class, () -> handler.getRegisteredDatabase(DatabaseType.MYSQL, "default")); + assertThrows(IllegalStateException.class, () -> handler.unregisterDatabase(DatabaseType.MYSQL, "default")); + assertThrows(IllegalStateException.class, handler::unregisterAllDatabases); + assertThrows(IllegalStateException.class, handler::getActiveDatabases); + assertThrows(IllegalStateException.class, handler::getActiveDatabaseReferenceCounts); + + verify(registry, never()).registerDatabase("plugin", DatabaseType.MYSQL, "default"); + verify(registry, never()).getDatabase("plugin", DatabaseType.MYSQL, "default"); + verify(registry, never()).unregisterDatabase("plugin", DatabaseType.MYSQL, "default"); + verify(registry, never()).unregisterAllDatabases("plugin"); + } } diff --git a/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistryTest.java b/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistryTest.java index 7d2e6a7..3d75134 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistryTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistryTest.java @@ -185,11 +185,48 @@ void shutdownDisconnectsAllAndClearsRegistry() { assertEquals(1, a.disconnectCalls); assertEquals(1, b.disconnectCalls); - assertTrue(registry.getActiveDatabases().isEmpty()); - assertTrue(registry.getActiveDatabaseReferenceCounts().isEmpty()); + assertTrue(registry.isClosed()); + assertThrows(IllegalStateException.class, registry::getActiveDatabases); + assertThrows(IllegalStateException.class, registry::getActiveDatabaseReferenceCounts); assertTrue(logger.infoMessages().stream().anyMatch(m -> m.contains("All database connections have been closed"))); } + @Test + void shutdownIsIdempotentAndBlocksFurtherOperations() { + DatabaseFactory factory = mock(DatabaseFactory.class); + ConfigHandler configHandler = mock(ConfigHandler.class); + when(configHandler.isDatabaseTypeEnabled(DatabaseType.MYSQL)).thenReturn(true); + RecordingLoggerAdapter logger = new RecordingLoggerAdapter(); + DataProviderRegistry registry = new DataProviderRegistry(factory, configHandler, logger); + + RecordingProvider provider = new RecordingProvider(true); + when(factory.createDatabaseProvider(DatabaseType.MYSQL, "default")).thenReturn(provider); + registry.registerDatabase("plugin", DatabaseType.MYSQL, "default"); + + registry.shutdownAllDatabases(); + registry.shutdownAllDatabases(); + + assertEquals(1, provider.disconnectCalls); + assertTrue(registry.isClosed()); + + IllegalStateException registerFailure = assertThrows( + IllegalStateException.class, + () -> registry.registerDatabase("plugin", DatabaseType.MYSQL, "default") + ); + assertTrue(registerFailure.getMessage().contains("shut down")); + assertThrows( + IllegalStateException.class, + () -> registry.getDatabase("plugin", DatabaseType.MYSQL, "default") + ); + assertThrows( + IllegalStateException.class, + () -> registry.unregisterDatabase("plugin", DatabaseType.MYSQL, "default") + ); + assertThrows(IllegalStateException.class, () -> registry.unregisterAllDatabases("plugin")); + assertThrows(IllegalStateException.class, registry::getActiveDatabases); + assertThrows(IllegalStateException.class, registry::getActiveDatabaseReferenceCounts); + } + @Test void registerReturnsNullAndCleansUpWhenConnectThrows() { DatabaseFactory factory = mock(DatabaseFactory.class); diff --git a/src/test/java/nl/hauntedmc/dataprovider/platform/bukkit/BukkitDataProviderTest.java b/src/test/java/nl/hauntedmc/dataprovider/platform/bukkit/BukkitDataProviderTest.java new file mode 100644 index 0000000..9dc0f20 --- /dev/null +++ b/src/test/java/nl/hauntedmc/dataprovider/platform/bukkit/BukkitDataProviderTest.java @@ -0,0 +1,52 @@ +package nl.hauntedmc.dataprovider.platform.bukkit; + +import nl.hauntedmc.dataprovider.DataProvider; +import nl.hauntedmc.dataprovider.api.DataProviderAPI; +import nl.hauntedmc.dataprovider.internal.DataProviderHandler; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class BukkitDataProviderTest { + + @Test + void getDataProviderApiThrowsWhenNotInitialized() throws ReflectiveOperationException { + DataProvider original = swapStaticDataProvider(null); + try { + assertThrows(IllegalStateException.class, BukkitDataProvider::getDataProviderAPI); + } finally { + swapStaticDataProvider(original); + } + } + + @Test + void getDataProviderApiReturnsFacadeWhenInitialized() throws ReflectiveOperationException { + DataProvider provider = mock(DataProvider.class); + DataProviderHandler handler = mock(DataProviderHandler.class); + when(provider.getDataProviderHandler()).thenReturn(handler); + + DataProvider original = swapStaticDataProvider(provider); + try { + DataProviderAPI api = BukkitDataProvider.getDataProviderAPI(); + assertNotNull(api); + api.unregisterAllDatabases(); + verify(handler).unregisterAllDatabases(); + } finally { + swapStaticDataProvider(original); + } + } + + private static DataProvider swapStaticDataProvider(DataProvider replacement) throws ReflectiveOperationException { + Field field = BukkitDataProvider.class.getDeclaredField("dataProvider"); + field.setAccessible(true); + DataProvider previous = (DataProvider) field.get(null); + field.set(null, replacement); + return previous; + } +} diff --git a/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProviderTest.java b/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProviderTest.java index 78751a7..b6671c8 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProviderTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProviderTest.java @@ -4,12 +4,19 @@ import com.velocitypowered.api.plugin.PluginDescription; import com.velocitypowered.api.plugin.PluginManager; import com.velocitypowered.api.proxy.ProxyServer; +import nl.hauntedmc.dataprovider.DataProvider; +import nl.hauntedmc.dataprovider.api.DataProviderAPI; +import nl.hauntedmc.dataprovider.internal.DataProviderHandler; import org.junit.jupiter.api.Test; +import java.lang.reflect.Field; import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class VelocityDataProviderTest { @@ -45,4 +52,39 @@ void resolvePluginVersionFallsBackToUnknownWhenVersionMissing() { assertEquals("unknown", VelocityDataProvider.resolvePluginVersion(proxyServer, pluginInstance)); } + + @Test + void getDataProviderApiThrowsWhenNotInitialized() throws ReflectiveOperationException { + DataProvider original = swapStaticDataProvider(null); + try { + assertThrows(IllegalStateException.class, VelocityDataProvider::getDataProviderAPI); + } finally { + swapStaticDataProvider(original); + } + } + + @Test + void getDataProviderApiReturnsFacadeWhenInitialized() throws ReflectiveOperationException { + DataProvider provider = mock(DataProvider.class); + DataProviderHandler handler = mock(DataProviderHandler.class); + when(provider.getDataProviderHandler()).thenReturn(handler); + + DataProvider original = swapStaticDataProvider(provider); + try { + DataProviderAPI api = VelocityDataProvider.getDataProviderAPI(); + assertNotNull(api); + api.unregisterAllDatabases(); + verify(handler).unregisterAllDatabases(); + } finally { + swapStaticDataProvider(original); + } + } + + private static DataProvider swapStaticDataProvider(DataProvider replacement) throws ReflectiveOperationException { + Field field = VelocityDataProvider.class.getDeclaredField("dataProvider"); + field.setAccessible(true); + DataProvider previous = (DataProvider) field.get(null); + field.set(null, replacement); + return previous; + } } From e4f469ee8d49339140e129e6711bc0d5f7157b1f Mon Sep 17 00:00:00 2001 From: remdui Date: Thu, 26 Mar 2026 16:57:41 +0100 Subject: [PATCH 06/17] Improve security accross all features --- docs/ARCHITECTURE.md | 4 + docs/BEST_PRACTICES.md | 11 +- docs/CONFIGURATION.md | 48 +++- docs/USAGE_GUIDE.md | 19 +- .../dataprovider/api/DataProviderAPI.java | 44 +++- .../dataprovider/config/ConfigHandler.java | 7 +- .../impl/mongodb/MongoDBDataAccess.java | 111 +++++++-- .../impl/mongodb/MongoDBDatabase.java | 136 +++++++++-- .../keyvalue/impl/redis/RedisDataAccess.java | 224 +++++++++++++----- .../keyvalue/impl/redis/RedisDatabase.java | 121 ++++++++-- .../impl/redis/RedisMessagingDataAccess.java | 115 ++++++++- .../impl/redis/RedisMessagingDatabase.java | 121 ++++++++-- .../impl/mysql/MySQLDataAccess.java | 109 ++++++--- .../relational/impl/mysql/MySQLDatabase.java | 180 ++++++++++++-- .../impl/mysql/MySQLSchemaManager.java | 33 +-- .../internal/DataProviderHandler.java | 99 +++++++- .../internal/DataProviderRegistry.java | 173 +++++++++++--- .../internal/DatabaseConfigMap.java | 7 +- .../internal/concurrent/AsyncTaskSupport.java | 64 +++++ .../concurrent/BoundedExecutorFactory.java | 2 +- .../StackCallerClassLoaderResolver.java | 1 + .../security/FilePermissionHardening.java | 59 +++++ .../security/FilePermissionSupport.java | 59 +++++ src/main/resources/databases/mongodb.yml | 5 + src/main/resources/databases/mysql.yml | 13 + src/main/resources/databases/redis.yml | 11 +- .../resources/databases/redis_messaging.yml | 7 + .../dataprovider/api/DataProviderAPITest.java | 13 + .../impl/mongodb/MongoDBDatabaseTest.java | 16 ++ .../impl/redis/RedisDatabaseTest.java | 16 ++ .../redis/RedisMessagingDatabaseTest.java | 10 + .../impl/mysql/MySQLDataAccessTest.java | 8 +- .../internal/DataProviderHandlerTest.java | 62 ++++- .../internal/DataProviderRegistryTest.java | 114 +++++++-- .../concurrent/AsyncTaskSupportTest.java | 63 +++++ .../StackCallerClassLoaderResolverTest.java | 1 + 36 files changed, 1787 insertions(+), 299 deletions(-) create mode 100644 src/main/java/nl/hauntedmc/dataprovider/internal/concurrent/AsyncTaskSupport.java create mode 100644 src/main/java/nl/hauntedmc/dataprovider/internal/security/FilePermissionHardening.java create mode 100644 src/main/java/nl/hauntedmc/dataprovider/internal/security/FilePermissionSupport.java create mode 100644 src/test/java/nl/hauntedmc/dataprovider/internal/concurrent/AsyncTaskSupportTest.java diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9585241..f7380fb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -30,6 +30,10 @@ Main modules: ## Lifecycle Safety - Per-caller ownership checks gate unregister operations. +- Reference ownership is tracked by owner scope. +- Default API methods use plugin-level owner scope for predictable lifecycle behavior. +- If one plugin/software process multiplexes multiple components through one wrapper class, use explicit scopes (`*ForScope` API methods) to preserve component isolation. +- Explicit plugin-wide cleanup is available for shutdown flows that span multiple caller scopes. - Stale/disconnected providers are evicted from registry lookup paths. - Shutdown hooks unregister or stop backend resources cleanly. - Bounded executors are used for asynchronous backend work queues. diff --git a/docs/BEST_PRACTICES.md b/docs/BEST_PRACTICES.md index 6569c0f..afe8212 100644 --- a/docs/BEST_PRACTICES.md +++ b/docs/BEST_PRACTICES.md @@ -9,16 +9,21 @@ ## Lifecycle -- Register once during feature/plugin init. +- Register once during plugin/software startup. - Unregister on disable. -- If you run multiple feature modules in one plugin, prefer releasing only the connections each feature acquired. -- Use `unregisterAllDatabases()` only when shutting down the entire plugin context. +- If you run multiple independent components in one plugin/software process, prefer releasing only the connections each component acquired. +- Use separate connection identifiers when component lifecycle differs. +- If multiple components share one wrapper class, use explicit scopes (`registerDatabaseForScope`, `unregisterAllDatabasesForScope`) keyed by component name. +- `registerDatabase(...)` / `unregisterAllDatabases()` use a default plugin-level owner scope. +- Use `*ForScope` methods only when you intentionally need isolated ownership domains inside one plugin/software process. +- For full plugin/software shutdown across multiple scopes/classes, use `unregisterAllDatabasesForPlugin()`. ## Messaging - Use one clear message class per channel contract. - Keep channels stable and namespaced (for example: `proxy.staffchat.message`). - Handle parse/dispatch failures as non-fatal. +- Keep handlers fast and non-blocking; use `security.max_queued_messages_per_handler` to cap per-handler backlog. ## ORM diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 3d0aaaa..0b44a4a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -44,7 +44,20 @@ databases: - `require_secure_transport` - `allow_public_key_retrieval` - `pool_size` +- `min_idle` - `queue_capacity` +- `connection_timeout_ms` +- `validation_timeout_ms` +- `idle_timeout_ms` +- `max_lifetime_ms` +- `leak_detection_threshold_ms` +- `connect_timeout_ms` +- `socket_timeout_ms` +- `query_timeout_seconds` +- `default_fetch_size` +- `cache_prepared_statements` +- `prepared_statement_cache_size` +- `prepared_statement_cache_sql_limit` ## MongoDB Keys (`databases/mongodb.yml`) @@ -52,43 +65,63 @@ databases: - `authSource` (note exact casing) - `require_secure_transport` - `tls.enabled` -- `tls.allow_invalid_hostnames` (deprecated, ignored for security) -- `tls.trust_all_certificates` (deprecated, ignored for security) +- `tls.allow_invalid_hostnames` (must remain `false`; startup fails otherwise) +- `tls.trust_all_certificates` (must remain `false`; startup fails otherwise) - `tls.trust_store_path` (optional JKS/PKCS12 path for private CA/self-managed trust) - `tls.trust_store_password` (optional trust store password) - `tls.trust_store_type` (optional, defaults to JVM `KeyStore.getDefaultType()`) - `pool_size` - `queue_capacity` +- `max_connection_pool_size` +- `min_connection_pool_size` +- `connect_timeout_ms` +- `socket_timeout_ms` +- `server_selection_timeout_ms` ## Redis Keys (`databases/redis.yml`) - `host`, `port`, `user`, `password`, `database` - `require_secure_transport` - `tls.enabled` -- `tls.verify_hostname` (deprecated, ignored for security) -- `tls.trust_all_certificates` (deprecated, ignored for security) +- `tls.verify_hostname` (must remain `true`; startup fails otherwise) +- `tls.trust_all_certificates` (must remain `false`; startup fails otherwise) - `tls.trust_store_path` (optional JKS/PKCS12 path for private CA/self-managed trust) - `tls.trust_store_password` (optional trust store password) - `tls.trust_store_type` (optional, defaults to JVM `KeyStore.getDefaultType()`) - `pool.connections` - `pool.threads` -- `queue_capacity` +- `pool.min_idle` +- `pool.max_idle` +- `pool.test_on_borrow` +- `pool.test_while_idle` +- `pool.queue_capacity` +- `connection_timeout_ms` +- `socket_timeout_ms` +- `scan_count` +- `security.max_scan_results` ## Redis Messaging Keys (`databases/redis_messaging.yml`) - Same network + TLS fields as Redis key-value - `pool.connections` - `pool.threads` +- `pool.min_idle` +- `pool.max_idle` +- `pool.test_on_borrow` +- `pool.test_while_idle` - `pool.queue_capacity` - `pool.max_subscriptions` +- `connection_timeout_ms` +- `socket_timeout_ms` - `security.max_payload_chars` +- `security.max_queued_messages_per_handler` (per-subscriber queue cap to isolate slow handlers) ## Common Mistakes - Identifier mismatch between code and config section names - Enabling TLS flags without server-side TLS support -- Using deprecated insecure TLS flags instead of a trust store for private CA deployments -- Assuming Redis and Redis Messaging use identical pool key paths (`queue_capacity` differs) +- Setting insecure TLS flags (`trust_all_certificates`, `allow_invalid_hostnames`, or `verify_hostname=false`) which now fail startup in 2.0 +- Using `queue_capacity` at the root of `redis.yml` instead of `pool.queue_capacity` ## Operational Notes @@ -96,3 +129,4 @@ databases: - Use explicit identifiers (for example `rw`, `ro`, `analytics`) for multi-backend setups. - Validate trust store configuration in staging before production rollout. - Never commit production credentials. +- During plugin shutdown across many classes/scopes, pair cleanup with `unregisterAllDatabasesForPlugin()`. diff --git a/docs/USAGE_GUIDE.md b/docs/USAGE_GUIDE.md index 74d341b..f61ff58 100644 --- a/docs/USAGE_GUIDE.md +++ b/docs/USAGE_GUIDE.md @@ -80,18 +80,35 @@ Optional dataSource = provider.getDataSourceOptional(); ## 4. Release connections +For most integrations, use only `registerDatabase(...)` and `unregisterDatabase(...)`. +Use scoped methods only when you intentionally split ownership inside one plugin/software process. + Release a specific connection: ```java api.unregisterDatabase(DatabaseType.MYSQL, "example"); ``` -Release all connections for your plugin context: +Release all connections for your default plugin/software scope: ```java api.unregisterAllDatabases(); ``` +If one plugin/software process manages multiple independent components, +use explicit scopes so each component can unload independently: + +```java +api.registerDatabaseForScope("component.chat", DatabaseType.REDIS_MESSAGING, "hauntedmc"); +api.unregisterAllDatabasesForScope("component.chat"); +``` + +For full plugin/software shutdown when registrations may come from multiple classes/scopes: + +```java +api.unregisterAllDatabasesForPlugin(); +``` + ## 5. ORM usage For relational providers: diff --git a/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderAPI.java b/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderAPI.java index fffc876..8bd098a 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderAPI.java +++ b/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderAPI.java @@ -22,6 +22,11 @@ * DataProviderAPI is the public facade that exposes safe, read-only database handles * for third-party plugins. Internally, it delegates to a DataProviderHandler, but it does not * expose lifecycle-sensitive methods (like shutdownAllDatabases or getActiveDatabases). + * + * For most integrations, the primary lifecycle is: + * register -> use provider/data access -> unregister. + * Scoped APIs are available for advanced cases where one plugin/software process + * needs isolated ownership domains for independently managed components. */ public class DataProviderAPI { @@ -38,6 +43,7 @@ public DataProviderAPI(DataProviderHandler handler) { /** * Registers a database connection for the resolved caller plugin. + * This is the default path for most integrations. * * @param databaseType the type of database (e.g. MYSQL, MONGODB, etc.) * @param connectionIdentifier a unique identifier for the connection @@ -47,6 +53,18 @@ public DatabaseProvider registerDatabase(DatabaseType databaseType, String conne return wrapProvider(handler.registerDatabase(databaseType, connectionIdentifier)); } + /** + * Registers a database connection under an explicit owner scope. + * Use explicit scopes when multiple components share the same wrapper class. + */ + public DatabaseProvider registerDatabaseForScope( + String ownerScope, + DatabaseType databaseType, + String connectionIdentifier + ) { + return wrapProvider(handler.registerDatabaseForScope(ownerScope, databaseType, connectionIdentifier)); + } + /** * Registers a database connection and returns the result as Optional. */ @@ -80,6 +98,7 @@ public Optional registerDataAccess( /** * Unregisters a specific database connection for the resolved caller plugin. + * This is the default path for most integrations. * * @param databaseType the type of database. * @param connectionIdentifier the connection identifier. @@ -89,12 +108,35 @@ public void unregisterDatabase(DatabaseType databaseType, String connectionIdent } /** - * Unregisters all database connections for the resolved caller plugin. + * Unregisters a scoped database connection for the resolved caller plugin. + */ + public void unregisterDatabaseForScope(String ownerScope, DatabaseType databaseType, String connectionIdentifier) { + handler.unregisterDatabaseForScope(ownerScope, databaseType, connectionIdentifier); + } + + /** + * Unregisters all database connections for the resolved caller plugin default owner scope. */ public void unregisterAllDatabases() { handler.unregisterAllDatabases(); } + /** + * Unregisters all scoped database connections for the resolved caller plugin. + * Prefer this only when you intentionally manage isolated ownership scopes. + */ + public void unregisterAllDatabasesForScope(String ownerScope) { + handler.unregisterAllDatabasesForScope(ownerScope); + } + + /** + * Unregisters all database connections for the caller plugin across all caller scopes. + * Use this for deterministic full-plugin shutdown cleanup. + */ + public void unregisterAllDatabasesForPlugin() { + handler.unregisterAllDatabasesForPlugin(); + } + /** * Retrieves a registered database connection for the resolved caller plugin. * diff --git a/src/main/java/nl/hauntedmc/dataprovider/config/ConfigHandler.java b/src/main/java/nl/hauntedmc/dataprovider/config/ConfigHandler.java index 9bb49cf..0edb9ae 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/config/ConfigHandler.java +++ b/src/main/java/nl/hauntedmc/dataprovider/config/ConfigHandler.java @@ -1,6 +1,7 @@ package nl.hauntedmc.dataprovider.config; import nl.hauntedmc.dataprovider.database.DatabaseType; +import nl.hauntedmc.dataprovider.internal.security.FilePermissionHardening; import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; import org.spongepowered.configurate.CommentedConfigurationNode; import org.spongepowered.configurate.loader.ConfigurationLoader; @@ -47,7 +48,9 @@ public ConfigHandler(Path dataDir, ILoggerAdapter logger) { */ private void ensureConfigFileExists() { try { - Files.createDirectories(configFile.getParent()); + Path parentDirectory = configFile.getParent(); + Files.createDirectories(parentDirectory); + FilePermissionHardening.restrictDirectoryToOwner(parentDirectory, logger, "DataProvider config directory"); if (!Files.exists(configFile)) { try (InputStream in = getClass().getResourceAsStream("/config.yml")) { if (in != null) { @@ -59,6 +62,7 @@ private void ensureConfigFileExists() { } } } + FilePermissionHardening.restrictFileToOwner(configFile, logger, "DataProvider config.yml"); } catch (IOException e) { throw new IllegalStateException("Error ensuring config file exists at " + configFile, e); } @@ -118,6 +122,7 @@ private void injectMissingKeys() { private void saveConfig() { try { loader.save(config); + FilePermissionHardening.restrictFileToOwner(configFile, logger, "DataProvider config.yml"); } catch (IOException e) { throw new IllegalStateException("Error saving config file at " + configFile, e); } diff --git a/src/main/java/nl/hauntedmc/dataprovider/database/document/impl/mongodb/MongoDBDataAccess.java b/src/main/java/nl/hauntedmc/dataprovider/database/document/impl/mongodb/MongoDBDataAccess.java index 4d7be2e..8943926 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/database/document/impl/mongodb/MongoDBDataAccess.java +++ b/src/main/java/nl/hauntedmc/dataprovider/database/document/impl/mongodb/MongoDBDataAccess.java @@ -9,6 +9,7 @@ import nl.hauntedmc.dataprovider.database.document.model.DocumentQuery; import nl.hauntedmc.dataprovider.database.document.model.DocumentUpdate; import nl.hauntedmc.dataprovider.database.document.model.DocumentUpdateOptions; +import nl.hauntedmc.dataprovider.internal.concurrent.AsyncTaskSupport; import org.bson.Document; import org.bson.conversions.Bson; @@ -18,6 +19,8 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.Objects; /** * MongoDBDataAccess converts our custom DSL objects into MongoDB Bson objects @@ -30,9 +33,12 @@ public class MongoDBDataAccess implements DocumentDataAccess { private final ExecutorService executor; public MongoDBDataAccess(MongoClient mongoClient, String databaseName, ExecutorService executor) { - this.mongoClient = mongoClient; + this.mongoClient = Objects.requireNonNull(mongoClient, "Mongo client cannot be null."); + if (databaseName == null || databaseName.isBlank()) { + throw new IllegalArgumentException("Database name cannot be null or blank."); + } this.databaseName = databaseName; - this.executor = executor; + this.executor = Objects.requireNonNull(executor, "Executor cannot be null."); } private MongoDatabase getDatabase() { @@ -40,7 +46,7 @@ private MongoDatabase getDatabase() { } private MongoCollection getCollection(String collection) { - return getDatabase().getCollection(collection); + return getDatabase().getCollection(requireCollection(collection)); } private Bson toBsonQuery(DocumentQuery query) { @@ -65,70 +71,88 @@ private Map documentToMap(Document doc) { @Override public CompletableFuture insertOne(String collection, Map document) { - return CompletableFuture.runAsync(() -> - getCollection(collection).insertOne(toMongoDocument(document)), executor); + Objects.requireNonNull(document, "Document cannot be null."); + Map safeDocument = Map.copyOf(document); + return AsyncTaskSupport.runAsync(executor, "mongodb.insertOne", () -> + getCollection(collection).insertOne(toMongoDocument(safeDocument))); } @Override public CompletableFuture> findOne(String collection, DocumentQuery query) { - return CompletableFuture.supplyAsync(() -> { + Objects.requireNonNull(query, "Document query cannot be null."); + return AsyncTaskSupport.supplyAsync(executor, "mongodb.findOne", () -> { Document found = getCollection(collection) .find(toBsonQuery(query)) .first(); return (found != null) ? documentToMap(found) : null; - }, executor); + }); } @Override public CompletableFuture>> findMany(String collection, DocumentQuery query) { - return CompletableFuture.supplyAsync(() -> { + Objects.requireNonNull(query, "Document query cannot be null."); + return AsyncTaskSupport.supplyAsync(executor, "mongodb.findMany", () -> { List> results = new ArrayList<>(); for (Document doc : getCollection(collection).find(toBsonQuery(query))) { results.add(documentToMap(doc)); } return results; - }, executor); + }); } @Override public CompletableFuture updateOne(String collection, DocumentQuery query, DocumentUpdate update, DocumentUpdateOptions options) { - return CompletableFuture.runAsync(() -> + Objects.requireNonNull(query, "Document query cannot be null."); + Objects.requireNonNull(update, "Document update cannot be null."); + Objects.requireNonNull(options, "Document update options cannot be null."); + return AsyncTaskSupport.runAsync(executor, "mongodb.updateOne", () -> getCollection(collection) - .updateOne(toBsonQuery(query), toBsonUpdate(update), toMongoUpdateOptions(options)), executor); + .updateOne(toBsonQuery(query), toBsonUpdate(update), toMongoUpdateOptions(options))); } @Override public CompletableFuture updateMany(String collection, DocumentQuery query, DocumentUpdate update, DocumentUpdateOptions options) { - return CompletableFuture.runAsync(() -> + Objects.requireNonNull(query, "Document query cannot be null."); + Objects.requireNonNull(update, "Document update cannot be null."); + Objects.requireNonNull(options, "Document update options cannot be null."); + return AsyncTaskSupport.runAsync(executor, "mongodb.updateMany", () -> getCollection(collection) - .updateMany(toBsonQuery(query), toBsonUpdate(update), toMongoUpdateOptions(options)), executor); + .updateMany(toBsonQuery(query), toBsonUpdate(update), toMongoUpdateOptions(options))); } @Override public CompletableFuture deleteOne(String collection, DocumentQuery query) { - return CompletableFuture.runAsync(() -> - getCollection(collection).deleteOne(toBsonQuery(query)), executor); + Objects.requireNonNull(query, "Document query cannot be null."); + return AsyncTaskSupport.runAsync(executor, "mongodb.deleteOne", () -> + getCollection(collection).deleteOne(toBsonQuery(query))); } @Override public CompletableFuture deleteMany(String collection, DocumentQuery query) { - return CompletableFuture.runAsync(() -> - getCollection(collection).deleteMany(toBsonQuery(query)), executor); + Objects.requireNonNull(query, "Document query cannot be null."); + return AsyncTaskSupport.runAsync(executor, "mongodb.deleteMany", () -> + getCollection(collection).deleteMany(toBsonQuery(query))); } @Override public CompletableFuture createIndex(String collection, Map indexSpec, Map indexOptions) { - return CompletableFuture.runAsync(() -> { - Document idxSpecDoc = new Document(indexSpec); - IndexOptions options = mapToIndexOptions(indexOptions); + Objects.requireNonNull(indexSpec, "Index specification cannot be null."); + Map safeSpec = Map.copyOf(indexSpec); + Map safeOptions = indexOptions == null ? null : Map.copyOf(indexOptions); + return AsyncTaskSupport.runAsync(executor, "mongodb.createIndex", () -> { + Document idxSpecDoc = new Document(safeSpec); + IndexOptions options = mapToIndexOptions(safeOptions); getCollection(collection).createIndex(idxSpecDoc, options); - }, executor); + }); } @Override public CompletableFuture dropIndex(String collection, String indexName) { - return CompletableFuture.runAsync(() -> - getCollection(collection).dropIndex(indexName), executor); + if (indexName == null || indexName.isBlank()) { + throw new IllegalArgumentException("Index name cannot be null or blank."); + } + return AsyncTaskSupport.runAsync(executor, "mongodb.dropIndex", () -> + getCollection(collection).dropIndex(indexName)); } private IndexOptions mapToIndexOptions(Map indexOptionsMap) { @@ -143,7 +167,48 @@ private IndexOptions mapToIndexOptions(Map indexOptionsMap) { if (indexOptionsMap.containsKey("name")) { indexOptions.name(String.valueOf(indexOptionsMap.get("name"))); } + if (indexOptionsMap.containsKey("sparse")) { + indexOptions.sparse(Boolean.TRUE.equals(indexOptionsMap.get("sparse"))); + } + if (indexOptionsMap.containsKey("expireAfterSeconds")) { + Object expireAfter = indexOptionsMap.get("expireAfterSeconds"); + if (expireAfter instanceof Number number) { + long seconds = number.longValue(); + if (seconds < 0) { + throw new IllegalArgumentException("Index option 'expireAfterSeconds' cannot be negative."); + } + indexOptions.expireAfter(seconds, TimeUnit.SECONDS); + } else { + throw new IllegalArgumentException("Index option 'expireAfterSeconds' must be numeric."); + } + } + if (indexOptionsMap.containsKey("partialFilterExpression")) { + Object partialFilterExpression = indexOptionsMap.get("partialFilterExpression"); + if (partialFilterExpression instanceof Map mapValue) { + Document partialDocument = new Document(); + for (Map.Entry entry : mapValue.entrySet()) { + partialDocument.put(String.valueOf(entry.getKey()), entry.getValue()); + } + indexOptions.partialFilterExpression(partialDocument); + } else if (partialFilterExpression instanceof Document documentValue) { + indexOptions.partialFilterExpression(documentValue); + } else { + throw new IllegalArgumentException( + "Index option 'partialFilterExpression' must be a map or BSON document." + ); + } + } } return indexOptions; } + + private static String requireCollection(String collection) { + if (collection == null || collection.isBlank()) { + throw new IllegalArgumentException("Collection name cannot be null or blank."); + } + if (collection.indexOf('\0') >= 0) { + throw new IllegalArgumentException("Collection name cannot contain null characters."); + } + return collection; + } } diff --git a/src/main/java/nl/hauntedmc/dataprovider/database/document/impl/mongodb/MongoDBDatabase.java b/src/main/java/nl/hauntedmc/dataprovider/database/document/impl/mongodb/MongoDBDatabase.java index da9122a..a5bcd5b 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/database/document/impl/mongodb/MongoDBDatabase.java +++ b/src/main/java/nl/hauntedmc/dataprovider/database/document/impl/mongodb/MongoDBDatabase.java @@ -19,6 +19,7 @@ import java.util.Objects; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; /** * MongoDBDatabase implements DocumentDatabaseProvider for MongoDB. @@ -26,6 +27,9 @@ */ public class MongoDBDatabase implements DocumentDatabaseProvider, ManagedDatabaseProvider { + private static final Pattern HOST_PATTERN = Pattern.compile("[A-Za-z0-9._:\\-\\[\\]]+"); + private static final Pattern DATABASE_PATTERN = Pattern.compile("[A-Za-z0-9_.\\-]+"); + private final CommentedConfigurationNode config; private final ILoggerAdapter logger; private MongoClient mongoClient; @@ -40,7 +44,7 @@ public MongoDBDatabase(CommentedConfigurationNode config, ILoggerAdapter logger) } @Override - public void connect() { + public synchronized void connect() { if (connected && mongoClient != null) { logger.info("[MongoDBDatabase] Already connected; skipping re–initialization."); return; @@ -48,12 +52,18 @@ public void connect() { MongoClient createdClient = null; ExecutorService createdExecutor = null; try { - final String host = config.node("host").getString("localhost"); - final int port = config.node("port").getInt(27017); - final String configuredDatabaseName = config.node("database").getString("minecraft"); + final String host = requireHost(config.node("host").getString("localhost")); + final int port = requireInRange(config.node("port").getInt(27017), 1, 65_535, "port"); + final String configuredDatabaseName = requireDatabaseName( + config.node("database").getString("minecraft"), + "database" + ); final String user = config.node("username").getString(""); final String password = config.node("password").getString(""); - final String authSource = config.node("authSource").getString(configuredDatabaseName); + final String authSource = requireDatabaseName( + config.node("authSource").getString(configuredDatabaseName), + "authSource" + ); final boolean tlsEnabled = config.node("tls", "enabled").getBoolean(false); final boolean allowInvalidHostnames = config.node("tls", "allow_invalid_hostnames").getBoolean(false); final boolean trustAllCertificates = config.node("tls", "trust_all_certificates").getBoolean(false); @@ -61,6 +71,43 @@ public void connect() { final String trustStorePassword = config.node("tls", "trust_store_password").getString(""); final String trustStoreType = config.node("tls", "trust_store_type").getString(""); final boolean requireSecureTransport = config.node("require_secure_transport").getBoolean(false); + final int workerPoolSize = requireInRange(config.node("pool_size").getInt(8), 1, 256, "pool_size"); + final int queueCapacity = requireInRange( + config.node("queue_capacity").getInt(workerPoolSize * 200), + workerPoolSize, + 1_000_000, + "queue_capacity" + ); + final int clientMaxPoolSize = requireInRange( + config.node("max_connection_pool_size").getInt(Math.max(16, workerPoolSize)), + 1, + 1_000, + "max_connection_pool_size" + ); + final int clientMinPoolSize = requireInRange( + config.node("min_connection_pool_size").getInt(0), + 0, + clientMaxPoolSize, + "min_connection_pool_size" + ); + final long connectTimeoutMs = requireInRange( + config.node("connect_timeout_ms").getLong(5_000L), + 250L, + 300_000L, + "connect_timeout_ms" + ); + final long socketTimeoutMs = requireInRange( + config.node("socket_timeout_ms").getLong(5_000L), + 250L, + 300_000L, + "socket_timeout_ms" + ); + final long serverSelectionTimeoutMs = requireInRange( + config.node("server_selection_timeout_ms").getLong(5_000L), + 250L, + 300_000L, + "server_selection_timeout_ms" + ); if (requireSecureTransport && !tlsEnabled) { throw new IllegalStateException("MongoDB require_secure_transport=true but tls.enabled=false"); @@ -68,9 +115,15 @@ public void connect() { if (!tlsEnabled) { logger.warn("[MongoDBDatabase] MongoDB connection is running without TLS."); } else if (trustAllCertificates || allowInvalidHostnames) { - logger.warn("[MongoDBDatabase] Insecure TLS flags (allow_invalid_hostnames=true or " - + "trust_all_certificates=true) are ignored. Strict certificate and hostname " - + "verification is always enforced."); + throw new IllegalStateException( + "MongoDB tls.allow_invalid_hostnames must be false and tls.trust_all_certificates must be false in DataProvider 2.0." + ); + } + if (user.isBlank() != password.isBlank()) { + throw new IllegalStateException("MongoDB username/password must either both be set or both be empty."); + } + if (user.isBlank()) { + logger.warn("[MongoDBDatabase] MongoDB credentials are not configured; using unauthenticated connection."); } final String connectionString; @@ -94,7 +147,15 @@ public void connect() { MongoClientSettings.Builder settingsBuilder = MongoClientSettings.builder() .applyConnectionString(connString) .retryWrites(true) - ; + .retryReads(true) + .applyToConnectionPoolSettings(pool -> pool + .maxSize(clientMaxPoolSize) + .minSize(clientMinPoolSize)) + .applyToSocketSettings(socket -> socket + .connectTimeout((int) connectTimeoutMs, TimeUnit.MILLISECONDS) + .readTimeout((int) socketTimeoutMs, TimeUnit.MILLISECONDS)) + .applyToClusterSettings(cluster -> + cluster.serverSelectionTimeout(serverSelectionTimeoutMs, TimeUnit.MILLISECONDS)); if (tlsEnabled) { SSLContext sslContext = TlsSupport.createSslContext(trustStorePath, trustStorePassword, trustStoreType); settingsBuilder.applyToSslSettings(ssl -> { @@ -109,9 +170,7 @@ public void connect() { createdClient = MongoClients.create(settings); createdClient.getDatabase(configuredDatabaseName).runCommand(new Document("ping", 1)); - final int poolSize = Math.max(1, config.node("pool_size").getInt(8)); - final int queueCapacity = Math.max(poolSize, config.node("queue_capacity").getInt(poolSize * 200)); - createdExecutor = BoundedExecutorFactory.create("dataprovider-mongodb", poolSize, queueCapacity); + createdExecutor = BoundedExecutorFactory.create("dataprovider-mongodb", workerPoolSize, queueCapacity); mongoClient = createdClient; executor = createdExecutor; @@ -120,10 +179,12 @@ public void connect() { connected = true; logger.info(String.format( - "[MongoDBDatabase] Connected successfully to Mongo at %s:%d (tls=%s, queueCapacity=%d)", + "[MongoDBDatabase] Connected successfully to Mongo at %s:%d (tls=%s, clientPool=%d, workerPool=%d, queueCapacity=%d)", host, port, tlsEnabled ? "enabled" : "disabled", + clientMaxPoolSize, + workerPoolSize, queueCapacity )); } catch (Exception e) { @@ -141,7 +202,7 @@ public void connect() { } @Override - public void disconnect() { + public synchronized void disconnect() { if (executor != null && !executor.isShutdown()) { executor.shutdown(); try { @@ -167,11 +228,13 @@ public void disconnect() { @Override public boolean isConnected() { - if (!connected || mongoClient == null || databaseName == null || databaseName.isBlank()) { + MongoClient clientSnapshot = mongoClient; + String databaseSnapshot = databaseName; + if (!connected || clientSnapshot == null || databaseSnapshot == null || databaseSnapshot.isBlank()) { return false; } try { - mongoClient.getDatabase(databaseName).runCommand(new Document("ping", 1)); + clientSnapshot.getDatabase(databaseSnapshot).runCommand(new Document("ping", 1)); return true; } catch (Exception e) { return false; @@ -186,4 +249,45 @@ public DocumentDataAccess getDataAccess() { private static String encodeCredential(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); } + + private static String requireNonBlank(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("MongoDB config '" + fieldName + "' cannot be null or blank."); + } + return value.trim(); + } + + private static String requireHost(String host) { + String normalized = requireNonBlank(host, "host"); + if (!HOST_PATTERN.matcher(normalized).matches()) { + throw new IllegalArgumentException("MongoDB config 'host' contains unsupported characters: " + normalized); + } + return normalized; + } + + private static String requireDatabaseName(String value, String fieldName) { + String normalized = requireNonBlank(value, fieldName); + if (!DATABASE_PATTERN.matcher(normalized).matches()) { + throw new IllegalArgumentException( + "MongoDB config '" + fieldName + "' contains unsupported characters: " + normalized + ); + } + return normalized; + } + + private static int requireInRange(int value, int min, int max, String fieldName) { + if (value < min || value > max) { + throw new IllegalArgumentException("MongoDB config '" + fieldName + "' must be between " + min + " and " + max + + ", but got " + value + "."); + } + return value; + } + + private static long requireInRange(long value, long min, long max, String fieldName) { + if (value < min || value > max) { + throw new IllegalArgumentException("MongoDB config '" + fieldName + "' must be between " + min + " and " + max + + ", but got " + value + "."); + } + return value; + } } diff --git a/src/main/java/nl/hauntedmc/dataprovider/database/keyvalue/impl/redis/RedisDataAccess.java b/src/main/java/nl/hauntedmc/dataprovider/database/keyvalue/impl/redis/RedisDataAccess.java index a2a185d..cec1128 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/database/keyvalue/impl/redis/RedisDataAccess.java +++ b/src/main/java/nl/hauntedmc/dataprovider/database/keyvalue/impl/redis/RedisDataAccess.java @@ -1,190 +1,290 @@ package nl.hauntedmc.dataprovider.database.keyvalue.impl.redis; import nl.hauntedmc.dataprovider.database.keyvalue.KeyValueDataAccess; -import redis.clients.jedis.*; +import nl.hauntedmc.dataprovider.internal.concurrent.AsyncTaskSupport; +import redis.clients.jedis.Jedis; +import redis.clients.jedis.JedisPool; +import redis.clients.jedis.Pipeline; +import redis.clients.jedis.Response; +import redis.clients.jedis.Transaction; import redis.clients.jedis.params.ScanParams; import redis.clients.jedis.resps.ScanResult; -import java.util.*; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; /** * RedisDataAccess implements KeyValueDataAccess using Jedis. */ - class RedisDataAccess implements KeyValueDataAccess { +final class RedisDataAccess implements KeyValueDataAccess { private final JedisPool jedisPool; private final ExecutorService executor; + private final int scanCount; + private final int maxScanResults; public RedisDataAccess(JedisPool jedisPool, ExecutorService executor) { - this.jedisPool = jedisPool; - this.executor = executor; + this(jedisPool, executor, 250, 10_000); + } + + public RedisDataAccess(JedisPool jedisPool, ExecutorService executor, int scanCount, int maxScanResults) { + this.jedisPool = Objects.requireNonNull(jedisPool, "Jedis pool cannot be null."); + this.executor = Objects.requireNonNull(executor, "Executor cannot be null."); + this.scanCount = Math.max(1, scanCount); + this.maxScanResults = Math.max(1, maxScanResults); } @Override public CompletableFuture setKey(String key, String value) { - return CompletableFuture.runAsync(() -> { + final String validatedKey = requireKey(key); + Objects.requireNonNull(value, "Value cannot be null."); + return AsyncTaskSupport.runAsync(executor, "redis.setKey", () -> { try (Jedis jedis = jedisPool.getResource()) { - jedis.set(key, value); + jedis.set(validatedKey, value); } - }, executor); + }); } @Override public CompletableFuture getKey(String key) { - return CompletableFuture.supplyAsync(() -> { + final String validatedKey = requireKey(key); + return AsyncTaskSupport.supplyAsync(executor, "redis.getKey", () -> { try (Jedis jedis = jedisPool.getResource()) { - return jedis.get(key); + return jedis.get(validatedKey); } - }, executor); + }); } @Override public CompletableFuture deleteKey(String key) { - return CompletableFuture.runAsync(() -> { + final String validatedKey = requireKey(key); + return AsyncTaskSupport.runAsync(executor, "redis.deleteKey", () -> { try (Jedis jedis = jedisPool.getResource()) { - jedis.del(key); + jedis.del(validatedKey); } - }, executor); + }); } @Override public CompletableFuture>> queryByPattern(String pattern) { - return CompletableFuture.supplyAsync(() -> { + final String validatedPattern = requirePattern(pattern); + return AsyncTaskSupport.supplyAsync(executor, "redis.queryByPattern", () -> { List> results = new ArrayList<>(); try (Jedis jedis = jedisPool.getResource()) { String cursor = ScanParams.SCAN_POINTER_START; - ScanParams scanParams = new ScanParams().match(pattern).count(100); + ScanParams scanParams = new ScanParams().match(validatedPattern).count(scanCount); do { ScanResult scanResult = jedis.scan(cursor, scanParams); cursor = scanResult.getCursor(); - for (String foundKey : scanResult.getResult()) { - String val = jedis.get(foundKey); - if (val != null) { - Map entry = new HashMap<>(); - entry.put("key", foundKey); - entry.put("value", val); - results.add(entry); + List foundKeys = scanResult.getResult(); + if (!foundKeys.isEmpty()) { + Pipeline pipeline = jedis.pipelined(); + Map> keyValues = new LinkedHashMap<>(); + for (String foundKey : foundKeys) { + keyValues.put(foundKey, pipeline.get(foundKey)); + } + pipeline.sync(); + + for (Map.Entry> entry : keyValues.entrySet()) { + String val = entry.getValue().get(); + if (val != null) { + Map resultEntry = new HashMap<>(); + resultEntry.put("key", entry.getKey()); + resultEntry.put("value", val); + results.add(resultEntry); + if (results.size() >= maxScanResults) { + return results; + } + } } } - } while (!ScanParams.SCAN_POINTER_START.equals(cursor)); + } while (!ScanParams.SCAN_POINTER_START.equals(cursor) && results.size() < maxScanResults); } return results; - }, executor); + }); } @Override public CompletableFuture setKeyWithExpiry(String key, String value, int ttlSeconds) { - return CompletableFuture.runAsync(() -> { + final String validatedKey = requireKey(key); + Objects.requireNonNull(value, "Value cannot be null."); + if (ttlSeconds < 1) { + throw new IllegalArgumentException("TTL seconds must be greater than zero."); + } + return AsyncTaskSupport.runAsync(executor, "redis.setKeyWithExpiry", () -> { try (Jedis jedis = jedisPool.getResource()) { - jedis.setex(key, ttlSeconds, value); + jedis.setex(validatedKey, ttlSeconds, value); } - }, executor); + }); } @Override public CompletableFuture pipelineSet(Map entries) { - return CompletableFuture.runAsync(() -> { + if (entries == null || entries.isEmpty()) { + return CompletableFuture.completedFuture(null); + } + Map safeEntries = Map.copyOf(entries); + for (Map.Entry entry : safeEntries.entrySet()) { + requireKey(entry.getKey()); + Objects.requireNonNull(entry.getValue(), "Pipeline value cannot be null for key " + entry.getKey()); + } + return AsyncTaskSupport.runAsync(executor, "redis.pipelineSet", () -> { try (Jedis jedis = jedisPool.getResource()) { Pipeline pipeline = jedis.pipelined(); - for (Map.Entry e : entries.entrySet()) { - pipeline.set(e.getKey(), e.getValue()); + for (Map.Entry entry : safeEntries.entrySet()) { + pipeline.set(entry.getKey(), entry.getValue()); } pipeline.sync(); } - }, executor); + }); } @Override public CompletableFuture watchCompareAndSet(String key, String oldValue, String newValue) { - return CompletableFuture.supplyAsync(() -> { + final String validatedKey = requireKey(key); + Objects.requireNonNull(newValue, "New value cannot be null."); + return AsyncTaskSupport.supplyAsync(executor, "redis.watchCompareAndSet", () -> { try (Jedis jedis = jedisPool.getResource()) { - jedis.watch(key); - String currentVal = jedis.get(key); + jedis.watch(validatedKey); + String currentVal = jedis.get(validatedKey); if (!Objects.equals(currentVal, oldValue)) { jedis.unwatch(); return false; } Transaction t = jedis.multi(); - t.set(key, newValue); + t.set(validatedKey, newValue); List result = t.exec(); return (result != null && !result.isEmpty()); } - }, executor); + }); } @Override public CompletableFuture hset(String hashKey, Map fields) { - return CompletableFuture.runAsync(() -> { + final String validatedHashKey = requireKey(hashKey); + if (fields == null || fields.isEmpty()) { + return CompletableFuture.completedFuture(null); + } + Map safeFields = Map.copyOf(fields); + for (Map.Entry entry : safeFields.entrySet()) { + requireKey(entry.getKey()); + Objects.requireNonNull(entry.getValue(), "Hash value cannot be null for field " + entry.getKey()); + } + return AsyncTaskSupport.runAsync(executor, "redis.hset", () -> { try (Jedis jedis = jedisPool.getResource()) { - jedis.hset(hashKey, fields); + jedis.hset(validatedHashKey, safeFields); } - }, executor); + }); } @Override public CompletableFuture> hgetAll(String hashKey) { - return CompletableFuture.supplyAsync(() -> { + final String validatedHashKey = requireKey(hashKey); + return AsyncTaskSupport.supplyAsync(executor, "redis.hgetAll", () -> { try (Jedis jedis = jedisPool.getResource()) { - return jedis.hgetAll(hashKey); + return jedis.hgetAll(validatedHashKey); } - }, executor); + }); } @Override public CompletableFuture hdel(String hashKey, String... fields) { - return CompletableFuture.runAsync(() -> { + final String validatedHashKey = requireKey(hashKey); + if (fields == null || fields.length == 0) { + return CompletableFuture.completedFuture(null); + } + for (String field : fields) { + requireKey(field); + } + return AsyncTaskSupport.runAsync(executor, "redis.hdel", () -> { try (Jedis jedis = jedisPool.getResource()) { - jedis.hdel(hashKey, fields); + jedis.hdel(validatedHashKey, fields); } - }, executor); + }); } @Override public CompletableFuture sadd(String key, String... members) { - return CompletableFuture.runAsync(() -> { + final String validatedKey = requireKey(key); + if (members == null || members.length == 0) { + return CompletableFuture.completedFuture(null); + } + for (String member : members) { + Objects.requireNonNull(member, "Set member cannot be null."); + } + return AsyncTaskSupport.runAsync(executor, "redis.sadd", () -> { try (Jedis jedis = jedisPool.getResource()) { - jedis.sadd(key, members); + jedis.sadd(validatedKey, members); } - }, executor); + }); } @Override public CompletableFuture> smembers(String key) { - return CompletableFuture.supplyAsync(() -> { + final String validatedKey = requireKey(key); + return AsyncTaskSupport.supplyAsync(executor, "redis.smembers", () -> { try (Jedis jedis = jedisPool.getResource()) { - return jedis.smembers(key); + return jedis.smembers(validatedKey); } - }, executor); + }); } @Override public CompletableFuture srem(String key, String... members) { - return CompletableFuture.runAsync(() -> { + final String validatedKey = requireKey(key); + if (members == null || members.length == 0) { + return CompletableFuture.completedFuture(null); + } + for (String member : members) { + Objects.requireNonNull(member, "Set member cannot be null."); + } + return AsyncTaskSupport.runAsync(executor, "redis.srem", () -> { try (Jedis jedis = jedisPool.getResource()) { - jedis.srem(key, members); + jedis.srem(validatedKey, members); } - }, executor); + }); } @Override public CompletableFuture zadd(String key, double score, String member) { - return CompletableFuture.runAsync(() -> { + final String validatedKey = requireKey(key); + Objects.requireNonNull(member, "Sorted set member cannot be null."); + return AsyncTaskSupport.runAsync(executor, "redis.zadd", () -> { try (Jedis jedis = jedisPool.getResource()) { - jedis.zadd(key, score, member); + jedis.zadd(validatedKey, score, member); } - }, executor); + }); } @Override public CompletableFuture> zrangeByScore(String key, double min, double max) { - return CompletableFuture.supplyAsync(() -> { + final String validatedKey = requireKey(key); + return AsyncTaskSupport.supplyAsync(executor, "redis.zrangeByScore", () -> { try (Jedis jedis = jedisPool.getResource()) { - Set rawSet = (Set) jedis.zrangeByScore(key, min, max); - return new ArrayList<>(rawSet); + return new ArrayList<>(jedis.zrangeByScore(validatedKey, min, max)); } - }, executor); + }); + } + + private static String requireKey(String key) { + if (key == null || key.isBlank()) { + throw new IllegalArgumentException("Redis key cannot be null or blank."); + } + return key; + } + + private static String requirePattern(String pattern) { + if (pattern == null || pattern.isBlank()) { + throw new IllegalArgumentException("Redis pattern cannot be null or blank."); + } + return pattern; } } diff --git a/src/main/java/nl/hauntedmc/dataprovider/database/keyvalue/impl/redis/RedisDatabase.java b/src/main/java/nl/hauntedmc/dataprovider/database/keyvalue/impl/redis/RedisDatabase.java index 20eeb43..5e5c11d 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/database/keyvalue/impl/redis/RedisDatabase.java +++ b/src/main/java/nl/hauntedmc/dataprovider/database/keyvalue/impl/redis/RedisDatabase.java @@ -16,12 +16,15 @@ import java.util.Objects; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; /** * RedisDatabase implements KeyValueDatabaseProvider, managing a JedisPool and an ExecutorService. */ public class RedisDatabase implements KeyValueDatabaseProvider, ManagedDatabaseProvider { + private static final Pattern HOST_PATTERN = Pattern.compile("[A-Za-z0-9._:\\-\\[\\]]+"); + private final CommentedConfigurationNode config; private final ILoggerAdapter logger; private JedisPool jedisPool; @@ -35,7 +38,7 @@ public RedisDatabase(CommentedConfigurationNode config, ILoggerAdapter logger) { } @Override - public void connect() { + public synchronized void connect() { if (connected && jedisPool != null) { logger.info("[RedisDatabase] Already connected; skipping re–initialization."); return; @@ -43,14 +46,60 @@ public void connect() { JedisPool createdPool = null; ExecutorService createdExecutor = null; try { - final String host = config.node("host").getString("localhost"); - final int port = config.node("port").getInt(6379); + final String host = requireHost(config.node("host").getString("localhost")); + final int port = requireInRange(config.node("port").getInt(6379), 1, 65_535, "port"); final String user = config.node("user").getString(""); final String password = config.node("password").getString(""); - final int databaseIndex = config.node("database").getInt(0); - final int poolSize = Math.max(1, - config.node("pool_size").getInt(config.node("pool", "connections").getInt(8))); - final int queueCapacity = Math.max(poolSize, config.node("queue_capacity").getInt(poolSize * 200)); + final int databaseIndex = requireInRange(config.node("database").getInt(0), 0, 65_535, "database"); + final int connectionPoolSize = requireInRange( + config.node("pool", "connections").getInt(8), + 1, + 256, + "pool.connections" + ); + final int workerPoolSize = requireInRange( + config.node("pool", "threads").getInt(connectionPoolSize), + 1, + 256, + "pool.threads" + ); + final int queueCapacity = requireInRange( + config.node("pool", "queue_capacity").getInt(workerPoolSize * 200), + workerPoolSize, + 1_000_000, + "pool.queue_capacity" + ); + final int maxIdleConnections = requireInRange( + config.node("pool", "max_idle").getInt(connectionPoolSize), + 0, + connectionPoolSize, + "pool.max_idle" + ); + final int minIdleConnections = requireInRange( + config.node("pool", "min_idle").getInt(Math.min(2, connectionPoolSize)), + 0, + maxIdleConnections, + "pool.min_idle" + ); + final int connectionTimeoutMs = requireInRange( + config.node("connection_timeout_ms").getInt(2_000), + 250, + 300_000, + "connection_timeout_ms" + ); + final int socketTimeoutMs = requireInRange( + config.node("socket_timeout_ms").getInt(2_000), + 250, + 300_000, + "socket_timeout_ms" + ); + final int scanCount = requireInRange(config.node("scan_count").getInt(250), 1, 10_000, "scan_count"); + final int maxScanResults = requireInRange( + config.node("security", "max_scan_results").getInt(10_000), + 1, + 1_000_000, + "security.max_scan_results" + ); final boolean tlsEnabled = config.node("tls", "enabled").getBoolean(false); final boolean verifyHostname = config.node("tls", "verify_hostname").getBoolean(true); final boolean trustAllCertificates = config.node("tls", "trust_all_certificates").getBoolean(false); @@ -65,19 +114,28 @@ public void connect() { if (!tlsEnabled) { logger.warn("[RedisDatabase] Redis connection is running without TLS."); } else if (!verifyHostname || trustAllCertificates) { - logger.warn("[RedisDatabase] Insecure TLS flags (verify_hostname=false or trust_all_certificates=true) " - + "are ignored. Strict certificate and hostname verification is always enforced."); + throw new IllegalStateException( + "Redis tls.verify_hostname must be true and tls.trust_all_certificates must be false in DataProvider 2.0." + ); + } + if (!user.isBlank() && password.isBlank()) { + logger.warn("[RedisDatabase] Redis user is configured without a password."); } JedisPoolConfig poolConfig = new JedisPoolConfig(); - poolConfig.setMaxTotal(poolSize); + poolConfig.setMaxTotal(connectionPoolSize); + poolConfig.setMaxIdle(maxIdleConnections); + poolConfig.setMinIdle(minIdleConnections); + poolConfig.setTestOnBorrow(config.node("pool", "test_on_borrow").getBoolean(true)); + poolConfig.setTestWhileIdle(config.node("pool", "test_while_idle").getBoolean(true)); + poolConfig.setBlockWhenExhausted(true); DefaultJedisClientConfig.Builder clientConfigBuilder = DefaultJedisClientConfig.builder() .user(user.isBlank() ? null : user) .password(password.isBlank() ? null : password) .database(databaseIndex) - .connectionTimeoutMillis(2000) - .socketTimeoutMillis(2000) + .connectionTimeoutMillis(connectionTimeoutMs) + .socketTimeoutMillis(socketTimeoutMs) .ssl(tlsEnabled); if (tlsEnabled) { SSLContext sslContext = TlsSupport.createSslContext(trustStorePath, trustStorePassword, trustStoreType); @@ -94,21 +152,22 @@ public void connect() { } } - createdExecutor = BoundedExecutorFactory.create("dataprovider-redis", poolSize, queueCapacity); + createdExecutor = BoundedExecutorFactory.create("dataprovider-redis", workerPoolSize, queueCapacity); jedisPool = createdPool; executor = createdExecutor; - dataAccess = new RedisDataAccess(jedisPool, executor); + dataAccess = new RedisDataAccess(jedisPool, executor, scanCount, maxScanResults); connected = true; logger.info(String.format( - "[RedisDatabase] Connected to Redis at %s:%d (DB %d, auth=%s, tls=%s), poolSize=%d, queueCapacity=%d", + "[RedisDatabase] Connected to Redis at %s:%d (DB %d, auth=%s, tls=%s), connectionPool=%d, workerPool=%d, queueCapacity=%d", host, port, databaseIndex, !password.isBlank() ? "enabled" : "disabled", tlsEnabled ? "enabled" : "disabled", - poolSize, + connectionPoolSize, + workerPoolSize, queueCapacity )); } catch (Exception e) { @@ -125,7 +184,7 @@ public void connect() { } @Override - public void disconnect() { + public synchronized void disconnect() { if (executor != null && !executor.isShutdown()) { executor.shutdown(); try { @@ -150,10 +209,11 @@ public void disconnect() { @Override public boolean isConnected() { - if (!connected || jedisPool == null || jedisPool.isClosed()) { + JedisPool snapshot = jedisPool; + if (!connected || snapshot == null || snapshot.isClosed()) { return false; } - try (var jedis = jedisPool.getResource()) { + try (var jedis = snapshot.getResource()) { return "PONG".equalsIgnoreCase(jedis.ping()); } catch (Exception e) { return false; @@ -164,4 +224,27 @@ public boolean isConnected() { public KeyValueDataAccess getDataAccess() { return dataAccess; } + + private static String requireNonBlank(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Redis config '" + fieldName + "' cannot be null or blank."); + } + return value.trim(); + } + + private static String requireHost(String host) { + String normalized = requireNonBlank(host, "host"); + if (!HOST_PATTERN.matcher(normalized).matches()) { + throw new IllegalArgumentException("Redis config 'host' contains unsupported characters: " + normalized); + } + return normalized; + } + + private static int requireInRange(int value, int min, int max, String fieldName) { + if (value < min || value > max) { + throw new IllegalArgumentException("Redis config '" + fieldName + "' must be between " + min + " and " + max + + ", but got " + value + "."); + } + return value; + } } diff --git a/src/main/java/nl/hauntedmc/dataprovider/database/messaging/impl/redis/RedisMessagingDataAccess.java b/src/main/java/nl/hauntedmc/dataprovider/database/messaging/impl/redis/RedisMessagingDataAccess.java index a1b66f4..258e23d 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/database/messaging/impl/redis/RedisMessagingDataAccess.java +++ b/src/main/java/nl/hauntedmc/dataprovider/database/messaging/impl/redis/RedisMessagingDataAccess.java @@ -4,16 +4,19 @@ import nl.hauntedmc.dataprovider.database.messaging.api.EventMessage; import nl.hauntedmc.dataprovider.database.messaging.api.MessageRegistry; import nl.hauntedmc.dataprovider.database.messaging.api.Subscription; +import nl.hauntedmc.dataprovider.internal.concurrent.AsyncTaskSupport; import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; import redis.clients.jedis.Jedis; import redis.clients.jedis.JedisPool; import redis.clients.jedis.JedisPubSub; +import java.util.ArrayDeque; import java.util.Map; import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; @@ -29,6 +32,7 @@ final class RedisMessagingDataAccess implements MessagingDataAccess { private final MessageRegistry messageRegistry; private final int maxSubscriptions; private final int maxPayloadChars; + private final int maxQueuedMessagesPerHandler; private final Map channelSubscriptions = new ConcurrentHashMap<>(); private final Object subscriptionLock = new Object(); private final AtomicBoolean shuttingDown = new AtomicBoolean(false); @@ -39,7 +43,8 @@ final class RedisMessagingDataAccess implements MessagingDataAccess { ILoggerAdapter logger, MessageRegistry messageRegistry, int maxSubscriptions, - int maxPayloadChars + int maxPayloadChars, + int maxQueuedMessagesPerHandler ) { this.pool = Objects.requireNonNull(pool, "Pool cannot be null"); this.workers = Objects.requireNonNull(workers, "Workers cannot be null"); @@ -53,6 +58,10 @@ final class RedisMessagingDataAccess implements MessagingDataAccess { throw new IllegalArgumentException("maxPayloadChars must be greater than zero"); } this.maxPayloadChars = maxPayloadChars; + if (maxQueuedMessagesPerHandler < 1) { + throw new IllegalArgumentException("maxQueuedMessagesPerHandler must be greater than zero"); + } + this.maxQueuedMessagesPerHandler = maxQueuedMessagesPerHandler; } @Override @@ -69,11 +78,11 @@ public CompletableFuture publish(String dest, T m return CompletableFuture.failedFuture(new IllegalArgumentException( "Message payload exceeds maxPayloadChars (" + maxPayloadChars + ")")); } - return CompletableFuture.runAsync(() -> { + return AsyncTaskSupport.runAsync(workers, "redis.messaging.publish", () -> { try (Jedis j = pool.getResource()) { j.publish(destination, json); } - }, workers); + }); } @Override @@ -149,7 +158,7 @@ public void onMessage(String channel, String raw) { return; } for (HandlerRegistration registration : handlers.values()) { - registration.dispatch(channel, raw); + registration.enqueue(channel, raw); } } }; @@ -163,7 +172,7 @@ public void onMessage(String channel, String raw) { } } finally { channelSubscriptions.remove(destination, this); - handlers.clear(); + closeAndClearHandlers(); closed.set(true); } }, "redis-sub-" + channelThreadName); @@ -183,7 +192,10 @@ private Subscription addHandler(Class type, Consumer } private CompletableFuture removeHandler(long handlerId) { - handlers.remove(handlerId); + HandlerRegistration removed = handlers.remove(handlerId); + if (removed != null) { + removed.close(); + } if (!handlers.isEmpty()) { return CompletableFuture.completedFuture(null); } @@ -195,8 +207,8 @@ private CompletableFuture unsubscribeChannel() { return CompletableFuture.completedFuture(null); } channelSubscriptions.remove(destination, this); - return CompletableFuture.runAsync(() -> { - handlers.clear(); + return AsyncTaskSupport.runAsync(workers, "redis.messaging.unsubscribeChannel", () -> { + closeAndClearHandlers(); try { pubSub.unsubscribe(); } catch (Exception ignored) { @@ -209,7 +221,14 @@ private CompletableFuture unsubscribeChannel() { Thread.currentThread().interrupt(); } } - }, workers); + }); + } + + private void closeAndClearHandlers() { + for (HandlerRegistration registration : handlers.values()) { + registration.close(); + } + handlers.clear(); } } @@ -217,12 +236,79 @@ private final class HandlerRegistration { private final Class type; private final Consumer handler; + private final Object queueLock = new Object(); + private final ArrayDeque queuedMessages = new ArrayDeque<>(); + private final AtomicBoolean closed = new AtomicBoolean(false); + private final AtomicLong droppedMessages = new AtomicLong(0L); + private boolean workerScheduled; private HandlerRegistration(Class type, Consumer handler) { this.type = Objects.requireNonNull(type, "Type cannot be null"); this.handler = Objects.requireNonNull(handler, "Handler cannot be null"); } + private void enqueue(String channel, String raw) { + if (closed.get()) { + return; + } + + boolean shouldSchedule = false; + synchronized (queueLock) { + if (closed.get()) { + return; + } + if (queuedMessages.size() >= maxQueuedMessagesPerHandler) { + long dropped = droppedMessages.incrementAndGet(); + if (dropped == 1 || dropped % 100 == 0) { + logger.warn("Dropped " + dropped + " queued message(s) for channel " + channel + + " because handler queue reached max_queued_messages_per_handler=" + + maxQueuedMessagesPerHandler); + } + return; + } + queuedMessages.addLast(new QueuedMessage(channel, raw)); + if (!workerScheduled) { + workerScheduled = true; + shouldSchedule = true; + } + } + + if (shouldSchedule) { + scheduleDrain(); + } + } + + private void scheduleDrain() { + try { + workers.execute(this::drainQueue); + } catch (RejectedExecutionException e) { + synchronized (queueLock) { + workerScheduled = false; + queuedMessages.clear(); + } + logger.warn("Dropped queued handler messages because dispatch worker pool is full.", e); + } + } + + private void drainQueue() { + while (true) { + QueuedMessage queuedMessage; + synchronized (queueLock) { + if (closed.get()) { + queuedMessages.clear(); + workerScheduled = false; + return; + } + queuedMessage = queuedMessages.pollFirst(); + if (queuedMessage == null) { + workerScheduled = false; + return; + } + } + dispatch(queuedMessage.channel(), queuedMessage.raw()); + } + } + private void dispatch(String channel, String raw) { try { T msg = messageRegistry.fromJson(raw, type); @@ -235,6 +321,17 @@ private void dispatch(String channel, String raw) { logger.error("Error while handling message from channel " + channel, ex); } } + + private void close() { + closed.set(true); + synchronized (queueLock) { + queuedMessages.clear(); + workerScheduled = false; + } + } + } + + private record QueuedMessage(String channel, String raw) { } private static String validateDestination(String destination) { diff --git a/src/main/java/nl/hauntedmc/dataprovider/database/messaging/impl/redis/RedisMessagingDatabase.java b/src/main/java/nl/hauntedmc/dataprovider/database/messaging/impl/redis/RedisMessagingDatabase.java index 79b1f2b..0628c08 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/database/messaging/impl/redis/RedisMessagingDatabase.java +++ b/src/main/java/nl/hauntedmc/dataprovider/database/messaging/impl/redis/RedisMessagingDatabase.java @@ -14,12 +14,15 @@ import java.util.Objects; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; /** * Production‑ready Redis back‑end using ACL user/password and Pub/Sub. */ public final class RedisMessagingDatabase implements MessagingDatabaseProvider, ManagedDatabaseProvider { + private static final Pattern HOST_PATTERN = Pattern.compile("[A-Za-z0-9._:\\-\\[\\]]+"); + private final CommentedConfigurationNode cfg; private final ILoggerAdapter logger; private final MessageRegistry messageRegistry; @@ -38,16 +41,56 @@ public RedisMessagingDatabase(CommentedConfigurationNode cfg, ILoggerAdapter log public synchronized void connect() { if (connected) return; - String host = cfg.node("host").getString("localhost"); - int port = cfg.node("port").getInt(6379); - int db = cfg.node("database").getInt(0); + String host = requireHost(cfg.node("host").getString("localhost")); + int port = requireInRange(cfg.node("port").getInt(6379), 1, 65_535, "port"); + int db = requireInRange(cfg.node("database").getInt(0), 0, 65_535, "database"); String user = cfg.node("user").getString(""); String pass = cfg.node("password").getString(""); - int connectionPoolSize = Math.max(1, cfg.node("pool", "connections").getInt(4)); - int workerPoolSize = Math.max(1, cfg.node("pool", "threads").getInt(8)); - int workerQueueCapacity = Math.max(workerPoolSize, cfg.node("pool", "queue_capacity").getInt(workerPoolSize * 200)); - int maxSubscriptions = Math.max(1, cfg.node("pool", "max_subscriptions").getInt(64)); - int maxPayloadChars = Math.max(256, cfg.node("security", "max_payload_chars").getInt(32_768)); + int connectionPoolSize = requireInRange(cfg.node("pool", "connections").getInt(4), 1, 256, "pool.connections"); + int workerPoolSize = requireInRange(cfg.node("pool", "threads").getInt(8), 1, 256, "pool.threads"); + int workerQueueCapacity = requireInRange( + cfg.node("pool", "queue_capacity").getInt(workerPoolSize * 200), + workerPoolSize, + 1_000_000, + "pool.queue_capacity" + ); + int maxSubscriptions = requireInRange(cfg.node("pool", "max_subscriptions").getInt(64), 1, 10_000, "pool.max_subscriptions"); + int maxPayloadChars = requireInRange( + cfg.node("security", "max_payload_chars").getInt(32_768), + 256, + 1_000_000, + "security.max_payload_chars" + ); + int maxQueuedMessagesPerHandler = requireInRange( + cfg.node("security", "max_queued_messages_per_handler").getInt(1_024), + 1, + 1_000_000, + "security.max_queued_messages_per_handler" + ); + int maxIdleConnections = requireInRange( + cfg.node("pool", "max_idle").getInt(connectionPoolSize), + 0, + connectionPoolSize, + "pool.max_idle" + ); + int minIdleConnections = requireInRange( + cfg.node("pool", "min_idle").getInt(Math.min(2, connectionPoolSize)), + 0, + maxIdleConnections, + "pool.min_idle" + ); + int connectionTimeoutMs = requireInRange( + cfg.node("connection_timeout_ms").getInt(2_000), + 250, + 300_000, + "connection_timeout_ms" + ); + int socketTimeoutMs = requireInRange( + cfg.node("socket_timeout_ms").getInt(2_000), + 250, + 300_000, + "socket_timeout_ms" + ); boolean tlsEnabled = cfg.node("tls", "enabled").getBoolean(false); boolean verifyHostname = cfg.node("tls", "verify_hostname").getBoolean(true); boolean trustAllCertificates = cfg.node("tls", "trust_all_certificates").getBoolean(false); @@ -62,18 +105,29 @@ public synchronized void connect() { if (!tlsEnabled) { logger.warn("[RedisMessagingDatabase] Redis messaging is running without TLS."); } else if (!verifyHostname || trustAllCertificates) { - logger.warn("[RedisMessagingDatabase] Insecure TLS flags (verify_hostname=false or trust_all_certificates=true) " - + "are ignored. Strict certificate and hostname verification is always enforced."); + throw new IllegalStateException( + "Redis messaging tls.verify_hostname must be true and tls.trust_all_certificates must be false in DataProvider 2.0." + ); + } + if (!user.isBlank() && pass.isBlank()) { + logger.warn("[RedisMessagingDatabase] Redis messaging user is configured without a password."); } try { JedisPoolConfig poolConfig = new JedisPoolConfig(); poolConfig.setMaxTotal(connectionPoolSize); + poolConfig.setMaxIdle(maxIdleConnections); + poolConfig.setMinIdle(minIdleConnections); + poolConfig.setTestOnBorrow(cfg.node("pool", "test_on_borrow").getBoolean(true)); + poolConfig.setTestWhileIdle(cfg.node("pool", "test_while_idle").getBoolean(true)); + poolConfig.setBlockWhenExhausted(true); DefaultJedisClientConfig.Builder clientConfigBuilder = DefaultJedisClientConfig.builder() .user(user.isBlank() ? null : user) .password(pass.isBlank() ? null : pass) .database(db) + .connectionTimeoutMillis(connectionTimeoutMs) + .socketTimeoutMillis(socketTimeoutMs) .ssl(tlsEnabled); if (tlsEnabled) { SSLContext sslContext = TlsSupport.createSslContext(trustStorePath, trustStorePassword, trustStoreType); @@ -90,19 +144,30 @@ public synchronized void connect() { } workers = BoundedExecutorFactory.create("dataprovider-redis-msg", workerPoolSize, workerQueueCapacity); - bus = new RedisMessagingDataAccess(pool, workers, logger, messageRegistry, maxSubscriptions, maxPayloadChars); + bus = new RedisMessagingDataAccess( + pool, + workers, + logger, + messageRegistry, + maxSubscriptions, + maxPayloadChars, + maxQueuedMessagesPerHandler + ); connected = true; logger.info(String.format( - "[RedisMessagingDatabase] Connected to Redis messaging at %s:%d (db=%d, auth=%s, tls=%s, maxSubscriptions=%d, queueCapacity=%d, maxPayloadChars=%d)", + "[RedisMessagingDatabase] Connected to Redis messaging at %s:%d (db=%d, auth=%s, tls=%s, connectionPool=%d, workerPool=%d, maxSubscriptions=%d, queueCapacity=%d, maxPayloadChars=%d, maxQueuedMessagesPerHandler=%d)", host, port, db, pass.isBlank() ? "disabled" : "enabled", tlsEnabled ? "enabled" : "disabled", + connectionPoolSize, + workerPoolSize, maxSubscriptions, workerQueueCapacity, - maxPayloadChars + maxPayloadChars, + maxQueuedMessagesPerHandler )); } catch (Exception e) { connected = false; @@ -128,10 +193,11 @@ public synchronized void disconnect() { @Override public boolean isConnected() { - if (!connected || pool == null || pool.isClosed()) { + JedisPool snapshot = pool; + if (!connected || snapshot == null || snapshot.isClosed()) { return false; } - try (Jedis jedis = pool.getResource()) { + try (Jedis jedis = snapshot.getResource()) { return "PONG".equalsIgnoreCase(jedis.ping()); } catch (Exception e) { return false; @@ -163,4 +229,29 @@ private void cleanupResources() { pool = null; bus = null; } + + private static String requireNonBlank(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Redis messaging config '" + fieldName + "' cannot be null or blank."); + } + return value.trim(); + } + + private static String requireHost(String host) { + String normalized = requireNonBlank(host, "host"); + if (!HOST_PATTERN.matcher(normalized).matches()) { + throw new IllegalArgumentException( + "Redis messaging config 'host' contains unsupported characters: " + normalized + ); + } + return normalized; + } + + private static int requireInRange(int value, int min, int max, String fieldName) { + if (value < min || value > max) { + throw new IllegalArgumentException("Redis messaging config '" + fieldName + "' must be between " + + min + " and " + max + ", but got " + value + "."); + } + return value; + } } diff --git a/src/main/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDataAccess.java b/src/main/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDataAccess.java index a5b0666..3e341ae 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDataAccess.java +++ b/src/main/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDataAccess.java @@ -2,6 +2,7 @@ import nl.hauntedmc.dataprovider.database.relational.RelationalDataAccess; import nl.hauntedmc.dataprovider.database.relational.TransactionCallback; +import nl.hauntedmc.dataprovider.internal.concurrent.AsyncTaskSupport; import javax.sql.DataSource; import java.sql.*; @@ -11,6 +12,7 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; +import java.util.Objects; /** * MySQL implementation of RelationalDataAccess (CRUD and query operations). @@ -19,30 +21,42 @@ public class MySQLDataAccess implements RelationalDataAccess { private final DataSource dataSource; private final ExecutorService executor; + private final int queryTimeoutSeconds; + private final int fetchSize; public MySQLDataAccess(DataSource dataSource, ExecutorService executor) { - this.dataSource = dataSource; - this.executor = executor; + this(dataSource, executor, 0, 0); + } + + public MySQLDataAccess(DataSource dataSource, ExecutorService executor, int queryTimeoutSeconds, int fetchSize) { + this.dataSource = Objects.requireNonNull(dataSource, "Data source cannot be null."); + this.executor = Objects.requireNonNull(executor, "Executor cannot be null."); + this.queryTimeoutSeconds = Math.max(0, queryTimeoutSeconds); + this.fetchSize = Math.max(0, fetchSize); } @Override public CompletableFuture executeUpdate(String query, Object... params) { - return CompletableFuture.runAsync(() -> { + final String sql = requireQuery(query); + return AsyncTaskSupport.runAsync(executor, "mysql.executeUpdate", () -> { try (Connection connection = dataSource.getConnection(); - PreparedStatement stmt = connection.prepareStatement(query)) { + PreparedStatement stmt = connection.prepareStatement(sql)) { + applyStatementTuning(stmt); setParameters(stmt, params); stmt.executeUpdate(); } catch (SQLException e) { throw new RuntimeException("Failed to execute update", e); } - }, executor); + }); } @Override public CompletableFuture> queryForSingle(String query, Object... params) { - return CompletableFuture.supplyAsync(() -> { + final String sql = requireQuery(query); + return AsyncTaskSupport.supplyAsync(executor, "mysql.queryForSingle", () -> { try (Connection connection = dataSource.getConnection(); - PreparedStatement stmt = connection.prepareStatement(query)) { + PreparedStatement stmt = connection.prepareStatement(sql)) { + applyStatementTuning(stmt); setParameters(stmt, params); try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { @@ -53,15 +67,17 @@ public CompletableFuture> queryForSingle(String query, Objec throw new RuntimeException("Failed to execute queryForSingle", e); } return null; - }, executor); + }); } @Override public CompletableFuture>> queryForList(String query, Object... params) { - return CompletableFuture.supplyAsync(() -> { + final String sql = requireQuery(query); + return AsyncTaskSupport.supplyAsync(executor, "mysql.queryForList", () -> { List> result = new ArrayList<>(); try (Connection connection = dataSource.getConnection(); - PreparedStatement stmt = connection.prepareStatement(query)) { + PreparedStatement stmt = connection.prepareStatement(sql)) { + applyStatementTuning(stmt); setParameters(stmt, params); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { @@ -72,14 +88,16 @@ public CompletableFuture>> queryForList(String query, O throw new RuntimeException("Failed to execute queryForList", e); } return result; - }, executor); + }); } @Override public CompletableFuture queryForSingleValue(String query, Object... params) { - return CompletableFuture.supplyAsync(() -> { + final String sql = requireQuery(query); + return AsyncTaskSupport.supplyAsync(executor, "mysql.queryForSingleValue", () -> { try (Connection connection = dataSource.getConnection(); - PreparedStatement stmt = connection.prepareStatement(query)) { + PreparedStatement stmt = connection.prepareStatement(sql)) { + applyStatementTuning(stmt); setParameters(stmt, params); try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { @@ -90,28 +108,44 @@ public CompletableFuture queryForSingleValue(String query, Object... par throw new RuntimeException("Failed to execute queryForSingleValue", e); } return null; - }, executor); + }); } @Override public CompletableFuture executeBatchUpdate(String query, List batchParams) { - return CompletableFuture.runAsync(() -> { + if (batchParams == null || batchParams.isEmpty()) { + return CompletableFuture.completedFuture(null); + } + final String sql = requireQuery(query); + return AsyncTaskSupport.runAsync(executor, "mysql.executeBatchUpdate", () -> { try (Connection connection = dataSource.getConnection(); - PreparedStatement stmt = connection.prepareStatement(query)) { - for (Object[] params : batchParams) { - setParameters(stmt, params); - stmt.addBatch(); + PreparedStatement stmt = connection.prepareStatement(sql)) { + applyStatementTuning(stmt); + boolean oldAutoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); + try { + for (Object[] params : batchParams) { + setParameters(stmt, params); + stmt.addBatch(); + } + stmt.executeBatch(); + connection.commit(); + } catch (SQLException e) { + connection.rollback(); + throw e; + } finally { + connection.setAutoCommit(oldAutoCommit); } - stmt.executeBatch(); } catch (SQLException e) { throw new RuntimeException("Failed to execute batch update", e); } - }, executor); + }); } @Override public CompletableFuture executeTransactionally(TransactionCallback callback) { - return CompletableFuture.supplyAsync(() -> { + Objects.requireNonNull(callback, "Transaction callback cannot be null."); + return AsyncTaskSupport.supplyAsync(executor, "mysql.executeTransactionally", () -> { try (Connection connection = dataSource.getConnection()) { boolean oldAutoCommit = connection.getAutoCommit(); connection.setAutoCommit(false); @@ -128,7 +162,7 @@ public CompletableFuture executeTransactionally(TransactionCallback ca } catch (SQLException e) { throw new RuntimeException("Failed to execute transactionally", e); } - }, executor); + }); } /** @@ -140,9 +174,11 @@ public CompletableFuture executeTransactionally(TransactionCallback ca */ @Override public CompletableFuture executeInsert(String query, Object... params) { - return CompletableFuture.supplyAsync(() -> { + final String sql = requireQuery(query); + return AsyncTaskSupport.supplyAsync(executor, "mysql.executeInsert", () -> { try (Connection connection = dataSource.getConnection(); - PreparedStatement stmt = connection.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) { + PreparedStatement stmt = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { + applyStatementTuning(stmt); setParameters(stmt, params); int affectedRows = stmt.executeUpdate(); if (affectedRows == 0) { @@ -158,10 +194,29 @@ public CompletableFuture executeInsert(String query, Object... params) { } catch (SQLException e) { throw new RuntimeException("Failed to execute insert", e); } - }, executor); + }); + } + + private static String requireQuery(String query) { + if (query == null || query.isBlank()) { + throw new IllegalArgumentException("SQL query cannot be null or blank."); + } + return query; + } + + private void applyStatementTuning(PreparedStatement stmt) throws SQLException { + if (queryTimeoutSeconds > 0) { + stmt.setQueryTimeout(queryTimeoutSeconds); + } + if (fetchSize > 0) { + stmt.setFetchSize(fetchSize); + } } private void setParameters(PreparedStatement stmt, Object... params) throws SQLException { + if (params == null || params.length == 0) { + return; + } for (int i = 0; i < params.length; i++) { stmt.setObject(i + 1, params[i]); } @@ -172,7 +227,7 @@ private Map mapRow(ResultSet rs) throws SQLException { ResultSetMetaData md = rs.getMetaData(); int columns = md.getColumnCount(); for (int i = 1; i <= columns; i++) { - row.put(md.getColumnName(i), rs.getObject(i)); + row.put(md.getColumnLabel(i), rs.getObject(i)); } return row; } diff --git a/src/main/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDatabase.java b/src/main/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDatabase.java index f4832fa..4f3a2f9 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDatabase.java +++ b/src/main/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDatabase.java @@ -14,8 +14,10 @@ import java.util.Locale; import java.util.Objects; import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; /** * MySQL implementation of RelationalDatabaseProvider. @@ -24,6 +26,9 @@ public class MySQLDatabase implements RelationalDatabaseProvider, ManagedDatabas static final String MYSQL_DRIVER_CLASS_NAME = "com.mysql.cj.jdbc.Driver"; private static final Set SECURE_SSL_MODES = Set.of("REQUIRED", "VERIFY_CA", "VERIFY_IDENTITY"); + private static final AtomicInteger POOL_SEQUENCE = new AtomicInteger(1); + private static final Pattern HOST_PATTERN = Pattern.compile("[A-Za-z0-9._:\\-\\[\\]]+"); + private static final Pattern DATABASE_PATTERN = Pattern.compile("[A-Za-z0-9_$.\\-]+"); private final CommentedConfigurationNode config; private final ILoggerAdapter logger; @@ -38,7 +43,7 @@ public MySQLDatabase(CommentedConfigurationNode config, ILoggerAdapter logger) { } @Override - public void connect() { + public synchronized void connect() { if (dataSource != null && !dataSource.isClosed()) { logger.info("[MySQLDatabase] Already connected, skipping re–initialization."); return; @@ -49,15 +54,95 @@ public void connect() { try { HikariConfig hikariConfig = new HikariConfig(); - final String host = config.node("host").getString("localhost"); - final int port = config.node("port").getInt(3306); - final String databaseName = config.node("database").getString("minecraft"); - final String user = config.node("username").getString("root"); + final String host = requireHost(config.node("host").getString("localhost")); + final int port = requirePort(config.node("port").getInt(3306)); + final String databaseName = requireDatabaseName(config.node("database").getString("minecraft")); + final String user = requireNonBlank(config.node("username").getString("root"), "username"); final String password = config.node("password").getString(""); final String sslMode = config.node("ssl_mode").getString("PREFERRED"); final String normalizedSslMode = (sslMode == null ? "PREFERRED" : sslMode).trim().toUpperCase(Locale.ROOT); final boolean allowPublicKeyRetrieval = config.node("allow_public_key_retrieval").getBoolean(false); final boolean requireSecureTransport = config.node("require_secure_transport").getBoolean(false); + final int poolSize = requireInRange(config.node("pool_size").getInt(10), 1, 256, "pool_size"); + final int minIdle = requireInRange( + config.node("min_idle").getInt(Math.min(2, poolSize)), + 0, + poolSize, + "min_idle" + ); + final int queueCapacity = requireInRange( + config.node("queue_capacity").getInt(poolSize * 200), + poolSize, + 1_000_000, + "queue_capacity" + ); + final long connectionTimeoutMs = requireInRange( + config.node("connection_timeout_ms").getLong(30_000L), + 250L, + 300_000L, + "connection_timeout_ms" + ); + final long validationTimeoutMs = requireInRange( + config.node("validation_timeout_ms").getLong(3_000L), + 250L, + 30_000L, + "validation_timeout_ms" + ); + final long idleTimeoutMs = requireInRange( + config.node("idle_timeout_ms").getLong(600_000L), + 10_000L, + 86_400_000L, + "idle_timeout_ms" + ); + final long maxLifetimeMs = requireInRange( + config.node("max_lifetime_ms").getLong(1_800_000L), + 30_000L, + 86_400_000L, + "max_lifetime_ms" + ); + final long leakDetectionThresholdMs = requireInRange( + config.node("leak_detection_threshold_ms").getLong(0L), + 0L, + 86_400_000L, + "leak_detection_threshold_ms" + ); + final int connectTimeoutMs = requireInRange( + config.node("connect_timeout_ms").getInt(10_000), + 250, + 300_000, + "connect_timeout_ms" + ); + final int socketTimeoutMs = requireInRange( + config.node("socket_timeout_ms").getInt(10_000), + 250, + 300_000, + "socket_timeout_ms" + ); + final int queryTimeoutSeconds = requireInRange( + config.node("query_timeout_seconds").getInt(0), + 0, + 3_600, + "query_timeout_seconds" + ); + final int defaultFetchSize = requireInRange( + config.node("default_fetch_size").getInt(0), + 0, + 100_000, + "default_fetch_size" + ); + final boolean cachePreparedStatements = config.node("cache_prepared_statements").getBoolean(true); + final int preparedStatementCacheSize = requireInRange( + config.node("prepared_statement_cache_size").getInt(250), + 25, + 10_000, + "prepared_statement_cache_size" + ); + final int preparedStatementCacheSqlLimit = requireInRange( + config.node("prepared_statement_cache_sql_limit").getInt(2_048), + 256, + 65_535, + "prepared_statement_cache_sql_limit" + ); if (requireSecureTransport && !SECURE_SSL_MODES.contains(normalizedSslMode)) { throw new IllegalStateException("MySQL require_secure_transport=true requires ssl_mode to be one of " @@ -67,6 +152,10 @@ public void connect() { logger.warn("[MySQLDatabase] MySQL connection is not configured for strict TLS verification " + "(ssl_mode=" + normalizedSslMode + ")."); } + if (allowPublicKeyRetrieval && !SECURE_SSL_MODES.contains(normalizedSslMode)) { + logger.warn("[MySQLDatabase] allow_public_key_retrieval=true without strict TLS verification can expose " + + "credentials to MITM risk."); + } final String jdbcUrl = String.format( "jdbc:mysql://%s:%d/%s?characterEncoding=UTF-8&sslMode=%s&allowPublicKeyRetrieval=%s", @@ -80,14 +169,24 @@ public void connect() { hikariConfig.setDriverClassName(MYSQL_DRIVER_CLASS_NAME); hikariConfig.setUsername(user); hikariConfig.setPassword(password); - - final int poolSize = Math.max(1, config.node("pool_size").getInt(10)); - final int queueCapacity = Math.max(poolSize, config.node("queue_capacity").getInt(poolSize * 200)); + hikariConfig.setPoolName("dataprovider-mysql-" + POOL_SEQUENCE.getAndIncrement()); hikariConfig.setMaximumPoolSize(poolSize); - hikariConfig.setConnectionTimeout(30000); - hikariConfig.setIdleTimeout(600000); - hikariConfig.setMaxLifetime(1800000); - hikariConfig.setLeakDetectionThreshold(2000); + hikariConfig.setMinimumIdle(minIdle); + hikariConfig.setConnectionTimeout(connectionTimeoutMs); + hikariConfig.setValidationTimeout(validationTimeoutMs); + hikariConfig.setIdleTimeout(idleTimeoutMs); + hikariConfig.setMaxLifetime(maxLifetimeMs); + hikariConfig.setLeakDetectionThreshold(leakDetectionThresholdMs); + hikariConfig.addDataSourceProperty("cachePrepStmts", String.valueOf(cachePreparedStatements)); + hikariConfig.addDataSourceProperty("prepStmtCacheSize", String.valueOf(preparedStatementCacheSize)); + hikariConfig.addDataSourceProperty("prepStmtCacheSqlLimit", String.valueOf(preparedStatementCacheSqlLimit)); + hikariConfig.addDataSourceProperty("useServerPrepStmts", "true"); + hikariConfig.addDataSourceProperty("rewriteBatchedStatements", "true"); + hikariConfig.addDataSourceProperty("cacheResultSetMetadata", "true"); + hikariConfig.addDataSourceProperty("maintainTimeStats", "false"); + hikariConfig.addDataSourceProperty("tcpKeepAlive", "true"); + hikariConfig.addDataSourceProperty("connectTimeout", String.valueOf(connectTimeoutMs)); + hikariConfig.addDataSourceProperty("socketTimeout", String.valueOf(socketTimeoutMs)); createdDataSource = createDataSource(hikariConfig); createdExecutor = BoundedExecutorFactory.create("dataprovider-mysql", poolSize, queueCapacity); @@ -100,15 +199,16 @@ public void connect() { dataSource = createdDataSource; executor = createdExecutor; - this.dataAccess = new MySQLDataAccess(dataSource, executor); + this.dataAccess = new MySQLDataAccess(dataSource, executor, queryTimeoutSeconds, defaultFetchSize); this.schemaManager = new MySQLSchemaManager(dataSource, executor); logger.info(String.format( - "[MySQLDatabase] Connected successfully to MySQL at %s:%d (database=%s, sslMode=%s, queueCapacity=%d)", + "[MySQLDatabase] Connected successfully to MySQL at %s:%d (database=%s, sslMode=%s, poolSize=%d, queueCapacity=%d)", host, port, databaseName, normalizedSslMode, + poolSize, queueCapacity )); } catch (Exception e) { @@ -125,7 +225,7 @@ public void connect() { } @Override - public void disconnect() { + public synchronized void disconnect() { if (executor != null && !executor.isShutdown()) { executor.shutdown(); try { @@ -150,10 +250,11 @@ public void disconnect() { @Override public boolean isConnected() { - if (dataSource == null || dataSource.isClosed()) { + HikariDataSource snapshot = dataSource; + if (snapshot == null || snapshot.isClosed()) { return false; } - try (var conn = dataSource.getConnection()) { + try (var conn = snapshot.getConnection()) { return conn.isValid(2); } catch (Exception e) { logger.error("[MySQLDatabase] Connection validation failed.", e); @@ -185,4 +286,49 @@ public DataSource getDataSource() { HikariDataSource createDataSource(HikariConfig hikariConfig) { return new HikariDataSource(hikariConfig); } + + private static String requireNonBlank(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("MySQL config '" + fieldName + "' cannot be null or blank."); + } + return value.trim(); + } + + private static String requireHost(String host) { + String normalized = requireNonBlank(host, "host"); + if (!HOST_PATTERN.matcher(normalized).matches()) { + throw new IllegalArgumentException("MySQL config 'host' contains unsupported characters: " + normalized); + } + return normalized; + } + + private static String requireDatabaseName(String databaseName) { + String normalized = requireNonBlank(databaseName, "database"); + if (!DATABASE_PATTERN.matcher(normalized).matches()) { + throw new IllegalArgumentException( + "MySQL config 'database' contains unsupported characters: " + normalized + ); + } + return normalized; + } + + private static int requirePort(int port) { + return requireInRange(port, 1, 65_535, "port"); + } + + private static int requireInRange(int value, int min, int max, String fieldName) { + if (value < min || value > max) { + throw new IllegalArgumentException("MySQL config '" + fieldName + "' must be between " + min + " and " + max + + ", but got " + value + "."); + } + return value; + } + + private static long requireInRange(long value, long min, long max, String fieldName) { + if (value < min || value > max) { + throw new IllegalArgumentException("MySQL config '" + fieldName + "' must be between " + min + " and " + max + + ", but got " + value + "."); + } + return value; + } } diff --git a/src/main/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLSchemaManager.java b/src/main/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLSchemaManager.java index ce620fb..632fc7d 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLSchemaManager.java +++ b/src/main/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLSchemaManager.java @@ -3,6 +3,7 @@ import nl.hauntedmc.dataprovider.database.relational.schema.ColumnDefinition; import nl.hauntedmc.dataprovider.database.relational.schema.SchemaManager; import nl.hauntedmc.dataprovider.database.relational.schema.TableDefinition; +import nl.hauntedmc.dataprovider.internal.concurrent.AsyncTaskSupport; import javax.sql.DataSource; import java.sql.Connection; @@ -33,7 +34,7 @@ public MySQLSchemaManager(DataSource dataSource, ExecutorService executor) { @Override public CompletableFuture createTable(TableDefinition tableDefinition) { - return CompletableFuture.runAsync(() -> { + return AsyncTaskSupport.runAsync(executor, "mysql.schema.createTable", () -> { if (tableDefinition == null) { throw new IllegalArgumentException("Table definition cannot be null."); } @@ -71,12 +72,12 @@ public CompletableFuture createTable(TableDefinition tableDefinition) { } catch (SQLException e) { throw new RuntimeException("Failed to create table: " + e.getMessage(), e); } - }, executor); + }); } @Override public CompletableFuture alterTable(TableDefinition tableDefinition) { - return CompletableFuture.runAsync(() -> { + return AsyncTaskSupport.runAsync(executor, "mysql.schema.alterTable", () -> { if (tableDefinition == null) { throw new IllegalArgumentException("Table definition cannot be null."); } @@ -111,12 +112,12 @@ public CompletableFuture alterTable(TableDefinition tableDefinition) { } catch (SQLException e) { throw new RuntimeException("Failed to alter table: " + e.getMessage(), e); } - }, executor); + }); } @Override public CompletableFuture dropTable(String tableName) { - return CompletableFuture.runAsync(() -> { + return AsyncTaskSupport.runAsync(executor, "mysql.schema.dropTable", () -> { final String query = "DROP TABLE IF EXISTS " + quoteIdentifier(tableName, "table"); try (Connection connection = dataSource.getConnection(); PreparedStatement stmt = connection.prepareStatement(query)) { @@ -124,12 +125,12 @@ public CompletableFuture dropTable(String tableName) { } catch (SQLException e) { throw new RuntimeException("Failed to drop table: " + e.getMessage(), e); } - }, executor); + }); } @Override public CompletableFuture tableExists(String tableName) { - return CompletableFuture.supplyAsync(() -> { + return AsyncTaskSupport.supplyAsync(executor, "mysql.schema.tableExists", () -> { final String query = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?"; try (Connection connection = dataSource.getConnection(); PreparedStatement stmt = connection.prepareStatement(query)) { @@ -144,12 +145,12 @@ public CompletableFuture tableExists(String tableName) { throw new RuntimeException("Failed to check table existence: " + e.getMessage(), e); } return false; - }, executor); + }); } @Override public CompletableFuture addIndex(String tableName, String column, boolean unique) { - return CompletableFuture.runAsync(() -> { + return AsyncTaskSupport.runAsync(executor, "mysql.schema.addIndex", () -> { String validatedColumn = validateIdentifier(column, "column"); final String indexType = unique ? "UNIQUE " : ""; final String indexName = quoteIdentifier(limitIdentifierLength("idx_" + validatedColumn), "index"); @@ -162,12 +163,12 @@ public CompletableFuture addIndex(String tableName, String column, boolean } catch (SQLException e) { throw new RuntimeException("Failed to add index: " + e.getMessage(), e); } - }, executor); + }); } @Override public CompletableFuture removeIndex(String tableName, String indexName) { - return CompletableFuture.runAsync(() -> { + return AsyncTaskSupport.runAsync(executor, "mysql.schema.removeIndex", () -> { final String query = "DROP INDEX " + quoteIdentifier(indexName, "index") + " ON " + quoteIdentifier(tableName, "table"); try (Connection connection = dataSource.getConnection(); @@ -176,12 +177,12 @@ public CompletableFuture removeIndex(String tableName, String indexName) { } catch (SQLException e) { throw new RuntimeException("Failed to remove index: " + e.getMessage(), e); } - }, executor); + }); } @Override public CompletableFuture addForeignKey(String table, String column, String referenceTable, String referenceColumn) { - return CompletableFuture.runAsync(() -> { + return AsyncTaskSupport.runAsync(executor, "mysql.schema.addForeignKey", () -> { String tableId = validateIdentifier(table, "table"); String columnId = validateIdentifier(column, "column"); String referenceTableId = validateIdentifier(referenceTable, "reference table"); @@ -198,12 +199,12 @@ public CompletableFuture addForeignKey(String table, String column, String } catch (SQLException e) { throw new RuntimeException("Failed to add foreign key: " + e.getMessage(), e); } - }, executor); + }); } @Override public CompletableFuture removeForeignKey(String table, String constraintName) { - return CompletableFuture.runAsync(() -> { + return AsyncTaskSupport.runAsync(executor, "mysql.schema.removeForeignKey", () -> { final String query = "ALTER TABLE " + quoteIdentifier(table, "table") + " DROP FOREIGN KEY " + quoteIdentifier(constraintName, "constraint"); try (Connection connection = dataSource.getConnection(); @@ -212,7 +213,7 @@ public CompletableFuture removeForeignKey(String table, String constraintN } catch (SQLException e) { throw new RuntimeException("Failed to remove foreign key: " + e.getMessage(), e); } - }, executor); + }); } private static String quoteIdentifier(String identifier, String kind) { diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderHandler.java b/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderHandler.java index 12d0877..e2a74be 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderHandler.java +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderHandler.java @@ -22,6 +22,8 @@ public class DataProviderHandler { private static final String INTERNAL_PACKAGE_PREFIX = "nl.hauntedmc.dataprovider.internal"; + private static final int MAX_OWNER_SCOPE_LENGTH = 256; + private static final Pattern OWNER_SCOPE_PATTERN = Pattern.compile("[A-Za-z0-9_.:$-]{1,256}"); private static final Pattern CONNECTION_IDENTIFIER_PATTERN = Pattern.compile("[A-Za-z0-9_.:-]{1,128}"); private static final String CLOSED_MESSAGE = "DataProvider API is no longer available. Obtain a fresh API instance after plugin enable."; @@ -64,33 +66,108 @@ public DataProviderHandler( /** * Registers a database connection for the resolved caller plugin. + * This is the default path for most integrations. */ public DatabaseProvider registerDatabase(DatabaseType databaseType, String connectionIdentifier) { requireOpen(); Objects.requireNonNull(databaseType, "Database type cannot be null"); requireConnectionIdentifier(connectionIdentifier); CallerContext caller = resolveCallerContext(); - return registry.registerDatabase(caller.pluginId(), databaseType, connectionIdentifier); + return registry.registerDatabase( + caller.pluginId(), + caller.pluginId(), + databaseType, + connectionIdentifier + ); + } + + /** + * Registers a database connection for the resolved caller plugin under an explicit owner scope. + * Use explicit scopes when multiple components share the same wrapper class. + */ + public DatabaseProvider registerDatabaseForScope( + String ownerScope, + DatabaseType databaseType, + String connectionIdentifier + ) { + requireOpen(); + Objects.requireNonNull(databaseType, "Database type cannot be null"); + requireConnectionIdentifier(connectionIdentifier); + String normalizedOwnerScope = requireOwnerScope(ownerScope); + CallerContext caller = resolveCallerContext(); + return registry.registerDatabase( + caller.pluginId(), + normalizedOwnerScope, + databaseType, + connectionIdentifier + ); } /** * Unregisters a specific database connection for the resolved caller plugin. + * This is the default path for most integrations. */ public void unregisterDatabase(DatabaseType databaseType, String connectionIdentifier) { requireOpen(); Objects.requireNonNull(databaseType, "Database type cannot be null"); requireConnectionIdentifier(connectionIdentifier); CallerContext caller = resolveCallerContext(); - registry.unregisterDatabase(caller.pluginId(), databaseType, connectionIdentifier); + registry.unregisterDatabase( + caller.pluginId(), + caller.pluginId(), + databaseType, + connectionIdentifier + ); + } + + /** + * Unregisters a specific database connection for the resolved caller plugin under an explicit owner scope. + */ + public void unregisterDatabaseForScope( + String ownerScope, + DatabaseType databaseType, + String connectionIdentifier + ) { + requireOpen(); + Objects.requireNonNull(databaseType, "Database type cannot be null"); + requireConnectionIdentifier(connectionIdentifier); + String normalizedOwnerScope = requireOwnerScope(ownerScope); + CallerContext caller = resolveCallerContext(); + registry.unregisterDatabase( + caller.pluginId(), + normalizedOwnerScope, + databaseType, + connectionIdentifier + ); } /** - * Unregisters all database connections for the resolved caller plugin. + * Unregisters all database connections for the resolved caller plugin default owner scope. */ public void unregisterAllDatabases() { requireOpen(); CallerContext caller = resolveCallerContext(); - registry.unregisterAllDatabases(caller.pluginId()); + registry.unregisterAllDatabases(caller.pluginId(), caller.pluginId()); + } + + /** + * Unregisters all database connections for the resolved caller plugin under an explicit owner scope. + */ + public void unregisterAllDatabasesForScope(String ownerScope) { + requireOpen(); + String normalizedOwnerScope = requireOwnerScope(ownerScope); + CallerContext caller = resolveCallerContext(); + registry.unregisterAllDatabases(caller.pluginId(), normalizedOwnerScope); + } + + /** + * Unregisters all database connections for the resolved caller plugin across all caller scopes. + * Intended for full plugin shutdown where registrations may originate from multiple owner scopes. + */ + public void unregisterAllDatabasesForPlugin() { + requireOpen(); + CallerContext caller = resolveCallerContext(); + registry.unregisterAllDatabasesForPlugin(caller.pluginId()); } /** @@ -148,6 +225,20 @@ private static void requireConnectionIdentifier(String connectionIdentifier) { } } + private static String requireOwnerScope(String ownerScope) { + if (ownerScope == null || ownerScope.isBlank()) { + throw new IllegalArgumentException("Owner scope cannot be null or blank."); + } + String normalized = ownerScope.trim(); + if (normalized.length() > MAX_OWNER_SCOPE_LENGTH) { + throw new IllegalArgumentException("Owner scope exceeds maximum supported length."); + } + if (!OWNER_SCOPE_PATTERN.matcher(normalized).matches()) { + throw new IllegalArgumentException("Owner scope contains unsupported characters."); + } + return normalized; + } + private void requireInternalCaller() { ClassLoader callerLoader = StackCallerClassLoaderResolver.resolveNearestCallerOutsidePackage(INTERNAL_PACKAGE_PREFIX); if (callerLoader == null || callerLoader != ownClassLoader) { diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistry.java b/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistry.java index fe35c1f..7a880d0 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistry.java +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistry.java @@ -11,7 +11,6 @@ import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; -import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; @@ -34,7 +33,16 @@ public DataProviderRegistry(DatabaseFactory factory, ConfigHandler configHandler this.logger = Objects.requireNonNull(logger, "Logger cannot be null."); } - protected DatabaseProvider registerDatabase(String pluginName, DatabaseType databaseType, String connectionIdentifier) { + protected DatabaseProvider registerDatabase( + String pluginName, + String ownerScope, + DatabaseType databaseType, + String connectionIdentifier + ) { + Objects.requireNonNull(pluginName, "Plugin name cannot be null."); + Objects.requireNonNull(ownerScope, "Owner scope cannot be null."); + Objects.requireNonNull(databaseType, "Database type cannot be null."); + Objects.requireNonNull(connectionIdentifier, "Connection identifier cannot be null."); Lock readLock = lifecycleLock.readLock(); readLock.lock(); try { @@ -45,7 +53,7 @@ protected DatabaseProvider registerDatabase(String pluginName, DatabaseType data ActiveDatabaseRegistration existingRegistration = activeDatabases.get(key); if (existingRegistration != null) { ManagedDatabaseProvider existingProvider = existingRegistration.provider(); - if (isProviderHealthy(existingProvider, key) && existingRegistration.tryAcquireReference()) { + if (isProviderHealthy(existingProvider, key) && existingRegistration.tryAcquireReference(ownerScope)) { int references = existingRegistration.referenceCount(); logger.info(pluginName + " reused " + databaseType.name() + " connection (" + connectionIdentifier + "), active references=" + references); @@ -83,7 +91,10 @@ protected DatabaseProvider registerDatabase(String pluginName, DatabaseType data return null; } - ActiveDatabaseRegistration createdRegistration = new ActiveDatabaseRegistration(createdProvider); + ActiveDatabaseRegistration createdRegistration = new ActiveDatabaseRegistration( + createdProvider, + ownerScope + ); ActiveDatabaseRegistration raceWinner = activeDatabases.putIfAbsent(key, createdRegistration); if (raceWinner == null) { logger.info(pluginName + " registered " + databaseType.name() + " connection (" + connectionIdentifier @@ -98,7 +109,7 @@ protected DatabaseProvider registerDatabase(String pluginName, DatabaseType data } ManagedDatabaseProvider raceWinnerProvider = raceWinner.provider(); - if (isProviderHealthy(raceWinnerProvider, key) && raceWinner.tryAcquireReference()) { + if (isProviderHealthy(raceWinnerProvider, key) && raceWinner.tryAcquireReference(ownerScope)) { int references = raceWinner.referenceCount(); logger.info(pluginName + " already has " + databaseType.name() + " connection (" + connectionIdentifier + "), active references=" + references); @@ -143,6 +154,9 @@ private void disconnectQuietly(ManagedDatabaseProvider provider, DatabaseConnect } protected DatabaseProvider getDatabase(String pluginName, DatabaseType databaseType, String connectionIdentifier) { + Objects.requireNonNull(pluginName, "Plugin name cannot be null."); + Objects.requireNonNull(databaseType, "Database type cannot be null."); + Objects.requireNonNull(connectionIdentifier, "Connection identifier cannot be null."); Lock readLock = lifecycleLock.readLock(); readLock.lock(); try { @@ -169,7 +183,16 @@ protected DatabaseProvider getDatabase(String pluginName, DatabaseType databaseT } } - protected void unregisterDatabase(String pluginName, DatabaseType databaseType, String connectionIdentifier) { + protected void unregisterDatabase( + String pluginName, + String ownerScope, + DatabaseType databaseType, + String connectionIdentifier + ) { + Objects.requireNonNull(pluginName, "Plugin name cannot be null."); + Objects.requireNonNull(ownerScope, "Owner scope cannot be null."); + Objects.requireNonNull(databaseType, "Database type cannot be null."); + Objects.requireNonNull(connectionIdentifier, "Connection identifier cannot be null."); Lock readLock = lifecycleLock.readLock(); readLock.lock(); try { @@ -180,7 +203,13 @@ protected void unregisterDatabase(String pluginName, DatabaseType databaseType, return; } - int references = registration.releaseReference(); + ReferenceReleaseResult releaseResult = registration.releaseReference(ownerScope); + if (!releaseResult.ownerHadReference()) { + logger.warn(pluginName + " attempted to release " + databaseType.name() + " connection (" + + connectionIdentifier + ") from unregistered scope " + ownerScope); + return; + } + int references = releaseResult.totalReferences(); if (references > 0) { logger.info(pluginName + " released " + databaseType.name() + " connection (" + connectionIdentifier + "), remaining references=" + references); @@ -202,9 +231,50 @@ protected void unregisterDatabase(String pluginName, DatabaseType databaseType, } } - protected void unregisterAllDatabases(String pluginName) { - Lock readLock = lifecycleLock.readLock(); - readLock.lock(); + /** + * Releases registrations for a specific plugin + owner scope pair. + */ + protected void unregisterAllDatabases(String pluginName, String ownerScope) { + Objects.requireNonNull(pluginName, "Plugin name cannot be null."); + Objects.requireNonNull(ownerScope, "Owner scope cannot be null."); + Lock writeLock = lifecycleLock.writeLock(); + writeLock.lock(); + try { + ensureOpen(); + for (Map.Entry entry : activeDatabases.entrySet()) { + DatabaseConnectionKey key = entry.getKey(); + if (!key.pluginName().equals(pluginName)) { + continue; + } + + ActiveDatabaseRegistration registration = entry.getValue(); + int referencesAfterRelease = registration.releaseAllForOwner(ownerScope); + if (referencesAfterRelease > 0) { + continue; + } + + if (!activeDatabases.remove(key, registration)) { + continue; + } + try { + registration.provider().disconnect(); + } catch (Exception e) { + logger.error("Error disconnecting " + key, e); + } + } + } finally { + writeLock.unlock(); + } + } + + /** + * Force-releases every registration for the plugin, regardless of owner scope. + * Intended for deterministic plugin/process shutdown cleanup. + */ + protected void unregisterAllDatabasesForPlugin(String pluginName) { + Objects.requireNonNull(pluginName, "Plugin name cannot be null."); + Lock writeLock = lifecycleLock.writeLock(); + writeLock.lock(); try { ensureOpen(); for (Map.Entry entry : activeDatabases.entrySet()) { @@ -226,7 +296,7 @@ protected void unregisterAllDatabases(String pluginName) { } } } finally { - readLock.unlock(); + writeLock.unlock(); } } @@ -294,48 +364,77 @@ private void ensureOpen() { private static final class ActiveDatabaseRegistration { private final ManagedDatabaseProvider provider; - private final AtomicInteger referenceCount; + // Tracks ownership per logical scope (default plugin scope or explicit scope string). + private final Map ownerReferenceCounts = new HashMap<>(); + // Total references across all owner scopes for this (plugin, type, identifier) key. + private int referenceCount; - private ActiveDatabaseRegistration(ManagedDatabaseProvider provider) { + private ActiveDatabaseRegistration(ManagedDatabaseProvider provider, String initialOwnerScope) { this.provider = Objects.requireNonNull(provider, "Database provider cannot be null."); - this.referenceCount = new AtomicInteger(1); + if (initialOwnerScope == null || initialOwnerScope.isBlank()) { + throw new IllegalArgumentException("Initial owner scope cannot be null or blank."); + } + this.referenceCount = 1; + ownerReferenceCounts.put(initialOwnerScope, 1); } private ManagedDatabaseProvider provider() { return provider; } - private boolean tryAcquireReference() { - while (true) { - int current = referenceCount.get(); - if (current <= 0) { - return false; - } - if (referenceCount.compareAndSet(current, current + 1)) { - return true; - } + private synchronized boolean tryAcquireReference(String ownerScope) { + if (ownerScope == null || ownerScope.isBlank()) { + return false; + } + if (referenceCount <= 0) { + return false; } + referenceCount++; + ownerReferenceCounts.merge(ownerScope, 1, Integer::sum); + return true; } - private int releaseReference() { - while (true) { - int current = referenceCount.get(); - if (current <= 0) { - return 0; - } - int next = current - 1; - if (referenceCount.compareAndSet(current, next)) { - return next; - } + private synchronized ReferenceReleaseResult releaseReference(String ownerScope) { + if (ownerScope == null || ownerScope.isBlank() || referenceCount <= 0) { + return new ReferenceReleaseResult(false, Math.max(referenceCount, 0)); + } + Integer ownerCount = ownerReferenceCounts.get(ownerScope); + if (ownerCount == null || ownerCount <= 0) { + return new ReferenceReleaseResult(false, Math.max(referenceCount, 0)); } + + if (ownerCount == 1) { + ownerReferenceCounts.remove(ownerScope); + } else { + ownerReferenceCounts.put(ownerScope, ownerCount - 1); + } + + referenceCount = Math.max(0, referenceCount - 1); + return new ReferenceReleaseResult(true, referenceCount); } - private int referenceCount() { - return Math.max(referenceCount.get(), 0); + private synchronized int releaseAllForOwner(String ownerScope) { + if (ownerScope == null || ownerScope.isBlank() || referenceCount <= 0) { + return Math.max(referenceCount, 0); + } + Integer ownerCount = ownerReferenceCounts.remove(ownerScope); + if (ownerCount == null || ownerCount <= 0) { + return Math.max(referenceCount, 0); + } + referenceCount = Math.max(0, referenceCount - ownerCount); + return referenceCount; + } + + private synchronized int referenceCount() { + return Math.max(referenceCount, 0); } - private void forceReleaseAll() { - referenceCount.set(0); + private synchronized void forceReleaseAll() { + referenceCount = 0; + ownerReferenceCounts.clear(); } } + + private record ReferenceReleaseResult(boolean ownerHadReference, int totalReferences) { + } } diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/DatabaseConfigMap.java b/src/main/java/nl/hauntedmc/dataprovider/internal/DatabaseConfigMap.java index 4d5fa18..a78f87d 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/internal/DatabaseConfigMap.java +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/DatabaseConfigMap.java @@ -1,6 +1,7 @@ package nl.hauntedmc.dataprovider.internal; import nl.hauntedmc.dataprovider.database.DatabaseType; +import nl.hauntedmc.dataprovider.internal.security.FilePermissionHardening; import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; import org.spongepowered.configurate.CommentedConfigurationNode; import org.spongepowered.configurate.loader.ConfigurationLoader; @@ -34,12 +35,14 @@ protected DatabaseConfigMap(Path dataPath, ILoggerAdapter logger, ClassLoader re private void initialize() { configMap.clear(); - File databasesFolder = new File(String.valueOf(dataPath), "databases"); + Path databasesPath = dataPath.resolve("databases"); + File databasesFolder = databasesPath.toFile(); if (!databasesFolder.exists() && !databasesFolder.mkdirs()) { logger.warn("Failed to create databases folder at: " + databasesFolder.getAbsolutePath()); } else { logger.info("Databases folder located at: " + databasesFolder.getAbsolutePath()); } + FilePermissionHardening.restrictDirectoryToOwner(databasesPath, logger, "database configuration directory"); for (DatabaseType type : DatabaseType.values()) { File configFile = new File(databasesFolder, type.getConfigFileName()); @@ -51,6 +54,7 @@ private void initialize() { } if (configFile.exists()) { Path path = configFile.toPath(); + FilePermissionHardening.restrictFileToOwner(path, logger, type.name() + " database config"); ConfigurationLoader loader = YamlConfigurationLoader.builder() .path(path) .build(); @@ -71,6 +75,7 @@ private boolean copyDefaultConfigFromResources(String resourcePath, File destina return false; } Files.copy(in, destinationFile.toPath()); + FilePermissionHardening.restrictFileToOwner(destinationFile.toPath(), logger, resourcePath + " default config"); logger.info("Copied default config: " + resourcePath); return true; } catch (IOException e) { diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/concurrent/AsyncTaskSupport.java b/src/main/java/nl/hauntedmc/dataprovider/internal/concurrent/AsyncTaskSupport.java new file mode 100644 index 0000000..e9a07de --- /dev/null +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/concurrent/AsyncTaskSupport.java @@ -0,0 +1,64 @@ +package nl.hauntedmc.dataprovider.internal.concurrent; + +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; + +/** + * Shared helpers for queue-backed async execution with rejection-safe futures. + */ +public final class AsyncTaskSupport { + + private AsyncTaskSupport() { + } + + @FunctionalInterface + public interface CheckedRunnable { + void run() throws Exception; + } + + @FunctionalInterface + public interface CheckedSupplier { + T get() throws Exception; + } + + public static CompletableFuture runAsync(Executor executor, String operationName, CheckedRunnable runnable) { + Objects.requireNonNull(runnable, "Runnable cannot be null."); + return supplyAsync(executor, operationName, () -> { + runnable.run(); + return null; + }); + } + + public static CompletableFuture supplyAsync( + Executor executor, + String operationName, + CheckedSupplier supplier + ) { + Objects.requireNonNull(executor, "Executor cannot be null."); + if (operationName == null || operationName.isBlank()) { + throw new IllegalArgumentException("Operation name cannot be null or blank."); + } + Objects.requireNonNull(supplier, "Supplier cannot be null."); + + CompletableFuture future = new CompletableFuture<>(); + try { + executor.execute(() -> { + try { + future.complete(supplier.get()); + } catch (Throwable throwable) { + future.completeExceptionally(throwable); + } + }); + } catch (RejectedExecutionException e) { + future.completeExceptionally(new RejectedExecutionException( + "Rejected async operation '" + operationName + "' because the worker queue is full or shutting down.", + e + )); + } catch (RuntimeException e) { + future.completeExceptionally(e); + } + return future; + } +} diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/concurrent/BoundedExecutorFactory.java b/src/main/java/nl/hauntedmc/dataprovider/internal/concurrent/BoundedExecutorFactory.java index 7f3bee5..a7c6264 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/internal/concurrent/BoundedExecutorFactory.java +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/concurrent/BoundedExecutorFactory.java @@ -38,7 +38,7 @@ public static ExecutorService create(String threadPrefix, int poolSize, int queu TimeUnit.SECONDS, queue, threadFactory, - (task, executor) -> { + (task, rejectedFromExecutor) -> { throw new RejectedExecutionException("Task queue is full for executor '" + threadPrefix + "'."); } ); diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/identity/StackCallerClassLoaderResolver.java b/src/main/java/nl/hauntedmc/dataprovider/internal/identity/StackCallerClassLoaderResolver.java index 5c44563..2c145de 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/internal/identity/StackCallerClassLoaderResolver.java +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/identity/StackCallerClassLoaderResolver.java @@ -48,4 +48,5 @@ public static ClassLoader resolveNearestCallerOutsidePackage(String packagePrefi .findFirst() .orElse(null)); } + } diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/security/FilePermissionHardening.java b/src/main/java/nl/hauntedmc/dataprovider/internal/security/FilePermissionHardening.java new file mode 100644 index 0000000..7efc734 --- /dev/null +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/security/FilePermissionHardening.java @@ -0,0 +1,59 @@ +package nl.hauntedmc.dataprovider.internal.security; + +import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFileAttributeView; +import java.nio.file.attribute.PosixFilePermission; +import java.util.Set; + +/** + * Best-effort hardening for config files that may contain credentials. + */ +public final class FilePermissionHardening { + + private static final Set OWNER_DIRECTORY_PERMISSIONS = Set.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE + ); + private static final Set OWNER_FILE_PERMISSIONS = Set.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE + ); + + private FilePermissionHardening() { + } + + public static void restrictDirectoryToOwner(Path directory, ILoggerAdapter logger, String description) { + restrictToOwner(directory, OWNER_DIRECTORY_PERMISSIONS, logger, description); + } + + public static void restrictFileToOwner(Path file, ILoggerAdapter logger, String description) { + restrictToOwner(file, OWNER_FILE_PERMISSIONS, logger, description); + } + + private static void restrictToOwner( + Path path, + Set permissions, + ILoggerAdapter logger, + String description + ) { + if (path == null || logger == null || description == null || !Files.exists(path)) { + return; + } + + PosixFileAttributeView attributeView = Files.getFileAttributeView(path, PosixFileAttributeView.class); + if (attributeView == null) { + return; + } + + try { + Files.setPosixFilePermissions(path, permissions); + } catch (IOException e) { + logger.warn("Failed to harden file permissions for " + description + " at " + path, e); + } + } +} diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/security/FilePermissionSupport.java b/src/main/java/nl/hauntedmc/dataprovider/internal/security/FilePermissionSupport.java new file mode 100644 index 0000000..a3df998 --- /dev/null +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/security/FilePermissionSupport.java @@ -0,0 +1,59 @@ +package nl.hauntedmc.dataprovider.internal.security; + +import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFileAttributeView; +import java.nio.file.attribute.PosixFilePermission; +import java.util.Set; + +/** + * Best-effort hardening for config files that may contain credentials. + */ +public final class FilePermissionSupport { + + private static final Set OWNER_DIRECTORY_PERMISSIONS = Set.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE + ); + private static final Set OWNER_FILE_PERMISSIONS = Set.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE + ); + + private FilePermissionSupport() { + } + + public static void restrictDirectoryToOwner(Path directory, ILoggerAdapter logger, String description) { + restrictToOwner(directory, OWNER_DIRECTORY_PERMISSIONS, logger, description); + } + + public static void restrictFileToOwner(Path file, ILoggerAdapter logger, String description) { + restrictToOwner(file, OWNER_FILE_PERMISSIONS, logger, description); + } + + private static void restrictToOwner( + Path path, + Set permissions, + ILoggerAdapter logger, + String description + ) { + if (path == null || logger == null || description == null || !Files.exists(path)) { + return; + } + + PosixFileAttributeView attributeView = Files.getFileAttributeView(path, PosixFileAttributeView.class); + if (attributeView == null) { + return; + } + + try { + Files.setPosixFilePermissions(path, permissions); + } catch (IOException e) { + logger.warn("Failed to harden file permissions for " + description + " at " + path, e); + } + } +} diff --git a/src/main/resources/databases/mongodb.yml b/src/main/resources/databases/mongodb.yml index 2d761a7..f46db23 100644 --- a/src/main/resources/databases/mongodb.yml +++ b/src/main/resources/databases/mongodb.yml @@ -15,3 +15,8 @@ default: trust_store_type: "" pool_size: 8 queue_capacity: 1600 + max_connection_pool_size: 16 + min_connection_pool_size: 0 + connect_timeout_ms: 5000 + socket_timeout_ms: 5000 + server_selection_timeout_ms: 5000 diff --git a/src/main/resources/databases/mysql.yml b/src/main/resources/databases/mysql.yml index a17b64f..6a00bd1 100644 --- a/src/main/resources/databases/mysql.yml +++ b/src/main/resources/databases/mysql.yml @@ -8,4 +8,17 @@ default: require_secure_transport: false allow_public_key_retrieval: false pool_size: 10 + min_idle: 2 queue_capacity: 2000 + connection_timeout_ms: 30000 + validation_timeout_ms: 3000 + idle_timeout_ms: 600000 + max_lifetime_ms: 1800000 + leak_detection_threshold_ms: 0 + connect_timeout_ms: 10000 + socket_timeout_ms: 10000 + query_timeout_seconds: 0 + default_fetch_size: 0 + cache_prepared_statements: true + prepared_statement_cache_size: 250 + prepared_statement_cache_sql_limit: 2048 diff --git a/src/main/resources/databases/redis.yml b/src/main/resources/databases/redis.yml index fafce12..bbf1fce 100644 --- a/src/main/resources/databases/redis.yml +++ b/src/main/resources/databases/redis.yml @@ -15,4 +15,13 @@ default: pool: connections: 8 threads: 8 - queue_capacity: 1600 + queue_capacity: 1600 + min_idle: 2 + max_idle: 8 + test_on_borrow: true + test_while_idle: true + connection_timeout_ms: 2000 + socket_timeout_ms: 2000 + scan_count: 250 + security: + max_scan_results: 10000 diff --git a/src/main/resources/databases/redis_messaging.yml b/src/main/resources/databases/redis_messaging.yml index 487e92c..a37d104 100644 --- a/src/main/resources/databases/redis_messaging.yml +++ b/src/main/resources/databases/redis_messaging.yml @@ -15,7 +15,14 @@ default: pool: connections: 8 threads: 8 + min_idle: 2 + max_idle: 8 + test_on_borrow: true + test_while_idle: true queue_capacity: 1600 max_subscriptions: 64 + connection_timeout_ms: 2000 + socket_timeout_ms: 2000 security: max_payload_chars: 32768 + max_queued_messages_per_handler: 1024 diff --git a/src/test/java/nl/hauntedmc/dataprovider/api/DataProviderAPITest.java b/src/test/java/nl/hauntedmc/dataprovider/api/DataProviderAPITest.java index 3b8e6fd..13ddd8b 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/api/DataProviderAPITest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/api/DataProviderAPITest.java @@ -20,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; @@ -37,11 +38,13 @@ void constructorRejectsNullHandler() { void registerAndLookupOptionalApisHandleNullProvider() { DataProviderHandler handler = mock(DataProviderHandler.class); when(handler.registerDatabase(DatabaseType.MYSQL, "default")).thenReturn(null); + when(handler.registerDatabaseForScope("component.scope", DatabaseType.MYSQL, "default")).thenReturn(null); when(handler.getRegisteredDatabase(DatabaseType.MYSQL, "default")).thenReturn(null); DataProviderAPI api = new DataProviderAPI(handler); assertEquals(Optional.empty(), api.registerDatabaseOptional(DatabaseType.MYSQL, "default")); + assertNull(api.registerDatabaseForScope("component.scope", DatabaseType.MYSQL, "default")); assertEquals(Optional.empty(), api.getRegisteredDatabaseOptional(DatabaseType.MYSQL, "default")); } @@ -50,6 +53,7 @@ void providerCastingAndDataAccessViewsReturnExpectedOptionalResults() { DataProviderHandler handler = mock(DataProviderHandler.class); StubMessagingDatabaseProvider provider = new StubMessagingDatabaseProvider(new StubMessagingDataAccess()); when(handler.registerDatabase(DatabaseType.REDIS, "cache")).thenReturn(provider); + when(handler.registerDatabaseForScope("component.scope", DatabaseType.REDIS, "cache")).thenReturn(provider); when(handler.getRegisteredDatabase(DatabaseType.REDIS, "cache")).thenReturn(provider); DataProviderAPI api = new DataProviderAPI(handler); @@ -69,6 +73,7 @@ void providerCastingAndDataAccessViewsReturnExpectedOptionalResults() { "cache", StubMessagingDataAccess.class ); + DatabaseProvider scoped = api.registerDatabaseForScope("component.scope", DatabaseType.REDIS, "cache"); Optional lookupAccess = api.getRegisteredDataAccess( DatabaseType.REDIS, "cache", @@ -79,8 +84,10 @@ void providerCastingAndDataAccessViewsReturnExpectedOptionalResults() { assertTrue(lookupAs.isPresent()); assertTrue(registerAccess.isPresent()); assertTrue(lookupAccess.isPresent()); + assertNotNull(scoped); assertNotSame(provider, registerAs.get()); assertNotSame(provider, lookupAs.get()); + assertNotSame(provider, scoped); } @Test @@ -119,10 +126,16 @@ void unregisterOperationsDelegateToHandler() { DataProviderAPI api = new DataProviderAPI(handler); api.unregisterDatabase(DatabaseType.MYSQL, "default"); + api.unregisterDatabaseForScope("component.scope", DatabaseType.MYSQL, "default"); api.unregisterAllDatabases(); + api.unregisterAllDatabasesForScope("component.scope"); + api.unregisterAllDatabasesForPlugin(); verify(handler).unregisterDatabase(DatabaseType.MYSQL, "default"); + verify(handler).unregisterDatabaseForScope("component.scope", DatabaseType.MYSQL, "default"); verify(handler).unregisterAllDatabases(); + verify(handler).unregisterAllDatabasesForScope("component.scope"); + verify(handler).unregisterAllDatabasesForPlugin(); } @Test diff --git a/src/test/java/nl/hauntedmc/dataprovider/database/document/impl/mongodb/MongoDBDatabaseTest.java b/src/test/java/nl/hauntedmc/dataprovider/database/document/impl/mongodb/MongoDBDatabaseTest.java index 8fa548f..0935cb9 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/database/document/impl/mongodb/MongoDBDatabaseTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/database/document/impl/mongodb/MongoDBDatabaseTest.java @@ -39,4 +39,20 @@ void disconnectIsSafeWhenNeverConnected() { assertFalse(database.isConnected()); assertNull(database.getDataAccess()); } + + @Test + void connectFailsWhenInsecureTlsFlagsAreEnabled() throws Exception { + CommentedConfigurationNode config = CommentedConfigurationNode.root(); + config.node("tls", "enabled").set(true); + config.node("tls", "allow_invalid_hostnames").set(true); + + RecordingLoggerAdapter logger = new RecordingLoggerAdapter(); + MongoDBDatabase database = new MongoDBDatabase(config, logger); + database.connect(); + + assertFalse(database.isConnected()); + assertNull(database.getDataAccess()); + assertTrue(logger.errorMessages().stream().anyMatch(message -> + message.contains("Connection failed"))); + } } diff --git a/src/test/java/nl/hauntedmc/dataprovider/database/keyvalue/impl/redis/RedisDatabaseTest.java b/src/test/java/nl/hauntedmc/dataprovider/database/keyvalue/impl/redis/RedisDatabaseTest.java index 3f525c6..981005d 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/database/keyvalue/impl/redis/RedisDatabaseTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/database/keyvalue/impl/redis/RedisDatabaseTest.java @@ -39,4 +39,20 @@ void disconnectIsSafeWhenNeverConnected() { assertFalse(database.isConnected()); assertNull(database.getDataAccess()); } + + @Test + void connectFailsWhenInsecureTlsFlagsAreEnabled() throws Exception { + CommentedConfigurationNode config = CommentedConfigurationNode.root(); + config.node("tls", "enabled").set(true); + config.node("tls", "verify_hostname").set(false); + + RecordingLoggerAdapter logger = new RecordingLoggerAdapter(); + RedisDatabase database = new RedisDatabase(config, logger); + database.connect(); + + assertFalse(database.isConnected()); + assertNull(database.getDataAccess()); + assertTrue(logger.errorMessages().stream().anyMatch(message -> + message.contains("[RedisDatabase] Connection failed."))); + } } diff --git a/src/test/java/nl/hauntedmc/dataprovider/database/messaging/impl/redis/RedisMessagingDatabaseTest.java b/src/test/java/nl/hauntedmc/dataprovider/database/messaging/impl/redis/RedisMessagingDatabaseTest.java index 6349401..81cd950 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/database/messaging/impl/redis/RedisMessagingDatabaseTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/database/messaging/impl/redis/RedisMessagingDatabaseTest.java @@ -36,4 +36,14 @@ void disconnectAndStateChecksAreSafeWhenNeverConnected() { assertFalse(database.isConnected()); assertNull(database.getDataAccess()); } + + @Test + void connectRejectsInsecureTlsFlags() throws Exception { + CommentedConfigurationNode config = CommentedConfigurationNode.root(); + config.node("tls", "enabled").set(true); + config.node("tls", "trust_all_certificates").set(true); + + RedisMessagingDatabase database = new RedisMessagingDatabase(config, new RecordingLoggerAdapter()); + assertThrows(IllegalStateException.class, database::connect); + } } diff --git a/src/test/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDataAccessTest.java b/src/test/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDataAccessTest.java index 0d68f7b..630f426 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDataAccessTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDataAccessTest.java @@ -58,8 +58,8 @@ void queryForSingleMapsFirstRow() throws Exception { when(resultSet.next()).thenReturn(true); when(resultSet.getMetaData()).thenReturn(metaData); when(metaData.getColumnCount()).thenReturn(2); - when(metaData.getColumnName(1)).thenReturn("id"); - when(metaData.getColumnName(2)).thenReturn("name"); + when(metaData.getColumnLabel(1)).thenReturn("id"); + when(metaData.getColumnLabel(2)).thenReturn("name"); when(resultSet.getObject(1)).thenReturn(7); when(resultSet.getObject(2)).thenReturn("Remy"); @@ -99,8 +99,8 @@ void queryForListMapsAllRows() throws Exception { when(resultSet.next()).thenReturn(true, true, false); when(resultSet.getMetaData()).thenReturn(metaData); when(metaData.getColumnCount()).thenReturn(2); - when(metaData.getColumnName(1)).thenReturn("id"); - when(metaData.getColumnName(2)).thenReturn("name"); + when(metaData.getColumnLabel(1)).thenReturn("id"); + when(metaData.getColumnLabel(2)).thenReturn("name"); when(resultSet.getObject(1)).thenReturn(1, 2); when(resultSet.getObject(2)).thenReturn("a", "b"); diff --git a/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderHandlerTest.java b/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderHandlerTest.java index ee81f75..b710a21 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderHandlerTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderHandlerTest.java @@ -25,7 +25,9 @@ class DataProviderHandlerTest { @Test void injectedConstructorValidatesArguments() { DataProviderRegistry registry = mock(DataProviderRegistry.class); - CallerContextResolver resolver = () -> new CallerContext("plugin", getClass().getClassLoader()); + ClassLoader pluginLoader = new ClassLoader() { + }; + CallerContextResolver resolver = () -> new CallerContext("plugin", pluginLoader); RecordingLoggerAdapter logger = new RecordingLoggerAdapter(); ClassLoader ownClassLoader = getClass().getClassLoader(); @@ -38,34 +40,52 @@ void injectedConstructorValidatesArguments() { @Test void registerAndLookupDelegateUsingResolvedPluginContext() { DataProviderRegistry registry = mock(DataProviderRegistry.class); - CallerContextResolver resolver = () -> new CallerContext("feature-plugin", getClass().getClassLoader()); + ClassLoader pluginLoader = new ClassLoader() { + }; + CallerContextResolver resolver = () -> new CallerContext("feature-plugin", pluginLoader); RecordingLoggerAdapter logger = new RecordingLoggerAdapter(); DataProviderHandler handler = new DataProviderHandler(registry, resolver, logger, getClass().getClassLoader()); DatabaseProvider provider = mock(DatabaseProvider.class); - when(registry.registerDatabase("feature-plugin", DatabaseType.MYSQL, "default")).thenReturn(provider); + when(registry.registerDatabase("feature-plugin", "feature-plugin", DatabaseType.MYSQL, "default")).thenReturn(provider); + when(registry.registerDatabase("feature-plugin", "component.scope", DatabaseType.MYSQL, "default")).thenReturn(provider); when(registry.getDatabase("feature-plugin", DatabaseType.MYSQL, "default")).thenReturn(provider); assertSame(provider, handler.registerDatabase(DatabaseType.MYSQL, "default")); + assertSame(provider, handler.registerDatabaseForScope("component.scope", DatabaseType.MYSQL, "default")); assertSame(provider, handler.getRegisteredDatabase(DatabaseType.MYSQL, "default")); handler.unregisterDatabase(DatabaseType.MYSQL, "default"); + handler.unregisterDatabaseForScope("component.scope", DatabaseType.MYSQL, "default"); handler.unregisterAllDatabases(); + handler.unregisterAllDatabasesForScope("component.scope"); + handler.unregisterAllDatabasesForPlugin(); - verify(registry).registerDatabase("feature-plugin", DatabaseType.MYSQL, "default"); + verify(registry).registerDatabase("feature-plugin", "feature-plugin", DatabaseType.MYSQL, "default"); + verify(registry).registerDatabase("feature-plugin", "component.scope", DatabaseType.MYSQL, "default"); verify(registry).getDatabase("feature-plugin", DatabaseType.MYSQL, "default"); - verify(registry).unregisterDatabase("feature-plugin", DatabaseType.MYSQL, "default"); - verify(registry).unregisterAllDatabases("feature-plugin"); + verify(registry).unregisterDatabase("feature-plugin", "feature-plugin", DatabaseType.MYSQL, "default"); + verify(registry).unregisterDatabase("feature-plugin", "component.scope", DatabaseType.MYSQL, "default"); + verify(registry).unregisterAllDatabases("feature-plugin", "feature-plugin"); + verify(registry).unregisterAllDatabases("feature-plugin", "component.scope"); + verify(registry).unregisterAllDatabasesForPlugin("feature-plugin"); } @Test void validatesDatabaseTypeAndConnectionIdentifier() { DataProviderRegistry registry = mock(DataProviderRegistry.class); - CallerContextResolver resolver = () -> new CallerContext("plugin", getClass().getClassLoader()); + ClassLoader pluginLoader = new ClassLoader() { + }; + CallerContextResolver resolver = () -> new CallerContext("plugin", pluginLoader); DataProviderHandler handler = new DataProviderHandler(registry, resolver, new RecordingLoggerAdapter(), getClass().getClassLoader()); assertThrows(NullPointerException.class, () -> handler.registerDatabase(null, "default")); assertThrows(IllegalArgumentException.class, () -> handler.registerDatabase(DatabaseType.MYSQL, " ")); assertThrows(IllegalArgumentException.class, () -> handler.registerDatabase(DatabaseType.MYSQL, "bad/identifier")); + assertThrows(IllegalArgumentException.class, () -> handler.registerDatabaseForScope(" ", DatabaseType.MYSQL, "default")); + assertThrows( + IllegalArgumentException.class, + () -> handler.registerDatabaseForScope("bad scope", DatabaseType.MYSQL, "default") + ); assertThrows(NullPointerException.class, () -> handler.getRegisteredDatabase(null, "default")); assertThrows(IllegalArgumentException.class, () -> handler.getRegisteredDatabase(DatabaseType.MYSQL, " ")); } @@ -103,7 +123,9 @@ void propagatesResolverSecurityException() { @Test void privilegedOperationsRejectNonInternalCallerClassLoader() { DataProviderRegistry registry = mock(DataProviderRegistry.class); - CallerContextResolver resolver = () -> new CallerContext("plugin", getClass().getClassLoader()); + ClassLoader pluginLoader = new ClassLoader() { + }; + CallerContextResolver resolver = () -> new CallerContext("plugin", pluginLoader); RecordingLoggerAdapter logger = new RecordingLoggerAdapter(); ClassLoader mismatchingLoader = new ClassLoader() { }; @@ -117,7 +139,9 @@ void privilegedOperationsRejectNonInternalCallerClassLoader() { @Test void privilegedOperationsReturnRegistrySnapshotsForInternalCaller() { DataProviderRegistry registry = mock(DataProviderRegistry.class); - CallerContextResolver resolver = () -> new CallerContext("plugin", getClass().getClassLoader()); + ClassLoader pluginLoader = new ClassLoader() { + }; + CallerContextResolver resolver = () -> new CallerContext("plugin", pluginLoader); RecordingLoggerAdapter logger = new RecordingLoggerAdapter(); DataProviderHandler handler = new DataProviderHandler(registry, resolver, logger, getClass().getClassLoader()); @@ -138,7 +162,9 @@ void operationsFailFastWhenRegistryIsClosed() { DataProviderRegistry registry = mock(DataProviderRegistry.class); when(registry.isClosed()).thenReturn(true); - CallerContextResolver resolver = () -> new CallerContext("plugin", getClass().getClassLoader()); + ClassLoader pluginLoader = new ClassLoader() { + }; + CallerContextResolver resolver = () -> new CallerContext("plugin", pluginLoader); DataProviderHandler handler = new DataProviderHandler( registry, resolver, @@ -147,15 +173,25 @@ void operationsFailFastWhenRegistryIsClosed() { ); assertThrows(IllegalStateException.class, () -> handler.registerDatabase(DatabaseType.MYSQL, "default")); + assertThrows(IllegalStateException.class, () -> + handler.registerDatabaseForScope("component.scope", DatabaseType.MYSQL, "default")); assertThrows(IllegalStateException.class, () -> handler.getRegisteredDatabase(DatabaseType.MYSQL, "default")); assertThrows(IllegalStateException.class, () -> handler.unregisterDatabase(DatabaseType.MYSQL, "default")); + assertThrows(IllegalStateException.class, () -> + handler.unregisterDatabaseForScope("component.scope", DatabaseType.MYSQL, "default")); assertThrows(IllegalStateException.class, handler::unregisterAllDatabases); + assertThrows(IllegalStateException.class, () -> handler.unregisterAllDatabasesForScope("component.scope")); + assertThrows(IllegalStateException.class, handler::unregisterAllDatabasesForPlugin); assertThrows(IllegalStateException.class, handler::getActiveDatabases); assertThrows(IllegalStateException.class, handler::getActiveDatabaseReferenceCounts); - verify(registry, never()).registerDatabase("plugin", DatabaseType.MYSQL, "default"); + verify(registry, never()).registerDatabase("plugin", "plugin", DatabaseType.MYSQL, "default"); + verify(registry, never()).registerDatabase("plugin", "component.scope", DatabaseType.MYSQL, "default"); verify(registry, never()).getDatabase("plugin", DatabaseType.MYSQL, "default"); - verify(registry, never()).unregisterDatabase("plugin", DatabaseType.MYSQL, "default"); - verify(registry, never()).unregisterAllDatabases("plugin"); + verify(registry, never()).unregisterDatabase("plugin", "plugin", DatabaseType.MYSQL, "default"); + verify(registry, never()).unregisterDatabase("plugin", "component.scope", DatabaseType.MYSQL, "default"); + verify(registry, never()).unregisterAllDatabases("plugin", "plugin"); + verify(registry, never()).unregisterAllDatabases("plugin", "component.scope"); + verify(registry, never()).unregisterAllDatabasesForPlugin("plugin"); } } diff --git a/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistryTest.java b/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistryTest.java index 3d75134..57c7748 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistryTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistryTest.java @@ -43,7 +43,7 @@ void returnsNullWhenDatabaseTypeIsDisabled() { RecordingLoggerAdapter logger = new RecordingLoggerAdapter(); DataProviderRegistry registry = new DataProviderRegistry(factory, configHandler, logger); - DatabaseProvider provider = registry.registerDatabase("plugin", DatabaseType.MYSQL, "default"); + DatabaseProvider provider = registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); assertNull(provider); verify(configHandler).isDatabaseTypeEnabled(DatabaseType.MYSQL); @@ -61,8 +61,8 @@ void registerReusesActiveConnectionAndReferenceCountingWorks() { RecordingProvider provider = new RecordingProvider(true); when(factory.createDatabaseProvider(DatabaseType.MYSQL, "default")).thenReturn(provider); - DatabaseProvider first = registry.registerDatabase("plugin", DatabaseType.MYSQL, "default"); - DatabaseProvider second = registry.registerDatabase("plugin", DatabaseType.MYSQL, "default"); + DatabaseProvider first = registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); + DatabaseProvider second = registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); assertSame(provider, first); assertSame(provider, second); @@ -71,15 +71,87 @@ void registerReusesActiveConnectionAndReferenceCountingWorks() { DatabaseConnectionKey key = new DatabaseConnectionKey("plugin", DatabaseType.MYSQL, "default"); assertEquals(2, registry.getActiveDatabaseReferenceCounts().get(key)); - registry.unregisterDatabase("plugin", DatabaseType.MYSQL, "default"); + registry.unregisterDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); assertEquals(0, provider.disconnectCalls); assertEquals(1, registry.getActiveDatabaseReferenceCounts().get(key)); - registry.unregisterDatabase("plugin", DatabaseType.MYSQL, "default"); + registry.unregisterDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); assertEquals(1, provider.disconnectCalls); assertTrue(registry.getActiveDatabases().isEmpty()); } + @Test + void unregisterByDifferentScopeDoesNotReleaseOtherFeatureReferences() { + DatabaseFactory factory = mock(DatabaseFactory.class); + ConfigHandler configHandler = mock(ConfigHandler.class); + when(configHandler.isDatabaseTypeEnabled(DatabaseType.MYSQL)).thenReturn(true); + RecordingLoggerAdapter logger = new RecordingLoggerAdapter(); + DataProviderRegistry registry = new DataProviderRegistry(factory, configHandler, logger); + + RecordingProvider provider = new RecordingProvider(true); + when(factory.createDatabaseProvider(DatabaseType.MYSQL, "default")).thenReturn(provider); + + registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); + registry.unregisterDatabase("plugin", "feature-b", DatabaseType.MYSQL, "default"); + + DatabaseConnectionKey key = new DatabaseConnectionKey("plugin", DatabaseType.MYSQL, "default"); + assertEquals(1, registry.getActiveDatabaseReferenceCounts().get(key)); + assertEquals(0, provider.disconnectCalls); + assertTrue(logger.warnMessages().stream().anyMatch(m -> m.contains("unregistered scope"))); + } + + @Test + void unregisterAllReleasesOnlyCallerScopeReferencesWithinPlugin() { + DatabaseFactory factory = mock(DatabaseFactory.class); + ConfigHandler configHandler = mock(ConfigHandler.class); + when(configHandler.isDatabaseTypeEnabled(DatabaseType.MYSQL)).thenReturn(true); + RecordingLoggerAdapter logger = new RecordingLoggerAdapter(); + DataProviderRegistry registry = new DataProviderRegistry(factory, configHandler, logger); + + RecordingProvider provider = new RecordingProvider(true); + when(factory.createDatabaseProvider(DatabaseType.MYSQL, "default")).thenReturn(provider); + + registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); + registry.registerDatabase("plugin", "feature-b", DatabaseType.MYSQL, "default"); + + registry.unregisterAllDatabases("plugin", "feature-a"); + + DatabaseConnectionKey key = new DatabaseConnectionKey("plugin", DatabaseType.MYSQL, "default"); + assertEquals(1, registry.getActiveDatabaseReferenceCounts().get(key)); + assertEquals(0, provider.disconnectCalls); + + registry.unregisterAllDatabases("plugin", "feature-b"); + assertEquals(1, provider.disconnectCalls); + assertTrue(registry.getActiveDatabases().isEmpty()); + } + + @Test + void unregisterAllForPluginReleasesAllScopesWithinPlugin() { + DatabaseFactory factory = mock(DatabaseFactory.class); + ConfigHandler configHandler = mock(ConfigHandler.class); + when(configHandler.isDatabaseTypeEnabled(DatabaseType.MYSQL)).thenReturn(true); + RecordingLoggerAdapter logger = new RecordingLoggerAdapter(); + DataProviderRegistry registry = new DataProviderRegistry(factory, configHandler, logger); + + RecordingProvider provider = new RecordingProvider(true); + RecordingProvider otherPluginProvider = new RecordingProvider(true); + when(factory.createDatabaseProvider(DatabaseType.MYSQL, "default")).thenReturn(provider); + when(factory.createDatabaseProvider(DatabaseType.MYSQL, "analytics")).thenReturn(otherPluginProvider); + + registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); + registry.registerDatabase("plugin", "feature-b", DatabaseType.MYSQL, "default"); + registry.registerDatabase("other-plugin", "feature-x", DatabaseType.MYSQL, "analytics"); + + registry.unregisterAllDatabasesForPlugin("plugin"); + + assertEquals(1, provider.disconnectCalls); + assertEquals(0, otherPluginProvider.disconnectCalls); + assertEquals(1, registry.getActiveDatabases().size()); + assertTrue(registry.getActiveDatabases().containsKey( + new DatabaseConnectionKey("other-plugin", DatabaseType.MYSQL, "analytics") + )); + } + @Test void staleProviderIsRemovedDuringLookup() { DatabaseFactory factory = mock(DatabaseFactory.class); @@ -91,7 +163,7 @@ void staleProviderIsRemovedDuringLookup() { RecordingProvider provider = new RecordingProvider(true); when(factory.createDatabaseProvider(DatabaseType.MYSQL, "default")).thenReturn(provider); - registry.registerDatabase("plugin", DatabaseType.MYSQL, "default"); + registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); provider.connected = false; DatabaseProvider lookedUp = registry.getDatabase("plugin", DatabaseType.MYSQL, "default"); @@ -110,7 +182,7 @@ void providerHealthCheckExceptionsAreTreatedAsStaleConnections() { RecordingProvider provider = new RecordingProvider(true); when(factory.createDatabaseProvider(DatabaseType.MYSQL, "default")).thenReturn(provider); - registry.registerDatabase("plugin", DatabaseType.MYSQL, "default"); + registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); provider.healthFailure = new RuntimeException("health check failed"); assertNull(registry.getDatabase("plugin", DatabaseType.MYSQL, "default")); @@ -131,9 +203,9 @@ void staleProviderIsReplacedOnRegister() { .thenReturn(stale) .thenReturn(replacement); - registry.registerDatabase("plugin", DatabaseType.MYSQL, "default"); + registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); stale.connected = false; - DatabaseProvider result = registry.registerDatabase("plugin", DatabaseType.MYSQL, "default"); + DatabaseProvider result = registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); assertSame(replacement, result); assertEquals(1, stale.disconnectCalls); @@ -153,10 +225,10 @@ void unregisterAllDatabasesOnlyAffectsRequestedPlugin() { when(factory.createDatabaseProvider(DatabaseType.MYSQL, "a")).thenReturn(a); when(factory.createDatabaseProvider(DatabaseType.MYSQL, "b")).thenReturn(b); - registry.registerDatabase("plugin-a", DatabaseType.MYSQL, "a"); - registry.registerDatabase("plugin-b", DatabaseType.MYSQL, "b"); + registry.registerDatabase("plugin-a", "feature-a", DatabaseType.MYSQL, "a"); + registry.registerDatabase("plugin-b", "feature-b", DatabaseType.MYSQL, "b"); - registry.unregisterAllDatabases("plugin-a"); + registry.unregisterAllDatabases("plugin-a", "feature-a"); assertEquals(1, a.disconnectCalls); assertEquals(0, b.disconnectCalls); @@ -178,8 +250,8 @@ void shutdownDisconnectsAllAndClearsRegistry() { when(factory.createDatabaseProvider(DatabaseType.MYSQL, "a")).thenReturn(a); when(factory.createDatabaseProvider(DatabaseType.MYSQL, "b")).thenReturn(b); - registry.registerDatabase("plugin-a", DatabaseType.MYSQL, "a"); - registry.registerDatabase("plugin-b", DatabaseType.MYSQL, "b"); + registry.registerDatabase("plugin-a", "feature-a", DatabaseType.MYSQL, "a"); + registry.registerDatabase("plugin-b", "feature-b", DatabaseType.MYSQL, "b"); registry.shutdownAllDatabases(); @@ -201,7 +273,7 @@ void shutdownIsIdempotentAndBlocksFurtherOperations() { RecordingProvider provider = new RecordingProvider(true); when(factory.createDatabaseProvider(DatabaseType.MYSQL, "default")).thenReturn(provider); - registry.registerDatabase("plugin", DatabaseType.MYSQL, "default"); + registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); registry.shutdownAllDatabases(); registry.shutdownAllDatabases(); @@ -211,7 +283,7 @@ void shutdownIsIdempotentAndBlocksFurtherOperations() { IllegalStateException registerFailure = assertThrows( IllegalStateException.class, - () -> registry.registerDatabase("plugin", DatabaseType.MYSQL, "default") + () -> registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default") ); assertTrue(registerFailure.getMessage().contains("shut down")); assertThrows( @@ -220,9 +292,9 @@ void shutdownIsIdempotentAndBlocksFurtherOperations() { ); assertThrows( IllegalStateException.class, - () -> registry.unregisterDatabase("plugin", DatabaseType.MYSQL, "default") + () -> registry.unregisterDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default") ); - assertThrows(IllegalStateException.class, () -> registry.unregisterAllDatabases("plugin")); + assertThrows(IllegalStateException.class, () -> registry.unregisterAllDatabases("plugin", "feature-a")); assertThrows(IllegalStateException.class, registry::getActiveDatabases); assertThrows(IllegalStateException.class, registry::getActiveDatabaseReferenceCounts); } @@ -239,7 +311,7 @@ void registerReturnsNullAndCleansUpWhenConnectThrows() { provider.connectFailure = new RuntimeException("connect failed"); when(factory.createDatabaseProvider(DatabaseType.MYSQL, "default")).thenReturn(provider); - DatabaseProvider result = registry.registerDatabase("plugin", DatabaseType.MYSQL, "default"); + DatabaseProvider result = registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); assertNull(result); assertEquals(1, provider.connectCalls); @@ -256,7 +328,7 @@ void registerReturnsNullWhenFactoryReturnsNullProvider() { DataProviderRegistry registry = new DataProviderRegistry(factory, configHandler, logger); when(factory.createDatabaseProvider(DatabaseType.MYSQL, "default")).thenReturn(null); - DatabaseProvider result = registry.registerDatabase("plugin", DatabaseType.MYSQL, "default"); + DatabaseProvider result = registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); assertNull(result); assertTrue(registry.getActiveDatabases().isEmpty()); } @@ -271,7 +343,7 @@ void getActiveSnapshotsExposeCurrentRegistryState() { RecordingProvider provider = new RecordingProvider(true); when(factory.createDatabaseProvider(DatabaseType.MYSQL, "default")).thenReturn(provider); - registry.registerDatabase("plugin", DatabaseType.MYSQL, "default"); + registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); ConcurrentMap active = registry.getActiveDatabases(); Map refs = registry.getActiveDatabaseReferenceCounts(); diff --git a/src/test/java/nl/hauntedmc/dataprovider/internal/concurrent/AsyncTaskSupportTest.java b/src/test/java/nl/hauntedmc/dataprovider/internal/concurrent/AsyncTaskSupportTest.java new file mode 100644 index 0000000..8c1de0b --- /dev/null +++ b/src/test/java/nl/hauntedmc/dataprovider/internal/concurrent/AsyncTaskSupportTest.java @@ -0,0 +1,63 @@ +package nl.hauntedmc.dataprovider.internal.concurrent; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AsyncTaskSupportTest { + + @Test + void supplyAsyncRunsOnExecutorAndReturnsValue() { + Executor directExecutor = Runnable::run; + + CompletableFuture result = AsyncTaskSupport.supplyAsync( + directExecutor, + "unit.supply", + () -> 42 + ); + + assertEquals(42, result.join()); + } + + @Test + void runAsyncReturnsFailedFutureWhenExecutorRejects() { + Executor rejectingExecutor = command -> { + throw new RejectedExecutionException("full"); + }; + + CompletableFuture future = AsyncTaskSupport.runAsync( + rejectingExecutor, + "unit.reject", + () -> { + } + ); + + CompletionException ex = assertThrows(CompletionException.class, future::join); + assertTrue(ex.getCause() instanceof RejectedExecutionException); + assertTrue(ex.getCause().getMessage().contains("unit.reject")); + } + + @Test + void runAsyncPropagatesTaskFailures() { + Executor directExecutor = Runnable::run; + + CompletableFuture future = AsyncTaskSupport.runAsync( + directExecutor, + "unit.failure", + () -> { + throw new IllegalStateException("boom"); + } + ); + + CompletionException ex = assertThrows(CompletionException.class, future::join); + assertTrue(ex.getCause() instanceof IllegalStateException); + assertEquals("boom", ex.getCause().getMessage()); + } +} diff --git a/src/test/java/nl/hauntedmc/dataprovider/internal/identity/StackCallerClassLoaderResolverTest.java b/src/test/java/nl/hauntedmc/dataprovider/internal/identity/StackCallerClassLoaderResolverTest.java index fc0a919..d089b91 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/internal/identity/StackCallerClassLoaderResolverTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/internal/identity/StackCallerClassLoaderResolverTest.java @@ -35,4 +35,5 @@ void resolvesExternalCallerChainAndNearestOutsidePackage() { ClassLoader nearest = StackCallerClassLoaderResolver.resolveNearestCallerOutsidePackage("no.matching.prefix"); assertEquals(getClass().getClassLoader(), nearest); } + } From 2c1689f036934e06c0601a58bf8e6eb0423f6a71 Mon Sep 17 00:00:00 2001 From: remdui Date: Thu, 26 Mar 2026 17:09:42 +0100 Subject: [PATCH 07/17] Add proper dataprovider scope api --- docs/ARCHITECTURE.md | 2 +- docs/BEST_PRACTICES.md | 13 ++- docs/SCOPED_LIFECYCLE.md | 45 ++++++++ docs/USAGE_GUIDE.md | 13 +-- .../dataprovider/api/DataProviderAPI.java | 36 ++---- .../dataprovider/api/DataProviderScope.java | 107 ++++++++++++++++++ .../internal/DataProviderHandler.java | 10 +- .../dataprovider/api/DataProviderAPITest.java | 30 +++-- .../api/DataProviderScopeTest.java | 102 +++++++++++++++++ 9 files changed, 299 insertions(+), 59 deletions(-) create mode 100644 docs/SCOPED_LIFECYCLE.md create mode 100644 src/main/java/nl/hauntedmc/dataprovider/api/DataProviderScope.java create mode 100644 src/test/java/nl/hauntedmc/dataprovider/api/DataProviderScopeTest.java diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f7380fb..ca2a74b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -32,7 +32,7 @@ Main modules: - Per-caller ownership checks gate unregister operations. - Reference ownership is tracked by owner scope. - Default API methods use plugin-level owner scope for predictable lifecycle behavior. -- If one plugin/software process multiplexes multiple components through one wrapper class, use explicit scopes (`*ForScope` API methods) to preserve component isolation. +- If one plugin/software process multiplexes multiple components through one wrapper class, use optional scoped lifecycle facades (`DataProviderAPI.scope(...)`) to preserve component isolation. - Explicit plugin-wide cleanup is available for shutdown flows that span multiple caller scopes. - Stale/disconnected providers are evicted from registry lookup paths. - Shutdown hooks unregister or stop backend resources cleanly. diff --git a/docs/BEST_PRACTICES.md b/docs/BEST_PRACTICES.md index afe8212..32c61f5 100644 --- a/docs/BEST_PRACTICES.md +++ b/docs/BEST_PRACTICES.md @@ -11,13 +11,16 @@ - Register once during plugin/software startup. - Unregister on disable. -- If you run multiple independent components in one plugin/software process, prefer releasing only the connections each component acquired. -- Use separate connection identifiers when component lifecycle differs. -- If multiple components share one wrapper class, use explicit scopes (`registerDatabaseForScope`, `unregisterAllDatabasesForScope`) keyed by component name. -- `registerDatabase(...)` / `unregisterAllDatabases()` use a default plugin-level owner scope. -- Use `*ForScope` methods only when you intentionally need isolated ownership domains inside one plugin/software process. +- `registerDatabase(...)` / `unregisterAllDatabases()` use the default plugin-level owner scope. - For full plugin/software shutdown across multiple scopes/classes, use `unregisterAllDatabasesForPlugin()`. +## Optional Scoped Ownership + +- Use scoped ownership only when one plugin/software process has independently managed components. +- Create a scope facade from `DataProviderAPI.scope("component.name")`. +- Register and release through that scope object so ownership remains isolated. +- Keep scope naming stable and deterministic. + ## Messaging - Use one clear message class per channel contract. diff --git a/docs/SCOPED_LIFECYCLE.md b/docs/SCOPED_LIFECYCLE.md new file mode 100644 index 0000000..50cda1d --- /dev/null +++ b/docs/SCOPED_LIFECYCLE.md @@ -0,0 +1,45 @@ +# Scoped Lifecycle (Optional) + +`DataProviderScope` is an advanced ownership feature. +Use it only when one plugin/software process contains multiple independently managed components. + +## Create a Scope + +```java +DataProviderScope chatScope = api.scope("component.chat"); +``` + +Scope names must be stable, non-blank, and use safe identifier characters. + +## Register Through the Scope + +```java +Optional bus = chatScope.registerDataAccess( + DatabaseType.REDIS_MESSAGING, + "hauntedmc", + MessagingDataAccess.class +); +``` + +## Release Only That Scope + +```java +chatScope.unregisterAllDatabases(); +``` + +`DataProviderScope` is `AutoCloseable`, so it can also be used with try-with-resources: + +```java +try (DataProviderScope tempScope = api.scope("component.temp")) { + tempScope.registerDatabase(DatabaseType.MYSQL, "default"); +} +``` + +## Full Plugin/Process Shutdown + +Scope cleanup is targeted. +For deterministic full shutdown across all scopes, call: + +```java +api.unregisterAllDatabasesForPlugin(); +``` diff --git a/docs/USAGE_GUIDE.md b/docs/USAGE_GUIDE.md index f61ff58..ce21b30 100644 --- a/docs/USAGE_GUIDE.md +++ b/docs/USAGE_GUIDE.md @@ -80,9 +80,6 @@ Optional dataSource = provider.getDataSourceOptional(); ## 4. Release connections -For most integrations, use only `registerDatabase(...)` and `unregisterDatabase(...)`. -Use scoped methods only when you intentionally split ownership inside one plugin/software process. - Release a specific connection: ```java @@ -95,20 +92,14 @@ Release all connections for your default plugin/software scope: api.unregisterAllDatabases(); ``` -If one plugin/software process manages multiple independent components, -use explicit scopes so each component can unload independently: - -```java -api.registerDatabaseForScope("component.chat", DatabaseType.REDIS_MESSAGING, "hauntedmc"); -api.unregisterAllDatabasesForScope("component.chat"); -``` - For full plugin/software shutdown when registrations may come from multiple classes/scopes: ```java api.unregisterAllDatabasesForPlugin(); ``` +Optional advanced scoped ownership is documented in `docs/SCOPED_LIFECYCLE.md`. + ## 5. ORM usage For relational providers: diff --git a/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderAPI.java b/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderAPI.java index 8bd098a..99e77f3 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderAPI.java +++ b/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderAPI.java @@ -25,8 +25,9 @@ * * For most integrations, the primary lifecycle is: * register -> use provider/data access -> unregister. - * Scoped APIs are available for advanced cases where one plugin/software process - * needs isolated ownership domains for independently managed components. + * Optional scoped ownership is available through {@link #scope(String)} for advanced cases + * where one plugin/software process needs isolated ownership domains for independently + * managed components. */ public class DataProviderAPI { @@ -54,15 +55,11 @@ public DatabaseProvider registerDatabase(DatabaseType databaseType, String conne } /** - * Registers a database connection under an explicit owner scope. - * Use explicit scopes when multiple components share the same wrapper class. + * Creates an optional scoped lifecycle facade. + * Default integrations usually do not need this. */ - public DatabaseProvider registerDatabaseForScope( - String ownerScope, - DatabaseType databaseType, - String connectionIdentifier - ) { - return wrapProvider(handler.registerDatabaseForScope(ownerScope, databaseType, connectionIdentifier)); + public DataProviderScope scope(String ownerScope) { + return new DataProviderScope(handler, ownerScope); } /** @@ -107,13 +104,6 @@ public void unregisterDatabase(DatabaseType databaseType, String connectionIdent handler.unregisterDatabase(databaseType, connectionIdentifier); } - /** - * Unregisters a scoped database connection for the resolved caller plugin. - */ - public void unregisterDatabaseForScope(String ownerScope, DatabaseType databaseType, String connectionIdentifier) { - handler.unregisterDatabaseForScope(ownerScope, databaseType, connectionIdentifier); - } - /** * Unregisters all database connections for the resolved caller plugin default owner scope. */ @@ -121,14 +111,6 @@ public void unregisterAllDatabases() { handler.unregisterAllDatabases(); } - /** - * Unregisters all scoped database connections for the resolved caller plugin. - * Prefer this only when you intentionally manage isolated ownership scopes. - */ - public void unregisterAllDatabasesForScope(String ownerScope) { - handler.unregisterAllDatabasesForScope(ownerScope); - } - /** * Unregisters all database connections for the caller plugin across all caller scopes. * Use this for deterministic full-plugin shutdown cleanup. @@ -179,7 +161,7 @@ public Optional getRegisteredDataAccess( .flatMap(provider -> provider.getDataAccessOptional(expectedDataAccessType)); } - private static Optional castProvider( + static Optional castProvider( DatabaseProvider provider, Class expectedProviderType ) { @@ -190,7 +172,7 @@ private static Optional castProvider( return Optional.of(expectedProviderType.cast(provider)); } - private static DatabaseProvider wrapProvider(DatabaseProvider provider) { + static DatabaseProvider wrapProvider(DatabaseProvider provider) { if (provider == null || provider instanceof WrappedDatabaseProvider) { return provider; } diff --git a/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderScope.java b/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderScope.java new file mode 100644 index 0000000..3776a96 --- /dev/null +++ b/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderScope.java @@ -0,0 +1,107 @@ +package nl.hauntedmc.dataprovider.api; + +import nl.hauntedmc.dataprovider.database.DataAccess; +import nl.hauntedmc.dataprovider.database.DatabaseProvider; +import nl.hauntedmc.dataprovider.database.DatabaseType; +import nl.hauntedmc.dataprovider.internal.DataProviderHandler; + +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Pattern; + +/** + * Optional scoped lifecycle helper for advanced integrations that need isolated ownership domains + * inside one plugin/software process. + * + * Typical use: + * - create one scope per logical component + * - register and use connections through this scope + * - release only this scope via {@link #unregisterAllDatabases()} or {@link #close()} + */ +public final class DataProviderScope implements AutoCloseable { + + private static final Pattern OWNER_SCOPE_PATTERN = Pattern.compile("[A-Za-z0-9_.:$-]{1,256}"); + + private final DataProviderHandler handler; + private final String ownerScope; + + DataProviderScope(DataProviderHandler handler, String ownerScope) { + this.handler = Objects.requireNonNull(handler, "DataProviderHandler cannot be null."); + this.ownerScope = validateOwnerScope(ownerScope); + } + + /** + * Returns the normalized scope identifier used for ownership tracking. + */ + public String ownerScope() { + return ownerScope; + } + + /** + * Registers a database connection under this scope. + */ + public DatabaseProvider registerDatabase(DatabaseType databaseType, String connectionIdentifier) { + return DataProviderAPI.wrapProvider(handler.registerDatabaseForScope(ownerScope, databaseType, connectionIdentifier)); + } + + /** + * Registers a database connection under this scope and returns Optional. + */ + public Optional registerDatabaseOptional(DatabaseType databaseType, String connectionIdentifier) { + return Optional.ofNullable(registerDatabase(databaseType, connectionIdentifier)); + } + + /** + * Registers and casts the scoped provider to the expected type. + */ + public Optional registerDatabaseAs( + DatabaseType databaseType, + String connectionIdentifier, + Class expectedProviderType + ) { + return DataProviderAPI.castProvider(registerDatabase(databaseType, connectionIdentifier), expectedProviderType); + } + + /** + * Registers and resolves typed data access from the scoped provider. + */ + public Optional registerDataAccess( + DatabaseType databaseType, + String connectionIdentifier, + Class expectedDataAccessType + ) { + Objects.requireNonNull(expectedDataAccessType, "Expected data access type cannot be null."); + return registerDatabaseOptional(databaseType, connectionIdentifier) + .flatMap(provider -> provider.getDataAccessOptional(expectedDataAccessType)); + } + + /** + * Releases one scoped registration reference. + */ + public void unregisterDatabase(DatabaseType databaseType, String connectionIdentifier) { + handler.unregisterDatabaseForScope(ownerScope, databaseType, connectionIdentifier); + } + + /** + * Releases all registrations held by this scope. + */ + public void unregisterAllDatabases() { + handler.unregisterAllDatabasesForScope(ownerScope); + } + + @Override + public void close() { + unregisterAllDatabases(); + } + + private static String validateOwnerScope(String ownerScope) { + if (ownerScope == null || ownerScope.isBlank()) { + throw new IllegalArgumentException("Owner scope cannot be null or blank."); + } + String normalized = ownerScope.trim(); + if (!OWNER_SCOPE_PATTERN.matcher(normalized).matches()) { + throw new IllegalArgumentException("Owner scope contains unsupported characters."); + } + return normalized; + } +} diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderHandler.java b/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderHandler.java index e2a74be..43bc681 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderHandler.java +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderHandler.java @@ -82,8 +82,8 @@ public DatabaseProvider registerDatabase(DatabaseType databaseType, String conne } /** - * Registers a database connection for the resolved caller plugin under an explicit owner scope. - * Use explicit scopes when multiple components share the same wrapper class. + * Registers a database connection under an explicit owner scope. + * Used by the optional scoped API facade. */ public DatabaseProvider registerDatabaseForScope( String ownerScope, @@ -121,7 +121,8 @@ public void unregisterDatabase(DatabaseType databaseType, String connectionIdent } /** - * Unregisters a specific database connection for the resolved caller plugin under an explicit owner scope. + * Unregisters a specific database connection under an explicit owner scope. + * Used by the optional scoped API facade. */ public void unregisterDatabaseForScope( String ownerScope, @@ -151,7 +152,8 @@ public void unregisterAllDatabases() { } /** - * Unregisters all database connections for the resolved caller plugin under an explicit owner scope. + * Unregisters all database connections under an explicit owner scope. + * Used by the optional scoped API facade. */ public void unregisterAllDatabasesForScope(String ownerScope) { requireOpen(); diff --git a/src/test/java/nl/hauntedmc/dataprovider/api/DataProviderAPITest.java b/src/test/java/nl/hauntedmc/dataprovider/api/DataProviderAPITest.java index 13ddd8b..f368102 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/api/DataProviderAPITest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/api/DataProviderAPITest.java @@ -20,7 +20,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotSame; -import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; @@ -38,13 +37,11 @@ void constructorRejectsNullHandler() { void registerAndLookupOptionalApisHandleNullProvider() { DataProviderHandler handler = mock(DataProviderHandler.class); when(handler.registerDatabase(DatabaseType.MYSQL, "default")).thenReturn(null); - when(handler.registerDatabaseForScope("component.scope", DatabaseType.MYSQL, "default")).thenReturn(null); when(handler.getRegisteredDatabase(DatabaseType.MYSQL, "default")).thenReturn(null); DataProviderAPI api = new DataProviderAPI(handler); assertEquals(Optional.empty(), api.registerDatabaseOptional(DatabaseType.MYSQL, "default")); - assertNull(api.registerDatabaseForScope("component.scope", DatabaseType.MYSQL, "default")); assertEquals(Optional.empty(), api.getRegisteredDatabaseOptional(DatabaseType.MYSQL, "default")); } @@ -53,7 +50,6 @@ void providerCastingAndDataAccessViewsReturnExpectedOptionalResults() { DataProviderHandler handler = mock(DataProviderHandler.class); StubMessagingDatabaseProvider provider = new StubMessagingDatabaseProvider(new StubMessagingDataAccess()); when(handler.registerDatabase(DatabaseType.REDIS, "cache")).thenReturn(provider); - when(handler.registerDatabaseForScope("component.scope", DatabaseType.REDIS, "cache")).thenReturn(provider); when(handler.getRegisteredDatabase(DatabaseType.REDIS, "cache")).thenReturn(provider); DataProviderAPI api = new DataProviderAPI(handler); @@ -73,7 +69,6 @@ void providerCastingAndDataAccessViewsReturnExpectedOptionalResults() { "cache", StubMessagingDataAccess.class ); - DatabaseProvider scoped = api.registerDatabaseForScope("component.scope", DatabaseType.REDIS, "cache"); Optional lookupAccess = api.getRegisteredDataAccess( DatabaseType.REDIS, "cache", @@ -84,10 +79,27 @@ void providerCastingAndDataAccessViewsReturnExpectedOptionalResults() { assertTrue(lookupAs.isPresent()); assertTrue(registerAccess.isPresent()); assertTrue(lookupAccess.isPresent()); - assertNotNull(scoped); assertNotSame(provider, registerAs.get()); assertNotSame(provider, lookupAs.get()); - assertNotSame(provider, scoped); + } + + @Test + void scopeFacadeDelegatesScopedOperations() { + DataProviderHandler handler = mock(DataProviderHandler.class); + StubMessagingDatabaseProvider provider = new StubMessagingDatabaseProvider(new StubMessagingDataAccess()); + when(handler.registerDatabaseForScope("component.scope", DatabaseType.REDIS, "cache")).thenReturn(provider); + + DataProviderAPI api = new DataProviderAPI(handler); + DataProviderScope scope = api.scope("component.scope"); + + assertEquals("component.scope", scope.ownerScope()); + assertNotNull(scope.registerDatabase(DatabaseType.REDIS, "cache")); + scope.unregisterDatabase(DatabaseType.REDIS, "cache"); + scope.unregisterAllDatabases(); + + verify(handler).registerDatabaseForScope("component.scope", DatabaseType.REDIS, "cache"); + verify(handler).unregisterDatabaseForScope("component.scope", DatabaseType.REDIS, "cache"); + verify(handler).unregisterAllDatabasesForScope("component.scope"); } @Test @@ -126,15 +138,11 @@ void unregisterOperationsDelegateToHandler() { DataProviderAPI api = new DataProviderAPI(handler); api.unregisterDatabase(DatabaseType.MYSQL, "default"); - api.unregisterDatabaseForScope("component.scope", DatabaseType.MYSQL, "default"); api.unregisterAllDatabases(); - api.unregisterAllDatabasesForScope("component.scope"); api.unregisterAllDatabasesForPlugin(); verify(handler).unregisterDatabase(DatabaseType.MYSQL, "default"); - verify(handler).unregisterDatabaseForScope("component.scope", DatabaseType.MYSQL, "default"); verify(handler).unregisterAllDatabases(); - verify(handler).unregisterAllDatabasesForScope("component.scope"); verify(handler).unregisterAllDatabasesForPlugin(); } diff --git a/src/test/java/nl/hauntedmc/dataprovider/api/DataProviderScopeTest.java b/src/test/java/nl/hauntedmc/dataprovider/api/DataProviderScopeTest.java new file mode 100644 index 0000000..515afe3 --- /dev/null +++ b/src/test/java/nl/hauntedmc/dataprovider/api/DataProviderScopeTest.java @@ -0,0 +1,102 @@ +package nl.hauntedmc.dataprovider.api; + +import nl.hauntedmc.dataprovider.database.DataAccess; +import nl.hauntedmc.dataprovider.database.DatabaseProvider; +import nl.hauntedmc.dataprovider.database.DatabaseType; +import nl.hauntedmc.dataprovider.internal.DataProviderHandler; +import org.junit.jupiter.api.Test; + +import javax.sql.DataSource; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class DataProviderScopeTest { + + @Test + void validatesOwnerScopeInput() { + DataProviderHandler handler = mock(DataProviderHandler.class); + DataProviderAPI api = new DataProviderAPI(handler); + + assertThrows(IllegalArgumentException.class, () -> api.scope(null)); + assertThrows(IllegalArgumentException.class, () -> api.scope(" ")); + assertThrows(IllegalArgumentException.class, () -> api.scope("bad scope")); + } + + @Test + void typedScopeMethodsDelegateAndWrapResults() { + DataProviderHandler handler = mock(DataProviderHandler.class); + StubDatabaseProvider provider = new StubDatabaseProvider(new StubDataAccess()); + when(handler.registerDatabaseForScope("component.scope", DatabaseType.MYSQL, "default")).thenReturn(provider); + + DataProviderScope scope = new DataProviderAPI(handler).scope("component.scope"); + + Optional optional = scope.registerDatabaseOptional(DatabaseType.MYSQL, "default"); + Optional typedProvider = scope.registerDatabaseAs( + DatabaseType.MYSQL, + "default", + StubDatabaseProvider.class + ); + Optional typedDataAccess = scope.registerDataAccess( + DatabaseType.MYSQL, + "default", + StubDataAccess.class + ); + + assertTrue(optional.isPresent()); + assertFalse(typedProvider.isPresent()); + assertTrue(typedDataAccess.isPresent()); + } + + @Test + void unregisterAndCloseDelegateToScopedLifecycleMethods() { + DataProviderHandler handler = mock(DataProviderHandler.class); + DataProviderScope scope = new DataProviderAPI(handler).scope("component.scope"); + + scope.unregisterDatabase(DatabaseType.MYSQL, "default"); + scope.unregisterAllDatabases(); + scope.close(); + + verify(handler).unregisterDatabaseForScope("component.scope", DatabaseType.MYSQL, "default"); + verify(handler, times(2)).unregisterAllDatabasesForScope("component.scope"); + } + + @Test + void exposesNormalizedOwnerScope() { + DataProviderScope scope = new DataProviderAPI(mock(DataProviderHandler.class)).scope(" component.scope "); + assertEquals("component.scope", scope.ownerScope()); + } + + private static final class StubDataAccess implements DataAccess { + } + + private static final class StubDatabaseProvider implements DatabaseProvider { + private final DataAccess dataAccess; + + private StubDatabaseProvider(DataAccess dataAccess) { + this.dataAccess = dataAccess; + } + + @Override + public boolean isConnected() { + return true; + } + + @Override + public DataAccess getDataAccess() { + return dataAccess; + } + + @Override + public DataSource getDataSource() { + return null; + } + } +} From beb333bb4dc698f387667806807718c12810a3c8 Mon Sep 17 00:00:00 2001 From: remdui Date: Thu, 26 Mar 2026 23:38:24 +0100 Subject: [PATCH 08/17] introduce typed ownership identifiers and harden connection lifecycle isolation --- README.md | 2 +- docs/SCOPED_LIFECYCLE.md | 9 +- .../dataprovider/api/DataProviderAPI.java | 7 + .../dataprovider/api/DataProviderScope.java | 22 +-- .../dataprovider/api/OwnerScope.java | 33 ++++ .../impl/mongodb/MongoDBDatabase.java | 10 +- .../keyvalue/impl/redis/RedisDataAccess.java | 15 +- .../keyvalue/impl/redis/RedisDatabase.java | 8 +- .../impl/redis/RedisMessagingDatabase.java | 6 +- .../relational/impl/mysql/MySQLDatabase.java | 8 +- .../internal/ConnectionIdentifier.java | 33 ++++ .../internal/DataProviderHandler.java | 117 +++++++------ .../internal/DataProviderRegistry.java | 159 +++++++++++++----- .../internal/DatabaseConfigMap.java | 11 +- .../internal/DatabaseFactory.java | 11 +- .../dataprovider/internal/OwnerScopeId.java | 40 +++++ .../dataprovider/internal/PluginId.java | 27 +++ .../security/FilePermissionSupport.java | 59 ------- .../dataprovider/api/DataProviderAPITest.java | 11 +- .../api/DataProviderScopeTest.java | 11 +- .../internal/DataProviderHandlerTest.java | 100 ++++++++--- .../internal/DataProviderRegistryTest.java | 42 +++-- .../internal/DatabaseFactoryTest.java | 4 +- 23 files changed, 501 insertions(+), 244 deletions(-) create mode 100644 src/main/java/nl/hauntedmc/dataprovider/api/OwnerScope.java create mode 100644 src/main/java/nl/hauntedmc/dataprovider/internal/ConnectionIdentifier.java create mode 100644 src/main/java/nl/hauntedmc/dataprovider/internal/OwnerScopeId.java create mode 100644 src/main/java/nl/hauntedmc/dataprovider/internal/PluginId.java delete mode 100644 src/main/java/nl/hauntedmc/dataprovider/internal/security/FilePermissionSupport.java diff --git a/README.md b/README.md index 04be8fa..b07ef16 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![License](https://img.shields.io/github/license/HauntedMC/dataprovider)](LICENSE) [![Java 21](https://img.shields.io/badge/Java-21-007396)](https://adoptium.net/) -Build plugin features, not database plumbing. +Build plugins and services, not database plumbing. `DataProvider` is shared infrastructure for plugin developers on Velocity and Bukkit/Paper. It gives you one clean API for MySQL, MongoDB, Redis, and Redis messaging so your plugin code can stay focused on gameplay and business logic. diff --git a/docs/SCOPED_LIFECYCLE.md b/docs/SCOPED_LIFECYCLE.md index 50cda1d..e624271 100644 --- a/docs/SCOPED_LIFECYCLE.md +++ b/docs/SCOPED_LIFECYCLE.md @@ -1,6 +1,6 @@ # Scoped Lifecycle (Optional) -`DataProviderScope` is an advanced ownership feature. +`DataProviderScope` is an advanced ownership option. Use it only when one plugin/software process contains multiple independently managed components. ## Create a Scope @@ -9,6 +9,13 @@ Use it only when one plugin/software process contains multiple independently man DataProviderScope chatScope = api.scope("component.chat"); ``` +You can also use a typed scope object: + +```java +OwnerScope chatOwner = OwnerScope.of("component.chat"); +DataProviderScope chatScope = api.scope(chatOwner); +``` + Scope names must be stable, non-blank, and use safe identifier characters. ## Register Through the Scope diff --git a/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderAPI.java b/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderAPI.java index 99e77f3..2af6ffc 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderAPI.java +++ b/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderAPI.java @@ -59,6 +59,13 @@ public DatabaseProvider registerDatabase(DatabaseType databaseType, String conne * Default integrations usually do not need this. */ public DataProviderScope scope(String ownerScope) { + return scope(OwnerScope.of(ownerScope)); + } + + /** + * Creates an optional scoped lifecycle facade using a typed owner scope. + */ + public DataProviderScope scope(OwnerScope ownerScope) { return new DataProviderScope(handler, ownerScope); } diff --git a/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderScope.java b/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderScope.java index 3776a96..fb6bd05 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderScope.java +++ b/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderScope.java @@ -7,7 +7,6 @@ import java.util.Objects; import java.util.Optional; -import java.util.regex.Pattern; /** * Optional scoped lifecycle helper for advanced integrations that need isolated ownership domains @@ -20,20 +19,18 @@ */ public final class DataProviderScope implements AutoCloseable { - private static final Pattern OWNER_SCOPE_PATTERN = Pattern.compile("[A-Za-z0-9_.:$-]{1,256}"); - private final DataProviderHandler handler; - private final String ownerScope; + private final OwnerScope ownerScope; - DataProviderScope(DataProviderHandler handler, String ownerScope) { + DataProviderScope(DataProviderHandler handler, OwnerScope ownerScope) { this.handler = Objects.requireNonNull(handler, "DataProviderHandler cannot be null."); - this.ownerScope = validateOwnerScope(ownerScope); + this.ownerScope = Objects.requireNonNull(ownerScope, "Owner scope cannot be null."); } /** * Returns the normalized scope identifier used for ownership tracking. */ - public String ownerScope() { + public OwnerScope ownerScope() { return ownerScope; } @@ -93,15 +90,4 @@ public void unregisterAllDatabases() { public void close() { unregisterAllDatabases(); } - - private static String validateOwnerScope(String ownerScope) { - if (ownerScope == null || ownerScope.isBlank()) { - throw new IllegalArgumentException("Owner scope cannot be null or blank."); - } - String normalized = ownerScope.trim(); - if (!OWNER_SCOPE_PATTERN.matcher(normalized).matches()) { - throw new IllegalArgumentException("Owner scope contains unsupported characters."); - } - return normalized; - } } diff --git a/src/main/java/nl/hauntedmc/dataprovider/api/OwnerScope.java b/src/main/java/nl/hauntedmc/dataprovider/api/OwnerScope.java new file mode 100644 index 0000000..19fd6e0 --- /dev/null +++ b/src/main/java/nl/hauntedmc/dataprovider/api/OwnerScope.java @@ -0,0 +1,33 @@ +package nl.hauntedmc.dataprovider.api; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * Typed owner-scope value for optional scoped lifecycle APIs. + */ +public record OwnerScope(String value) { + + private static final Pattern SCOPE_PATTERN = Pattern.compile("[A-Za-z0-9_.:$-]{1,256}"); + + public OwnerScope { + Objects.requireNonNull(value, "Owner scope cannot be null."); + String normalized = value.trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException("Owner scope cannot be blank."); + } + if (!SCOPE_PATTERN.matcher(normalized).matches()) { + throw new IllegalArgumentException("Owner scope contains unsupported characters."); + } + value = normalized; + } + + public static OwnerScope of(String value) { + return new OwnerScope(value); + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/main/java/nl/hauntedmc/dataprovider/database/document/impl/mongodb/MongoDBDatabase.java b/src/main/java/nl/hauntedmc/dataprovider/database/document/impl/mongodb/MongoDBDatabase.java index a5bcd5b..f23487f 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/database/document/impl/mongodb/MongoDBDatabase.java +++ b/src/main/java/nl/hauntedmc/dataprovider/database/document/impl/mongodb/MongoDBDatabase.java @@ -32,11 +32,11 @@ public class MongoDBDatabase implements DocumentDatabaseProvider, ManagedDatabas private final CommentedConfigurationNode config; private final ILoggerAdapter logger; - private MongoClient mongoClient; - private ExecutorService executor; - private MongoDBDataAccess dataAccess; - private boolean connected; - private String databaseName; + private volatile MongoClient mongoClient; + private volatile ExecutorService executor; + private volatile MongoDBDataAccess dataAccess; + private volatile boolean connected; + private volatile String databaseName; public MongoDBDatabase(CommentedConfigurationNode config, ILoggerAdapter logger) { this.config = config; diff --git a/src/main/java/nl/hauntedmc/dataprovider/database/keyvalue/impl/redis/RedisDataAccess.java b/src/main/java/nl/hauntedmc/dataprovider/database/keyvalue/impl/redis/RedisDataAccess.java index cec1128..51d64e5 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/database/keyvalue/impl/redis/RedisDataAccess.java +++ b/src/main/java/nl/hauntedmc/dataprovider/database/keyvalue/impl/redis/RedisDataAccess.java @@ -201,12 +201,13 @@ public CompletableFuture hdel(String hashKey, String... fields) { if (fields == null || fields.length == 0) { return CompletableFuture.completedFuture(null); } - for (String field : fields) { + String[] safeFields = fields.clone(); + for (String field : safeFields) { requireKey(field); } return AsyncTaskSupport.runAsync(executor, "redis.hdel", () -> { try (Jedis jedis = jedisPool.getResource()) { - jedis.hdel(validatedHashKey, fields); + jedis.hdel(validatedHashKey, safeFields); } }); } @@ -217,12 +218,13 @@ public CompletableFuture sadd(String key, String... members) { if (members == null || members.length == 0) { return CompletableFuture.completedFuture(null); } - for (String member : members) { + String[] safeMembers = members.clone(); + for (String member : safeMembers) { Objects.requireNonNull(member, "Set member cannot be null."); } return AsyncTaskSupport.runAsync(executor, "redis.sadd", () -> { try (Jedis jedis = jedisPool.getResource()) { - jedis.sadd(validatedKey, members); + jedis.sadd(validatedKey, safeMembers); } }); } @@ -243,12 +245,13 @@ public CompletableFuture srem(String key, String... members) { if (members == null || members.length == 0) { return CompletableFuture.completedFuture(null); } - for (String member : members) { + String[] safeMembers = members.clone(); + for (String member : safeMembers) { Objects.requireNonNull(member, "Set member cannot be null."); } return AsyncTaskSupport.runAsync(executor, "redis.srem", () -> { try (Jedis jedis = jedisPool.getResource()) { - jedis.srem(validatedKey, members); + jedis.srem(validatedKey, safeMembers); } }); } diff --git a/src/main/java/nl/hauntedmc/dataprovider/database/keyvalue/impl/redis/RedisDatabase.java b/src/main/java/nl/hauntedmc/dataprovider/database/keyvalue/impl/redis/RedisDatabase.java index 5e5c11d..dca20b4 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/database/keyvalue/impl/redis/RedisDatabase.java +++ b/src/main/java/nl/hauntedmc/dataprovider/database/keyvalue/impl/redis/RedisDatabase.java @@ -27,10 +27,10 @@ public class RedisDatabase implements KeyValueDatabaseProvider, ManagedDatabaseP private final CommentedConfigurationNode config; private final ILoggerAdapter logger; - private JedisPool jedisPool; - private ExecutorService executor; - private RedisDataAccess dataAccess; - private boolean connected; + private volatile JedisPool jedisPool; + private volatile ExecutorService executor; + private volatile RedisDataAccess dataAccess; + private volatile boolean connected; public RedisDatabase(CommentedConfigurationNode config, ILoggerAdapter logger) { this.config = config; diff --git a/src/main/java/nl/hauntedmc/dataprovider/database/messaging/impl/redis/RedisMessagingDatabase.java b/src/main/java/nl/hauntedmc/dataprovider/database/messaging/impl/redis/RedisMessagingDatabase.java index 0628c08..69386f5 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/database/messaging/impl/redis/RedisMessagingDatabase.java +++ b/src/main/java/nl/hauntedmc/dataprovider/database/messaging/impl/redis/RedisMessagingDatabase.java @@ -26,9 +26,9 @@ public final class RedisMessagingDatabase implements MessagingDatabaseProvider, private final CommentedConfigurationNode cfg; private final ILoggerAdapter logger; private final MessageRegistry messageRegistry; - private JedisPool pool; - private ExecutorService workers; - private RedisMessagingDataAccess bus; + private volatile JedisPool pool; + private volatile ExecutorService workers; + private volatile RedisMessagingDataAccess bus; private volatile boolean connected; public RedisMessagingDatabase(CommentedConfigurationNode cfg, ILoggerAdapter logger) { diff --git a/src/main/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDatabase.java b/src/main/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDatabase.java index 4f3a2f9..3bb155c 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDatabase.java +++ b/src/main/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDatabase.java @@ -32,10 +32,10 @@ public class MySQLDatabase implements RelationalDatabaseProvider, ManagedDatabas private final CommentedConfigurationNode config; private final ILoggerAdapter logger; - private HikariDataSource dataSource; - private ExecutorService executor; - private RelationalDataAccess dataAccess; - private SchemaManager schemaManager; + private volatile HikariDataSource dataSource; + private volatile ExecutorService executor; + private volatile RelationalDataAccess dataAccess; + private volatile SchemaManager schemaManager; public MySQLDatabase(CommentedConfigurationNode config, ILoggerAdapter logger) { this.config = config; diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/ConnectionIdentifier.java b/src/main/java/nl/hauntedmc/dataprovider/internal/ConnectionIdentifier.java new file mode 100644 index 0000000..8842f4f --- /dev/null +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/ConnectionIdentifier.java @@ -0,0 +1,33 @@ +package nl.hauntedmc.dataprovider.internal; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * Internal typed representation for connection identifier keys. + */ +record ConnectionIdentifier(String value) { + + private static final Pattern IDENTIFIER_PATTERN = Pattern.compile("[A-Za-z0-9_.:-]{1,128}"); + + ConnectionIdentifier { + Objects.requireNonNull(value, "Connection identifier cannot be null."); + String normalized = value.trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException("Connection identifier cannot be blank."); + } + if (!IDENTIFIER_PATTERN.matcher(normalized).matches()) { + throw new IllegalArgumentException("Connection identifier contains unsupported characters."); + } + value = normalized; + } + + static ConnectionIdentifier of(String value) { + return new ConnectionIdentifier(value); + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderHandler.java b/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderHandler.java index 43bc681..b107e54 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderHandler.java +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderHandler.java @@ -1,5 +1,6 @@ package nl.hauntedmc.dataprovider.internal; +import nl.hauntedmc.dataprovider.api.OwnerScope; import nl.hauntedmc.dataprovider.database.DatabaseConnectionKey; import nl.hauntedmc.dataprovider.database.DatabaseProvider; import nl.hauntedmc.dataprovider.database.DatabaseType; @@ -13,7 +14,6 @@ import java.nio.file.Path; import java.util.Map; import java.util.concurrent.ConcurrentMap; -import java.util.regex.Pattern; /** * Public entry point for plugin-scoped database operations. @@ -22,9 +22,6 @@ public class DataProviderHandler { private static final String INTERNAL_PACKAGE_PREFIX = "nl.hauntedmc.dataprovider.internal"; - private static final int MAX_OWNER_SCOPE_LENGTH = 256; - private static final Pattern OWNER_SCOPE_PATTERN = Pattern.compile("[A-Za-z0-9_.:$-]{1,256}"); - private static final Pattern CONNECTION_IDENTIFIER_PATTERN = Pattern.compile("[A-Za-z0-9_.:-]{1,128}"); private static final String CLOSED_MESSAGE = "DataProvider API is no longer available. Obtain a fresh API instance after plugin enable."; @@ -71,13 +68,14 @@ public DataProviderHandler( public DatabaseProvider registerDatabase(DatabaseType databaseType, String connectionIdentifier) { requireOpen(); Objects.requireNonNull(databaseType, "Database type cannot be null"); - requireConnectionIdentifier(connectionIdentifier); CallerContext caller = resolveCallerContext(); + PluginId pluginId = PluginId.of(caller.pluginId()); + ConnectionIdentifier identifier = ConnectionIdentifier.of(connectionIdentifier); return registry.registerDatabase( - caller.pluginId(), - caller.pluginId(), + pluginId, + OwnerScopeId.of(pluginId.value()), databaseType, - connectionIdentifier + identifier ); } @@ -89,17 +87,30 @@ public DatabaseProvider registerDatabaseForScope( String ownerScope, DatabaseType databaseType, String connectionIdentifier + ) { + return registerDatabaseForScope(OwnerScope.of(ownerScope), databaseType, connectionIdentifier); + } + + /** + * Registers a database connection under a typed explicit owner scope. + * Used by the optional scoped API facade. + */ + public DatabaseProvider registerDatabaseForScope( + OwnerScope ownerScope, + DatabaseType databaseType, + String connectionIdentifier ) { requireOpen(); Objects.requireNonNull(databaseType, "Database type cannot be null"); - requireConnectionIdentifier(connectionIdentifier); - String normalizedOwnerScope = requireOwnerScope(ownerScope); + Objects.requireNonNull(ownerScope, "Owner scope cannot be null."); CallerContext caller = resolveCallerContext(); + PluginId pluginId = PluginId.of(caller.pluginId()); + ConnectionIdentifier identifier = ConnectionIdentifier.of(connectionIdentifier); return registry.registerDatabase( - caller.pluginId(), - normalizedOwnerScope, + pluginId, + OwnerScopeId.from(ownerScope), databaseType, - connectionIdentifier + identifier ); } @@ -110,13 +121,14 @@ public DatabaseProvider registerDatabaseForScope( public void unregisterDatabase(DatabaseType databaseType, String connectionIdentifier) { requireOpen(); Objects.requireNonNull(databaseType, "Database type cannot be null"); - requireConnectionIdentifier(connectionIdentifier); CallerContext caller = resolveCallerContext(); + PluginId pluginId = PluginId.of(caller.pluginId()); + ConnectionIdentifier identifier = ConnectionIdentifier.of(connectionIdentifier); registry.unregisterDatabase( - caller.pluginId(), - caller.pluginId(), + pluginId, + OwnerScopeId.of(pluginId.value()), databaseType, - connectionIdentifier + identifier ); } @@ -128,17 +140,30 @@ public void unregisterDatabaseForScope( String ownerScope, DatabaseType databaseType, String connectionIdentifier + ) { + unregisterDatabaseForScope(OwnerScope.of(ownerScope), databaseType, connectionIdentifier); + } + + /** + * Unregisters a specific database connection under a typed explicit owner scope. + * Used by the optional scoped API facade. + */ + public void unregisterDatabaseForScope( + OwnerScope ownerScope, + DatabaseType databaseType, + String connectionIdentifier ) { requireOpen(); Objects.requireNonNull(databaseType, "Database type cannot be null"); - requireConnectionIdentifier(connectionIdentifier); - String normalizedOwnerScope = requireOwnerScope(ownerScope); + Objects.requireNonNull(ownerScope, "Owner scope cannot be null."); CallerContext caller = resolveCallerContext(); + PluginId pluginId = PluginId.of(caller.pluginId()); + ConnectionIdentifier identifier = ConnectionIdentifier.of(connectionIdentifier); registry.unregisterDatabase( - caller.pluginId(), - normalizedOwnerScope, + pluginId, + OwnerScopeId.from(ownerScope), databaseType, - connectionIdentifier + identifier ); } @@ -148,7 +173,8 @@ public void unregisterDatabaseForScope( public void unregisterAllDatabases() { requireOpen(); CallerContext caller = resolveCallerContext(); - registry.unregisterAllDatabases(caller.pluginId(), caller.pluginId()); + PluginId pluginId = PluginId.of(caller.pluginId()); + registry.unregisterAllDatabases(pluginId, OwnerScopeId.of(pluginId.value())); } /** @@ -156,10 +182,19 @@ public void unregisterAllDatabases() { * Used by the optional scoped API facade. */ public void unregisterAllDatabasesForScope(String ownerScope) { + unregisterAllDatabasesForScope(OwnerScope.of(ownerScope)); + } + + /** + * Unregisters all database connections under a typed explicit owner scope. + * Used by the optional scoped API facade. + */ + public void unregisterAllDatabasesForScope(OwnerScope ownerScope) { requireOpen(); - String normalizedOwnerScope = requireOwnerScope(ownerScope); + Objects.requireNonNull(ownerScope, "Owner scope cannot be null."); CallerContext caller = resolveCallerContext(); - registry.unregisterAllDatabases(caller.pluginId(), normalizedOwnerScope); + PluginId pluginId = PluginId.of(caller.pluginId()); + registry.unregisterAllDatabases(pluginId, OwnerScopeId.from(ownerScope)); } /** @@ -169,7 +204,7 @@ public void unregisterAllDatabasesForScope(String ownerScope) { public void unregisterAllDatabasesForPlugin() { requireOpen(); CallerContext caller = resolveCallerContext(); - registry.unregisterAllDatabasesForPlugin(caller.pluginId()); + registry.unregisterAllDatabasesForPlugin(PluginId.of(caller.pluginId())); } /** @@ -186,9 +221,12 @@ public void shutdownAllDatabases() { public DatabaseProvider getRegisteredDatabase(DatabaseType databaseType, String connectionIdentifier) { requireOpen(); Objects.requireNonNull(databaseType, "Database type cannot be null"); - requireConnectionIdentifier(connectionIdentifier); CallerContext caller = resolveCallerContext(); - return registry.getDatabase(caller.pluginId(), databaseType, connectionIdentifier); + return registry.getDatabase( + PluginId.of(caller.pluginId()), + databaseType, + ConnectionIdentifier.of(connectionIdentifier) + ); } /** @@ -218,29 +256,6 @@ private CallerContext resolveCallerContext() { return caller; } - private static void requireConnectionIdentifier(String connectionIdentifier) { - if (connectionIdentifier == null || connectionIdentifier.isBlank()) { - throw new IllegalArgumentException("Connection identifier cannot be null or blank."); - } - if (!CONNECTION_IDENTIFIER_PATTERN.matcher(connectionIdentifier).matches()) { - throw new IllegalArgumentException("Connection identifier contains unsupported characters."); - } - } - - private static String requireOwnerScope(String ownerScope) { - if (ownerScope == null || ownerScope.isBlank()) { - throw new IllegalArgumentException("Owner scope cannot be null or blank."); - } - String normalized = ownerScope.trim(); - if (normalized.length() > MAX_OWNER_SCOPE_LENGTH) { - throw new IllegalArgumentException("Owner scope exceeds maximum supported length."); - } - if (!OWNER_SCOPE_PATTERN.matcher(normalized).matches()) { - throw new IllegalArgumentException("Owner scope contains unsupported characters."); - } - return normalized; - } - private void requireInternalCaller() { ClassLoader callerLoader = StackCallerClassLoaderResolver.resolveNearestCallerOutsidePackage(INTERNAL_PACKAGE_PREFIX); if (callerLoader == null || callerLoader != ownClassLoader) { diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistry.java b/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistry.java index 7a880d0..ad48348 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistry.java +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistry.java @@ -20,7 +20,14 @@ class DataProviderRegistry { private static final String SHUTDOWN_MESSAGE = "DataProvider is shut down. Obtain a fresh API instance after plugin enable."; - private final ConcurrentMap activeDatabases = new ConcurrentHashMap<>(); + /** + * Active registrations keyed by typed plugin/type/identifier identity. + */ + private final ConcurrentMap activeDatabases = new ConcurrentHashMap<>(); + /** + * Guards lifecycle transitions (shutdown / bulk unregister) while allowing concurrent + * register/get/unregister operations through the read lock. + */ private final ReadWriteLock lifecycleLock = new ReentrantReadWriteLock(true); private final DatabaseFactory factory; private final ConfigHandler configHandler; @@ -39,15 +46,31 @@ protected DatabaseProvider registerDatabase( DatabaseType databaseType, String connectionIdentifier ) { - Objects.requireNonNull(pluginName, "Plugin name cannot be null."); + return registerDatabase( + PluginId.of(pluginName), + OwnerScopeId.of(ownerScope), + databaseType, + ConnectionIdentifier.of(connectionIdentifier) + ); + } + + DatabaseProvider registerDatabase( + PluginId pluginId, + OwnerScopeId ownerScope, + DatabaseType databaseType, + ConnectionIdentifier connectionIdentifier + ) { + 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(connectionIdentifier, "Connection identifier cannot be null."); + RegistrationKey key = new RegistrationKey(pluginId, databaseType, connectionIdentifier); + String pluginName = key.pluginId().value(); + String identifierValue = key.connectionIdentifier().value(); Lock readLock = lifecycleLock.readLock(); readLock.lock(); try { ensureOpen(); - DatabaseConnectionKey key = new DatabaseConnectionKey(pluginName, databaseType, connectionIdentifier); while (true) { ActiveDatabaseRegistration existingRegistration = activeDatabases.get(key); @@ -55,7 +78,7 @@ protected DatabaseProvider registerDatabase( ManagedDatabaseProvider existingProvider = existingRegistration.provider(); if (isProviderHealthy(existingProvider, key) && existingRegistration.tryAcquireReference(ownerScope)) { int references = existingRegistration.referenceCount(); - logger.info(pluginName + " reused " + databaseType.name() + " connection (" + connectionIdentifier + logger.info(pluginName + " reused " + databaseType.name() + " connection (" + identifierValue + "), active references=" + references); return existingProvider; } @@ -64,7 +87,7 @@ protected DatabaseProvider registerDatabase( } disconnectQuietly(existingProvider, key, "stale existing connection"); logger.warn("Removed stale " + databaseType.name() + " connection for " + pluginName - + " (" + connectionIdentifier + ") before re-registering."); + + " (" + identifierValue + ") before re-registering."); } if (!configHandler.isDatabaseTypeEnabled(databaseType)) { @@ -87,7 +110,7 @@ protected DatabaseProvider registerDatabase( logger.error("Failed to clean up failed connection for " + key, e); } logger.error("Failed to establish connection for " + pluginName + " with " + databaseType.name() - + " (" + connectionIdentifier + ")"); + + " (" + identifierValue + ")"); return null; } @@ -97,7 +120,7 @@ protected DatabaseProvider registerDatabase( ); ActiveDatabaseRegistration raceWinner = activeDatabases.putIfAbsent(key, createdRegistration); if (raceWinner == null) { - logger.info(pluginName + " registered " + databaseType.name() + " connection (" + connectionIdentifier + logger.info(pluginName + " registered " + databaseType.name() + " connection (" + identifierValue + "), active references=1"); return createdProvider; } @@ -111,7 +134,7 @@ protected DatabaseProvider registerDatabase( ManagedDatabaseProvider raceWinnerProvider = raceWinner.provider(); if (isProviderHealthy(raceWinnerProvider, key) && raceWinner.tryAcquireReference(ownerScope)) { int references = raceWinner.referenceCount(); - logger.info(pluginName + " already has " + databaseType.name() + " connection (" + connectionIdentifier + logger.info(pluginName + " already has " + databaseType.name() + " connection (" + identifierValue + "), active references=" + references); return raceWinnerProvider; } @@ -136,7 +159,7 @@ protected DatabaseProvider registerDatabase( } } - private boolean isProviderHealthy(DatabaseProvider provider, DatabaseConnectionKey key) { + private boolean isProviderHealthy(DatabaseProvider provider, RegistrationKey key) { try { return provider.isConnected(); } catch (Exception e) { @@ -145,7 +168,7 @@ private boolean isProviderHealthy(DatabaseProvider provider, DatabaseConnectionK } } - private void disconnectQuietly(ManagedDatabaseProvider provider, DatabaseConnectionKey key, String reason) { + private void disconnectQuietly(ManagedDatabaseProvider provider, RegistrationKey key, String reason) { try { provider.disconnect(); } catch (Exception e) { @@ -154,14 +177,28 @@ private void disconnectQuietly(ManagedDatabaseProvider provider, DatabaseConnect } protected DatabaseProvider getDatabase(String pluginName, DatabaseType databaseType, String connectionIdentifier) { - Objects.requireNonNull(pluginName, "Plugin name cannot be null."); + return getDatabase( + PluginId.of(pluginName), + databaseType, + ConnectionIdentifier.of(connectionIdentifier) + ); + } + + DatabaseProvider getDatabase( + PluginId pluginId, + DatabaseType databaseType, + ConnectionIdentifier connectionIdentifier + ) { + Objects.requireNonNull(pluginId, "Plugin id cannot be null."); Objects.requireNonNull(databaseType, "Database type cannot be null."); Objects.requireNonNull(connectionIdentifier, "Connection identifier cannot be null."); + RegistrationKey key = new RegistrationKey(pluginId, databaseType, connectionIdentifier); + String pluginName = key.pluginId().value(); + String identifierValue = key.connectionIdentifier().value(); Lock readLock = lifecycleLock.readLock(); readLock.lock(); try { ensureOpen(); - DatabaseConnectionKey key = new DatabaseConnectionKey(pluginName, databaseType, connectionIdentifier); ActiveDatabaseRegistration registration = activeDatabases.get(key); if (registration == null) { return null; @@ -175,7 +212,7 @@ protected DatabaseProvider getDatabase(String pluginName, DatabaseType databaseT if (activeDatabases.remove(key, registration)) { disconnectQuietly(provider, key, "stale connection during lookup"); logger.warn("Removed stale " + databaseType.name() + " connection for " + pluginName - + " (" + connectionIdentifier + ") while retrieving the provider."); + + " (" + identifierValue + ") while retrieving the provider."); } return null; } finally { @@ -189,15 +226,31 @@ protected void unregisterDatabase( DatabaseType databaseType, String connectionIdentifier ) { - Objects.requireNonNull(pluginName, "Plugin name cannot be null."); + unregisterDatabase( + PluginId.of(pluginName), + OwnerScopeId.of(ownerScope), + databaseType, + ConnectionIdentifier.of(connectionIdentifier) + ); + } + + void unregisterDatabase( + PluginId pluginId, + OwnerScopeId ownerScope, + DatabaseType databaseType, + ConnectionIdentifier connectionIdentifier + ) { + 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(connectionIdentifier, "Connection identifier cannot be null."); + RegistrationKey key = new RegistrationKey(pluginId, databaseType, connectionIdentifier); + String pluginName = key.pluginId().value(); + String identifierValue = key.connectionIdentifier().value(); Lock readLock = lifecycleLock.readLock(); readLock.lock(); try { ensureOpen(); - DatabaseConnectionKey key = new DatabaseConnectionKey(pluginName, databaseType, connectionIdentifier); ActiveDatabaseRegistration registration = activeDatabases.get(key); if (registration == null) { return; @@ -206,12 +259,12 @@ protected void unregisterDatabase( ReferenceReleaseResult releaseResult = registration.releaseReference(ownerScope); if (!releaseResult.ownerHadReference()) { logger.warn(pluginName + " attempted to release " + databaseType.name() + " connection (" - + connectionIdentifier + ") from unregistered scope " + ownerScope); + + identifierValue + ") from unregistered scope " + ownerScope.value()); return; } int references = releaseResult.totalReferences(); if (references > 0) { - logger.info(pluginName + " released " + databaseType.name() + " connection (" + connectionIdentifier + logger.info(pluginName + " released " + databaseType.name() + " connection (" + identifierValue + "), remaining references=" + references); return; } @@ -225,7 +278,7 @@ protected void unregisterDatabase( } catch (Exception e) { logger.error("Error disconnecting " + key, e); } - logger.info(pluginName + " unregistered " + databaseType.name() + " connection (" + connectionIdentifier + ")"); + logger.info(pluginName + " unregistered " + databaseType.name() + " connection (" + identifierValue + ")"); } finally { readLock.unlock(); } @@ -235,15 +288,19 @@ protected void unregisterDatabase( * Releases registrations for a specific plugin + owner scope pair. */ protected void unregisterAllDatabases(String pluginName, String ownerScope) { - Objects.requireNonNull(pluginName, "Plugin name cannot be null."); + unregisterAllDatabases(PluginId.of(pluginName), OwnerScopeId.of(ownerScope)); + } + + void unregisterAllDatabases(PluginId pluginId, OwnerScopeId ownerScope) { + Objects.requireNonNull(pluginId, "Plugin id cannot be null."); Objects.requireNonNull(ownerScope, "Owner scope cannot be null."); Lock writeLock = lifecycleLock.writeLock(); writeLock.lock(); try { ensureOpen(); - for (Map.Entry entry : activeDatabases.entrySet()) { - DatabaseConnectionKey key = entry.getKey(); - if (!key.pluginName().equals(pluginName)) { + for (Map.Entry entry : activeDatabases.entrySet()) { + RegistrationKey key = entry.getKey(); + if (!key.pluginId().equals(pluginId)) { continue; } @@ -272,14 +329,18 @@ protected void unregisterAllDatabases(String pluginName, String ownerScope) { * Intended for deterministic plugin/process shutdown cleanup. */ protected void unregisterAllDatabasesForPlugin(String pluginName) { - Objects.requireNonNull(pluginName, "Plugin name cannot be null."); + unregisterAllDatabasesForPlugin(PluginId.of(pluginName)); + } + + void unregisterAllDatabasesForPlugin(PluginId pluginId) { + Objects.requireNonNull(pluginId, "Plugin id cannot be null."); Lock writeLock = lifecycleLock.writeLock(); writeLock.lock(); try { ensureOpen(); - for (Map.Entry entry : activeDatabases.entrySet()) { - DatabaseConnectionKey key = entry.getKey(); - if (!key.pluginName().equals(pluginName)) { + for (Map.Entry entry : activeDatabases.entrySet()) { + RegistrationKey key = entry.getKey(); + if (!key.pluginId().equals(pluginId)) { continue; } @@ -308,7 +369,7 @@ protected void shutdownAllDatabases() { return; } closed = true; - for (Map.Entry entry : activeDatabases.entrySet()) { + for (Map.Entry entry : activeDatabases.entrySet()) { try { entry.getValue().provider().disconnect(); } catch (Exception e) { @@ -328,8 +389,8 @@ protected ConcurrentMap getActiveDataba try { ensureOpen(); ConcurrentMap snapshot = new ConcurrentHashMap<>(); - for (Map.Entry entry : activeDatabases.entrySet()) { - snapshot.put(entry.getKey(), entry.getValue().provider()); + for (Map.Entry entry : activeDatabases.entrySet()) { + snapshot.put(entry.getKey().toExternalKey(), entry.getValue().provider()); } return snapshot; } finally { @@ -343,8 +404,8 @@ protected Map getActiveDatabaseReferenceCounts() try { ensureOpen(); Map snapshot = new HashMap<>(); - for (Map.Entry entry : activeDatabases.entrySet()) { - snapshot.put(entry.getKey(), entry.getValue().referenceCount()); + for (Map.Entry entry : activeDatabases.entrySet()) { + snapshot.put(entry.getKey().toExternalKey(), entry.getValue().referenceCount()); } return snapshot; } finally { @@ -362,18 +423,32 @@ private void ensureOpen() { } } + /** + * Internal key representation that keeps identity typed across registry operations. + * Conversion to {@link DatabaseConnectionKey} is done only for external snapshots. + */ + private record RegistrationKey(PluginId pluginId, DatabaseType type, ConnectionIdentifier connectionIdentifier) { + private RegistrationKey { + Objects.requireNonNull(pluginId, "Plugin id cannot be null."); + Objects.requireNonNull(type, "Database type cannot be null."); + Objects.requireNonNull(connectionIdentifier, "Connection identifier cannot be null."); + } + + private DatabaseConnectionKey toExternalKey() { + return new DatabaseConnectionKey(pluginId.value(), type, connectionIdentifier.value()); + } + } + private static final class ActiveDatabaseRegistration { private final ManagedDatabaseProvider provider; // Tracks ownership per logical scope (default plugin scope or explicit scope string). - private final Map ownerReferenceCounts = new HashMap<>(); + private final Map ownerReferenceCounts = new HashMap<>(); // Total references across all owner scopes for this (plugin, type, identifier) key. private int referenceCount; - private ActiveDatabaseRegistration(ManagedDatabaseProvider provider, String initialOwnerScope) { + private ActiveDatabaseRegistration(ManagedDatabaseProvider provider, OwnerScopeId initialOwnerScope) { this.provider = Objects.requireNonNull(provider, "Database provider cannot be null."); - if (initialOwnerScope == null || initialOwnerScope.isBlank()) { - throw new IllegalArgumentException("Initial owner scope cannot be null or blank."); - } + Objects.requireNonNull(initialOwnerScope, "Initial owner scope cannot be null."); this.referenceCount = 1; ownerReferenceCounts.put(initialOwnerScope, 1); } @@ -382,8 +457,8 @@ private ManagedDatabaseProvider provider() { return provider; } - private synchronized boolean tryAcquireReference(String ownerScope) { - if (ownerScope == null || ownerScope.isBlank()) { + private synchronized boolean tryAcquireReference(OwnerScopeId ownerScope) { + if (ownerScope == null) { return false; } if (referenceCount <= 0) { @@ -394,8 +469,8 @@ private synchronized boolean tryAcquireReference(String ownerScope) { return true; } - private synchronized ReferenceReleaseResult releaseReference(String ownerScope) { - if (ownerScope == null || ownerScope.isBlank() || referenceCount <= 0) { + private synchronized ReferenceReleaseResult releaseReference(OwnerScopeId ownerScope) { + if (ownerScope == null || referenceCount <= 0) { return new ReferenceReleaseResult(false, Math.max(referenceCount, 0)); } Integer ownerCount = ownerReferenceCounts.get(ownerScope); @@ -413,8 +488,8 @@ private synchronized ReferenceReleaseResult releaseReference(String ownerScope) return new ReferenceReleaseResult(true, referenceCount); } - private synchronized int releaseAllForOwner(String ownerScope) { - if (ownerScope == null || ownerScope.isBlank() || referenceCount <= 0) { + private synchronized int releaseAllForOwner(OwnerScopeId ownerScope) { + if (ownerScope == null || referenceCount <= 0) { return Math.max(referenceCount, 0); } Integer ownerCount = ownerReferenceCounts.remove(ownerScope); diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/DatabaseConfigMap.java b/src/main/java/nl/hauntedmc/dataprovider/internal/DatabaseConfigMap.java index a78f87d..4c0ac65 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/internal/DatabaseConfigMap.java +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/DatabaseConfigMap.java @@ -102,15 +102,22 @@ private InputStream openResource(String resourcePath) throws IOException { * @return the corresponding CommentedConfigurationNode, or null if not found. */ protected CommentedConfigurationNode getConfig(DatabaseType type, String connectionIdentifier) { + return getConfig(type, ConnectionIdentifier.of(connectionIdentifier)); + } + + protected CommentedConfigurationNode getConfig(DatabaseType type, ConnectionIdentifier connectionIdentifier) { + Objects.requireNonNull(type, "Database type cannot be null."); + Objects.requireNonNull(connectionIdentifier, "Connection identifier cannot be null."); CommentedConfigurationNode config = configMap.get(type); if (config == null) { logger.warn("No configuration loaded for database type " + type.name()); return null; } - CommentedConfigurationNode section = config.node(connectionIdentifier); + String identifierValue = connectionIdentifier.value(); + CommentedConfigurationNode section = config.node(identifierValue); if (section.virtual()) { - logger.warn("No configuration section found for '" + connectionIdentifier + "' in " + logger.warn("No configuration section found for '" + identifierValue + "' in " + type.getConfigFileName() + ". Available sections: " + describeAvailableSections(config)); return null; } diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/DatabaseFactory.java b/src/main/java/nl/hauntedmc/dataprovider/internal/DatabaseFactory.java index 4672ed0..8500186 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/internal/DatabaseFactory.java +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/DatabaseFactory.java @@ -21,9 +21,18 @@ protected DatabaseFactory(DatabaseConfigMap configMap, ILoggerAdapter logger) { } protected ManagedDatabaseProvider createDatabaseProvider(DatabaseType type, String connectionIdentifier) { + return createDatabaseProvider(type, ConnectionIdentifier.of(connectionIdentifier)); + } + + protected ManagedDatabaseProvider createDatabaseProvider( + DatabaseType type, + ConnectionIdentifier connectionIdentifier + ) { + Objects.requireNonNull(type, "Database type cannot be null."); + Objects.requireNonNull(connectionIdentifier, "Connection identifier cannot be null."); CommentedConfigurationNode connectionConfig = configMap.getConfig(type, connectionIdentifier); if (connectionConfig == null) { - logger.error("Could not load configuration for " + connectionIdentifier + " (" + type.name() + ")"); + logger.error("Could not load configuration for " + connectionIdentifier.value() + " (" + type.name() + ")"); return null; } return switch (type) { diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/OwnerScopeId.java b/src/main/java/nl/hauntedmc/dataprovider/internal/OwnerScopeId.java new file mode 100644 index 0000000..74efcf0 --- /dev/null +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/OwnerScopeId.java @@ -0,0 +1,40 @@ +package nl.hauntedmc.dataprovider.internal; + +import nl.hauntedmc.dataprovider.api.OwnerScope; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * Internal typed representation for ownership scope keys. + */ +record OwnerScopeId(String value) { + + private static final Pattern SCOPE_PATTERN = Pattern.compile("[A-Za-z0-9_.:$-]{1,256}"); + + OwnerScopeId { + Objects.requireNonNull(value, "Owner scope cannot be null."); + String normalized = value.trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException("Owner scope cannot be blank."); + } + if (!SCOPE_PATTERN.matcher(normalized).matches()) { + throw new IllegalArgumentException("Owner scope contains unsupported characters."); + } + value = normalized; + } + + static OwnerScopeId of(String value) { + return new OwnerScopeId(value); + } + + static OwnerScopeId from(OwnerScope ownerScope) { + Objects.requireNonNull(ownerScope, "Owner scope cannot be null."); + return new OwnerScopeId(ownerScope.value()); + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/PluginId.java b/src/main/java/nl/hauntedmc/dataprovider/internal/PluginId.java new file mode 100644 index 0000000..75aa577 --- /dev/null +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/PluginId.java @@ -0,0 +1,27 @@ +package nl.hauntedmc.dataprovider.internal; + +import java.util.Objects; + +/** + * Internal typed representation for resolved plugin identity. + */ +record PluginId(String value) { + + PluginId { + Objects.requireNonNull(value, "Plugin id cannot be null."); + String normalized = value.trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException("Plugin id cannot be blank."); + } + value = normalized; + } + + static PluginId of(String value) { + return new PluginId(value); + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/security/FilePermissionSupport.java b/src/main/java/nl/hauntedmc/dataprovider/internal/security/FilePermissionSupport.java deleted file mode 100644 index a3df998..0000000 --- a/src/main/java/nl/hauntedmc/dataprovider/internal/security/FilePermissionSupport.java +++ /dev/null @@ -1,59 +0,0 @@ -package nl.hauntedmc.dataprovider.internal.security; - -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.attribute.PosixFileAttributeView; -import java.nio.file.attribute.PosixFilePermission; -import java.util.Set; - -/** - * Best-effort hardening for config files that may contain credentials. - */ -public final class FilePermissionSupport { - - private static final Set OWNER_DIRECTORY_PERMISSIONS = Set.of( - PosixFilePermission.OWNER_READ, - PosixFilePermission.OWNER_WRITE, - PosixFilePermission.OWNER_EXECUTE - ); - private static final Set OWNER_FILE_PERMISSIONS = Set.of( - PosixFilePermission.OWNER_READ, - PosixFilePermission.OWNER_WRITE - ); - - private FilePermissionSupport() { - } - - public static void restrictDirectoryToOwner(Path directory, ILoggerAdapter logger, String description) { - restrictToOwner(directory, OWNER_DIRECTORY_PERMISSIONS, logger, description); - } - - public static void restrictFileToOwner(Path file, ILoggerAdapter logger, String description) { - restrictToOwner(file, OWNER_FILE_PERMISSIONS, logger, description); - } - - private static void restrictToOwner( - Path path, - Set permissions, - ILoggerAdapter logger, - String description - ) { - if (path == null || logger == null || description == null || !Files.exists(path)) { - return; - } - - PosixFileAttributeView attributeView = Files.getFileAttributeView(path, PosixFileAttributeView.class); - if (attributeView == null) { - return; - } - - try { - Files.setPosixFilePermissions(path, permissions); - } catch (IOException e) { - logger.warn("Failed to harden file permissions for " + description + " at " + path, e); - } - } -} diff --git a/src/test/java/nl/hauntedmc/dataprovider/api/DataProviderAPITest.java b/src/test/java/nl/hauntedmc/dataprovider/api/DataProviderAPITest.java index f368102..73eacdc 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/api/DataProviderAPITest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/api/DataProviderAPITest.java @@ -87,19 +87,20 @@ void providerCastingAndDataAccessViewsReturnExpectedOptionalResults() { void scopeFacadeDelegatesScopedOperations() { DataProviderHandler handler = mock(DataProviderHandler.class); StubMessagingDatabaseProvider provider = new StubMessagingDatabaseProvider(new StubMessagingDataAccess()); - when(handler.registerDatabaseForScope("component.scope", DatabaseType.REDIS, "cache")).thenReturn(provider); + when(handler.registerDatabaseForScope(OwnerScope.of("component.scope"), DatabaseType.REDIS, "cache")) + .thenReturn(provider); DataProviderAPI api = new DataProviderAPI(handler); DataProviderScope scope = api.scope("component.scope"); - assertEquals("component.scope", scope.ownerScope()); + assertEquals(OwnerScope.of("component.scope"), scope.ownerScope()); assertNotNull(scope.registerDatabase(DatabaseType.REDIS, "cache")); scope.unregisterDatabase(DatabaseType.REDIS, "cache"); scope.unregisterAllDatabases(); - verify(handler).registerDatabaseForScope("component.scope", DatabaseType.REDIS, "cache"); - verify(handler).unregisterDatabaseForScope("component.scope", DatabaseType.REDIS, "cache"); - verify(handler).unregisterAllDatabasesForScope("component.scope"); + verify(handler).registerDatabaseForScope(OwnerScope.of("component.scope"), DatabaseType.REDIS, "cache"); + verify(handler).unregisterDatabaseForScope(OwnerScope.of("component.scope"), DatabaseType.REDIS, "cache"); + verify(handler).unregisterAllDatabasesForScope(OwnerScope.of("component.scope")); } @Test diff --git a/src/test/java/nl/hauntedmc/dataprovider/api/DataProviderScopeTest.java b/src/test/java/nl/hauntedmc/dataprovider/api/DataProviderScopeTest.java index 515afe3..a351e98 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/api/DataProviderScopeTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/api/DataProviderScopeTest.java @@ -25,7 +25,7 @@ void validatesOwnerScopeInput() { DataProviderHandler handler = mock(DataProviderHandler.class); DataProviderAPI api = new DataProviderAPI(handler); - assertThrows(IllegalArgumentException.class, () -> api.scope(null)); + assertThrows(NullPointerException.class, () -> api.scope((String) null)); assertThrows(IllegalArgumentException.class, () -> api.scope(" ")); assertThrows(IllegalArgumentException.class, () -> api.scope("bad scope")); } @@ -34,7 +34,8 @@ void validatesOwnerScopeInput() { void typedScopeMethodsDelegateAndWrapResults() { DataProviderHandler handler = mock(DataProviderHandler.class); StubDatabaseProvider provider = new StubDatabaseProvider(new StubDataAccess()); - when(handler.registerDatabaseForScope("component.scope", DatabaseType.MYSQL, "default")).thenReturn(provider); + when(handler.registerDatabaseForScope(OwnerScope.of("component.scope"), DatabaseType.MYSQL, "default")) + .thenReturn(provider); DataProviderScope scope = new DataProviderAPI(handler).scope("component.scope"); @@ -64,14 +65,14 @@ void unregisterAndCloseDelegateToScopedLifecycleMethods() { scope.unregisterAllDatabases(); scope.close(); - verify(handler).unregisterDatabaseForScope("component.scope", DatabaseType.MYSQL, "default"); - verify(handler, times(2)).unregisterAllDatabasesForScope("component.scope"); + verify(handler).unregisterDatabaseForScope(OwnerScope.of("component.scope"), DatabaseType.MYSQL, "default"); + verify(handler, times(2)).unregisterAllDatabasesForScope(OwnerScope.of("component.scope")); } @Test void exposesNormalizedOwnerScope() { DataProviderScope scope = new DataProviderAPI(mock(DataProviderHandler.class)).scope(" component.scope "); - assertEquals("component.scope", scope.ownerScope()); + assertEquals(OwnerScope.of("component.scope"), scope.ownerScope()); } private static final class StubDataAccess implements DataAccess { diff --git a/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderHandlerTest.java b/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderHandlerTest.java index b710a21..21c773c 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderHandlerTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderHandlerTest.java @@ -47,9 +47,23 @@ void registerAndLookupDelegateUsingResolvedPluginContext() { DataProviderHandler handler = new DataProviderHandler(registry, resolver, logger, getClass().getClassLoader()); DatabaseProvider provider = mock(DatabaseProvider.class); - when(registry.registerDatabase("feature-plugin", "feature-plugin", DatabaseType.MYSQL, "default")).thenReturn(provider); - when(registry.registerDatabase("feature-plugin", "component.scope", DatabaseType.MYSQL, "default")).thenReturn(provider); - when(registry.getDatabase("feature-plugin", DatabaseType.MYSQL, "default")).thenReturn(provider); + when(registry.registerDatabase( + PluginId.of("feature-plugin"), + OwnerScopeId.of("feature-plugin"), + DatabaseType.MYSQL, + ConnectionIdentifier.of("default") + )).thenReturn(provider); + when(registry.registerDatabase( + PluginId.of("feature-plugin"), + OwnerScopeId.of("component.scope"), + DatabaseType.MYSQL, + ConnectionIdentifier.of("default") + )).thenReturn(provider); + when(registry.getDatabase( + PluginId.of("feature-plugin"), + DatabaseType.MYSQL, + ConnectionIdentifier.of("default") + )).thenReturn(provider); assertSame(provider, handler.registerDatabase(DatabaseType.MYSQL, "default")); assertSame(provider, handler.registerDatabaseForScope("component.scope", DatabaseType.MYSQL, "default")); @@ -60,14 +74,38 @@ void registerAndLookupDelegateUsingResolvedPluginContext() { handler.unregisterAllDatabasesForScope("component.scope"); handler.unregisterAllDatabasesForPlugin(); - verify(registry).registerDatabase("feature-plugin", "feature-plugin", DatabaseType.MYSQL, "default"); - verify(registry).registerDatabase("feature-plugin", "component.scope", DatabaseType.MYSQL, "default"); - verify(registry).getDatabase("feature-plugin", DatabaseType.MYSQL, "default"); - verify(registry).unregisterDatabase("feature-plugin", "feature-plugin", DatabaseType.MYSQL, "default"); - verify(registry).unregisterDatabase("feature-plugin", "component.scope", DatabaseType.MYSQL, "default"); - verify(registry).unregisterAllDatabases("feature-plugin", "feature-plugin"); - verify(registry).unregisterAllDatabases("feature-plugin", "component.scope"); - verify(registry).unregisterAllDatabasesForPlugin("feature-plugin"); + verify(registry).registerDatabase( + PluginId.of("feature-plugin"), + OwnerScopeId.of("feature-plugin"), + DatabaseType.MYSQL, + ConnectionIdentifier.of("default") + ); + verify(registry).registerDatabase( + PluginId.of("feature-plugin"), + OwnerScopeId.of("component.scope"), + DatabaseType.MYSQL, + ConnectionIdentifier.of("default") + ); + verify(registry).getDatabase( + PluginId.of("feature-plugin"), + DatabaseType.MYSQL, + ConnectionIdentifier.of("default") + ); + verify(registry).unregisterDatabase( + PluginId.of("feature-plugin"), + OwnerScopeId.of("feature-plugin"), + DatabaseType.MYSQL, + ConnectionIdentifier.of("default") + ); + verify(registry).unregisterDatabase( + PluginId.of("feature-plugin"), + OwnerScopeId.of("component.scope"), + DatabaseType.MYSQL, + ConnectionIdentifier.of("default") + ); + verify(registry).unregisterAllDatabases(PluginId.of("feature-plugin"), OwnerScopeId.of("feature-plugin")); + verify(registry).unregisterAllDatabases(PluginId.of("feature-plugin"), OwnerScopeId.of("component.scope")); + verify(registry).unregisterAllDatabasesForPlugin(PluginId.of("feature-plugin")); } @Test @@ -185,13 +223,37 @@ void operationsFailFastWhenRegistryIsClosed() { assertThrows(IllegalStateException.class, handler::getActiveDatabases); assertThrows(IllegalStateException.class, handler::getActiveDatabaseReferenceCounts); - verify(registry, never()).registerDatabase("plugin", "plugin", DatabaseType.MYSQL, "default"); - verify(registry, never()).registerDatabase("plugin", "component.scope", DatabaseType.MYSQL, "default"); - verify(registry, never()).getDatabase("plugin", DatabaseType.MYSQL, "default"); - verify(registry, never()).unregisterDatabase("plugin", "plugin", DatabaseType.MYSQL, "default"); - verify(registry, never()).unregisterDatabase("plugin", "component.scope", DatabaseType.MYSQL, "default"); - verify(registry, never()).unregisterAllDatabases("plugin", "plugin"); - verify(registry, never()).unregisterAllDatabases("plugin", "component.scope"); - verify(registry, never()).unregisterAllDatabasesForPlugin("plugin"); + verify(registry, never()).registerDatabase( + PluginId.of("plugin"), + OwnerScopeId.of("plugin"), + DatabaseType.MYSQL, + ConnectionIdentifier.of("default") + ); + verify(registry, never()).registerDatabase( + PluginId.of("plugin"), + OwnerScopeId.of("component.scope"), + DatabaseType.MYSQL, + ConnectionIdentifier.of("default") + ); + verify(registry, never()).getDatabase( + PluginId.of("plugin"), + DatabaseType.MYSQL, + ConnectionIdentifier.of("default") + ); + verify(registry, never()).unregisterDatabase( + PluginId.of("plugin"), + OwnerScopeId.of("plugin"), + DatabaseType.MYSQL, + ConnectionIdentifier.of("default") + ); + verify(registry, never()).unregisterDatabase( + PluginId.of("plugin"), + OwnerScopeId.of("component.scope"), + DatabaseType.MYSQL, + ConnectionIdentifier.of("default") + ); + verify(registry, never()).unregisterAllDatabases(PluginId.of("plugin"), OwnerScopeId.of("plugin")); + verify(registry, never()).unregisterAllDatabases(PluginId.of("plugin"), OwnerScopeId.of("component.scope")); + verify(registry, never()).unregisterAllDatabasesForPlugin(PluginId.of("plugin")); } } diff --git a/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistryTest.java b/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistryTest.java index 57c7748..864488b 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistryTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistryTest.java @@ -59,7 +59,8 @@ void registerReusesActiveConnectionAndReferenceCountingWorks() { DataProviderRegistry registry = new DataProviderRegistry(factory, configHandler, logger); RecordingProvider provider = new RecordingProvider(true); - when(factory.createDatabaseProvider(DatabaseType.MYSQL, "default")).thenReturn(provider); + when(factory.createDatabaseProvider(DatabaseType.MYSQL, ConnectionIdentifier.of("default"))) + .thenReturn(provider); DatabaseProvider first = registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); DatabaseProvider second = registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); @@ -89,7 +90,8 @@ void unregisterByDifferentScopeDoesNotReleaseOtherFeatureReferences() { DataProviderRegistry registry = new DataProviderRegistry(factory, configHandler, logger); RecordingProvider provider = new RecordingProvider(true); - when(factory.createDatabaseProvider(DatabaseType.MYSQL, "default")).thenReturn(provider); + when(factory.createDatabaseProvider(DatabaseType.MYSQL, ConnectionIdentifier.of("default"))) + .thenReturn(provider); registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); registry.unregisterDatabase("plugin", "feature-b", DatabaseType.MYSQL, "default"); @@ -109,7 +111,8 @@ void unregisterAllReleasesOnlyCallerScopeReferencesWithinPlugin() { DataProviderRegistry registry = new DataProviderRegistry(factory, configHandler, logger); RecordingProvider provider = new RecordingProvider(true); - when(factory.createDatabaseProvider(DatabaseType.MYSQL, "default")).thenReturn(provider); + when(factory.createDatabaseProvider(DatabaseType.MYSQL, ConnectionIdentifier.of("default"))) + .thenReturn(provider); registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); registry.registerDatabase("plugin", "feature-b", DatabaseType.MYSQL, "default"); @@ -135,8 +138,10 @@ void unregisterAllForPluginReleasesAllScopesWithinPlugin() { RecordingProvider provider = new RecordingProvider(true); RecordingProvider otherPluginProvider = new RecordingProvider(true); - when(factory.createDatabaseProvider(DatabaseType.MYSQL, "default")).thenReturn(provider); - when(factory.createDatabaseProvider(DatabaseType.MYSQL, "analytics")).thenReturn(otherPluginProvider); + when(factory.createDatabaseProvider(DatabaseType.MYSQL, ConnectionIdentifier.of("default"))) + .thenReturn(provider); + when(factory.createDatabaseProvider(DatabaseType.MYSQL, ConnectionIdentifier.of("analytics"))) + .thenReturn(otherPluginProvider); registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); registry.registerDatabase("plugin", "feature-b", DatabaseType.MYSQL, "default"); @@ -161,7 +166,8 @@ void staleProviderIsRemovedDuringLookup() { DataProviderRegistry registry = new DataProviderRegistry(factory, configHandler, logger); RecordingProvider provider = new RecordingProvider(true); - when(factory.createDatabaseProvider(DatabaseType.MYSQL, "default")).thenReturn(provider); + when(factory.createDatabaseProvider(DatabaseType.MYSQL, ConnectionIdentifier.of("default"))) + .thenReturn(provider); registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); provider.connected = false; @@ -181,7 +187,8 @@ void providerHealthCheckExceptionsAreTreatedAsStaleConnections() { DataProviderRegistry registry = new DataProviderRegistry(factory, configHandler, logger); RecordingProvider provider = new RecordingProvider(true); - when(factory.createDatabaseProvider(DatabaseType.MYSQL, "default")).thenReturn(provider); + when(factory.createDatabaseProvider(DatabaseType.MYSQL, ConnectionIdentifier.of("default"))) + .thenReturn(provider); registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); provider.healthFailure = new RuntimeException("health check failed"); @@ -199,7 +206,7 @@ void staleProviderIsReplacedOnRegister() { RecordingProvider stale = new RecordingProvider(true); RecordingProvider replacement = new RecordingProvider(true); - when(factory.createDatabaseProvider(DatabaseType.MYSQL, "default")) + when(factory.createDatabaseProvider(DatabaseType.MYSQL, ConnectionIdentifier.of("default"))) .thenReturn(stale) .thenReturn(replacement); @@ -222,8 +229,8 @@ void unregisterAllDatabasesOnlyAffectsRequestedPlugin() { RecordingProvider a = new RecordingProvider(true); RecordingProvider b = new RecordingProvider(true); - when(factory.createDatabaseProvider(DatabaseType.MYSQL, "a")).thenReturn(a); - when(factory.createDatabaseProvider(DatabaseType.MYSQL, "b")).thenReturn(b); + when(factory.createDatabaseProvider(DatabaseType.MYSQL, ConnectionIdentifier.of("a"))).thenReturn(a); + when(factory.createDatabaseProvider(DatabaseType.MYSQL, ConnectionIdentifier.of("b"))).thenReturn(b); registry.registerDatabase("plugin-a", "feature-a", DatabaseType.MYSQL, "a"); registry.registerDatabase("plugin-b", "feature-b", DatabaseType.MYSQL, "b"); @@ -247,8 +254,8 @@ void shutdownDisconnectsAllAndClearsRegistry() { RecordingProvider a = new RecordingProvider(true); RecordingProvider b = new RecordingProvider(true); - when(factory.createDatabaseProvider(DatabaseType.MYSQL, "a")).thenReturn(a); - when(factory.createDatabaseProvider(DatabaseType.MYSQL, "b")).thenReturn(b); + when(factory.createDatabaseProvider(DatabaseType.MYSQL, ConnectionIdentifier.of("a"))).thenReturn(a); + when(factory.createDatabaseProvider(DatabaseType.MYSQL, ConnectionIdentifier.of("b"))).thenReturn(b); registry.registerDatabase("plugin-a", "feature-a", DatabaseType.MYSQL, "a"); registry.registerDatabase("plugin-b", "feature-b", DatabaseType.MYSQL, "b"); @@ -272,7 +279,8 @@ void shutdownIsIdempotentAndBlocksFurtherOperations() { DataProviderRegistry registry = new DataProviderRegistry(factory, configHandler, logger); RecordingProvider provider = new RecordingProvider(true); - when(factory.createDatabaseProvider(DatabaseType.MYSQL, "default")).thenReturn(provider); + when(factory.createDatabaseProvider(DatabaseType.MYSQL, ConnectionIdentifier.of("default"))) + .thenReturn(provider); registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); registry.shutdownAllDatabases(); @@ -309,7 +317,8 @@ void registerReturnsNullAndCleansUpWhenConnectThrows() { RecordingProvider provider = new RecordingProvider(true); provider.connectFailure = new RuntimeException("connect failed"); - when(factory.createDatabaseProvider(DatabaseType.MYSQL, "default")).thenReturn(provider); + when(factory.createDatabaseProvider(DatabaseType.MYSQL, ConnectionIdentifier.of("default"))) + .thenReturn(provider); DatabaseProvider result = registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); @@ -326,7 +335,7 @@ void registerReturnsNullWhenFactoryReturnsNullProvider() { when(configHandler.isDatabaseTypeEnabled(DatabaseType.MYSQL)).thenReturn(true); RecordingLoggerAdapter logger = new RecordingLoggerAdapter(); DataProviderRegistry registry = new DataProviderRegistry(factory, configHandler, logger); - when(factory.createDatabaseProvider(DatabaseType.MYSQL, "default")).thenReturn(null); + when(factory.createDatabaseProvider(DatabaseType.MYSQL, ConnectionIdentifier.of("default"))).thenReturn(null); DatabaseProvider result = registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); assertNull(result); @@ -342,7 +351,8 @@ void getActiveSnapshotsExposeCurrentRegistryState() { DataProviderRegistry registry = new DataProviderRegistry(factory, configHandler, logger); RecordingProvider provider = new RecordingProvider(true); - when(factory.createDatabaseProvider(DatabaseType.MYSQL, "default")).thenReturn(provider); + when(factory.createDatabaseProvider(DatabaseType.MYSQL, ConnectionIdentifier.of("default"))) + .thenReturn(provider); registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); ConcurrentMap active = registry.getActiveDatabases(); diff --git a/src/test/java/nl/hauntedmc/dataprovider/internal/DatabaseFactoryTest.java b/src/test/java/nl/hauntedmc/dataprovider/internal/DatabaseFactoryTest.java index 6d90751..5320e93 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/internal/DatabaseFactoryTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/internal/DatabaseFactoryTest.java @@ -32,7 +32,7 @@ void constructorValidatesArguments() { void returnsNullAndLogsWhenConfigurationIsMissing() { RecordingLoggerAdapter logger = new RecordingLoggerAdapter(); DatabaseConfigMap configMap = mock(DatabaseConfigMap.class); - when(configMap.getConfig(DatabaseType.MYSQL, "missing")).thenReturn(null); + when(configMap.getConfig(DatabaseType.MYSQL, ConnectionIdentifier.of("missing"))).thenReturn(null); DatabaseFactory factory = new DatabaseFactory(configMap, logger); DatabaseProvider provider = factory.createDatabaseProvider(DatabaseType.MYSQL, "missing"); @@ -48,7 +48,7 @@ void createsProviderImplementationForEachDatabaseType() { CommentedConfigurationNode node = CommentedConfigurationNode.root(); for (DatabaseType type : DatabaseType.values()) { - when(configMap.getConfig(type, "default")).thenReturn(node); + when(configMap.getConfig(type, ConnectionIdentifier.of("default"))).thenReturn(node); } DatabaseFactory factory = new DatabaseFactory(configMap, logger); From a152f747b16c09f937cebe633c9b4a96ca47e1ae Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Thu, 26 Mar 2026 23:56:34 +0100 Subject: [PATCH 09/17] use Subscribe priority for deterministic DataProvider lifecycle order: : --- .../velocity/VelocityDataProvider.java | 7 ++++-- .../velocity/VelocityDataProviderTest.java | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProvider.java b/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProvider.java index 6d13d0f..e4c678b 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProvider.java +++ b/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProvider.java @@ -27,6 +27,9 @@ ) public class VelocityDataProvider { + private static final short INITIALIZE_EVENT_PRIORITY = Short.MAX_VALUE; + private static final short SHUTDOWN_EVENT_PRIORITY = Short.MIN_VALUE; + private final ProxyServer proxyServer; private final Logger logger; private final Path dataDirectory; @@ -39,7 +42,7 @@ public VelocityDataProvider(ProxyServer proxyServer, Logger logger, @DataDirecto this.dataDirectory = dataDirectory; } - @Subscribe + @Subscribe(priority = INITIALIZE_EVENT_PRIORITY) public void onProxyInitialize(ProxyInitializeEvent event) { DataProvider previousProvider = dataProvider; if (previousProvider != null) { @@ -69,7 +72,7 @@ public void onProxyInitialize(ProxyInitializeEvent event) { logger.info("DataProvider plugin enabled on Velocity (v{}).", pluginVersion); } - @Subscribe + @Subscribe(priority = SHUTDOWN_EVENT_PRIORITY) public void onProxyShutdown(ProxyShutdownEvent event) { DataProvider providerToShutdown = dataProvider; dataProvider = null; diff --git a/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProviderTest.java b/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProviderTest.java index b6671c8..39abf78 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProviderTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProviderTest.java @@ -1,5 +1,8 @@ package nl.hauntedmc.dataprovider.platform.velocity; +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; +import com.velocitypowered.api.event.proxy.ProxyShutdownEvent; import com.velocitypowered.api.plugin.PluginContainer; import com.velocitypowered.api.plugin.PluginDescription; import com.velocitypowered.api.plugin.PluginManager; @@ -10,6 +13,7 @@ import org.junit.jupiter.api.Test; import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -21,6 +25,25 @@ class VelocityDataProviderTest { + @Test + void lifecycleHandlersUseDeterministicVelocityEventOrder() throws ReflectiveOperationException { + Method initializeHandler = VelocityDataProvider.class.getDeclaredMethod( + "onProxyInitialize", + ProxyInitializeEvent.class + ); + Subscribe initializeSubscribe = initializeHandler.getAnnotation(Subscribe.class); + assertNotNull(initializeSubscribe); + assertEquals(Short.MAX_VALUE, initializeSubscribe.priority()); + + Method shutdownHandler = VelocityDataProvider.class.getDeclaredMethod( + "onProxyShutdown", + ProxyShutdownEvent.class + ); + Subscribe shutdownSubscribe = shutdownHandler.getAnnotation(Subscribe.class); + assertNotNull(shutdownSubscribe); + assertEquals(Short.MIN_VALUE, shutdownSubscribe.priority()); + } + @Test void resolvePluginVersionReturnsDescriptionVersionValue() { ProxyServer proxyServer = mock(ProxyServer.class); From be65e158623bcb90c854319636df6215542b2c87 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Fri, 27 Mar 2026 00:11:16 +0100 Subject: [PATCH 10/17] centralize Bukkit/Velocity lifecycle and command wrappers --- docs/ARCHITECTURE.md | 10 +- docs/DEVELOPMENT.md | 2 + .../platform/bukkit/BukkitDataProvider.java | 75 +++++------ .../bukkit/command/DataProviderCommand.java | 81 ++--------- .../bukkit/logger/BukkitLoggerAdapter.java | 5 +- .../command/DataProviderCommandService.java | 117 ++++++++++++++++ .../PlatformDataProviderRuntime.java | 77 +++++++++++ .../velocity/VelocityDataProvider.java | 66 ++++----- .../velocity/command/DataProviderCommand.java | 84 ++---------- .../velocity/logger/SLF4JLoggerAdapter.java | 6 +- src/main/resources/plugin.yml | 2 +- .../bukkit/BukkitDataProviderTest.java | 26 ++-- .../DataProviderCommandServiceTest.java | 127 ++++++++++++++++++ .../PlatformDataProviderRuntimeTest.java | 68 ++++++++++ .../velocity/VelocityDataProviderTest.java | 26 ++-- 15 files changed, 520 insertions(+), 252 deletions(-) create mode 100644 src/main/java/nl/hauntedmc/dataprovider/platform/common/command/DataProviderCommandService.java create mode 100644 src/main/java/nl/hauntedmc/dataprovider/platform/common/lifecycle/PlatformDataProviderRuntime.java create mode 100644 src/test/java/nl/hauntedmc/dataprovider/platform/common/command/DataProviderCommandServiceTest.java create mode 100644 src/test/java/nl/hauntedmc/dataprovider/platform/common/lifecycle/PlatformDataProviderRuntimeTest.java diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ca2a74b..68e773e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -10,7 +10,8 @@ Main modules: - `api`: public registration/lookup surface - `internal`: registry, factory, config mapping, identity, and lifecycle logic - `database.*`: backend implementations and typed data-access contracts -- `platform.bukkit` / `platform.velocity`: platform bootstrap and caller context resolution +- `platform.common`: shared platform runtime lifecycle and command behavior +- `platform.bukkit` / `platform.velocity`: platform adapters (bootstrap, command wiring, caller context resolution) ## Registration Model @@ -37,6 +38,13 @@ Main modules: - Stale/disconnected providers are evicted from registry lookup paths. - Shutdown hooks unregister or stop backend resources cleanly. - Bounded executors are used for asynchronous backend work queues. +- Platform runtime wrappers use a shared thread-safe lifecycle holder to prevent stale instance leaks across enable/disable cycles. + +## Platform Layer Design + +- `PlatformDataProviderRuntime` centralizes bootstrap shutdown behavior and static API access checks. +- Platform command adapters delegate to a shared `DataProviderCommandService` so Bukkit and Velocity command behavior stays identical. +- Platform-specific wrappers only map host APIs to shared internals (logger, command registration, event/plugin lifecycle hooks). ## ORM Integration diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index c962a17..760c59c 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -23,6 +23,7 @@ mvn -B package - `src/main/java/nl/hauntedmc/dataprovider/api`: public API contracts - `src/main/java/nl/hauntedmc/dataprovider/internal`: registry/factory/config internals - `src/main/java/nl/hauntedmc/dataprovider/database`: backend implementations +- `src/main/java/nl/hauntedmc/dataprovider/platform/common`: shared platform lifecycle + command behavior - `src/main/java/nl/hauntedmc/dataprovider/platform`: Bukkit/Velocity adapters - `src/test/java`: unit tests by package @@ -32,6 +33,7 @@ mvn -B package - Keep connection registration in startup lifecycle paths, not request hot paths. - Handle external IO failures as non-fatal where possible and log actionable context. - Keep platform-specific integration (`platform.bukkit`, `platform.velocity`) thin and isolated. +- Put cross-platform wrapper behavior in `platform.common` before adding platform-local duplication. - Avoid leaking plugin context across module boundaries. ## Manual Validation Checklist diff --git a/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/BukkitDataProvider.java b/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/BukkitDataProvider.java index 03cb95c..9ea8939 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/BukkitDataProvider.java +++ b/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/BukkitDataProvider.java @@ -2,68 +2,63 @@ import nl.hauntedmc.dataprovider.DataProvider; import nl.hauntedmc.dataprovider.api.DataProviderAPI; +import nl.hauntedmc.dataprovider.internal.DataProviderHandler; import nl.hauntedmc.dataprovider.platform.bukkit.command.DataProviderCommand; import nl.hauntedmc.dataprovider.platform.bukkit.identity.BukkitCallerContextResolver; import nl.hauntedmc.dataprovider.platform.bukkit.logger.BukkitLoggerAdapter; +import nl.hauntedmc.dataprovider.platform.common.lifecycle.PlatformDataProviderRuntime; +import org.bukkit.command.PluginCommand; import org.bukkit.plugin.java.JavaPlugin; -import java.util.Objects; -import java.util.logging.Level; +public final class BukkitDataProvider extends JavaPlugin { -public class BukkitDataProvider extends JavaPlugin { - - private static volatile DataProvider dataProvider; + private static final String COMMAND_NAME = "dataprovider"; + private static final PlatformDataProviderRuntime RUNTIME = new PlatformDataProviderRuntime(); @Override public void onEnable() { - DataProvider previousProvider = dataProvider; - if (previousProvider != null) { - getLogger().warning("Detected leftover DataProvider instance during enable; forcing cleanup first."); - dataProvider = null; - try { - previousProvider.shutdownAllDatabases(); - } catch (Exception e) { - getLogger().log(Level.SEVERE, "Failed to shut down leftover DataProvider instance.", e); - } - } - - BukkitLoggerAdapter logInstance = new BukkitLoggerAdapter(getLogger()); - dataProvider = new DataProvider( - logInstance, - getDataPath(), - this.getClassLoader(), - new BukkitCallerContextResolver(this.getClassLoader()) + BukkitLoggerAdapter loggerAdapter = new BukkitLoggerAdapter(getLogger()); + DataProvider provider = RUNTIME.start( + () -> new DataProvider( + loggerAdapter, + getDataPath(), + getClassLoader(), + new BukkitCallerContextResolver(getClassLoader()) + ), + loggerAdapter ); - - // Init Bukkit Command - DataProviderCommand commandExecutor = new DataProviderCommand(dataProvider.getDataProviderHandler()); - Objects.requireNonNull(getCommand("dataprovider")).setExecutor(commandExecutor); - Objects.requireNonNull(getCommand("dataprovider")).setTabCompleter(commandExecutor); + try { + registerCommand(provider.getDataProviderHandler()); + } catch (RuntimeException exception) { + loggerAdapter.error("Failed to initialize Bukkit command wiring.", exception); + RUNTIME.stop(loggerAdapter); + throw exception; + } getLogger().info("Enabled (v" + getDescription().getVersion() + ")."); } @Override public void onDisable() { - DataProvider providerToShutdown = dataProvider; - dataProvider = null; - if (providerToShutdown != null) { - try { - providerToShutdown.shutdownAllDatabases(); - } catch (Exception e) { - getLogger().log(Level.SEVERE, "Failed to shut down DataProvider cleanly.", e); - } - } + RUNTIME.stop(new BukkitLoggerAdapter(getLogger())); getLogger().info("Disabled."); } // START EXTERNALLY ACCESSIBLE public static DataProviderAPI getDataProviderAPI() { - if (dataProvider == null) { - throw new IllegalStateException("DataProvider is not initialized yet."); - } - return new DataProviderAPI(dataProvider.getDataProviderHandler()); + return RUNTIME.getDataProviderAPI(); } // END EXTERNALLY ACCESSIBLE + + private void registerCommand(DataProviderHandler handler) { + PluginCommand command = getCommand(COMMAND_NAME); + if (command == null) { + throw new IllegalStateException("Command '" + COMMAND_NAME + "' is missing from plugin.yml."); + } + + DataProviderCommand commandExecutor = new DataProviderCommand(handler); + command.setExecutor(commandExecutor); + command.setTabCompleter(commandExecutor); + } } diff --git a/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/command/DataProviderCommand.java b/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/command/DataProviderCommand.java index 788b714..e6e288d 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/command/DataProviderCommand.java +++ b/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/command/DataProviderCommand.java @@ -1,97 +1,38 @@ package nl.hauntedmc.dataprovider.platform.bukkit.command; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import nl.hauntedmc.dataprovider.database.DatabaseConnectionKey; -import nl.hauntedmc.dataprovider.database.DatabaseProvider; import nl.hauntedmc.dataprovider.internal.DataProviderHandler; +import nl.hauntedmc.dataprovider.platform.common.command.DataProviderCommandService; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; import org.bukkit.command.TabCompleter; import org.jetbrains.annotations.NotNull; -import java.util.ArrayList; import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentMap; +import java.util.Objects; -public class DataProviderCommand implements CommandExecutor, TabCompleter { +public final class DataProviderCommand implements CommandExecutor, TabCompleter { - private final DataProviderHandler dataProviderHandler; + private final DataProviderCommandService commandService; public DataProviderCommand(DataProviderHandler dataProviderHandler) { - this.dataProviderHandler = dataProviderHandler; + this(new DataProviderCommandService(dataProviderHandler)); + } + + DataProviderCommand(DataProviderCommandService commandService) { + this.commandService = Objects.requireNonNull(commandService, "Command service cannot be null."); } @Override public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, String @NotNull [] args) { - // Display usage or help message when no arguments or "help" is provided. - if (args.length == 0 || args[0].equalsIgnoreCase("help")) { - sender.sendMessage(Component.text("Usage: /dataprovider status", NamedTextColor.YELLOW)); - return true; - } - - // Handle the "status" subcommand. - if (args[0].equalsIgnoreCase("status")) { - // Check for the required permission before executing any subcommand. - if (!sender.hasPermission("dataprovider.command.status")) { - sender.sendMessage(Component.text("You do not have permission to use this command.", NamedTextColor.RED)); - return true; - } - - ConcurrentMap activeDatabases = - dataProviderHandler.getActiveDatabases(); - Map referenceCounts = - dataProviderHandler.getActiveDatabaseReferenceCounts(); - - if (activeDatabases.isEmpty()) { - sender.sendMessage(Component.text("No active database connections found.", NamedTextColor.YELLOW)); - return true; - } - - sender.sendMessage(Component.text("Active Database Connections:", NamedTextColor.GREEN)); - for (Map.Entry entry : activeDatabases.entrySet()) { - int references = referenceCounts.getOrDefault(entry.getKey(), 1); - Component connectionInfo = getConnectionComponent(entry, references); - sender.sendMessage(connectionInfo); - } - return true; - } - - sender.sendMessage(Component.text("Unknown subcommand. Use /dataprovider help for usage.", NamedTextColor.RED)); + commandService.execute(args, sender::hasPermission, sender::sendMessage); return true; } - private static @NotNull Component getConnectionComponent(Map.Entry entry, int references) { - DatabaseConnectionKey key = entry.getKey(); - - Component statusComponent = Component.text("Registered (" + references + " refs)", NamedTextColor.GREEN); - - return Component.text("Plugin: ", NamedTextColor.YELLOW) - .append(Component.text(key.pluginName(), NamedTextColor.WHITE)) - .append(Component.text(", Type: ", NamedTextColor.YELLOW)) - .append(Component.text(key.type().name(), NamedTextColor.WHITE)) - .append(Component.text(", Identifier: ", NamedTextColor.YELLOW)) - .append(Component.text(key.connectionIdentifier(), NamedTextColor.WHITE)) - .append(Component.text(" -> ", NamedTextColor.YELLOW)) - .append(statusComponent); - } - @Override public List onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String alias, String[] args) { - List completions = new ArrayList<>(); - if (args.length == 1) { - String partial = args[0].toLowerCase(); - if ("status".startsWith(partial)) { - completions.add("status"); - } - if ("help".startsWith(partial)) { - completions.add("help"); - } - } - return completions; + return commandService.suggest(args); } } diff --git a/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/logger/BukkitLoggerAdapter.java b/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/logger/BukkitLoggerAdapter.java index c6de300..e4119f1 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/logger/BukkitLoggerAdapter.java +++ b/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/logger/BukkitLoggerAdapter.java @@ -2,15 +2,16 @@ import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; +import java.util.Objects; import java.util.logging.Level; import java.util.logging.Logger; -public class BukkitLoggerAdapter implements ILoggerAdapter { +public final class BukkitLoggerAdapter implements ILoggerAdapter { private final Logger logger; public BukkitLoggerAdapter(Logger logger) { - this.logger = logger; + this.logger = Objects.requireNonNull(logger, "Logger cannot be null."); } @Override diff --git a/src/main/java/nl/hauntedmc/dataprovider/platform/common/command/DataProviderCommandService.java b/src/main/java/nl/hauntedmc/dataprovider/platform/common/command/DataProviderCommandService.java new file mode 100644 index 0000000..c2ddc2c --- /dev/null +++ b/src/main/java/nl/hauntedmc/dataprovider/platform/common/command/DataProviderCommandService.java @@ -0,0 +1,117 @@ +package nl.hauntedmc.dataprovider.platform.common.command; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import nl.hauntedmc.dataprovider.database.DatabaseConnectionKey; +import nl.hauntedmc.dataprovider.database.DatabaseProvider; +import nl.hauntedmc.dataprovider.internal.DataProviderHandler; + +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentMap; +import java.util.function.Consumer; +import java.util.function.Predicate; + +/** + * Shared command behavior used by all platform command adapters. + */ +public final class DataProviderCommandService { + + private static final String STATUS_SUBCOMMAND = "status"; + private static final String HELP_SUBCOMMAND = "help"; + private static final String STATUS_PERMISSION = "dataprovider.command.status"; + + private static final Component USAGE_MESSAGE = + Component.text("Usage: /dataprovider status", NamedTextColor.YELLOW); + private static final Component NO_PERMISSION_MESSAGE = + Component.text("You do not have permission to use this command.", NamedTextColor.RED); + private static final Component NO_ACTIVE_CONNECTIONS_MESSAGE = + Component.text("No active database connections found.", NamedTextColor.YELLOW); + private static final Component CONNECTIONS_HEADER = + Component.text("Active Database Connections:", NamedTextColor.GREEN); + private static final Component UNKNOWN_SUBCOMMAND_MESSAGE = + Component.text("Unknown subcommand. Use /dataprovider help for usage.", NamedTextColor.RED); + + private static final List ROOT_COMPLETIONS = List.of(STATUS_SUBCOMMAND, HELP_SUBCOMMAND); + private static final Comparator CONNECTION_KEY_COMPARATOR = + Comparator.comparing(DatabaseConnectionKey::pluginName) + .thenComparing(key -> key.type().name()) + .thenComparing(DatabaseConnectionKey::connectionIdentifier); + + private final DataProviderHandler dataProviderHandler; + + public DataProviderCommandService(DataProviderHandler dataProviderHandler) { + this.dataProviderHandler = Objects.requireNonNull(dataProviderHandler, "Data provider handler cannot be null."); + } + + public void execute( + String[] args, + Predicate permissionChecker, + Consumer messageSink + ) { + Objects.requireNonNull(args, "Args cannot be null."); + Objects.requireNonNull(permissionChecker, "Permission checker cannot be null."); + Objects.requireNonNull(messageSink, "Message sink cannot be null."); + + if (args.length == 0 || HELP_SUBCOMMAND.equalsIgnoreCase(args[0])) { + messageSink.accept(USAGE_MESSAGE); + return; + } + + if (!STATUS_SUBCOMMAND.equalsIgnoreCase(args[0])) { + messageSink.accept(UNKNOWN_SUBCOMMAND_MESSAGE); + return; + } + + if (!permissionChecker.test(STATUS_PERMISSION)) { + messageSink.accept(NO_PERMISSION_MESSAGE); + return; + } + + ConcurrentMap activeDatabases = + dataProviderHandler.getActiveDatabases(); + Map referenceCounts = + dataProviderHandler.getActiveDatabaseReferenceCounts(); + + if (activeDatabases.isEmpty()) { + messageSink.accept(NO_ACTIVE_CONNECTIONS_MESSAGE); + return; + } + + messageSink.accept(CONNECTIONS_HEADER); + activeDatabases.keySet().stream() + .sorted(CONNECTION_KEY_COMPARATOR) + .forEach(key -> { + int references = referenceCounts.getOrDefault(key, 1); + messageSink.accept(toConnectionComponent(key, references)); + }); + } + + public List suggest(String[] args) { + Objects.requireNonNull(args, "Args cannot be null."); + if (args.length != 1) { + return List.of(); + } + + String partial = args[0].toLowerCase(Locale.ROOT); + return ROOT_COMPLETIONS.stream() + .filter(completion -> completion.startsWith(partial)) + .toList(); + } + + private static Component toConnectionComponent(DatabaseConnectionKey key, int references) { + Component statusComponent = Component.text("Registered (" + references + " refs)", NamedTextColor.GREEN); + + return Component.text("Plugin: ", NamedTextColor.YELLOW) + .append(Component.text(key.pluginName(), NamedTextColor.WHITE)) + .append(Component.text(", Type: ", NamedTextColor.YELLOW)) + .append(Component.text(key.type().name(), NamedTextColor.WHITE)) + .append(Component.text(", Identifier: ", NamedTextColor.YELLOW)) + .append(Component.text(key.connectionIdentifier(), NamedTextColor.WHITE)) + .append(Component.text(" -> ", NamedTextColor.YELLOW)) + .append(statusComponent); + } +} diff --git a/src/main/java/nl/hauntedmc/dataprovider/platform/common/lifecycle/PlatformDataProviderRuntime.java b/src/main/java/nl/hauntedmc/dataprovider/platform/common/lifecycle/PlatformDataProviderRuntime.java new file mode 100644 index 0000000..845ea4e --- /dev/null +++ b/src/main/java/nl/hauntedmc/dataprovider/platform/common/lifecycle/PlatformDataProviderRuntime.java @@ -0,0 +1,77 @@ +package nl.hauntedmc.dataprovider.platform.common.lifecycle; + +import nl.hauntedmc.dataprovider.DataProvider; +import nl.hauntedmc.dataprovider.api.DataProviderAPI; +import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; + +/** + * Thread-safe runtime holder for the active platform DataProvider instance. + */ +public final class PlatformDataProviderRuntime { + + private static final String NOT_INITIALIZED_MESSAGE = "DataProvider is not initialized yet."; + private static final String LEFTOVER_INSTANCE_MESSAGE = + "Detected leftover DataProvider instance during enable; forcing cleanup first."; + private static final String LEFTOVER_SHUTDOWN_FAILURE_MESSAGE = + "Failed to shut down leftover DataProvider instance cleanly."; + private static final String SHUTDOWN_FAILURE_MESSAGE = + "Failed to shut down DataProvider cleanly."; + + private final AtomicReference activeProvider = new AtomicReference<>(); + + /** + * Starts a fresh DataProvider instance. + * If an old runtime instance is still present, it is shut down first. + */ + public synchronized DataProvider start(Supplier providerFactory, ILoggerAdapter logger) { + Objects.requireNonNull(providerFactory, "Provider factory cannot be null."); + Objects.requireNonNull(logger, "Logger cannot be null."); + + DataProvider previousProvider = activeProvider.getAndSet(null); + if (previousProvider != null) { + logger.warn(LEFTOVER_INSTANCE_MESSAGE); + shutdownProvider(previousProvider, logger, LEFTOVER_SHUTDOWN_FAILURE_MESSAGE); + } + + DataProvider createdProvider = Objects.requireNonNull( + providerFactory.get(), + "Provider factory cannot return null." + ); + activeProvider.set(createdProvider); + return createdProvider; + } + + /** + * Stops the current DataProvider instance, if one is active. + */ + public synchronized void stop(ILoggerAdapter logger) { + Objects.requireNonNull(logger, "Logger cannot be null."); + DataProvider providerToShutdown = activeProvider.getAndSet(null); + if (providerToShutdown != null) { + shutdownProvider(providerToShutdown, logger, SHUTDOWN_FAILURE_MESSAGE); + } + } + + /** + * Resolves a new API facade for the currently active provider. + */ + public DataProviderAPI getDataProviderAPI() { + DataProvider provider = activeProvider.get(); + if (provider == null) { + throw new IllegalStateException(NOT_INITIALIZED_MESSAGE); + } + return new DataProviderAPI(provider.getDataProviderHandler()); + } + + private static void shutdownProvider(DataProvider provider, ILoggerAdapter logger, String failureMessage) { + try { + provider.shutdownAllDatabases(); + } catch (Exception e) { + logger.error(failureMessage, e); + } + } +} diff --git a/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProvider.java b/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProvider.java index e4c678b..447375e 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProvider.java +++ b/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProvider.java @@ -11,6 +11,8 @@ import com.velocitypowered.api.proxy.ProxyServer; import nl.hauntedmc.dataprovider.DataProvider; import nl.hauntedmc.dataprovider.api.DataProviderAPI; +import nl.hauntedmc.dataprovider.internal.DataProviderHandler; +import nl.hauntedmc.dataprovider.platform.common.lifecycle.PlatformDataProviderRuntime; import nl.hauntedmc.dataprovider.platform.velocity.command.DataProviderCommand; import nl.hauntedmc.dataprovider.platform.velocity.identity.VelocityCallerContextResolver; import nl.hauntedmc.dataprovider.platform.velocity.logger.SLF4JLoggerAdapter; @@ -25,15 +27,16 @@ description = "A cross-platform data provider plugin.", authors = {"HauntedMC"} ) -public class VelocityDataProvider { +public final class VelocityDataProvider { private static final short INITIALIZE_EVENT_PRIORITY = Short.MAX_VALUE; private static final short SHUTDOWN_EVENT_PRIORITY = Short.MIN_VALUE; + private static final String COMMAND_NAME = "dataprovider"; + private static final PlatformDataProviderRuntime RUNTIME = new PlatformDataProviderRuntime(); private final ProxyServer proxyServer; private final Logger logger; private final Path dataDirectory; - private static volatile DataProvider dataProvider; @Inject public VelocityDataProvider(ProxyServer proxyServer, Logger logger, @DataDirectory Path dataDirectory) { @@ -44,29 +47,23 @@ public VelocityDataProvider(ProxyServer proxyServer, Logger logger, @DataDirecto @Subscribe(priority = INITIALIZE_EVENT_PRIORITY) public void onProxyInitialize(ProxyInitializeEvent event) { - DataProvider previousProvider = dataProvider; - if (previousProvider != null) { - logger.warn("Detected leftover DataProvider instance during enable; forcing cleanup first."); - dataProvider = null; - try { - previousProvider.shutdownAllDatabases(); - } catch (Exception e) { - logger.error("Failed to shut down leftover DataProvider instance cleanly.", e); - } - } - - SLF4JLoggerAdapter logInstance = new SLF4JLoggerAdapter(logger); - dataProvider = new DataProvider( - logInstance, - dataDirectory, - getClass().getClassLoader(), - new VelocityCallerContextResolver(proxyServer, getClass().getClassLoader()) + SLF4JLoggerAdapter loggerAdapter = new SLF4JLoggerAdapter(logger); + DataProvider provider = RUNTIME.start( + () -> new DataProvider( + loggerAdapter, + dataDirectory, + getClass().getClassLoader(), + new VelocityCallerContextResolver(proxyServer, getClass().getClassLoader()) + ), + loggerAdapter ); - - CommandManager commandManager = proxyServer.getCommandManager(); - CommandMeta meta = commandManager.metaBuilder("dataprovider") - .build(); - commandManager.register(meta, new DataProviderCommand(dataProvider.getDataProviderHandler())); + try { + registerCommand(provider.getDataProviderHandler()); + } catch (RuntimeException exception) { + loggerAdapter.error("Failed to initialize Velocity command wiring.", exception); + RUNTIME.stop(loggerAdapter); + throw exception; + } String pluginVersion = resolvePluginVersion(proxyServer, this); logger.info("DataProvider plugin enabled on Velocity (v{}).", pluginVersion); @@ -74,27 +71,22 @@ public void onProxyInitialize(ProxyInitializeEvent event) { @Subscribe(priority = SHUTDOWN_EVENT_PRIORITY) public void onProxyShutdown(ProxyShutdownEvent event) { - DataProvider providerToShutdown = dataProvider; - dataProvider = null; - if (providerToShutdown != null) { - try { - providerToShutdown.shutdownAllDatabases(); - } catch (Exception e) { - logger.error("Failed to shut down DataProvider cleanly.", e); - } - } + RUNTIME.stop(new SLF4JLoggerAdapter(logger)); logger.info("DataProvider plugin disabled on Velocity."); } // START EXTERNALLY ACCESSIBLE public static DataProviderAPI getDataProviderAPI() { - if (dataProvider == null) { - throw new IllegalStateException("DataProvider is not initialized yet."); - } - return new DataProviderAPI(dataProvider.getDataProviderHandler()); + return RUNTIME.getDataProviderAPI(); } // END EXTERNALLY ACCESSIBLE + private void registerCommand(DataProviderHandler handler) { + CommandManager commandManager = proxyServer.getCommandManager(); + CommandMeta meta = commandManager.metaBuilder(COMMAND_NAME).build(); + commandManager.register(meta, new DataProviderCommand(handler)); + } + static String resolvePluginVersion(ProxyServer proxyServer, Object pluginInstance) { return proxyServer.getPluginManager() .fromInstance(pluginInstance) diff --git a/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/command/DataProviderCommand.java b/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/command/DataProviderCommand.java index d8b1edd..4ebecc9 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/command/DataProviderCommand.java +++ b/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/command/DataProviderCommand.java @@ -2,95 +2,33 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.command.SimpleCommand; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import nl.hauntedmc.dataprovider.database.DatabaseConnectionKey; -import nl.hauntedmc.dataprovider.database.DatabaseProvider; import nl.hauntedmc.dataprovider.internal.DataProviderHandler; +import nl.hauntedmc.dataprovider.platform.common.command.DataProviderCommandService; -import java.util.ArrayList; import java.util.List; -import java.util.Map; +import java.util.Objects; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentMap; -public class DataProviderCommand implements SimpleCommand { +public final class DataProviderCommand implements SimpleCommand { - private final DataProviderHandler dataProviderHandler; + private final DataProviderCommandService commandService; public DataProviderCommand(DataProviderHandler dataProviderHandler) { - this.dataProviderHandler = dataProviderHandler; + this(new DataProviderCommandService(dataProviderHandler)); + } + + DataProviderCommand(DataProviderCommandService commandService) { + this.commandService = Objects.requireNonNull(commandService, "Command service cannot be null."); } @Override public void execute(Invocation invocation) { CommandSource source = invocation.source(); - String[] args = invocation.arguments(); - - // Display usage or help message when no arguments or "help" is provided. - if (args.length == 0 || args[0].equalsIgnoreCase("help")) { - source.sendMessage(Component.text("Usage: /dataprovider status", NamedTextColor.YELLOW)); - return; - } - - // Handle the "status" subcommand. - if (args[0].equalsIgnoreCase("status")) { - // Check permission before executing any subcommand. - if (!source.hasPermission("dataprovider.command.status")) { - source.sendMessage(Component.text("You do not have permission to use this command.", NamedTextColor.RED)); - return; - } - - ConcurrentMap activeDatabases = - dataProviderHandler.getActiveDatabases(); - Map referenceCounts = - dataProviderHandler.getActiveDatabaseReferenceCounts(); - - if (activeDatabases.isEmpty()) { - source.sendMessage(Component.text("No active database connections found.", NamedTextColor.YELLOW)); - return; - } - - source.sendMessage(Component.text("Active Database Connections:", NamedTextColor.GREEN)); - for (Map.Entry entry : activeDatabases.entrySet()) { - int references = referenceCounts.getOrDefault(entry.getKey(), 1); - Component connectionInfo = getConnectionComponent(entry, references); - source.sendMessage(connectionInfo); - } - return; - } - - source.sendMessage(Component.text("Unknown subcommand. Use /dataprovider help for usage.", NamedTextColor.RED)); - } - - private static Component getConnectionComponent(Map.Entry entry, int references) { - DatabaseConnectionKey key = entry.getKey(); - - Component statusComponent = Component.text("Registered (" + references + " refs)", NamedTextColor.GREEN); - - return Component.text("Plugin: ", NamedTextColor.YELLOW) - .append(Component.text(key.pluginName(), NamedTextColor.WHITE)) - .append(Component.text(", Type: ", NamedTextColor.YELLOW)) - .append(Component.text(key.type().name(), NamedTextColor.WHITE)) - .append(Component.text(", Identifier: ", NamedTextColor.YELLOW)) - .append(Component.text(key.connectionIdentifier(), NamedTextColor.WHITE)) - .append(Component.text(" -> ", NamedTextColor.YELLOW)) - .append(statusComponent); + commandService.execute(invocation.arguments(), source::hasPermission, source::sendMessage); } @Override public CompletableFuture> suggestAsync(Invocation invocation) { - List completions = new ArrayList<>(); - String[] args = invocation.arguments(); - if (args.length == 1) { - String partial = args[0].toLowerCase(); - if ("status".startsWith(partial)) { - completions.add("status"); - } - if ("help".startsWith(partial)) { - completions.add("help"); - } - } - return CompletableFuture.completedFuture(completions); + return CompletableFuture.completedFuture(commandService.suggest(invocation.arguments())); } } diff --git a/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/logger/SLF4JLoggerAdapter.java b/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/logger/SLF4JLoggerAdapter.java index 3ce4ad9..a7fa209 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/logger/SLF4JLoggerAdapter.java +++ b/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/logger/SLF4JLoggerAdapter.java @@ -3,11 +3,13 @@ import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; import org.slf4j.Logger; -public class SLF4JLoggerAdapter implements ILoggerAdapter { +import java.util.Objects; + +public final class SLF4JLoggerAdapter implements ILoggerAdapter { private final Logger logger; public SLF4JLoggerAdapter(Logger logger) { - this.logger = logger; + this.logger = Objects.requireNonNull(logger, "Logger cannot be null."); } @Override diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 7478e2a..1024d48 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -6,4 +6,4 @@ api-version: 1.19 commands: dataprovider: description: Displays status of active database connections. - usage: /dataprovider \ No newline at end of file + usage: /dataprovider diff --git a/src/test/java/nl/hauntedmc/dataprovider/platform/bukkit/BukkitDataProviderTest.java b/src/test/java/nl/hauntedmc/dataprovider/platform/bukkit/BukkitDataProviderTest.java index 9dc0f20..00da1a7 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/platform/bukkit/BukkitDataProviderTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/platform/bukkit/BukkitDataProviderTest.java @@ -3,6 +3,8 @@ import nl.hauntedmc.dataprovider.DataProvider; import nl.hauntedmc.dataprovider.api.DataProviderAPI; import nl.hauntedmc.dataprovider.internal.DataProviderHandler; +import nl.hauntedmc.dataprovider.platform.common.lifecycle.PlatformDataProviderRuntime; +import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; import org.junit.jupiter.api.Test; import java.lang.reflect.Field; @@ -17,12 +19,10 @@ class BukkitDataProviderTest { @Test void getDataProviderApiThrowsWhenNotInitialized() throws ReflectiveOperationException { - DataProvider original = swapStaticDataProvider(null); - try { - assertThrows(IllegalStateException.class, BukkitDataProvider::getDataProviderAPI); - } finally { - swapStaticDataProvider(original); - } + PlatformDataProviderRuntime runtime = resolveRuntime(); + runtime.stop(mock(ILoggerAdapter.class)); + + assertThrows(IllegalStateException.class, BukkitDataProvider::getDataProviderAPI); } @Test @@ -31,22 +31,22 @@ void getDataProviderApiReturnsFacadeWhenInitialized() throws ReflectiveOperation DataProviderHandler handler = mock(DataProviderHandler.class); when(provider.getDataProviderHandler()).thenReturn(handler); - DataProvider original = swapStaticDataProvider(provider); + PlatformDataProviderRuntime runtime = resolveRuntime(); + runtime.stop(mock(ILoggerAdapter.class)); + runtime.start(() -> provider, mock(ILoggerAdapter.class)); try { DataProviderAPI api = BukkitDataProvider.getDataProviderAPI(); assertNotNull(api); api.unregisterAllDatabases(); verify(handler).unregisterAllDatabases(); } finally { - swapStaticDataProvider(original); + runtime.stop(mock(ILoggerAdapter.class)); } } - private static DataProvider swapStaticDataProvider(DataProvider replacement) throws ReflectiveOperationException { - Field field = BukkitDataProvider.class.getDeclaredField("dataProvider"); + private static PlatformDataProviderRuntime resolveRuntime() throws ReflectiveOperationException { + Field field = BukkitDataProvider.class.getDeclaredField("RUNTIME"); field.setAccessible(true); - DataProvider previous = (DataProvider) field.get(null); - field.set(null, replacement); - return previous; + return (PlatformDataProviderRuntime) field.get(null); } } diff --git a/src/test/java/nl/hauntedmc/dataprovider/platform/common/command/DataProviderCommandServiceTest.java b/src/test/java/nl/hauntedmc/dataprovider/platform/common/command/DataProviderCommandServiceTest.java new file mode 100644 index 0000000..6deb5cb --- /dev/null +++ b/src/test/java/nl/hauntedmc/dataprovider/platform/common/command/DataProviderCommandServiceTest.java @@ -0,0 +1,127 @@ +package nl.hauntedmc.dataprovider.platform.common.command; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import nl.hauntedmc.dataprovider.database.DatabaseConnectionKey; +import nl.hauntedmc.dataprovider.database.DatabaseProvider; +import nl.hauntedmc.dataprovider.database.DatabaseType; +import nl.hauntedmc.dataprovider.internal.DataProviderHandler; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.function.Predicate; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class DataProviderCommandServiceTest { + + @Test + void executeShowsUsageForEmptyArgsAndHelpSubcommand() { + DataProviderHandler handler = mock(DataProviderHandler.class); + DataProviderCommandService service = new DataProviderCommandService(handler); + RecordingOutput output = new RecordingOutput(); + + service.execute(new String[0], alwaysDenied(), output::record); + service.execute(new String[]{"help"}, alwaysDenied(), output::record); + + assertTrue(output.hasMessageContaining("Usage: /dataprovider status")); + verify(handler, never()).getActiveDatabases(); + } + + @Test + void executeStatusRequiresPermission() { + DataProviderHandler handler = mock(DataProviderHandler.class); + DataProviderCommandService service = new DataProviderCommandService(handler); + RecordingOutput output = new RecordingOutput(); + + service.execute(new String[]{"status"}, alwaysDenied(), output::record); + + assertTrue(output.hasMessageContaining("do not have permission")); + verify(handler, never()).getActiveDatabases(); + } + + @Test + void executeStatusShowsEmptyAndPopulatedStates() { + DataProviderHandler handler = mock(DataProviderHandler.class); + DataProviderCommandService service = new DataProviderCommandService(handler); + RecordingOutput output = new RecordingOutput(); + + when(handler.getActiveDatabases()).thenReturn(new ConcurrentHashMap<>()); + when(handler.getActiveDatabaseReferenceCounts()).thenReturn(Map.of()); + service.execute(new String[]{"status"}, alwaysGranted(), output::record); + assertTrue(output.hasMessageContaining("No active database connections found.")); + + ConcurrentMap activeDatabases = new ConcurrentHashMap<>(); + DatabaseConnectionKey keyA = new DatabaseConnectionKey("APlugin", DatabaseType.REDIS, "cache"); + DatabaseConnectionKey keyB = new DatabaseConnectionKey("BPlugin", DatabaseType.MYSQL, "default"); + activeDatabases.put(keyB, mock(DatabaseProvider.class)); + activeDatabases.put(keyA, mock(DatabaseProvider.class)); + when(handler.getActiveDatabases()).thenReturn(activeDatabases); + when(handler.getActiveDatabaseReferenceCounts()).thenReturn(Map.of(keyA, 1, keyB, 3)); + + output.clear(); + service.execute(new String[]{"status"}, alwaysGranted(), output::record); + + assertEquals("Active Database Connections:", output.messageAt(0)); + assertTrue(output.messageAt(1).contains("Plugin: APlugin")); + assertTrue(output.messageAt(2).contains("Plugin: BPlugin")); + } + + @Test + void executeUnknownSubcommandShowsError() { + DataProviderCommandService service = new DataProviderCommandService(mock(DataProviderHandler.class)); + RecordingOutput output = new RecordingOutput(); + + service.execute(new String[]{"unknown"}, alwaysGranted(), output::record); + + assertTrue(output.hasMessageContaining("Unknown subcommand")); + } + + @Test + void suggestReturnsExpectedRootCompletions() { + DataProviderCommandService service = new DataProviderCommandService(mock(DataProviderHandler.class)); + + assertEquals(List.of("status"), service.suggest(new String[]{"s"})); + assertEquals(List.of("help"), service.suggest(new String[]{"h"})); + assertTrue(service.suggest(new String[]{"x"}).isEmpty()); + assertTrue(service.suggest(new String[0]).isEmpty()); + assertTrue(service.suggest(new String[]{"status", "extra"}).isEmpty()); + } + + private static Predicate alwaysGranted() { + return permission -> true; + } + + private static Predicate alwaysDenied() { + return permission -> false; + } + + private static final class RecordingOutput { + private final List messages = new ArrayList<>(); + + private void record(Component message) { + messages.add(PlainTextComponentSerializer.plainText().serialize(message)); + } + + private boolean hasMessageContaining(String fragment) { + return messages.stream().anyMatch(text -> text.contains(fragment)); + } + + private String messageAt(int index) { + return messages.get(index); + } + + private void clear() { + messages.clear(); + } + } +} diff --git a/src/test/java/nl/hauntedmc/dataprovider/platform/common/lifecycle/PlatformDataProviderRuntimeTest.java b/src/test/java/nl/hauntedmc/dataprovider/platform/common/lifecycle/PlatformDataProviderRuntimeTest.java new file mode 100644 index 0000000..5d7fa4c --- /dev/null +++ b/src/test/java/nl/hauntedmc/dataprovider/platform/common/lifecycle/PlatformDataProviderRuntimeTest.java @@ -0,0 +1,68 @@ +package nl.hauntedmc.dataprovider.platform.common.lifecycle; + +import nl.hauntedmc.dataprovider.DataProvider; +import nl.hauntedmc.dataprovider.api.DataProviderAPI; +import nl.hauntedmc.dataprovider.internal.DataProviderHandler; +import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PlatformDataProviderRuntimeTest { + + @Test + void startShutsDownLeftoverProviderBeforeReplacing() { + PlatformDataProviderRuntime runtime = new PlatformDataProviderRuntime(); + ILoggerAdapter logger = mock(ILoggerAdapter.class); + DataProvider previousProvider = mock(DataProvider.class); + DataProvider replacementProvider = mock(DataProvider.class); + + runtime.start(() -> previousProvider, logger); + runtime.start(() -> replacementProvider, logger); + + verify(logger).warn("Detected leftover DataProvider instance during enable; forcing cleanup first."); + verify(previousProvider).shutdownAllDatabases(); + } + + @Test + void stopShutsDownActiveProviderAndMakesApiUnavailable() { + PlatformDataProviderRuntime runtime = new PlatformDataProviderRuntime(); + ILoggerAdapter logger = mock(ILoggerAdapter.class); + DataProvider provider = mock(DataProvider.class); + + runtime.start(() -> provider, logger); + runtime.stop(logger); + + verify(provider).shutdownAllDatabases(); + assertThrows(IllegalStateException.class, runtime::getDataProviderAPI); + } + + @Test + void getDataProviderApiReturnsFacadeForActiveProvider() { + PlatformDataProviderRuntime runtime = new PlatformDataProviderRuntime(); + ILoggerAdapter logger = mock(ILoggerAdapter.class); + DataProvider provider = mock(DataProvider.class); + DataProviderHandler handler = mock(DataProviderHandler.class); + when(provider.getDataProviderHandler()).thenReturn(handler); + + runtime.start(() -> provider, logger); + try { + DataProviderAPI api = runtime.getDataProviderAPI(); + assertNotNull(api); + api.unregisterAllDatabases(); + verify(handler).unregisterAllDatabases(); + } finally { + runtime.stop(logger); + } + } + + @Test + void getDataProviderApiThrowsWhenNotStarted() { + PlatformDataProviderRuntime runtime = new PlatformDataProviderRuntime(); + assertThrows(IllegalStateException.class, runtime::getDataProviderAPI); + } +} diff --git a/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProviderTest.java b/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProviderTest.java index 39abf78..6a2b1f7 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProviderTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProviderTest.java @@ -10,6 +10,8 @@ import nl.hauntedmc.dataprovider.DataProvider; import nl.hauntedmc.dataprovider.api.DataProviderAPI; import nl.hauntedmc.dataprovider.internal.DataProviderHandler; +import nl.hauntedmc.dataprovider.platform.common.lifecycle.PlatformDataProviderRuntime; +import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; import org.junit.jupiter.api.Test; import java.lang.reflect.Field; @@ -78,12 +80,10 @@ void resolvePluginVersionFallsBackToUnknownWhenVersionMissing() { @Test void getDataProviderApiThrowsWhenNotInitialized() throws ReflectiveOperationException { - DataProvider original = swapStaticDataProvider(null); - try { - assertThrows(IllegalStateException.class, VelocityDataProvider::getDataProviderAPI); - } finally { - swapStaticDataProvider(original); - } + PlatformDataProviderRuntime runtime = resolveRuntime(); + runtime.stop(mock(ILoggerAdapter.class)); + + assertThrows(IllegalStateException.class, VelocityDataProvider::getDataProviderAPI); } @Test @@ -92,22 +92,22 @@ void getDataProviderApiReturnsFacadeWhenInitialized() throws ReflectiveOperation DataProviderHandler handler = mock(DataProviderHandler.class); when(provider.getDataProviderHandler()).thenReturn(handler); - DataProvider original = swapStaticDataProvider(provider); + PlatformDataProviderRuntime runtime = resolveRuntime(); + runtime.stop(mock(ILoggerAdapter.class)); + runtime.start(() -> provider, mock(ILoggerAdapter.class)); try { DataProviderAPI api = VelocityDataProvider.getDataProviderAPI(); assertNotNull(api); api.unregisterAllDatabases(); verify(handler).unregisterAllDatabases(); } finally { - swapStaticDataProvider(original); + runtime.stop(mock(ILoggerAdapter.class)); } } - private static DataProvider swapStaticDataProvider(DataProvider replacement) throws ReflectiveOperationException { - Field field = VelocityDataProvider.class.getDeclaredField("dataProvider"); + private static PlatformDataProviderRuntime resolveRuntime() throws ReflectiveOperationException { + Field field = VelocityDataProvider.class.getDeclaredField("RUNTIME"); field.setAccessible(true); - DataProvider previous = (DataProvider) field.get(null); - field.set(null, replacement); - return previous; + return (PlatformDataProviderRuntime) field.get(null); } } From 7b29b15b702f7926c86f27ae0189390dde21fb9e Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Fri, 27 Mar 2026 09:42:08 +0100 Subject: [PATCH 11/17] Refactor platform specific architecture --- README.md | 25 ++++++++- docs/ARCHITECTURE.md | 5 +- docs/DEVELOPMENT.md | 5 +- docs/USAGE_GUIDE.md | 15 +++++- docs/examples/RelationalOrmExample.java | 4 +- .../hauntedmc/dataprovider/DataProvider.java | 8 +-- .../api/DataProviderApiSupplier.java | 9 ++++ .../dataprovider/api/orm/ORMContext.java | 10 ++-- .../dataprovider/config/ConfigHandler.java | 6 +-- .../impl/mongodb/MongoDBDatabase.java | 6 +-- .../keyvalue/impl/redis/RedisDatabase.java | 6 +-- .../messaging/api/MessageRegistry.java | 6 +-- .../impl/redis/RedisMessagingDataAccess.java | 6 +-- .../impl/redis/RedisMessagingDatabase.java | 6 +-- .../relational/impl/mysql/MySQLDatabase.java | 6 +-- .../internal/DataProviderHandler.java | 8 +-- .../internal/DataProviderRegistry.java | 6 +-- .../internal/DatabaseConfigMap.java | 6 +-- .../internal/DatabaseFactory.java | 6 +-- .../security/FilePermissionHardening.java | 8 +-- .../dataprovider/logging/LogLevel.java | 10 ++++ .../dataprovider/logging/LoggerAdapter.java | 37 +++++++++++++ .../logging/adapters/JulLoggerAdapter.java | 33 ++++++++++++ .../logging/adapters/Slf4jLoggerAdapter.java | 50 +++++++++++++++++ .../platform/bukkit/BukkitDataProvider.java | 38 +++++++------ .../bukkit/command/DataProviderCommand.java | 2 +- .../bukkit/logger/BukkitLoggerAdapter.java | 46 ---------------- .../common/logger/ILoggerAdapter.java | 13 ----- .../command/DataProviderCommandService.java | 2 +- .../PlatformDataProviderRuntime.java | 44 ++++++++++----- .../velocity/VelocityDataProvider.java | 42 ++++++++------- .../velocity/command/DataProviderCommand.java | 2 +- .../velocity/logger/SLF4JLoggerAdapter.java | 44 --------------- .../adapters/JulLoggerAdapterTest.java} | 8 +-- .../adapters/Slf4jLoggerAdapterTest.java} | 6 +-- .../bukkit/BukkitDataProviderTest.java | 52 ------------------ .../DataProviderCommandServiceTest.java | 2 +- .../PlatformDataProviderRuntimeTest.java | 43 +++++++++++---- .../velocity/VelocityDataProviderTest.java | 53 +++++++++---------- .../testutil/RecordingLoggerAdapter.java | 41 +++++--------- 40 files changed, 390 insertions(+), 335 deletions(-) create mode 100644 src/main/java/nl/hauntedmc/dataprovider/api/DataProviderApiSupplier.java create mode 100644 src/main/java/nl/hauntedmc/dataprovider/logging/LogLevel.java create mode 100644 src/main/java/nl/hauntedmc/dataprovider/logging/LoggerAdapter.java create mode 100644 src/main/java/nl/hauntedmc/dataprovider/logging/adapters/JulLoggerAdapter.java create mode 100644 src/main/java/nl/hauntedmc/dataprovider/logging/adapters/Slf4jLoggerAdapter.java delete mode 100644 src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/logger/BukkitLoggerAdapter.java delete mode 100644 src/main/java/nl/hauntedmc/dataprovider/platform/common/logger/ILoggerAdapter.java rename src/main/java/nl/hauntedmc/dataprovider/platform/{common => internal}/command/DataProviderCommandService.java (98%) rename src/main/java/nl/hauntedmc/dataprovider/platform/{common => internal}/lifecycle/PlatformDataProviderRuntime.java (59%) delete mode 100644 src/main/java/nl/hauntedmc/dataprovider/platform/velocity/logger/SLF4JLoggerAdapter.java rename src/test/java/nl/hauntedmc/dataprovider/{platform/bukkit/logger/BukkitLoggerAdapterTest.java => logging/adapters/JulLoggerAdapterTest.java} (87%) rename src/test/java/nl/hauntedmc/dataprovider/{platform/velocity/logger/SLF4JLoggerAdapterTest.java => logging/adapters/Slf4jLoggerAdapterTest.java} (84%) delete mode 100644 src/test/java/nl/hauntedmc/dataprovider/platform/bukkit/BukkitDataProviderTest.java rename src/test/java/nl/hauntedmc/dataprovider/platform/{common => internal}/command/DataProviderCommandServiceTest.java (98%) rename src/test/java/nl/hauntedmc/dataprovider/platform/{common => internal}/lifecycle/PlatformDataProviderRuntimeTest.java (61%) diff --git a/README.md b/README.md index b07ef16..1279a43 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,32 @@ It gives you one clean API for MySQL, MongoDB, Redis, and Redis messaging so you ## Quick Start +Resolve the API from your platform runtime: + +Velocity: + +```java +DataProviderAPI api = proxyServer.getPluginManager() + .getPlugin("dataprovider") + .flatMap(container -> container.getInstance() + .filter(DataProviderApiSupplier.class::isInstance) + .map(DataProviderApiSupplier.class::cast) + .map(DataProviderApiSupplier::dataProviderApi)) + .orElseThrow(() -> new IllegalStateException("DataProvider is unavailable.")); +``` + +Bukkit/Paper: + ```java -DataProviderAPI api = VelocityDataProvider.getDataProviderAPI(); +RegisteredServiceProvider registration = + Bukkit.getServicesManager().getRegistration(DataProviderAPI.class); +if (registration == null) { + return; +} +DataProviderAPI api = registration.getProvider(); +``` +```java Optional mysql = api.registerDatabaseAs( DatabaseType.MYSQL, "default", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 68e773e..dfb171c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -10,7 +10,7 @@ Main modules: - `api`: public registration/lookup surface - `internal`: registry, factory, config mapping, identity, and lifecycle logic - `database.*`: backend implementations and typed data-access contracts -- `platform.common`: shared platform runtime lifecycle and command behavior +- `platform.internal`: shared platform runtime lifecycle and command behavior - `platform.bukkit` / `platform.velocity`: platform adapters (bootstrap, command wiring, caller context resolution) ## Registration Model @@ -42,8 +42,9 @@ Main modules: ## Platform Layer Design -- `PlatformDataProviderRuntime` centralizes bootstrap shutdown behavior and static API access checks. +- `PlatformDataProviderRuntime` centralizes bootstrap shutdown behavior and startup rollback handling. - Platform command adapters delegate to a shared `DataProviderCommandService` so Bukkit and Velocity command behavior stays identical. +- API discovery is platform-native: Bukkit registers `DataProviderAPI` in `ServicesManager`; Velocity exposes `DataProviderApiSupplier` on plugin instance. - Platform-specific wrappers only map host APIs to shared internals (logger, command registration, event/plugin lifecycle hooks). ## ORM Integration diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 760c59c..d83fd49 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -23,7 +23,8 @@ mvn -B package - `src/main/java/nl/hauntedmc/dataprovider/api`: public API contracts - `src/main/java/nl/hauntedmc/dataprovider/internal`: registry/factory/config internals - `src/main/java/nl/hauntedmc/dataprovider/database`: backend implementations -- `src/main/java/nl/hauntedmc/dataprovider/platform/common`: shared platform lifecycle + command behavior +- `src/main/java/nl/hauntedmc/dataprovider/logging`: backend-agnostic logging contracts + adapters +- `src/main/java/nl/hauntedmc/dataprovider/platform/internal`: shared platform lifecycle + command behavior - `src/main/java/nl/hauntedmc/dataprovider/platform`: Bukkit/Velocity adapters - `src/test/java`: unit tests by package @@ -33,7 +34,7 @@ mvn -B package - Keep connection registration in startup lifecycle paths, not request hot paths. - Handle external IO failures as non-fatal where possible and log actionable context. - Keep platform-specific integration (`platform.bukkit`, `platform.velocity`) thin and isolated. -- Put cross-platform wrapper behavior in `platform.common` before adding platform-local duplication. +- Put cross-platform wrapper behavior in `platform.internal` before adding platform-local duplication. - Avoid leaking plugin context across module boundaries. ## Manual Validation Checklist diff --git a/docs/USAGE_GUIDE.md b/docs/USAGE_GUIDE.md index ce21b30..a690129 100644 --- a/docs/USAGE_GUIDE.md +++ b/docs/USAGE_GUIDE.md @@ -5,13 +5,24 @@ Velocity: ```java -DataProviderAPI api = VelocityDataProvider.getDataProviderAPI(); +DataProviderAPI api = proxyServer.getPluginManager() + .getPlugin("dataprovider") + .flatMap(container -> container.getInstance() + .filter(DataProviderApiSupplier.class::isInstance) + .map(DataProviderApiSupplier.class::cast) + .map(DataProviderApiSupplier::dataProviderApi)) + .orElseThrow(() -> new IllegalStateException("DataProvider is unavailable.")); ``` Bukkit/Paper: ```java -DataProviderAPI api = BukkitDataProvider.getDataProviderAPI(); +RegisteredServiceProvider registration = + Bukkit.getServicesManager().getRegistration(DataProviderAPI.class); +if (registration == null) { + throw new IllegalStateException("DataProvider is unavailable."); +} +DataProviderAPI api = registration.getProvider(); ``` Caller identity is resolved automatically from the plugin runtime context. diff --git a/docs/examples/RelationalOrmExample.java b/docs/examples/RelationalOrmExample.java index 32c7439..8bb92a6 100644 --- a/docs/examples/RelationalOrmExample.java +++ b/docs/examples/RelationalOrmExample.java @@ -2,7 +2,7 @@ import nl.hauntedmc.dataprovider.api.orm.ORMContext; import nl.hauntedmc.dataprovider.database.DatabaseType; import nl.hauntedmc.dataprovider.database.relational.RelationalDatabaseProvider; -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; +import nl.hauntedmc.dataprovider.logging.LoggerAdapter; import java.util.Optional; @@ -13,7 +13,7 @@ public final class RelationalOrmExample { private ORMContext ormContext; - public void onEnable(DataProviderAPI api, ILoggerAdapter logger) { + public void onEnable(DataProviderAPI api, LoggerAdapter logger) { Optional relational = api.registerDatabaseAs( DatabaseType.MYSQL, "example", diff --git a/src/main/java/nl/hauntedmc/dataprovider/DataProvider.java b/src/main/java/nl/hauntedmc/dataprovider/DataProvider.java index adc478f..7f6545e 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/DataProvider.java +++ b/src/main/java/nl/hauntedmc/dataprovider/DataProvider.java @@ -3,7 +3,7 @@ import nl.hauntedmc.dataprovider.config.ConfigHandler; import nl.hauntedmc.dataprovider.internal.DataProviderHandler; import nl.hauntedmc.dataprovider.internal.identity.CallerContextResolver; -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; +import nl.hauntedmc.dataprovider.logging.LoggerAdapter; import org.jspecify.annotations.Nullable; import java.io.IOException; @@ -15,14 +15,14 @@ public class DataProvider { - private final ILoggerAdapter logger; + private final LoggerAdapter logger; private final Path dataPath; private final ClassLoader parentClassLoader; private final ConfigHandler configHandler; private final DataProviderHandler dataProviderHandler; public DataProvider( - ILoggerAdapter logger, + LoggerAdapter logger, Path dataDirectory, ClassLoader classLoader, CallerContextResolver callerContextResolver @@ -45,7 +45,7 @@ public DataProvider( ); } - public ILoggerAdapter getLogger() { + public LoggerAdapter getLogger() { return logger; } diff --git a/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderApiSupplier.java b/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderApiSupplier.java new file mode 100644 index 0000000..2771a50 --- /dev/null +++ b/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderApiSupplier.java @@ -0,0 +1,9 @@ +package nl.hauntedmc.dataprovider.api; + +/** + * Runtime contract for obtaining a live DataProvider API instance from a host plugin. + */ +public interface DataProviderApiSupplier { + + DataProviderAPI dataProviderApi(); +} diff --git a/src/main/java/nl/hauntedmc/dataprovider/api/orm/ORMContext.java b/src/main/java/nl/hauntedmc/dataprovider/api/orm/ORMContext.java index 767b904..7849f14 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/api/orm/ORMContext.java +++ b/src/main/java/nl/hauntedmc/dataprovider/api/orm/ORMContext.java @@ -1,7 +1,7 @@ package nl.hauntedmc.dataprovider.api.orm; import nl.hauntedmc.dataprovider.config.ConfigHandler; -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; +import nl.hauntedmc.dataprovider.logging.LoggerAdapter; import org.hibernate.Session; import org.hibernate.SessionFactory; import org.hibernate.Transaction; @@ -27,7 +27,7 @@ public class ORMContext { private final DataSource dataSource; private final String plugin; - private final ILoggerAdapter logger; + private final LoggerAdapter logger; private final String schemaMode; private SessionFactory sessionFactory; private StandardServiceRegistry registry; @@ -46,7 +46,7 @@ public ORMContext( String plugin, DataSource dataSource, ConfigHandler configHandler, - ILoggerAdapter logger, + LoggerAdapter logger, Class... entityClasses ) { this(plugin, dataSource, logger, resolveSchemaMode(configHandler), entityClasses); @@ -64,7 +64,7 @@ public ORMContext( public ORMContext( String plugin, DataSource dataSource, - ILoggerAdapter logger, + LoggerAdapter logger, String schemaMode, Class... entityClasses ) { @@ -128,7 +128,7 @@ private static String resolveSchemaMode(ConfigHandler configHandler) { return configHandler.getOrmSchemaMode(); } - private static String normalizeSchemaMode(String schemaMode, ILoggerAdapter logger) { + private static String normalizeSchemaMode(String schemaMode, LoggerAdapter logger) { if (schemaMode == null || schemaMode.isBlank()) { return DEFAULT_SCHEMA_MODE; } diff --git a/src/main/java/nl/hauntedmc/dataprovider/config/ConfigHandler.java b/src/main/java/nl/hauntedmc/dataprovider/config/ConfigHandler.java index 0edb9ae..5ed2d0f 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/config/ConfigHandler.java +++ b/src/main/java/nl/hauntedmc/dataprovider/config/ConfigHandler.java @@ -2,7 +2,7 @@ import nl.hauntedmc.dataprovider.database.DatabaseType; import nl.hauntedmc.dataprovider.internal.security.FilePermissionHardening; -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; +import nl.hauntedmc.dataprovider.logging.LoggerAdapter; import org.spongepowered.configurate.CommentedConfigurationNode; import org.spongepowered.configurate.loader.ConfigurationLoader; import org.spongepowered.configurate.serialize.SerializationException; @@ -21,7 +21,7 @@ public class ConfigHandler { private static final String DEFAULT_ORM_SCHEMA_MODE = "validate"; private static final Set SUPPORTED_ORM_SCHEMA_MODES = Set.of("validate", "none", "update", "create"); - private final ILoggerAdapter logger; + private final LoggerAdapter logger; private CommentedConfigurationNode config; private final Path configFile; private final ConfigurationLoader loader; @@ -29,7 +29,7 @@ public class ConfigHandler { /** * Creates a new ConfigHandler using a default data directory and config file. */ - public ConfigHandler(Path dataDir, ILoggerAdapter logger) { + public ConfigHandler(Path dataDir, LoggerAdapter logger) { this.logger = Objects.requireNonNull(logger, "Logger cannot be null."); Objects.requireNonNull(dataDir, "Data directory cannot be null."); this.configFile = dataDir.resolve("config.yml"); diff --git a/src/main/java/nl/hauntedmc/dataprovider/database/document/impl/mongodb/MongoDBDatabase.java b/src/main/java/nl/hauntedmc/dataprovider/database/document/impl/mongodb/MongoDBDatabase.java index f23487f..71b22cd 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/database/document/impl/mongodb/MongoDBDatabase.java +++ b/src/main/java/nl/hauntedmc/dataprovider/database/document/impl/mongodb/MongoDBDatabase.java @@ -9,7 +9,7 @@ import nl.hauntedmc.dataprovider.database.document.DocumentDataAccess; import nl.hauntedmc.dataprovider.database.document.DocumentDatabaseProvider; import nl.hauntedmc.dataprovider.database.security.TlsSupport; -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; +import nl.hauntedmc.dataprovider.logging.LoggerAdapter; import org.spongepowered.configurate.CommentedConfigurationNode; import org.bson.Document; @@ -31,14 +31,14 @@ public class MongoDBDatabase implements DocumentDatabaseProvider, ManagedDatabas private static final Pattern DATABASE_PATTERN = Pattern.compile("[A-Za-z0-9_.\\-]+"); private final CommentedConfigurationNode config; - private final ILoggerAdapter logger; + private final LoggerAdapter logger; private volatile MongoClient mongoClient; private volatile ExecutorService executor; private volatile MongoDBDataAccess dataAccess; private volatile boolean connected; private volatile String databaseName; - public MongoDBDatabase(CommentedConfigurationNode config, ILoggerAdapter logger) { + public MongoDBDatabase(CommentedConfigurationNode config, LoggerAdapter logger) { this.config = config; this.logger = Objects.requireNonNull(logger, "Logger cannot be null."); } diff --git a/src/main/java/nl/hauntedmc/dataprovider/database/keyvalue/impl/redis/RedisDatabase.java b/src/main/java/nl/hauntedmc/dataprovider/database/keyvalue/impl/redis/RedisDatabase.java index dca20b4..c521445 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/database/keyvalue/impl/redis/RedisDatabase.java +++ b/src/main/java/nl/hauntedmc/dataprovider/database/keyvalue/impl/redis/RedisDatabase.java @@ -5,7 +5,7 @@ import nl.hauntedmc.dataprovider.database.keyvalue.KeyValueDataAccess; import nl.hauntedmc.dataprovider.database.keyvalue.KeyValueDatabaseProvider; import nl.hauntedmc.dataprovider.database.security.TlsSupport; -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; +import nl.hauntedmc.dataprovider.logging.LoggerAdapter; import org.spongepowered.configurate.CommentedConfigurationNode; import redis.clients.jedis.DefaultJedisClientConfig; import redis.clients.jedis.HostAndPort; @@ -26,13 +26,13 @@ public class RedisDatabase implements KeyValueDatabaseProvider, ManagedDatabaseP private static final Pattern HOST_PATTERN = Pattern.compile("[A-Za-z0-9._:\\-\\[\\]]+"); private final CommentedConfigurationNode config; - private final ILoggerAdapter logger; + private final LoggerAdapter logger; private volatile JedisPool jedisPool; private volatile ExecutorService executor; private volatile RedisDataAccess dataAccess; private volatile boolean connected; - public RedisDatabase(CommentedConfigurationNode config, ILoggerAdapter logger) { + public RedisDatabase(CommentedConfigurationNode config, LoggerAdapter logger) { this.config = config; this.logger = Objects.requireNonNull(logger, "Logger cannot be null."); } diff --git a/src/main/java/nl/hauntedmc/dataprovider/database/messaging/api/MessageRegistry.java b/src/main/java/nl/hauntedmc/dataprovider/database/messaging/api/MessageRegistry.java index 7591df2..e72ef0b 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/database/messaging/api/MessageRegistry.java +++ b/src/main/java/nl/hauntedmc/dataprovider/database/messaging/api/MessageRegistry.java @@ -1,7 +1,7 @@ package nl.hauntedmc.dataprovider.database.messaging.api; import com.google.gson.*; -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; +import nl.hauntedmc.dataprovider.logging.LoggerAdapter; import java.util.Map; import java.util.Objects; @@ -17,9 +17,9 @@ public final class MessageRegistry { private final Gson gson = new Gson(); private final Map> types = new ConcurrentHashMap<>(); - private final ILoggerAdapter logger; + private final LoggerAdapter logger; - public MessageRegistry(ILoggerAdapter logger) { + public MessageRegistry(LoggerAdapter logger) { this.logger = Objects.requireNonNull(logger, "Logger cannot be null."); } diff --git a/src/main/java/nl/hauntedmc/dataprovider/database/messaging/impl/redis/RedisMessagingDataAccess.java b/src/main/java/nl/hauntedmc/dataprovider/database/messaging/impl/redis/RedisMessagingDataAccess.java index 258e23d..4356d26 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/database/messaging/impl/redis/RedisMessagingDataAccess.java +++ b/src/main/java/nl/hauntedmc/dataprovider/database/messaging/impl/redis/RedisMessagingDataAccess.java @@ -5,7 +5,7 @@ import nl.hauntedmc.dataprovider.database.messaging.api.MessageRegistry; import nl.hauntedmc.dataprovider.database.messaging.api.Subscription; import nl.hauntedmc.dataprovider.internal.concurrent.AsyncTaskSupport; -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; +import nl.hauntedmc.dataprovider.logging.LoggerAdapter; import redis.clients.jedis.Jedis; import redis.clients.jedis.JedisPool; import redis.clients.jedis.JedisPubSub; @@ -28,7 +28,7 @@ final class RedisMessagingDataAccess implements MessagingDataAccess { private final JedisPool pool; private final ExecutorService workers; - private final ILoggerAdapter logger; + private final LoggerAdapter logger; private final MessageRegistry messageRegistry; private final int maxSubscriptions; private final int maxPayloadChars; @@ -40,7 +40,7 @@ final class RedisMessagingDataAccess implements MessagingDataAccess { RedisMessagingDataAccess( JedisPool pool, ExecutorService workers, - ILoggerAdapter logger, + LoggerAdapter logger, MessageRegistry messageRegistry, int maxSubscriptions, int maxPayloadChars, diff --git a/src/main/java/nl/hauntedmc/dataprovider/database/messaging/impl/redis/RedisMessagingDatabase.java b/src/main/java/nl/hauntedmc/dataprovider/database/messaging/impl/redis/RedisMessagingDatabase.java index 69386f5..f9b6184 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/database/messaging/impl/redis/RedisMessagingDatabase.java +++ b/src/main/java/nl/hauntedmc/dataprovider/database/messaging/impl/redis/RedisMessagingDatabase.java @@ -6,7 +6,7 @@ import nl.hauntedmc.dataprovider.database.messaging.MessagingDatabaseProvider; import nl.hauntedmc.dataprovider.database.messaging.api.MessageRegistry; import nl.hauntedmc.dataprovider.database.security.TlsSupport; -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; +import nl.hauntedmc.dataprovider.logging.LoggerAdapter; import org.spongepowered.configurate.CommentedConfigurationNode; import redis.clients.jedis.*; @@ -24,14 +24,14 @@ public final class RedisMessagingDatabase implements MessagingDatabaseProvider, private static final Pattern HOST_PATTERN = Pattern.compile("[A-Za-z0-9._:\\-\\[\\]]+"); private final CommentedConfigurationNode cfg; - private final ILoggerAdapter logger; + private final LoggerAdapter logger; private final MessageRegistry messageRegistry; private volatile JedisPool pool; private volatile ExecutorService workers; private volatile RedisMessagingDataAccess bus; private volatile boolean connected; - public RedisMessagingDatabase(CommentedConfigurationNode cfg, ILoggerAdapter logger) { + public RedisMessagingDatabase(CommentedConfigurationNode cfg, LoggerAdapter logger) { this.cfg = cfg; this.logger = Objects.requireNonNull(logger, "Logger cannot be null."); this.messageRegistry = new MessageRegistry(logger); diff --git a/src/main/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDatabase.java b/src/main/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDatabase.java index 3bb155c..6275596 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDatabase.java +++ b/src/main/java/nl/hauntedmc/dataprovider/database/relational/impl/mysql/MySQLDatabase.java @@ -7,7 +7,7 @@ import nl.hauntedmc.dataprovider.database.relational.RelationalDataAccess; import nl.hauntedmc.dataprovider.database.relational.RelationalDatabaseProvider; import nl.hauntedmc.dataprovider.database.relational.schema.SchemaManager; -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; +import nl.hauntedmc.dataprovider.logging.LoggerAdapter; import org.spongepowered.configurate.CommentedConfigurationNode; import javax.sql.DataSource; @@ -31,13 +31,13 @@ public class MySQLDatabase implements RelationalDatabaseProvider, ManagedDatabas private static final Pattern DATABASE_PATTERN = Pattern.compile("[A-Za-z0-9_$.\\-]+"); private final CommentedConfigurationNode config; - private final ILoggerAdapter logger; + private final LoggerAdapter logger; private volatile HikariDataSource dataSource; private volatile ExecutorService executor; private volatile RelationalDataAccess dataAccess; private volatile SchemaManager schemaManager; - public MySQLDatabase(CommentedConfigurationNode config, ILoggerAdapter logger) { + public MySQLDatabase(CommentedConfigurationNode config, LoggerAdapter logger) { this.config = config; this.logger = Objects.requireNonNull(logger, "Logger cannot be null."); } diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderHandler.java b/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderHandler.java index b107e54..514f6ac 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderHandler.java +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderHandler.java @@ -8,7 +8,7 @@ import nl.hauntedmc.dataprovider.internal.identity.CallerContext; import nl.hauntedmc.dataprovider.internal.identity.CallerContextResolver; import nl.hauntedmc.dataprovider.internal.identity.StackCallerClassLoaderResolver; -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; +import nl.hauntedmc.dataprovider.logging.LoggerAdapter; import java.util.Objects; import java.nio.file.Path; @@ -27,7 +27,7 @@ public class DataProviderHandler { private final DataProviderRegistry registry; private final CallerContextResolver callerContextResolver; - private final ILoggerAdapter logger; + private final LoggerAdapter logger; private final ClassLoader ownClassLoader; public DataProviderHandler( @@ -35,7 +35,7 @@ public DataProviderHandler( ClassLoader resourceClassLoader, ConfigHandler configHandler, CallerContextResolver callerContextResolver, - ILoggerAdapter logger + LoggerAdapter logger ) { Objects.requireNonNull(dataPath, "Data path cannot be null."); Objects.requireNonNull(resourceClassLoader, "Resource class loader cannot be null."); @@ -52,7 +52,7 @@ public DataProviderHandler( DataProviderHandler( DataProviderRegistry registry, CallerContextResolver callerContextResolver, - ILoggerAdapter logger, + LoggerAdapter logger, ClassLoader ownClassLoader ) { this.logger = Objects.requireNonNull(logger, "Logger cannot be null."); diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistry.java b/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistry.java index ad48348..1674fa7 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistry.java +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistry.java @@ -4,7 +4,7 @@ import nl.hauntedmc.dataprovider.database.DatabaseConnectionKey; import nl.hauntedmc.dataprovider.database.DatabaseType; import nl.hauntedmc.dataprovider.database.DatabaseProvider; -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; +import nl.hauntedmc.dataprovider.logging.LoggerAdapter; import java.util.HashMap; import java.util.Map; @@ -31,10 +31,10 @@ class DataProviderRegistry { private final ReadWriteLock lifecycleLock = new ReentrantReadWriteLock(true); private final DatabaseFactory factory; private final ConfigHandler configHandler; - private final ILoggerAdapter logger; + private final LoggerAdapter logger; private volatile boolean closed; - public DataProviderRegistry(DatabaseFactory factory, ConfigHandler configHandler, ILoggerAdapter logger) { + public DataProviderRegistry(DatabaseFactory factory, ConfigHandler configHandler, LoggerAdapter logger) { this.factory = Objects.requireNonNull(factory, "Factory cannot be null."); this.configHandler = Objects.requireNonNull(configHandler, "Config handler cannot be null."); this.logger = Objects.requireNonNull(logger, "Logger cannot be null."); diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/DatabaseConfigMap.java b/src/main/java/nl/hauntedmc/dataprovider/internal/DatabaseConfigMap.java index 4c0ac65..ffdf9bc 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/internal/DatabaseConfigMap.java +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/DatabaseConfigMap.java @@ -2,7 +2,7 @@ import nl.hauntedmc.dataprovider.database.DatabaseType; import nl.hauntedmc.dataprovider.internal.security.FilePermissionHardening; -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; +import nl.hauntedmc.dataprovider.logging.LoggerAdapter; import org.spongepowered.configurate.CommentedConfigurationNode; import org.spongepowered.configurate.loader.ConfigurationLoader; import org.spongepowered.configurate.yaml.YamlConfigurationLoader; @@ -22,11 +22,11 @@ class DatabaseConfigMap { private final Path dataPath; - private final ILoggerAdapter logger; + private final LoggerAdapter logger; private final ClassLoader resourceClassLoader; private final Map configMap = new HashMap<>(); - protected DatabaseConfigMap(Path dataPath, ILoggerAdapter logger, ClassLoader resourceClassLoader) { + protected DatabaseConfigMap(Path dataPath, LoggerAdapter logger, ClassLoader resourceClassLoader) { this.dataPath = Objects.requireNonNull(dataPath, "Data path cannot be null."); this.logger = Objects.requireNonNull(logger, "Logger cannot be null."); this.resourceClassLoader = Objects.requireNonNull(resourceClassLoader, "Resource class loader cannot be null."); diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/DatabaseFactory.java b/src/main/java/nl/hauntedmc/dataprovider/internal/DatabaseFactory.java index 8500186..579151b 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/internal/DatabaseFactory.java +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/DatabaseFactory.java @@ -5,7 +5,7 @@ import nl.hauntedmc.dataprovider.database.keyvalue.impl.redis.RedisDatabase; import nl.hauntedmc.dataprovider.database.messaging.impl.redis.RedisMessagingDatabase; import nl.hauntedmc.dataprovider.database.relational.impl.mysql.MySQLDatabase; -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; +import nl.hauntedmc.dataprovider.logging.LoggerAdapter; import org.spongepowered.configurate.CommentedConfigurationNode; import java.util.Objects; @@ -13,9 +13,9 @@ class DatabaseFactory { private final DatabaseConfigMap configMap; - private final ILoggerAdapter logger; + private final LoggerAdapter logger; - protected DatabaseFactory(DatabaseConfigMap configMap, ILoggerAdapter logger) { + protected DatabaseFactory(DatabaseConfigMap configMap, LoggerAdapter logger) { this.configMap = Objects.requireNonNull(configMap, "Config map cannot be null."); this.logger = Objects.requireNonNull(logger, "Logger cannot be null."); } diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/security/FilePermissionHardening.java b/src/main/java/nl/hauntedmc/dataprovider/internal/security/FilePermissionHardening.java index 7efc734..76d8f6b 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/internal/security/FilePermissionHardening.java +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/security/FilePermissionHardening.java @@ -1,6 +1,6 @@ package nl.hauntedmc.dataprovider.internal.security; -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; +import nl.hauntedmc.dataprovider.logging.LoggerAdapter; import java.io.IOException; import java.nio.file.Files; @@ -27,18 +27,18 @@ public final class FilePermissionHardening { private FilePermissionHardening() { } - public static void restrictDirectoryToOwner(Path directory, ILoggerAdapter logger, String description) { + public static void restrictDirectoryToOwner(Path directory, LoggerAdapter logger, String description) { restrictToOwner(directory, OWNER_DIRECTORY_PERMISSIONS, logger, description); } - public static void restrictFileToOwner(Path file, ILoggerAdapter logger, String description) { + public static void restrictFileToOwner(Path file, LoggerAdapter logger, String description) { restrictToOwner(file, OWNER_FILE_PERMISSIONS, logger, description); } private static void restrictToOwner( Path path, Set permissions, - ILoggerAdapter logger, + LoggerAdapter logger, String description ) { if (path == null || logger == null || description == null || !Files.exists(path)) { diff --git a/src/main/java/nl/hauntedmc/dataprovider/logging/LogLevel.java b/src/main/java/nl/hauntedmc/dataprovider/logging/LogLevel.java new file mode 100644 index 0000000..cbd2d23 --- /dev/null +++ b/src/main/java/nl/hauntedmc/dataprovider/logging/LogLevel.java @@ -0,0 +1,10 @@ +package nl.hauntedmc.dataprovider.logging; + +/** + * Severity levels used by DataProvider's logging abstraction. + */ +public enum LogLevel { + INFO, + WARN, + ERROR +} diff --git a/src/main/java/nl/hauntedmc/dataprovider/logging/LoggerAdapter.java b/src/main/java/nl/hauntedmc/dataprovider/logging/LoggerAdapter.java new file mode 100644 index 0000000..cb0d200 --- /dev/null +++ b/src/main/java/nl/hauntedmc/dataprovider/logging/LoggerAdapter.java @@ -0,0 +1,37 @@ +package nl.hauntedmc.dataprovider.logging; + +/** + * Backend-agnostic logging contract for DataProvider internals. + */ +public interface LoggerAdapter { + + void log(LogLevel level, String message, Throwable throwable); + + default void log(LogLevel level, String message) { + log(level, message, null); + } + + default void info(String message) { + log(LogLevel.INFO, message); + } + + default void warn(String message) { + log(LogLevel.WARN, message); + } + + default void error(String message) { + log(LogLevel.ERROR, message); + } + + default void info(String message, Throwable throwable) { + log(LogLevel.INFO, message, throwable); + } + + default void warn(String message, Throwable throwable) { + log(LogLevel.WARN, message, throwable); + } + + default void error(String message, Throwable throwable) { + log(LogLevel.ERROR, message, throwable); + } +} diff --git a/src/main/java/nl/hauntedmc/dataprovider/logging/adapters/JulLoggerAdapter.java b/src/main/java/nl/hauntedmc/dataprovider/logging/adapters/JulLoggerAdapter.java new file mode 100644 index 0000000..37326bf --- /dev/null +++ b/src/main/java/nl/hauntedmc/dataprovider/logging/adapters/JulLoggerAdapter.java @@ -0,0 +1,33 @@ +package nl.hauntedmc.dataprovider.logging.adapters; + +import nl.hauntedmc.dataprovider.logging.LogLevel; +import nl.hauntedmc.dataprovider.logging.LoggerAdapter; + +import java.util.Objects; +import java.util.logging.Level; +import java.util.logging.Logger; + +public final class JulLoggerAdapter implements LoggerAdapter { + + private final Logger logger; + + public JulLoggerAdapter(Logger logger) { + this.logger = Objects.requireNonNull(logger, "Logger cannot be null."); + } + + @Override + public void log(LogLevel level, String message, Throwable throwable) { + Objects.requireNonNull(level, "Log level cannot be null."); + Level julLevel = switch (level) { + case INFO -> Level.INFO; + case WARN -> Level.WARNING; + case ERROR -> Level.SEVERE; + }; + + if (throwable == null) { + logger.log(julLevel, message); + } else { + logger.log(julLevel, message, throwable); + } + } +} diff --git a/src/main/java/nl/hauntedmc/dataprovider/logging/adapters/Slf4jLoggerAdapter.java b/src/main/java/nl/hauntedmc/dataprovider/logging/adapters/Slf4jLoggerAdapter.java new file mode 100644 index 0000000..d8e0b85 --- /dev/null +++ b/src/main/java/nl/hauntedmc/dataprovider/logging/adapters/Slf4jLoggerAdapter.java @@ -0,0 +1,50 @@ +package nl.hauntedmc.dataprovider.logging.adapters; + +import nl.hauntedmc.dataprovider.logging.LogLevel; +import nl.hauntedmc.dataprovider.logging.LoggerAdapter; +import org.slf4j.Logger; + +import java.util.Objects; + +public final class Slf4jLoggerAdapter implements LoggerAdapter { + + private final Logger logger; + + public Slf4jLoggerAdapter(Logger logger) { + this.logger = Objects.requireNonNull(logger, "Logger cannot be null."); + } + + @Override + public void log(LogLevel level, String message, Throwable throwable) { + Objects.requireNonNull(level, "Log level cannot be null."); + switch (level) { + case INFO -> logInfo(message, throwable); + case WARN -> logWarn(message, throwable); + case ERROR -> logError(message, throwable); + } + } + + private void logInfo(String message, Throwable throwable) { + if (throwable == null) { + logger.info(message); + } else { + logger.info(message, throwable); + } + } + + private void logWarn(String message, Throwable throwable) { + if (throwable == null) { + logger.warn(message); + } else { + logger.warn(message, throwable); + } + } + + private void logError(String message, Throwable throwable) { + if (throwable == null) { + logger.error(message); + } else { + logger.error(message, throwable); + } + } +} diff --git a/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/BukkitDataProvider.java b/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/BukkitDataProvider.java index 9ea8939..4015927 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/BukkitDataProvider.java +++ b/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/BukkitDataProvider.java @@ -5,51 +5,46 @@ import nl.hauntedmc.dataprovider.internal.DataProviderHandler; import nl.hauntedmc.dataprovider.platform.bukkit.command.DataProviderCommand; import nl.hauntedmc.dataprovider.platform.bukkit.identity.BukkitCallerContextResolver; -import nl.hauntedmc.dataprovider.platform.bukkit.logger.BukkitLoggerAdapter; -import nl.hauntedmc.dataprovider.platform.common.lifecycle.PlatformDataProviderRuntime; +import nl.hauntedmc.dataprovider.logging.adapters.JulLoggerAdapter; +import nl.hauntedmc.dataprovider.platform.internal.lifecycle.PlatformDataProviderRuntime; import org.bukkit.command.PluginCommand; +import org.bukkit.plugin.ServicePriority; import org.bukkit.plugin.java.JavaPlugin; public final class BukkitDataProvider extends JavaPlugin { private static final String COMMAND_NAME = "dataprovider"; - private static final PlatformDataProviderRuntime RUNTIME = new PlatformDataProviderRuntime(); + private final PlatformDataProviderRuntime runtime = new PlatformDataProviderRuntime(); @Override public void onEnable() { - BukkitLoggerAdapter loggerAdapter = new BukkitLoggerAdapter(getLogger()); - DataProvider provider = RUNTIME.start( + JulLoggerAdapter loggerAdapter = new JulLoggerAdapter(getLogger()); + runtime.start( () -> new DataProvider( loggerAdapter, getDataPath(), getClassLoader(), new BukkitCallerContextResolver(getClassLoader()) ), + this::initializeBindings, loggerAdapter ); - try { - registerCommand(provider.getDataProviderHandler()); - } catch (RuntimeException exception) { - loggerAdapter.error("Failed to initialize Bukkit command wiring.", exception); - RUNTIME.stop(loggerAdapter); - throw exception; - } getLogger().info("Enabled (v" + getDescription().getVersion() + ")."); } @Override public void onDisable() { - RUNTIME.stop(new BukkitLoggerAdapter(getLogger())); + getServer().getServicesManager().unregisterAll(this); + runtime.stop(new JulLoggerAdapter(getLogger())); getLogger().info("Disabled."); } - // START EXTERNALLY ACCESSIBLE - public static DataProviderAPI getDataProviderAPI() { - return RUNTIME.getDataProviderAPI(); + private void initializeBindings(DataProvider provider) { + registerCommand(provider.getDataProviderHandler()); + registerApiService(new DataProviderAPI(provider.getDataProviderHandler())); } - // END EXTERNALLY ACCESSIBLE private void registerCommand(DataProviderHandler handler) { PluginCommand command = getCommand(COMMAND_NAME); @@ -61,4 +56,13 @@ private void registerCommand(DataProviderHandler handler) { command.setExecutor(commandExecutor); command.setTabCompleter(commandExecutor); } + + private void registerApiService(DataProviderAPI dataProviderAPI) { + getServer().getServicesManager().register( + DataProviderAPI.class, + dataProviderAPI, + this, + ServicePriority.Normal + ); + } } diff --git a/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/command/DataProviderCommand.java b/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/command/DataProviderCommand.java index e6e288d..80169b8 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/command/DataProviderCommand.java +++ b/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/command/DataProviderCommand.java @@ -1,7 +1,7 @@ package nl.hauntedmc.dataprovider.platform.bukkit.command; import nl.hauntedmc.dataprovider.internal.DataProviderHandler; -import nl.hauntedmc.dataprovider.platform.common.command.DataProviderCommandService; +import nl.hauntedmc.dataprovider.platform.internal.command.DataProviderCommandService; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; diff --git a/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/logger/BukkitLoggerAdapter.java b/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/logger/BukkitLoggerAdapter.java deleted file mode 100644 index e4119f1..0000000 --- a/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/logger/BukkitLoggerAdapter.java +++ /dev/null @@ -1,46 +0,0 @@ -package nl.hauntedmc.dataprovider.platform.bukkit.logger; - -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; - -import java.util.Objects; -import java.util.logging.Level; -import java.util.logging.Logger; - -public final class BukkitLoggerAdapter implements ILoggerAdapter { - - private final Logger logger; - - public BukkitLoggerAdapter(Logger logger) { - this.logger = Objects.requireNonNull(logger, "Logger cannot be null."); - } - - @Override - public void info(String message) { - logger.log(Level.INFO, message); - } - - @Override - public void warn(String message) { - logger.log(Level.WARNING, message); - } - - @Override - public void error(String message) { - logger.log(Level.SEVERE, message); - } - - @Override - public void info(String message, Throwable throwable) { - logger.log(Level.INFO, message, throwable); - } - - @Override - public void warn(String message, Throwable throwable) { - logger.log(Level.WARNING, message, throwable); - } - - @Override - public void error(String message, Throwable throwable) { - logger.log(Level.SEVERE, message, throwable); - } -} diff --git a/src/main/java/nl/hauntedmc/dataprovider/platform/common/logger/ILoggerAdapter.java b/src/main/java/nl/hauntedmc/dataprovider/platform/common/logger/ILoggerAdapter.java deleted file mode 100644 index bac139f..0000000 --- a/src/main/java/nl/hauntedmc/dataprovider/platform/common/logger/ILoggerAdapter.java +++ /dev/null @@ -1,13 +0,0 @@ -package nl.hauntedmc.dataprovider.platform.common.logger; - -/** - * A common logging interface for both Bukkit and Velocity implementations. - */ -public interface ILoggerAdapter { - void info(String message); - void warn(String message); - void error(String message); - void info(String message, Throwable throwable); - void warn(String message, Throwable throwable); - void error(String message, Throwable throwable); -} diff --git a/src/main/java/nl/hauntedmc/dataprovider/platform/common/command/DataProviderCommandService.java b/src/main/java/nl/hauntedmc/dataprovider/platform/internal/command/DataProviderCommandService.java similarity index 98% rename from src/main/java/nl/hauntedmc/dataprovider/platform/common/command/DataProviderCommandService.java rename to src/main/java/nl/hauntedmc/dataprovider/platform/internal/command/DataProviderCommandService.java index c2ddc2c..de094b3 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/platform/common/command/DataProviderCommandService.java +++ b/src/main/java/nl/hauntedmc/dataprovider/platform/internal/command/DataProviderCommandService.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.dataprovider.platform.common.command; +package nl.hauntedmc.dataprovider.platform.internal.command; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; diff --git a/src/main/java/nl/hauntedmc/dataprovider/platform/common/lifecycle/PlatformDataProviderRuntime.java b/src/main/java/nl/hauntedmc/dataprovider/platform/internal/lifecycle/PlatformDataProviderRuntime.java similarity index 59% rename from src/main/java/nl/hauntedmc/dataprovider/platform/common/lifecycle/PlatformDataProviderRuntime.java rename to src/main/java/nl/hauntedmc/dataprovider/platform/internal/lifecycle/PlatformDataProviderRuntime.java index 845ea4e..2f964f2 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/platform/common/lifecycle/PlatformDataProviderRuntime.java +++ b/src/main/java/nl/hauntedmc/dataprovider/platform/internal/lifecycle/PlatformDataProviderRuntime.java @@ -1,11 +1,11 @@ -package nl.hauntedmc.dataprovider.platform.common.lifecycle; +package nl.hauntedmc.dataprovider.platform.internal.lifecycle; import nl.hauntedmc.dataprovider.DataProvider; import nl.hauntedmc.dataprovider.api.DataProviderAPI; -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; +import nl.hauntedmc.dataprovider.logging.LoggerAdapter; import java.util.Objects; -import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; import java.util.function.Supplier; /** @@ -18,20 +18,30 @@ public final class PlatformDataProviderRuntime { "Detected leftover DataProvider instance during enable; forcing cleanup first."; private static final String LEFTOVER_SHUTDOWN_FAILURE_MESSAGE = "Failed to shut down leftover DataProvider instance cleanly."; + private static final String STARTUP_INITIALIZATION_FAILURE_MESSAGE = + "Failed to complete DataProvider startup initialization."; + private static final String STARTUP_FAILURE_SHUTDOWN_MESSAGE = + "Failed to shut down DataProvider instance after startup initialization failure."; private static final String SHUTDOWN_FAILURE_MESSAGE = "Failed to shut down DataProvider cleanly."; - private final AtomicReference activeProvider = new AtomicReference<>(); + private DataProvider activeProvider; /** * Starts a fresh DataProvider instance. * If an old runtime instance is still present, it is shut down first. */ - public synchronized DataProvider start(Supplier providerFactory, ILoggerAdapter logger) { + public synchronized DataProvider start( + Supplier providerFactory, + Consumer startupInitializer, + LoggerAdapter logger + ) { Objects.requireNonNull(providerFactory, "Provider factory cannot be null."); + Objects.requireNonNull(startupInitializer, "Startup initializer cannot be null."); Objects.requireNonNull(logger, "Logger cannot be null."); - DataProvider previousProvider = activeProvider.getAndSet(null); + DataProvider previousProvider = activeProvider; + activeProvider = null; if (previousProvider != null) { logger.warn(LEFTOVER_INSTANCE_MESSAGE); shutdownProvider(previousProvider, logger, LEFTOVER_SHUTDOWN_FAILURE_MESSAGE); @@ -41,16 +51,26 @@ public synchronized DataProvider start(Supplier providerFactory, I providerFactory.get(), "Provider factory cannot return null." ); - activeProvider.set(createdProvider); + + try { + startupInitializer.accept(createdProvider); + } catch (RuntimeException | Error exception) { + logger.error(STARTUP_INITIALIZATION_FAILURE_MESSAGE, exception); + shutdownProvider(createdProvider, logger, STARTUP_FAILURE_SHUTDOWN_MESSAGE); + throw exception; + } + + activeProvider = createdProvider; return createdProvider; } /** * Stops the current DataProvider instance, if one is active. */ - public synchronized void stop(ILoggerAdapter logger) { + public synchronized void stop(LoggerAdapter logger) { Objects.requireNonNull(logger, "Logger cannot be null."); - DataProvider providerToShutdown = activeProvider.getAndSet(null); + DataProvider providerToShutdown = activeProvider; + activeProvider = null; if (providerToShutdown != null) { shutdownProvider(providerToShutdown, logger, SHUTDOWN_FAILURE_MESSAGE); } @@ -59,15 +79,15 @@ public synchronized void stop(ILoggerAdapter logger) { /** * Resolves a new API facade for the currently active provider. */ - public DataProviderAPI getDataProviderAPI() { - DataProvider provider = activeProvider.get(); + public synchronized DataProviderAPI getDataProviderAPI() { + DataProvider provider = activeProvider; if (provider == null) { throw new IllegalStateException(NOT_INITIALIZED_MESSAGE); } return new DataProviderAPI(provider.getDataProviderHandler()); } - private static void shutdownProvider(DataProvider provider, ILoggerAdapter logger, String failureMessage) { + private static void shutdownProvider(DataProvider provider, LoggerAdapter logger, String failureMessage) { try { provider.shutdownAllDatabases(); } catch (Exception e) { diff --git a/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProvider.java b/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProvider.java index 447375e..8987df1 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProvider.java +++ b/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProvider.java @@ -11,11 +11,12 @@ import com.velocitypowered.api.proxy.ProxyServer; import nl.hauntedmc.dataprovider.DataProvider; import nl.hauntedmc.dataprovider.api.DataProviderAPI; +import nl.hauntedmc.dataprovider.api.DataProviderApiSupplier; import nl.hauntedmc.dataprovider.internal.DataProviderHandler; -import nl.hauntedmc.dataprovider.platform.common.lifecycle.PlatformDataProviderRuntime; +import nl.hauntedmc.dataprovider.platform.internal.lifecycle.PlatformDataProviderRuntime; import nl.hauntedmc.dataprovider.platform.velocity.command.DataProviderCommand; import nl.hauntedmc.dataprovider.platform.velocity.identity.VelocityCallerContextResolver; -import nl.hauntedmc.dataprovider.platform.velocity.logger.SLF4JLoggerAdapter; +import nl.hauntedmc.dataprovider.logging.adapters.Slf4jLoggerAdapter; import org.slf4j.Logger; import java.nio.file.Path; @@ -27,16 +28,18 @@ description = "A cross-platform data provider plugin.", authors = {"HauntedMC"} ) -public final class VelocityDataProvider { +public final class VelocityDataProvider implements DataProviderApiSupplier { private static final short INITIALIZE_EVENT_PRIORITY = Short.MAX_VALUE; private static final short SHUTDOWN_EVENT_PRIORITY = Short.MIN_VALUE; private static final String COMMAND_NAME = "dataprovider"; - private static final PlatformDataProviderRuntime RUNTIME = new PlatformDataProviderRuntime(); + private static final String NOT_INITIALIZED_MESSAGE = "DataProvider is not initialized yet."; private final ProxyServer proxyServer; private final Logger logger; private final Path dataDirectory; + private final PlatformDataProviderRuntime runtime = new PlatformDataProviderRuntime(); + private volatile DataProviderAPI dataProviderApi; @Inject public VelocityDataProvider(ProxyServer proxyServer, Logger logger, @DataDirectory Path dataDirectory) { @@ -47,23 +50,17 @@ public VelocityDataProvider(ProxyServer proxyServer, Logger logger, @DataDirecto @Subscribe(priority = INITIALIZE_EVENT_PRIORITY) public void onProxyInitialize(ProxyInitializeEvent event) { - SLF4JLoggerAdapter loggerAdapter = new SLF4JLoggerAdapter(logger); - DataProvider provider = RUNTIME.start( + Slf4jLoggerAdapter loggerAdapter = new Slf4jLoggerAdapter(logger); + runtime.start( () -> new DataProvider( loggerAdapter, dataDirectory, getClass().getClassLoader(), new VelocityCallerContextResolver(proxyServer, getClass().getClassLoader()) ), + this::initializeBindings, loggerAdapter ); - try { - registerCommand(provider.getDataProviderHandler()); - } catch (RuntimeException exception) { - loggerAdapter.error("Failed to initialize Velocity command wiring.", exception); - RUNTIME.stop(loggerAdapter); - throw exception; - } String pluginVersion = resolvePluginVersion(proxyServer, this); logger.info("DataProvider plugin enabled on Velocity (v{}).", pluginVersion); @@ -71,15 +68,24 @@ public void onProxyInitialize(ProxyInitializeEvent event) { @Subscribe(priority = SHUTDOWN_EVENT_PRIORITY) public void onProxyShutdown(ProxyShutdownEvent event) { - RUNTIME.stop(new SLF4JLoggerAdapter(logger)); + dataProviderApi = null; + runtime.stop(new Slf4jLoggerAdapter(logger)); logger.info("DataProvider plugin disabled on Velocity."); } - // START EXTERNALLY ACCESSIBLE - public static DataProviderAPI getDataProviderAPI() { - return RUNTIME.getDataProviderAPI(); + @Override + public DataProviderAPI dataProviderApi() { + DataProviderAPI api = dataProviderApi; + if (api == null) { + throw new IllegalStateException(NOT_INITIALIZED_MESSAGE); + } + return api; + } + + private void initializeBindings(DataProvider provider) { + registerCommand(provider.getDataProviderHandler()); + dataProviderApi = new DataProviderAPI(provider.getDataProviderHandler()); } - // END EXTERNALLY ACCESSIBLE private void registerCommand(DataProviderHandler handler) { CommandManager commandManager = proxyServer.getCommandManager(); diff --git a/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/command/DataProviderCommand.java b/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/command/DataProviderCommand.java index 4ebecc9..2ceeb1f 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/command/DataProviderCommand.java +++ b/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/command/DataProviderCommand.java @@ -3,7 +3,7 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.command.SimpleCommand; import nl.hauntedmc.dataprovider.internal.DataProviderHandler; -import nl.hauntedmc.dataprovider.platform.common.command.DataProviderCommandService; +import nl.hauntedmc.dataprovider.platform.internal.command.DataProviderCommandService; import java.util.List; import java.util.Objects; diff --git a/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/logger/SLF4JLoggerAdapter.java b/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/logger/SLF4JLoggerAdapter.java deleted file mode 100644 index a7fa209..0000000 --- a/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/logger/SLF4JLoggerAdapter.java +++ /dev/null @@ -1,44 +0,0 @@ -package nl.hauntedmc.dataprovider.platform.velocity.logger; - -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; -import org.slf4j.Logger; - -import java.util.Objects; - -public final class SLF4JLoggerAdapter implements ILoggerAdapter { - private final Logger logger; - - public SLF4JLoggerAdapter(Logger logger) { - this.logger = Objects.requireNonNull(logger, "Logger cannot be null."); - } - - @Override - public void info(String message) { - logger.info(message); - } - - @Override - public void warn(String message) { - logger.warn(message); - } - - @Override - public void error(String message) { - logger.error(message); - } - - @Override - public void info(String message, Throwable throwable) { - logger.info(message, throwable); - } - - @Override - public void warn(String message, Throwable throwable) { - logger.warn(message, throwable); - } - - @Override - public void error(String message, Throwable throwable) { - logger.error(message, throwable); - } -} diff --git a/src/test/java/nl/hauntedmc/dataprovider/platform/bukkit/logger/BukkitLoggerAdapterTest.java b/src/test/java/nl/hauntedmc/dataprovider/logging/adapters/JulLoggerAdapterTest.java similarity index 87% rename from src/test/java/nl/hauntedmc/dataprovider/platform/bukkit/logger/BukkitLoggerAdapterTest.java rename to src/test/java/nl/hauntedmc/dataprovider/logging/adapters/JulLoggerAdapterTest.java index 422a80e..ad95595 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/platform/bukkit/logger/BukkitLoggerAdapterTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/logging/adapters/JulLoggerAdapterTest.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.dataprovider.platform.bukkit.logger; +package nl.hauntedmc.dataprovider.logging.adapters; import org.junit.jupiter.api.Test; @@ -13,16 +13,16 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertSame; -class BukkitLoggerAdapterTest { +class JulLoggerAdapterTest { @Test void forwardsMessagesWithExpectedLevels() { - Logger logger = Logger.getLogger("BukkitLoggerAdapterTest-" + UUID.randomUUID()); + Logger logger = Logger.getLogger("JulLoggerAdapterTest-" + UUID.randomUUID()); logger.setUseParentHandlers(false); RecordingHandler handler = new RecordingHandler(); logger.addHandler(handler); - BukkitLoggerAdapter adapter = new BukkitLoggerAdapter(logger); + JulLoggerAdapter adapter = new JulLoggerAdapter(logger); RuntimeException throwable = new RuntimeException("boom"); adapter.info("info"); diff --git a/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/logger/SLF4JLoggerAdapterTest.java b/src/test/java/nl/hauntedmc/dataprovider/logging/adapters/Slf4jLoggerAdapterTest.java similarity index 84% rename from src/test/java/nl/hauntedmc/dataprovider/platform/velocity/logger/SLF4JLoggerAdapterTest.java rename to src/test/java/nl/hauntedmc/dataprovider/logging/adapters/Slf4jLoggerAdapterTest.java index 7c54f4c..232c54f 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/logger/SLF4JLoggerAdapterTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/logging/adapters/Slf4jLoggerAdapterTest.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.dataprovider.platform.velocity.logger; +package nl.hauntedmc.dataprovider.logging.adapters; import org.junit.jupiter.api.Test; import org.slf4j.Logger; @@ -6,13 +6,13 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; -class SLF4JLoggerAdapterTest { +class Slf4jLoggerAdapterTest { @Test void forwardsAllLogCallsToSlf4jLogger() { Logger logger = mock(Logger.class); RuntimeException throwable = new RuntimeException("boom"); - SLF4JLoggerAdapter adapter = new SLF4JLoggerAdapter(logger); + Slf4jLoggerAdapter adapter = new Slf4jLoggerAdapter(logger); adapter.info("info"); adapter.warn("warn"); diff --git a/src/test/java/nl/hauntedmc/dataprovider/platform/bukkit/BukkitDataProviderTest.java b/src/test/java/nl/hauntedmc/dataprovider/platform/bukkit/BukkitDataProviderTest.java deleted file mode 100644 index 00da1a7..0000000 --- a/src/test/java/nl/hauntedmc/dataprovider/platform/bukkit/BukkitDataProviderTest.java +++ /dev/null @@ -1,52 +0,0 @@ -package nl.hauntedmc.dataprovider.platform.bukkit; - -import nl.hauntedmc.dataprovider.DataProvider; -import nl.hauntedmc.dataprovider.api.DataProviderAPI; -import nl.hauntedmc.dataprovider.internal.DataProviderHandler; -import nl.hauntedmc.dataprovider.platform.common.lifecycle.PlatformDataProviderRuntime; -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; -import org.junit.jupiter.api.Test; - -import java.lang.reflect.Field; - -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -class BukkitDataProviderTest { - - @Test - void getDataProviderApiThrowsWhenNotInitialized() throws ReflectiveOperationException { - PlatformDataProviderRuntime runtime = resolveRuntime(); - runtime.stop(mock(ILoggerAdapter.class)); - - assertThrows(IllegalStateException.class, BukkitDataProvider::getDataProviderAPI); - } - - @Test - void getDataProviderApiReturnsFacadeWhenInitialized() throws ReflectiveOperationException { - DataProvider provider = mock(DataProvider.class); - DataProviderHandler handler = mock(DataProviderHandler.class); - when(provider.getDataProviderHandler()).thenReturn(handler); - - PlatformDataProviderRuntime runtime = resolveRuntime(); - runtime.stop(mock(ILoggerAdapter.class)); - runtime.start(() -> provider, mock(ILoggerAdapter.class)); - try { - DataProviderAPI api = BukkitDataProvider.getDataProviderAPI(); - assertNotNull(api); - api.unregisterAllDatabases(); - verify(handler).unregisterAllDatabases(); - } finally { - runtime.stop(mock(ILoggerAdapter.class)); - } - } - - private static PlatformDataProviderRuntime resolveRuntime() throws ReflectiveOperationException { - Field field = BukkitDataProvider.class.getDeclaredField("RUNTIME"); - field.setAccessible(true); - return (PlatformDataProviderRuntime) field.get(null); - } -} diff --git a/src/test/java/nl/hauntedmc/dataprovider/platform/common/command/DataProviderCommandServiceTest.java b/src/test/java/nl/hauntedmc/dataprovider/platform/internal/command/DataProviderCommandServiceTest.java similarity index 98% rename from src/test/java/nl/hauntedmc/dataprovider/platform/common/command/DataProviderCommandServiceTest.java rename to src/test/java/nl/hauntedmc/dataprovider/platform/internal/command/DataProviderCommandServiceTest.java index 6deb5cb..2f0afd4 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/platform/common/command/DataProviderCommandServiceTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/platform/internal/command/DataProviderCommandServiceTest.java @@ -1,4 +1,4 @@ -package nl.hauntedmc.dataprovider.platform.common.command; +package nl.hauntedmc.dataprovider.platform.internal.command; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; diff --git a/src/test/java/nl/hauntedmc/dataprovider/platform/common/lifecycle/PlatformDataProviderRuntimeTest.java b/src/test/java/nl/hauntedmc/dataprovider/platform/internal/lifecycle/PlatformDataProviderRuntimeTest.java similarity index 61% rename from src/test/java/nl/hauntedmc/dataprovider/platform/common/lifecycle/PlatformDataProviderRuntimeTest.java rename to src/test/java/nl/hauntedmc/dataprovider/platform/internal/lifecycle/PlatformDataProviderRuntimeTest.java index 5d7fa4c..4aeb18a 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/platform/common/lifecycle/PlatformDataProviderRuntimeTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/platform/internal/lifecycle/PlatformDataProviderRuntimeTest.java @@ -1,9 +1,9 @@ -package nl.hauntedmc.dataprovider.platform.common.lifecycle; +package nl.hauntedmc.dataprovider.platform.internal.lifecycle; import nl.hauntedmc.dataprovider.DataProvider; import nl.hauntedmc.dataprovider.api.DataProviderAPI; import nl.hauntedmc.dataprovider.internal.DataProviderHandler; -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; +import nl.hauntedmc.dataprovider.logging.LoggerAdapter; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -17,12 +17,14 @@ class PlatformDataProviderRuntimeTest { @Test void startShutsDownLeftoverProviderBeforeReplacing() { PlatformDataProviderRuntime runtime = new PlatformDataProviderRuntime(); - ILoggerAdapter logger = mock(ILoggerAdapter.class); + LoggerAdapter logger = mock(LoggerAdapter.class); DataProvider previousProvider = mock(DataProvider.class); DataProvider replacementProvider = mock(DataProvider.class); - runtime.start(() -> previousProvider, logger); - runtime.start(() -> replacementProvider, logger); + runtime.start(() -> previousProvider, provider -> { + }, logger); + runtime.start(() -> replacementProvider, provider -> { + }, logger); verify(logger).warn("Detected leftover DataProvider instance during enable; forcing cleanup first."); verify(previousProvider).shutdownAllDatabases(); @@ -31,10 +33,11 @@ void startShutsDownLeftoverProviderBeforeReplacing() { @Test void stopShutsDownActiveProviderAndMakesApiUnavailable() { PlatformDataProviderRuntime runtime = new PlatformDataProviderRuntime(); - ILoggerAdapter logger = mock(ILoggerAdapter.class); + LoggerAdapter logger = mock(LoggerAdapter.class); DataProvider provider = mock(DataProvider.class); - runtime.start(() -> provider, logger); + runtime.start(() -> provider, created -> { + }, logger); runtime.stop(logger); verify(provider).shutdownAllDatabases(); @@ -44,12 +47,13 @@ void stopShutsDownActiveProviderAndMakesApiUnavailable() { @Test void getDataProviderApiReturnsFacadeForActiveProvider() { PlatformDataProviderRuntime runtime = new PlatformDataProviderRuntime(); - ILoggerAdapter logger = mock(ILoggerAdapter.class); + LoggerAdapter logger = mock(LoggerAdapter.class); DataProvider provider = mock(DataProvider.class); DataProviderHandler handler = mock(DataProviderHandler.class); when(provider.getDataProviderHandler()).thenReturn(handler); - runtime.start(() -> provider, logger); + runtime.start(() -> provider, created -> { + }, logger); try { DataProviderAPI api = runtime.getDataProviderAPI(); assertNotNull(api); @@ -65,4 +69,25 @@ void getDataProviderApiThrowsWhenNotStarted() { PlatformDataProviderRuntime runtime = new PlatformDataProviderRuntime(); assertThrows(IllegalStateException.class, runtime::getDataProviderAPI); } + + @Test + void startRollsBackProviderWhenInitializerFails() { + PlatformDataProviderRuntime runtime = new PlatformDataProviderRuntime(); + LoggerAdapter logger = mock(LoggerAdapter.class); + DataProvider provider = mock(DataProvider.class); + + assertThrows( + IllegalStateException.class, + () -> runtime.start( + () -> provider, + created -> { + throw new IllegalStateException("startup failed"); + }, + logger + ) + ); + + verify(provider).shutdownAllDatabases(); + assertThrows(IllegalStateException.class, runtime::getDataProviderAPI); + } } diff --git a/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProviderTest.java b/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProviderTest.java index 6a2b1f7..a32d404 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProviderTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProviderTest.java @@ -7,15 +7,13 @@ import com.velocitypowered.api.plugin.PluginDescription; import com.velocitypowered.api.plugin.PluginManager; import com.velocitypowered.api.proxy.ProxyServer; -import nl.hauntedmc.dataprovider.DataProvider; import nl.hauntedmc.dataprovider.api.DataProviderAPI; -import nl.hauntedmc.dataprovider.internal.DataProviderHandler; -import nl.hauntedmc.dataprovider.platform.common.lifecycle.PlatformDataProviderRuntime; -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; import org.junit.jupiter.api.Test; +import org.slf4j.Logger; import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.nio.file.Path; import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -79,35 +77,34 @@ void resolvePluginVersionFallsBackToUnknownWhenVersionMissing() { } @Test - void getDataProviderApiThrowsWhenNotInitialized() throws ReflectiveOperationException { - PlatformDataProviderRuntime runtime = resolveRuntime(); - runtime.stop(mock(ILoggerAdapter.class)); + void dataProviderApiThrowsWhenNotInitialized() { + VelocityDataProvider provider = new VelocityDataProvider( + mock(ProxyServer.class), + mock(Logger.class), + Path.of(".") + ); - assertThrows(IllegalStateException.class, VelocityDataProvider::getDataProviderAPI); + assertThrows(IllegalStateException.class, provider::dataProviderApi); } @Test - void getDataProviderApiReturnsFacadeWhenInitialized() throws ReflectiveOperationException { - DataProvider provider = mock(DataProvider.class); - DataProviderHandler handler = mock(DataProviderHandler.class); - when(provider.getDataProviderHandler()).thenReturn(handler); - - PlatformDataProviderRuntime runtime = resolveRuntime(); - runtime.stop(mock(ILoggerAdapter.class)); - runtime.start(() -> provider, mock(ILoggerAdapter.class)); - try { - DataProviderAPI api = VelocityDataProvider.getDataProviderAPI(); - assertNotNull(api); - api.unregisterAllDatabases(); - verify(handler).unregisterAllDatabases(); - } finally { - runtime.stop(mock(ILoggerAdapter.class)); - } - } + void dataProviderApiReturnsStoredApiWhenInitialized() throws ReflectiveOperationException { + nl.hauntedmc.dataprovider.internal.DataProviderHandler handler = + mock(nl.hauntedmc.dataprovider.internal.DataProviderHandler.class); + + VelocityDataProvider velocityDataProvider = new VelocityDataProvider( + mock(ProxyServer.class), + mock(Logger.class), + Path.of(".") + ); - private static PlatformDataProviderRuntime resolveRuntime() throws ReflectiveOperationException { - Field field = VelocityDataProvider.class.getDeclaredField("RUNTIME"); + Field field = VelocityDataProvider.class.getDeclaredField("dataProviderApi"); field.setAccessible(true); - return (PlatformDataProviderRuntime) field.get(null); + field.set(velocityDataProvider, new DataProviderAPI(handler)); + + DataProviderAPI api = velocityDataProvider.dataProviderApi(); + assertNotNull(api); + api.unregisterAllDatabases(); + verify(handler).unregisterAllDatabases(); } } diff --git a/src/test/java/nl/hauntedmc/dataprovider/testutil/RecordingLoggerAdapter.java b/src/test/java/nl/hauntedmc/dataprovider/testutil/RecordingLoggerAdapter.java index d83fc75..f28c9ca 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/testutil/RecordingLoggerAdapter.java +++ b/src/test/java/nl/hauntedmc/dataprovider/testutil/RecordingLoggerAdapter.java @@ -1,45 +1,28 @@ package nl.hauntedmc.dataprovider.testutil; -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; +import nl.hauntedmc.dataprovider.logging.LogLevel; +import nl.hauntedmc.dataprovider.logging.LoggerAdapter; import java.util.ArrayList; import java.util.Collections; import java.util.List; -public final class RecordingLoggerAdapter implements ILoggerAdapter { +public final class RecordingLoggerAdapter implements LoggerAdapter { private final List infoMessages = Collections.synchronizedList(new ArrayList<>()); private final List warnMessages = Collections.synchronizedList(new ArrayList<>()); private final List errorMessages = Collections.synchronizedList(new ArrayList<>()); @Override - public void info(String message) { - infoMessages.add(message); - } - - @Override - public void warn(String message) { - warnMessages.add(message); - } - - @Override - public void error(String message) { - errorMessages.add(message); - } - - @Override - public void info(String message, Throwable throwable) { - infoMessages.add(message + " (" + throwable.getClass().getSimpleName() + ")"); - } - - @Override - public void warn(String message, Throwable throwable) { - warnMessages.add(message + " (" + throwable.getClass().getSimpleName() + ")"); - } - - @Override - public void error(String message, Throwable throwable) { - errorMessages.add(message + " (" + throwable.getClass().getSimpleName() + ")"); + public void log(LogLevel level, String message, Throwable throwable) { + String rendered = throwable == null + ? message + : message + " (" + throwable.getClass().getSimpleName() + ")"; + switch (level) { + case INFO -> infoMessages.add(rendered); + case WARN -> warnMessages.add(rendered); + case ERROR -> errorMessages.add(rendered); + } } public List infoMessages() { From 46b598be9fa3040c20a43b0f97e601138b6a3100 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Fri, 27 Mar 2026 12:34:46 +0100 Subject: [PATCH 12/17] Improve command functionality --- README.md | 13 + docs/ARCHITECTURE.md | 1 + docs/USAGE_GUIDE.md | 14 + .../internal/DataProviderHandler.java | 27 + .../internal/DataProviderRegistry.java | 39 ++ .../bukkit/command/DataProviderCommand.java | 2 +- .../command/DataProviderCommandService.java | 553 ++++++++++++++++-- .../velocity/command/DataProviderCommand.java | 2 +- src/main/resources/plugin.yml | 14 +- .../internal/DataProviderHandlerTest.java | 17 + .../internal/DataProviderRegistryTest.java | 26 + .../command/DataProviderCommandTest.java | 9 +- .../DataProviderCommandServiceTest.java | 214 +++++-- .../command/DataProviderCommandTest.java | 9 +- 14 files changed, 851 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 1279a43..0719a85 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,19 @@ api.unregisterDatabase(DatabaseType.MYSQL, "default"); If you maintain multiple plugins, this gives your team one standard integration model instead of backend-specific code per project. +## Admin Commands + +- `/dataprovider help` shows command usage. +- `/dataprovider status [summary|connections] [unhealthy] [plugin ] [type ]` shows active connection diagnostics. +- `/dataprovider config` prints current runtime config state (`orm.schema_mode` + backend enablement). +- `/dataprovider reload` reloads `config.yml` from disk. + +Permissions: + +- `dataprovider.command.status` +- `dataprovider.command.config` +- `dataprovider.command.reload` + ## Install DataProvider (Server) 1. Build or download `DataProvider.jar`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dfb171c..baae422 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -44,6 +44,7 @@ Main modules: - `PlatformDataProviderRuntime` centralizes bootstrap shutdown behavior and startup rollback handling. - Platform command adapters delegate to a shared `DataProviderCommandService` so Bukkit and Velocity command behavior stays identical. +- Command service exposes diagnostics-focused admin commands (`status`, `config`, `reload`) with permission-gated filtering and runtime health summaries. - API discovery is platform-native: Bukkit registers `DataProviderAPI` in `ServicesManager`; Velocity exposes `DataProviderApiSupplier` on plugin instance. - Platform-specific wrappers only map host APIs to shared internals (logger, command registration, event/plugin lifecycle hooks). diff --git a/docs/USAGE_GUIDE.md b/docs/USAGE_GUIDE.md index a690129..10292ec 100644 --- a/docs/USAGE_GUIDE.md +++ b/docs/USAGE_GUIDE.md @@ -35,6 +35,20 @@ Treat `DataProviderAPI` as runtime-scoped, not permanent. - Do not keep API references across plugin reloads or disable/enable cycles. - After DataProvider shuts down, old API handles throw `IllegalStateException`; reacquire a fresh API after DataProvider is enabled again. +## 1.2 Built-in admin commands + +`DataProvider` ships with runtime diagnostics commands for Bukkit/Paper and Velocity: + +- `/dataprovider status [summary|connections] [unhealthy] [plugin ] [type ]` +- `/dataprovider config` +- `/dataprovider reload` + +Permission nodes: + +- `dataprovider.command.status` +- `dataprovider.command.config` +- `dataprovider.command.reload` + ## 2. Register a connection Basic: diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderHandler.java b/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderHandler.java index 514f6ac..b0bf7cc 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderHandler.java +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderHandler.java @@ -247,6 +247,33 @@ public Map getActiveDatabaseReferenceCounts() { return registry.getActiveDatabaseReferenceCounts(); } + /** + * Returns the configured enabled/disabled state per database backend type. + */ + public Map getConfiguredDatabaseTypeStates() { + requireOpen(); + requireInternalCaller(); + return registry.getConfiguredDatabaseTypeStates(); + } + + /** + * Returns the normalized configured ORM schema mode. + */ + public String getConfiguredOrmSchemaMode() { + requireOpen(); + requireInternalCaller(); + return registry.getOrmSchemaMode(); + } + + /** + * Reloads DataProvider configuration from disk. + */ + public void reloadConfiguration() { + requireOpen(); + requireInternalCaller(); + registry.reloadConfiguration(); + } + private CallerContext resolveCallerContext() { CallerContext caller = callerContextResolver.resolveCaller(); if (caller == null) { diff --git a/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistry.java b/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistry.java index 1674fa7..5327a98 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistry.java +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistry.java @@ -6,6 +6,7 @@ import nl.hauntedmc.dataprovider.database.DatabaseProvider; import nl.hauntedmc.dataprovider.logging.LoggerAdapter; +import java.util.EnumMap; import java.util.HashMap; import java.util.Map; import java.util.Objects; @@ -413,6 +414,44 @@ protected Map getActiveDatabaseReferenceCounts() } } + protected Map getConfiguredDatabaseTypeStates() { + Lock readLock = lifecycleLock.readLock(); + readLock.lock(); + try { + ensureOpen(); + Map states = new EnumMap<>(DatabaseType.class); + for (DatabaseType type : DatabaseType.values()) { + states.put(type, configHandler.isDatabaseTypeEnabled(type)); + } + return states; + } finally { + readLock.unlock(); + } + } + + protected String getOrmSchemaMode() { + Lock readLock = lifecycleLock.readLock(); + readLock.lock(); + try { + ensureOpen(); + return configHandler.getOrmSchemaMode(); + } finally { + readLock.unlock(); + } + } + + protected void reloadConfiguration() { + Lock writeLock = lifecycleLock.writeLock(); + writeLock.lock(); + try { + ensureOpen(); + configHandler.reloadConfig(); + logger.info("Reloaded DataProvider configuration from disk."); + } finally { + writeLock.unlock(); + } + } + protected boolean isClosed() { return closed; } diff --git a/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/command/DataProviderCommand.java b/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/command/DataProviderCommand.java index 80169b8..5d0ad9c 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/command/DataProviderCommand.java +++ b/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/command/DataProviderCommand.java @@ -33,6 +33,6 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command @Override public List onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String alias, String[] args) { - return commandService.suggest(args); + return commandService.suggest(args, sender::hasPermission); } } diff --git a/src/main/java/nl/hauntedmc/dataprovider/platform/internal/command/DataProviderCommandService.java b/src/main/java/nl/hauntedmc/dataprovider/platform/internal/command/DataProviderCommandService.java index de094b3..da78fc8 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/platform/internal/command/DataProviderCommandService.java +++ b/src/main/java/nl/hauntedmc/dataprovider/platform/internal/command/DataProviderCommandService.java @@ -4,13 +4,18 @@ import net.kyori.adventure.text.format.NamedTextColor; import nl.hauntedmc.dataprovider.database.DatabaseConnectionKey; import nl.hauntedmc.dataprovider.database.DatabaseProvider; +import nl.hauntedmc.dataprovider.database.DatabaseType; import nl.hauntedmc.dataprovider.internal.DataProviderHandler; +import java.util.ArrayList; +import java.util.Arrays; import java.util.Comparator; +import java.util.EnumMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.TreeMap; import java.util.concurrent.ConcurrentMap; import java.util.function.Consumer; import java.util.function.Predicate; @@ -21,25 +26,51 @@ public final class DataProviderCommandService { private static final String STATUS_SUBCOMMAND = "status"; + private static final String CONFIG_SUBCOMMAND = "config"; + private static final String RELOAD_SUBCOMMAND = "reload"; private static final String HELP_SUBCOMMAND = "help"; private static final String STATUS_PERMISSION = "dataprovider.command.status"; + private static final String CONFIG_PERMISSION = "dataprovider.command.config"; + private static final String RELOAD_PERMISSION = "dataprovider.command.reload"; - private static final Component USAGE_MESSAGE = - Component.text("Usage: /dataprovider status", NamedTextColor.YELLOW); - private static final Component NO_PERMISSION_MESSAGE = - Component.text("You do not have permission to use this command.", NamedTextColor.RED); + private static final Component HELP_HEADER = + Component.text("DataProvider command help:", NamedTextColor.GOLD); private static final Component NO_ACTIVE_CONNECTIONS_MESSAGE = Component.text("No active database connections found.", NamedTextColor.YELLOW); - private static final Component CONNECTIONS_HEADER = - Component.text("Active Database Connections:", NamedTextColor.GREEN); + private static final Component NO_MATCHING_CONNECTIONS_MESSAGE = + Component.text("No active database connections match the selected filters.", NamedTextColor.YELLOW); private static final Component UNKNOWN_SUBCOMMAND_MESSAGE = Component.text("Unknown subcommand. Use /dataprovider help for usage.", NamedTextColor.RED); + private static final Component STATUS_USAGE_MESSAGE = + Component.text( + "Usage: /dataprovider status [summary|connections] [unhealthy] [plugin ] [type ]", + NamedTextColor.YELLOW + ); + private static final Component CONFIG_USAGE_MESSAGE = + Component.text("Usage: /dataprovider config", NamedTextColor.YELLOW); + private static final Component RELOAD_USAGE_MESSAGE = + Component.text("Usage: /dataprovider reload", NamedTextColor.YELLOW); - private static final List ROOT_COMPLETIONS = List.of(STATUS_SUBCOMMAND, HELP_SUBCOMMAND); + private static final List ROOT_COMPLETIONS = List.of( + HELP_SUBCOMMAND, + STATUS_SUBCOMMAND, + CONFIG_SUBCOMMAND, + RELOAD_SUBCOMMAND + ); + private static final List STATUS_OPTION_COMPLETIONS = List.of( + "summary", + "connections", + "unhealthy", + "plugin", + "type" + ); + private static final List DATABASE_TYPE_COMPLETIONS = Arrays.stream(DatabaseType.values()) + .map(type -> type.name().toLowerCase(Locale.ROOT)) + .toList(); private static final Comparator CONNECTION_KEY_COMPARATOR = - Comparator.comparing(DatabaseConnectionKey::pluginName) + Comparator.comparing(DatabaseConnectionKey::pluginName, String.CASE_INSENSITIVE_ORDER) .thenComparing(key -> key.type().name()) - .thenComparing(DatabaseConnectionKey::connectionIdentifier); + .thenComparing(DatabaseConnectionKey::connectionIdentifier, String.CASE_INSENSITIVE_ORDER); private final DataProviderHandler dataProviderHandler; @@ -57,61 +88,499 @@ public void execute( Objects.requireNonNull(messageSink, "Message sink cannot be null."); if (args.length == 0 || HELP_SUBCOMMAND.equalsIgnoreCase(args[0])) { - messageSink.accept(USAGE_MESSAGE); + sendHelp(messageSink); return; } - if (!STATUS_SUBCOMMAND.equalsIgnoreCase(args[0])) { - messageSink.accept(UNKNOWN_SUBCOMMAND_MESSAGE); - return; + String rootSubcommand = args[0].toLowerCase(Locale.ROOT); + switch (rootSubcommand) { + case STATUS_SUBCOMMAND -> executeStatus(args, permissionChecker, messageSink); + case CONFIG_SUBCOMMAND -> executeConfig(args, permissionChecker, messageSink); + case RELOAD_SUBCOMMAND -> executeReload(args, permissionChecker, messageSink); + default -> messageSink.accept(UNKNOWN_SUBCOMMAND_MESSAGE); + } + } + + public List suggest(String[] args, Predicate permissionChecker) { + Objects.requireNonNull(args, "Args cannot be null."); + Objects.requireNonNull(permissionChecker, "Permission checker cannot be null."); + if (args.length == 0) { + return List.of(); + } + + if (args.length == 1) { + List visibleRootCompletions = ROOT_COMPLETIONS.stream() + .filter(subcommand -> isRootSubcommandVisible(subcommand, permissionChecker)) + .toList(); + return filterCompletions(visibleRootCompletions, args[0]); + } + + if (STATUS_SUBCOMMAND.equalsIgnoreCase(args[0])) { + if (!permissionChecker.test(STATUS_PERMISSION)) { + return List.of(); + } + return suggestStatusArguments(args); } + return List.of(); + } + + private void executeStatus( + String[] args, + Predicate permissionChecker, + Consumer messageSink + ) { if (!permissionChecker.test(STATUS_PERMISSION)) { - messageSink.accept(NO_PERMISSION_MESSAGE); + messageSink.accept(noPermissionMessage(STATUS_PERMISSION)); + return; + } + + StatusOptions statusOptions = parseStatusOptions(args, messageSink); + if (statusOptions == null) { return; } - ConcurrentMap activeDatabases = - dataProviderHandler.getActiveDatabases(); - Map referenceCounts = - dataProviderHandler.getActiveDatabaseReferenceCounts(); + List allConnections = listConnectionStatuses(messageSink); + if (allConnections == null) { + return; + } - if (activeDatabases.isEmpty()) { + if (allConnections.isEmpty()) { messageSink.accept(NO_ACTIVE_CONNECTIONS_MESSAGE); return; } - messageSink.accept(CONNECTIONS_HEADER); - activeDatabases.keySet().stream() - .sorted(CONNECTION_KEY_COMPARATOR) - .forEach(key -> { - int references = referenceCounts.getOrDefault(key, 1); - messageSink.accept(toConnectionComponent(key, references)); - }); + List filteredConnections = allConnections.stream() + .filter(status -> statusOptions.pluginFilter() == null + || status.key().pluginName().equalsIgnoreCase(statusOptions.pluginFilter())) + .filter(status -> statusOptions.typeFilter() == null + || status.key().type() == statusOptions.typeFilter()) + .filter(status -> !statusOptions.unhealthyOnly() + || status.health() != ConnectionHealth.CONNECTED) + .toList(); + + if (filteredConnections.isEmpty()) { + messageSink.accept(NO_MATCHING_CONNECTIONS_MESSAGE); + return; + } + + sendStatusOverview(filteredConnections, statusOptions, messageSink); + sendStatusAggregatesByPlugin(filteredConnections, messageSink); + sendStatusAggregatesByType(filteredConnections, messageSink); + if (!statusOptions.summaryOnly()) { + sendConnectionRows(filteredConnections, messageSink); + } } - public List suggest(String[] args) { - Objects.requireNonNull(args, "Args cannot be null."); - if (args.length != 1) { + private void executeConfig( + String[] args, + Predicate permissionChecker, + Consumer messageSink + ) { + if (args.length > 1) { + messageSink.accept(CONFIG_USAGE_MESSAGE); + return; + } + if (!permissionChecker.test(CONFIG_PERMISSION)) { + messageSink.accept(noPermissionMessage(CONFIG_PERMISSION)); + return; + } + + Map states; + String ormSchemaMode; + try { + states = dataProviderHandler.getConfiguredDatabaseTypeStates(); + ormSchemaMode = dataProviderHandler.getConfiguredOrmSchemaMode(); + } catch (RuntimeException exception) { + messageSink.accept(failedOperationMessage("Failed to inspect DataProvider config", exception)); + return; + } + + long enabledCount = states.values().stream().filter(Boolean::booleanValue).count(); + messageSink.accept(Component.text("DataProvider Config", NamedTextColor.GOLD)); + messageSink.accept(Component.text( + "ORM schema_mode=" + ormSchemaMode + + ", enabled backends=" + enabledCount + "/" + DatabaseType.values().length, + NamedTextColor.YELLOW + )); + for (DatabaseType type : DatabaseType.values()) { + boolean enabled = states.getOrDefault(type, Boolean.TRUE); + messageSink.accept(Component.text(" - " + type.name() + ": ", NamedTextColor.YELLOW) + .append(Component.text(enabled ? "enabled" : "disabled", enabled ? NamedTextColor.GREEN : NamedTextColor.RED))); + } + } + + private void executeReload( + String[] args, + Predicate permissionChecker, + Consumer messageSink + ) { + if (args.length > 1) { + messageSink.accept(RELOAD_USAGE_MESSAGE); + return; + } + if (!permissionChecker.test(RELOAD_PERMISSION)) { + messageSink.accept(noPermissionMessage(RELOAD_PERMISSION)); + return; + } + + try { + dataProviderHandler.reloadConfiguration(); + } catch (RuntimeException exception) { + messageSink.accept(failedOperationMessage("Failed to reload DataProvider config", exception)); + return; + } + + messageSink.accept(Component.text("Reloaded DataProvider configuration from disk.", NamedTextColor.GREEN)); + messageSink.accept(Component.text("Use /dataprovider config to inspect the new values.", NamedTextColor.YELLOW)); + } + + private void sendHelp(Consumer messageSink) { + messageSink.accept(HELP_HEADER); + messageSink.accept(Component.text("/dataprovider help", NamedTextColor.YELLOW) + .append(Component.text(" - Show this help.", NamedTextColor.GRAY))); + messageSink.accept(Component.text( + "/dataprovider status [summary|connections] [unhealthy] [plugin ] [type ]", + NamedTextColor.YELLOW + ) + .append(Component.text(" - Connection diagnostics.", NamedTextColor.GRAY)) + .append(Component.text(" (" + STATUS_PERMISSION + ")", NamedTextColor.DARK_GRAY))); + messageSink.accept(Component.text("/dataprovider config", NamedTextColor.YELLOW) + .append(Component.text(" - Show runtime config state.", NamedTextColor.GRAY)) + .append(Component.text(" (" + CONFIG_PERMISSION + ")", NamedTextColor.DARK_GRAY))); + messageSink.accept(Component.text("/dataprovider reload", NamedTextColor.YELLOW) + .append(Component.text(" - Reload config.yml from disk.", NamedTextColor.GRAY)) + .append(Component.text(" (" + RELOAD_PERMISSION + ")", NamedTextColor.DARK_GRAY))); + } + + private StatusOptions parseStatusOptions(String[] args, Consumer messageSink) { + boolean summaryOnly = false; + boolean unhealthyOnly = false; + boolean sawSummary = false; + boolean sawConnections = false; + String pluginFilter = null; + DatabaseType typeFilter = null; + + int index = 1; + while (index < args.length) { + String token = args[index].toLowerCase(Locale.ROOT); + switch (token) { + case "summary" -> { + if (sawConnections) { + messageSink.accept(Component.text( + "Cannot combine 'summary' with 'connections' in the same command.", + NamedTextColor.RED + )); + messageSink.accept(STATUS_USAGE_MESSAGE); + return null; + } + summaryOnly = true; + sawSummary = true; + index++; + } + case "connections" -> { + if (sawSummary) { + messageSink.accept(Component.text( + "Cannot combine 'connections' with 'summary' in the same command.", + NamedTextColor.RED + )); + messageSink.accept(STATUS_USAGE_MESSAGE); + return null; + } + summaryOnly = false; + sawConnections = true; + index++; + } + case "unhealthy" -> { + unhealthyOnly = true; + index++; + } + case "plugin" -> { + if (index + 1 >= args.length) { + messageSink.accept(Component.text("Missing plugin name after 'plugin'.", NamedTextColor.RED)); + messageSink.accept(STATUS_USAGE_MESSAGE); + return null; + } + if (pluginFilter != null) { + messageSink.accept(Component.text("Plugin filter can only be set once.", NamedTextColor.RED)); + messageSink.accept(STATUS_USAGE_MESSAGE); + return null; + } + String pluginName = args[index + 1].trim(); + if (pluginName.isEmpty()) { + messageSink.accept(Component.text("Plugin filter cannot be blank.", NamedTextColor.RED)); + messageSink.accept(STATUS_USAGE_MESSAGE); + return null; + } + pluginFilter = pluginName; + index += 2; + } + case "type" -> { + if (index + 1 >= args.length) { + messageSink.accept(Component.text("Missing database type after 'type'.", NamedTextColor.RED)); + messageSink.accept(STATUS_USAGE_MESSAGE); + return null; + } + if (typeFilter != null) { + messageSink.accept(Component.text("Type filter can only be set once.", NamedTextColor.RED)); + messageSink.accept(STATUS_USAGE_MESSAGE); + return null; + } + String rawType = args[index + 1]; + DatabaseType parsedType = parseDatabaseType(rawType); + if (parsedType == null) { + messageSink.accept(Component.text( + "Unknown database type '" + rawType + "'. Valid types: " + String.join(", ", DATABASE_TYPE_COMPLETIONS), + NamedTextColor.RED + )); + messageSink.accept(STATUS_USAGE_MESSAGE); + return null; + } + typeFilter = parsedType; + index += 2; + } + default -> { + messageSink.accept(Component.text("Unknown status option '" + args[index] + "'.", NamedTextColor.RED)); + messageSink.accept(STATUS_USAGE_MESSAGE); + return null; + } + } + } + + return new StatusOptions(summaryOnly, unhealthyOnly, pluginFilter, typeFilter); + } + + private List listConnectionStatuses(Consumer messageSink) { + ConcurrentMap activeDatabases; + Map referenceCounts; + try { + activeDatabases = dataProviderHandler.getActiveDatabases(); + referenceCounts = dataProviderHandler.getActiveDatabaseReferenceCounts(); + } catch (RuntimeException exception) { + messageSink.accept(failedOperationMessage("Failed to inspect active connections", exception)); + return null; + } + + return activeDatabases.entrySet().stream() + .sorted(Map.Entry.comparingByKey(CONNECTION_KEY_COMPARATOR)) + .map(entry -> { + DatabaseConnectionKey key = entry.getKey(); + int references = Math.max(1, referenceCounts.getOrDefault(key, 1)); + return new ConnectionStatus(key, references, probeHealth(entry.getValue())); + }) + .toList(); + } + + private void sendStatusOverview( + List filteredConnections, + StatusOptions statusOptions, + Consumer messageSink + ) { + long unhealthyCount = filteredConnections.stream() + .filter(status -> status.health() != ConnectionHealth.CONNECTED) + .count(); + int totalReferences = filteredConnections.stream().mapToInt(ConnectionStatus::references).sum(); + long uniquePluginCount = filteredConnections.stream().map(status -> status.key().pluginName()).distinct().count(); + long uniqueTypeCount = filteredConnections.stream().map(status -> status.key().type()).distinct().count(); + + messageSink.accept(Component.text("DataProvider Status", NamedTextColor.GOLD)); + messageSink.accept(Component.text( + "connections=" + filteredConnections.size() + + ", references=" + totalReferences + + ", plugins=" + uniquePluginCount + + ", backends=" + uniqueTypeCount + + ", unhealthy=" + unhealthyCount, + NamedTextColor.YELLOW + )); + + List activeFilters = new ArrayList<>(); + if (statusOptions.pluginFilter() != null) { + activeFilters.add("plugin=" + statusOptions.pluginFilter()); + } + if (statusOptions.typeFilter() != null) { + activeFilters.add("type=" + statusOptions.typeFilter().name()); + } + if (statusOptions.unhealthyOnly()) { + activeFilters.add("health=unhealthy"); + } + activeFilters.add(statusOptions.summaryOnly() ? "view=summary" : "view=connections"); + messageSink.accept(Component.text("filters: " + String.join(", ", activeFilters), NamedTextColor.GRAY)); + } + + private void sendStatusAggregatesByPlugin(List statuses, Consumer messageSink) { + Map byPlugin = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + for (ConnectionStatus status : statuses) { + AggregateCounters counters = byPlugin.computeIfAbsent(status.key().pluginName(), ignored -> new AggregateCounters()); + counters.increment(status.references(), status.health()); + } + + messageSink.accept(Component.text("By plugin:", NamedTextColor.GREEN)); + for (Map.Entry entry : byPlugin.entrySet()) { + AggregateCounters counters = entry.getValue(); + messageSink.accept(Component.text(" - " + entry.getKey() + ": ", NamedTextColor.YELLOW) + .append(Component.text( + "connections=" + counters.connectionCount + + ", refs=" + counters.referenceCount + + ", unhealthy=" + counters.unhealthyCount, + NamedTextColor.WHITE + ))); + } + } + + private void sendStatusAggregatesByType(List statuses, Consumer messageSink) { + Map byType = new EnumMap<>(DatabaseType.class); + for (ConnectionStatus status : statuses) { + AggregateCounters counters = byType.computeIfAbsent(status.key().type(), ignored -> new AggregateCounters()); + counters.increment(status.references(), status.health()); + } + + messageSink.accept(Component.text("By backend:", NamedTextColor.GREEN)); + for (DatabaseType type : DatabaseType.values()) { + AggregateCounters counters = byType.get(type); + if (counters == null) { + continue; + } + messageSink.accept(Component.text(" - " + type.name() + ": ", NamedTextColor.YELLOW) + .append(Component.text( + "connections=" + counters.connectionCount + + ", refs=" + counters.referenceCount + + ", unhealthy=" + counters.unhealthyCount, + NamedTextColor.WHITE + ))); + } + } + + private void sendConnectionRows(List statuses, Consumer messageSink) { + messageSink.accept(Component.text("Connections:", NamedTextColor.GREEN)); + for (ConnectionStatus status : statuses) { + DatabaseConnectionKey key = status.key(); + messageSink.accept(Component.text(" - plugin=", NamedTextColor.YELLOW) + .append(Component.text(key.pluginName(), NamedTextColor.WHITE)) + .append(Component.text(", type=", NamedTextColor.YELLOW)) + .append(Component.text(key.type().name(), NamedTextColor.WHITE)) + .append(Component.text(", id=", NamedTextColor.YELLOW)) + .append(Component.text(key.connectionIdentifier(), NamedTextColor.WHITE)) + .append(Component.text(", refs=", NamedTextColor.YELLOW)) + .append(Component.text(status.references(), NamedTextColor.WHITE)) + .append(Component.text(", state=", NamedTextColor.YELLOW)) + .append(status.health().asComponent())); + } + } + + private List suggestStatusArguments(String[] args) { + String currentToken = args[args.length - 1]; + if (args.length >= 3) { + String previousToken = args[args.length - 2].toLowerCase(Locale.ROOT); + if ("plugin".equals(previousToken)) { + return suggestPluginNames(currentToken); + } + if ("type".equals(previousToken)) { + return filterCompletions(DATABASE_TYPE_COMPLETIONS, currentToken); + } + } + return filterCompletions(STATUS_OPTION_COMPLETIONS, currentToken); + } + + private List suggestPluginNames(String partial) { + try { + return dataProviderHandler.getActiveDatabases().keySet().stream() + .map(DatabaseConnectionKey::pluginName) + .distinct() + .sorted(String.CASE_INSENSITIVE_ORDER) + .filter(name -> name.toLowerCase(Locale.ROOT).startsWith(partial.toLowerCase(Locale.ROOT))) + .toList(); + } catch (RuntimeException exception) { return List.of(); } + } + + private static ConnectionHealth probeHealth(DatabaseProvider provider) { + if (provider == null) { + return ConnectionHealth.ERROR; + } + try { + return provider.isConnected() ? ConnectionHealth.CONNECTED : ConnectionHealth.DISCONNECTED; + } catch (RuntimeException exception) { + return ConnectionHealth.ERROR; + } + } - String partial = args[0].toLowerCase(Locale.ROOT); - return ROOT_COMPLETIONS.stream() - .filter(completion -> completion.startsWith(partial)) + private static boolean isRootSubcommandVisible(String subcommand, Predicate permissionChecker) { + return switch (subcommand) { + case HELP_SUBCOMMAND -> true; + case STATUS_SUBCOMMAND -> permissionChecker.test(STATUS_PERMISSION); + case CONFIG_SUBCOMMAND -> permissionChecker.test(CONFIG_PERMISSION); + case RELOAD_SUBCOMMAND -> permissionChecker.test(RELOAD_PERMISSION); + default -> false; + }; + } + + private static List filterCompletions(List completions, String partial) { + String normalizedPartial = partial.toLowerCase(Locale.ROOT); + return completions.stream() + .filter(completion -> completion.startsWith(normalizedPartial)) .toList(); } - private static Component toConnectionComponent(DatabaseConnectionKey key, int references) { - Component statusComponent = Component.text("Registered (" + references + " refs)", NamedTextColor.GREEN); + private static DatabaseType parseDatabaseType(String rawType) { + String normalized = rawType.toUpperCase(Locale.ROOT).replace('-', '_'); + try { + return DatabaseType.valueOf(normalized); + } catch (IllegalArgumentException exception) { + return null; + } + } + + private static Component noPermissionMessage(String permissionNode) { + return Component.text("Missing permission: " + permissionNode, NamedTextColor.RED); + } + + private static Component failedOperationMessage(String operation, RuntimeException exception) { + String reason = exception.getMessage(); + if (reason == null || reason.isBlank()) { + reason = exception.getClass().getSimpleName(); + } + return Component.text(operation + " (" + reason + ").", NamedTextColor.RED); + } + + private record ConnectionStatus(DatabaseConnectionKey key, int references, ConnectionHealth health) { + } + + private record StatusOptions( + boolean summaryOnly, + boolean unhealthyOnly, + String pluginFilter, + DatabaseType typeFilter + ) { + } + + private enum ConnectionHealth { + CONNECTED(NamedTextColor.GREEN), + DISCONNECTED(NamedTextColor.YELLOW), + ERROR(NamedTextColor.RED); + + private final NamedTextColor color; - return Component.text("Plugin: ", NamedTextColor.YELLOW) - .append(Component.text(key.pluginName(), NamedTextColor.WHITE)) - .append(Component.text(", Type: ", NamedTextColor.YELLOW)) - .append(Component.text(key.type().name(), NamedTextColor.WHITE)) - .append(Component.text(", Identifier: ", NamedTextColor.YELLOW)) - .append(Component.text(key.connectionIdentifier(), NamedTextColor.WHITE)) - .append(Component.text(" -> ", NamedTextColor.YELLOW)) - .append(statusComponent); + ConnectionHealth(NamedTextColor color) { + this.color = color; + } + + private Component asComponent() { + return Component.text(name(), color); + } + } + + private static final class AggregateCounters { + private int connectionCount; + private int referenceCount; + private int unhealthyCount; + + private void increment(int references, ConnectionHealth health) { + connectionCount++; + referenceCount += references; + if (health != ConnectionHealth.CONNECTED) { + unhealthyCount++; + } + } } } diff --git a/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/command/DataProviderCommand.java b/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/command/DataProviderCommand.java index 2ceeb1f..dfafa53 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/command/DataProviderCommand.java +++ b/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/command/DataProviderCommand.java @@ -29,6 +29,6 @@ public void execute(Invocation invocation) { @Override public CompletableFuture> suggestAsync(Invocation invocation) { - return CompletableFuture.completedFuture(commandService.suggest(invocation.arguments())); + return CompletableFuture.completedFuture(commandService.suggest(invocation.arguments(), invocation.source()::hasPermission)); } } diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 1024d48..8df5bff 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -5,5 +5,15 @@ author: HauntedMC api-version: 1.19 commands: dataprovider: - description: Displays status of active database connections. - usage: /dataprovider + description: DataProvider diagnostics and runtime administration commands. + usage: /dataprovider +permissions: + dataprovider.command.status: + description: View active DataProvider connection diagnostics. + default: op + dataprovider.command.config: + description: Inspect DataProvider runtime configuration values. + default: op + dataprovider.command.reload: + description: Reload DataProvider configuration from disk. + default: op diff --git a/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderHandlerTest.java b/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderHandlerTest.java index 21c773c..96667e4 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderHandlerTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderHandlerTest.java @@ -12,6 +12,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -172,6 +173,9 @@ void privilegedOperationsRejectNonInternalCallerClassLoader() { assertThrows(SecurityException.class, handler::shutdownAllDatabases); assertThrows(SecurityException.class, handler::getActiveDatabases); assertThrows(SecurityException.class, handler::getActiveDatabaseReferenceCounts); + assertThrows(SecurityException.class, handler::getConfiguredDatabaseTypeStates); + assertThrows(SecurityException.class, handler::getConfiguredOrmSchemaMode); + assertThrows(SecurityException.class, handler::reloadConfiguration); } @Test @@ -185,14 +189,21 @@ void privilegedOperationsReturnRegistrySnapshotsForInternalCaller() { ConcurrentMap active = new ConcurrentHashMap<>(); Map refs = Map.of(); + Map states = Map.of(DatabaseType.MYSQL, true); when(registry.getActiveDatabases()).thenReturn(active); when(registry.getActiveDatabaseReferenceCounts()).thenReturn(refs); + when(registry.getConfiguredDatabaseTypeStates()).thenReturn(states); + when(registry.getOrmSchemaMode()).thenReturn("validate"); assertSame(active, handler.getActiveDatabases()); assertSame(refs, handler.getActiveDatabaseReferenceCounts()); + assertSame(states, handler.getConfiguredDatabaseTypeStates()); + assertEquals("validate", handler.getConfiguredOrmSchemaMode()); handler.shutdownAllDatabases(); + handler.reloadConfiguration(); verify(registry).shutdownAllDatabases(); + verify(registry).reloadConfiguration(); } @Test @@ -222,6 +233,9 @@ void operationsFailFastWhenRegistryIsClosed() { assertThrows(IllegalStateException.class, handler::unregisterAllDatabasesForPlugin); assertThrows(IllegalStateException.class, handler::getActiveDatabases); assertThrows(IllegalStateException.class, handler::getActiveDatabaseReferenceCounts); + assertThrows(IllegalStateException.class, handler::getConfiguredDatabaseTypeStates); + assertThrows(IllegalStateException.class, handler::getConfiguredOrmSchemaMode); + assertThrows(IllegalStateException.class, handler::reloadConfiguration); verify(registry, never()).registerDatabase( PluginId.of("plugin"), @@ -255,5 +269,8 @@ void operationsFailFastWhenRegistryIsClosed() { verify(registry, never()).unregisterAllDatabases(PluginId.of("plugin"), OwnerScopeId.of("plugin")); verify(registry, never()).unregisterAllDatabases(PluginId.of("plugin"), OwnerScopeId.of("component.scope")); verify(registry, never()).unregisterAllDatabasesForPlugin(PluginId.of("plugin")); + verify(registry, never()).getConfiguredDatabaseTypeStates(); + verify(registry, never()).getOrmSchemaMode(); + verify(registry, never()).reloadConfiguration(); } } diff --git a/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistryTest.java b/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistryTest.java index 864488b..a326864 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistryTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistryTest.java @@ -267,6 +267,9 @@ void shutdownDisconnectsAllAndClearsRegistry() { assertTrue(registry.isClosed()); assertThrows(IllegalStateException.class, registry::getActiveDatabases); assertThrows(IllegalStateException.class, registry::getActiveDatabaseReferenceCounts); + assertThrows(IllegalStateException.class, registry::getConfiguredDatabaseTypeStates); + assertThrows(IllegalStateException.class, registry::getOrmSchemaMode); + assertThrows(IllegalStateException.class, registry::reloadConfiguration); assertTrue(logger.infoMessages().stream().anyMatch(m -> m.contains("All database connections have been closed"))); } @@ -305,6 +308,9 @@ void shutdownIsIdempotentAndBlocksFurtherOperations() { assertThrows(IllegalStateException.class, () -> registry.unregisterAllDatabases("plugin", "feature-a")); assertThrows(IllegalStateException.class, registry::getActiveDatabases); assertThrows(IllegalStateException.class, registry::getActiveDatabaseReferenceCounts); + assertThrows(IllegalStateException.class, registry::getConfiguredDatabaseTypeStates); + assertThrows(IllegalStateException.class, registry::getOrmSchemaMode); + assertThrows(IllegalStateException.class, registry::reloadConfiguration); } @Test @@ -363,6 +369,26 @@ void getActiveSnapshotsExposeCurrentRegistryState() { assertEquals(1, refs.get(key)); } + @Test + void configStateAndReloadOperationsDelegateToConfigHandler() { + DatabaseFactory factory = mock(DatabaseFactory.class); + ConfigHandler configHandler = mock(ConfigHandler.class); + for (DatabaseType type : DatabaseType.values()) { + when(configHandler.isDatabaseTypeEnabled(type)).thenReturn(type != DatabaseType.REDIS); + } + when(configHandler.getOrmSchemaMode()).thenReturn("update"); + RecordingLoggerAdapter logger = new RecordingLoggerAdapter(); + DataProviderRegistry registry = new DataProviderRegistry(factory, configHandler, logger); + + Map states = registry.getConfiguredDatabaseTypeStates(); + assertEquals(DatabaseType.values().length, states.size()); + assertEquals(Boolean.FALSE, states.get(DatabaseType.REDIS)); + assertEquals("update", registry.getOrmSchemaMode()); + + registry.reloadConfiguration(); + verify(configHandler).reloadConfig(); + } + private static final class RecordingProvider implements ManagedDatabaseProvider { private boolean connected; private int connectCalls; diff --git a/src/test/java/nl/hauntedmc/dataprovider/platform/bukkit/command/DataProviderCommandTest.java b/src/test/java/nl/hauntedmc/dataprovider/platform/bukkit/command/DataProviderCommandTest.java index 93a0aec..9dcf9c7 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/platform/bukkit/command/DataProviderCommandTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/platform/bukkit/command/DataProviderCommandTest.java @@ -37,7 +37,7 @@ void showsHelpWhenNoArgumentsAreProvided() { command.onCommand(sender.sender(), mock(Command.class), "dataprovider", new String[0]); - assertTrue(sender.hasMessageContaining("Usage: /dataprovider status")); + assertTrue(sender.hasMessageContaining("DataProvider command help:")); verify(handler, never()).getActiveDatabases(); } @@ -49,7 +49,7 @@ void statusRequiresPermission() { command.onCommand(sender.sender(), mock(Command.class), "dataprovider", new String[]{"status"}); - assertTrue(sender.hasMessageContaining("do not have permission")); + assertTrue(sender.hasMessageContaining("Missing permission: dataprovider.command.status")); verify(handler, never()).getActiveDatabases(); } @@ -74,8 +74,8 @@ void statusDisplaysEmptyAndPopulatedConnectionStates() { command.onCommand(sender.sender(), mock(Command.class), "dataprovider", new String[]{"status"}); - assertTrue(sender.hasMessageContaining("Active Database Connections:")); - assertTrue(sender.hasMessageContaining("Plugin: FeatureA")); + assertTrue(sender.hasMessageContaining("DataProvider Status")); + assertTrue(sender.hasMessageContaining("plugin=FeatureA")); } @Test @@ -93,6 +93,7 @@ void unknownSubcommandReturnsErrorMessage() { void tabCompletionSuggestsStatusAndHelp() { DataProviderCommand command = new DataProviderCommand(mock(DataProviderHandler.class)); RecordingBukkitSender sender = new RecordingBukkitSender(); + sender.grantPermission("dataprovider.command.status"); List completions = command.onTabComplete( sender.sender(), mock(Command.class), diff --git a/src/test/java/nl/hauntedmc/dataprovider/platform/internal/command/DataProviderCommandServiceTest.java b/src/test/java/nl/hauntedmc/dataprovider/platform/internal/command/DataProviderCommandServiceTest.java index 2f0afd4..032b2b5 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/platform/internal/command/DataProviderCommandServiceTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/platform/internal/command/DataProviderCommandServiceTest.java @@ -11,11 +11,13 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.function.Predicate; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -25,15 +27,16 @@ class DataProviderCommandServiceTest { @Test - void executeShowsUsageForEmptyArgsAndHelpSubcommand() { + void executeShowsHelpForEmptyArgsAndHelpSubcommand() { DataProviderHandler handler = mock(DataProviderHandler.class); DataProviderCommandService service = new DataProviderCommandService(handler); RecordingOutput output = new RecordingOutput(); - service.execute(new String[0], alwaysDenied(), output::record); - service.execute(new String[]{"help"}, alwaysDenied(), output::record); + service.execute(new String[0], deniedPermissions(), output::record); + service.execute(new String[]{"help"}, deniedPermissions(), output::record); - assertTrue(output.hasMessageContaining("Usage: /dataprovider status")); + assertTrue(output.hasMessageContaining("DataProvider command help:")); + assertTrue(output.hasMessageContaining("/dataprovider status")); verify(handler, never()).getActiveDatabases(); } @@ -43,37 +46,154 @@ void executeStatusRequiresPermission() { DataProviderCommandService service = new DataProviderCommandService(handler); RecordingOutput output = new RecordingOutput(); - service.execute(new String[]{"status"}, alwaysDenied(), output::record); + service.execute(new String[]{"status"}, deniedPermissions(), output::record); - assertTrue(output.hasMessageContaining("do not have permission")); + assertTrue(output.hasMessageContaining("Missing permission: dataprovider.command.status")); verify(handler, never()).getActiveDatabases(); } @Test - void executeStatusShowsEmptyAndPopulatedStates() { + void executeStatusShowsOverviewAggregatesAndConnectionRows() { DataProviderHandler handler = mock(DataProviderHandler.class); DataProviderCommandService service = new DataProviderCommandService(handler); RecordingOutput output = new RecordingOutput(); - when(handler.getActiveDatabases()).thenReturn(new ConcurrentHashMap<>()); - when(handler.getActiveDatabaseReferenceCounts()).thenReturn(Map.of()); - service.execute(new String[]{"status"}, alwaysGranted(), output::record); - assertTrue(output.hasMessageContaining("No active database connections found.")); - - ConcurrentMap activeDatabases = new ConcurrentHashMap<>(); DatabaseConnectionKey keyA = new DatabaseConnectionKey("APlugin", DatabaseType.REDIS, "cache"); DatabaseConnectionKey keyB = new DatabaseConnectionKey("BPlugin", DatabaseType.MYSQL, "default"); - activeDatabases.put(keyB, mock(DatabaseProvider.class)); - activeDatabases.put(keyA, mock(DatabaseProvider.class)); + ConcurrentMap activeDatabases = new ConcurrentHashMap<>(); + activeDatabases.put(keyA, connectedProvider()); + activeDatabases.put(keyB, disconnectedProvider()); + when(handler.getActiveDatabases()).thenReturn(activeDatabases); + when(handler.getActiveDatabaseReferenceCounts()).thenReturn(Map.of(keyA, 2, keyB, 3)); + + service.execute(new String[]{"status"}, permissions("dataprovider.command.status"), output::record); + + assertTrue(output.hasMessageContaining("DataProvider Status")); + assertTrue(output.hasMessageContaining("connections=2")); + assertTrue(output.hasMessageContaining("By plugin:")); + assertTrue(output.hasMessageContaining("By backend:")); + assertTrue(output.hasMessageContaining("Connections:")); + assertTrue(output.hasMessageContaining("plugin=APlugin")); + assertTrue(output.hasMessageContaining("state=CONNECTED")); + assertTrue(output.hasMessageContaining("state=DISCONNECTED")); + } + + @Test + void executeStatusSupportsSummaryAndFilters() { + DataProviderHandler handler = mock(DataProviderHandler.class); + DataProviderCommandService service = new DataProviderCommandService(handler); + RecordingOutput output = new RecordingOutput(); + + DatabaseConnectionKey keyA = new DatabaseConnectionKey("FeatureA", DatabaseType.REDIS, "cache"); + DatabaseConnectionKey keyB = new DatabaseConnectionKey("FeatureA", DatabaseType.MYSQL, "main"); + ConcurrentMap activeDatabases = new ConcurrentHashMap<>(); + activeDatabases.put(keyA, connectedProvider()); + activeDatabases.put(keyB, connectedProvider()); + when(handler.getActiveDatabases()).thenReturn(activeDatabases); + when(handler.getActiveDatabaseReferenceCounts()).thenReturn(Map.of(keyA, 1, keyB, 1)); + + service.execute( + new String[]{"status", "summary", "plugin", "FeatureA", "type", "redis"}, + permissions("dataprovider.command.status"), + output::record + ); + + assertTrue(output.hasMessageContaining("connections=1")); + assertTrue(output.hasMessageContaining("filters: plugin=FeatureA")); + assertTrue(output.hasMessageContaining("type=REDIS")); + assertTrue(output.hasMessageContaining("view=summary")); + assertFalse(output.hasMessageContaining("Connections:")); + } + + @Test + void executeStatusSupportsUnhealthyFiltering() { + DataProviderHandler handler = mock(DataProviderHandler.class); + DataProviderCommandService service = new DataProviderCommandService(handler); + RecordingOutput output = new RecordingOutput(); + + DatabaseConnectionKey healthyKey = new DatabaseConnectionKey("HealthyPlugin", DatabaseType.REDIS, "cache"); + DatabaseConnectionKey unhealthyKey = new DatabaseConnectionKey("UnhealthyPlugin", DatabaseType.REDIS, "cache"); + ConcurrentMap activeDatabases = new ConcurrentHashMap<>(); + activeDatabases.put(healthyKey, connectedProvider()); + activeDatabases.put(unhealthyKey, disconnectedProvider()); when(handler.getActiveDatabases()).thenReturn(activeDatabases); - when(handler.getActiveDatabaseReferenceCounts()).thenReturn(Map.of(keyA, 1, keyB, 3)); + when(handler.getActiveDatabaseReferenceCounts()).thenReturn(Map.of(healthyKey, 1, unhealthyKey, 1)); + + service.execute( + new String[]{"status", "unhealthy"}, + permissions("dataprovider.command.status"), + output::record + ); + + assertTrue(output.hasMessageContaining("connections=1")); + assertTrue(output.hasMessageContaining("plugin=UnhealthyPlugin")); + assertFalse(output.hasMessageContaining("plugin=HealthyPlugin")); + assertTrue(output.hasMessageContaining("health=unhealthy")); + } + + @Test + void executeStatusRejectsInvalidOptions() { + DataProviderCommandService service = new DataProviderCommandService(mock(DataProviderHandler.class)); + RecordingOutput output = new RecordingOutput(); + + service.execute( + new String[]{"status", "unknown-option"}, + permissions("dataprovider.command.status"), + output::record + ); + + assertTrue(output.hasMessageContaining("Unknown status option")); + assertTrue(output.hasMessageContaining("Usage: /dataprovider status")); + } + + @Test + void executeConfigRequiresPermissionAndShowsCurrentConfig() { + DataProviderHandler handler = mock(DataProviderHandler.class); + DataProviderCommandService service = new DataProviderCommandService(handler); + RecordingOutput output = new RecordingOutput(); + + service.execute(new String[]{"config"}, deniedPermissions(), output::record); + assertTrue(output.hasMessageContaining("Missing permission: dataprovider.command.config")); + + when(handler.getConfiguredDatabaseTypeStates()).thenReturn(Map.of( + DatabaseType.MYSQL, true, + DatabaseType.MONGODB, false, + DatabaseType.REDIS, true, + DatabaseType.REDIS_MESSAGING, true + )); + when(handler.getConfiguredOrmSchemaMode()).thenReturn("update"); output.clear(); - service.execute(new String[]{"status"}, alwaysGranted(), output::record); + service.execute( + new String[]{"config"}, + permissions("dataprovider.command.config"), + output::record + ); - assertEquals("Active Database Connections:", output.messageAt(0)); - assertTrue(output.messageAt(1).contains("Plugin: APlugin")); - assertTrue(output.messageAt(2).contains("Plugin: BPlugin")); + assertTrue(output.hasMessageContaining("DataProvider Config")); + assertTrue(output.hasMessageContaining("ORM schema_mode=update")); + assertTrue(output.hasMessageContaining("MYSQL: enabled")); + assertTrue(output.hasMessageContaining("MONGODB: disabled")); + } + + @Test + void executeReloadRequiresPermissionAndDelegatesToHandler() { + DataProviderHandler handler = mock(DataProviderHandler.class); + DataProviderCommandService service = new DataProviderCommandService(handler); + RecordingOutput output = new RecordingOutput(); + + service.execute(new String[]{"reload"}, deniedPermissions(), output::record); + assertTrue(output.hasMessageContaining("Missing permission: dataprovider.command.reload")); + + output.clear(); + service.execute( + new String[]{"reload"}, + permissions("dataprovider.command.reload"), + output::record + ); + + verify(handler).reloadConfiguration(); + assertTrue(output.hasMessageContaining("Reloaded DataProvider configuration from disk.")); } @Test @@ -81,30 +201,58 @@ void executeUnknownSubcommandShowsError() { DataProviderCommandService service = new DataProviderCommandService(mock(DataProviderHandler.class)); RecordingOutput output = new RecordingOutput(); - service.execute(new String[]{"unknown"}, alwaysGranted(), output::record); + service.execute(new String[]{"unknown"}, permissions("dataprovider.command.status"), output::record); assertTrue(output.hasMessageContaining("Unknown subcommand")); } @Test - void suggestReturnsExpectedRootCompletions() { - DataProviderCommandService service = new DataProviderCommandService(mock(DataProviderHandler.class)); + void suggestRespectsPermissionsAndSupportsStatusArguments() { + DataProviderHandler handler = mock(DataProviderHandler.class); + DataProviderCommandService service = new DataProviderCommandService(handler); + + DatabaseConnectionKey keyA = new DatabaseConnectionKey("AlphaPlugin", DatabaseType.MYSQL, "main"); + DatabaseConnectionKey keyB = new DatabaseConnectionKey("BetaPlugin", DatabaseType.REDIS, "cache"); + ConcurrentMap activeDatabases = new ConcurrentHashMap<>(); + activeDatabases.put(keyA, connectedProvider()); + activeDatabases.put(keyB, connectedProvider()); + when(handler.getActiveDatabases()).thenReturn(activeDatabases); + + assertEquals(List.of("help"), service.suggest(new String[]{""}, deniedPermissions())); + assertEquals(List.of("help", "status"), service.suggest(new String[]{""}, permissions("dataprovider.command.status"))); + + Predicate allPermissions = permissions( + "dataprovider.command.status", + "dataprovider.command.config", + "dataprovider.command.reload" + ); + assertEquals(List.of("help", "status", "config", "reload"), service.suggest(new String[]{""}, allPermissions)); + assertEquals(List.of("summary"), service.suggest(new String[]{"status", "s"}, permissions("dataprovider.command.status"))); + assertEquals(List.of("AlphaPlugin"), service.suggest(new String[]{"status", "plugin", "A"}, permissions("dataprovider.command.status"))); + assertEquals(List.of("redis", "redis_messaging"), service.suggest(new String[]{"status", "type", "r"}, permissions("dataprovider.command.status"))); + } - assertEquals(List.of("status"), service.suggest(new String[]{"s"})); - assertEquals(List.of("help"), service.suggest(new String[]{"h"})); - assertTrue(service.suggest(new String[]{"x"}).isEmpty()); - assertTrue(service.suggest(new String[0]).isEmpty()); - assertTrue(service.suggest(new String[]{"status", "extra"}).isEmpty()); + private static DatabaseProvider connectedProvider() { + DatabaseProvider provider = mock(DatabaseProvider.class); + when(provider.isConnected()).thenReturn(true); + return provider; } - private static Predicate alwaysGranted() { - return permission -> true; + private static DatabaseProvider disconnectedProvider() { + DatabaseProvider provider = mock(DatabaseProvider.class); + when(provider.isConnected()).thenReturn(false); + return provider; } - private static Predicate alwaysDenied() { + private static Predicate deniedPermissions() { return permission -> false; } + private static Predicate permissions(String... grantedPermissions) { + Set granted = Set.of(grantedPermissions); + return granted::contains; + } + private static final class RecordingOutput { private final List messages = new ArrayList<>(); @@ -116,10 +264,6 @@ private boolean hasMessageContaining(String fragment) { return messages.stream().anyMatch(text -> text.contains(fragment)); } - private String messageAt(int index) { - return messages.get(index); - } - private void clear() { messages.clear(); } diff --git a/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/command/DataProviderCommandTest.java b/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/command/DataProviderCommandTest.java index 54d5340..603f8da 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/command/DataProviderCommandTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/command/DataProviderCommandTest.java @@ -34,10 +34,10 @@ void executeHandlesHelpPermissionAndUnknownSubcommands() { RecordingVelocitySource source = new RecordingVelocitySource(); command.execute(new TestInvocation(source.source(), new String[0])); - assertTrue(source.hasMessageContaining("Usage: /dataprovider status")); + assertTrue(source.hasMessageContaining("DataProvider command help:")); command.execute(new TestInvocation(source.source(), new String[]{"status"})); - assertTrue(source.hasMessageContaining("do not have permission")); + assertTrue(source.hasMessageContaining("Missing permission: dataprovider.command.status")); command.execute(new TestInvocation(source.source(), new String[]{"unknown"})); assertTrue(source.hasMessageContaining("Unknown subcommand")); @@ -62,14 +62,15 @@ void executeStatusShowsEmptyAndPopulatedConnections() { when(handler.getActiveDatabaseReferenceCounts()).thenReturn(Map.of(key, 3)); command.execute(new TestInvocation(source.source(), new String[]{"status"})); - assertTrue(source.hasMessageContaining("Active Database Connections:")); - assertTrue(source.hasMessageContaining("VelocityFeature")); + assertTrue(source.hasMessageContaining("DataProvider Status")); + assertTrue(source.hasMessageContaining("plugin=VelocityFeature")); } @Test void suggestAsyncReturnsExpectedCompletions() { DataProviderCommand command = new DataProviderCommand(mock(DataProviderHandler.class)); RecordingVelocitySource source = new RecordingVelocitySource(); + source.grantPermission("dataprovider.command.status"); assertEquals(List.of("status"), command.suggestAsync(new TestInvocation(source.source(), new String[]{"s"})).join()); assertEquals(List.of("help"), command.suggestAsync(new TestInvocation(source.source(), new String[]{"h"})).join()); assertTrue(command.suggestAsync(new TestInvocation(source.source(), new String[]{"x"})).join().isEmpty()); From 8f81f1453f553431f3d4800cabd9a31ae6e5781b Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Fri, 27 Mar 2026 13:18:17 +0100 Subject: [PATCH 13/17] remove version txt file and use maven as source of truth --- docs/RELEASE.md | 8 +- update_version.sh | 214 +++++++++++++++++++++++++++------------------- version.txt | 1 - 3 files changed, 131 insertions(+), 92 deletions(-) delete mode 100644 version.txt diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 078aaef..cd579e2 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -21,11 +21,13 @@ Use `update_version.sh` to bump `major`, `minor`, or `patch`: The script updates: -- `version.txt` -- `pom.xml` -- `README.md` (version examples) +- `pom.xml` (via Maven `versions:set`; source of truth) - `src/main/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProvider.java` +Manual step: + +- Update README dependency version examples if needed. + Then it commits and tags (`vX.Y.Z`) locally. Push when ready: diff --git a/update_version.sh b/update_version.sh index e91cb57..61688f0 100755 --- a/update_version.sh +++ b/update_version.sh @@ -1,117 +1,155 @@ #!/usr/bin/env bash set -euo pipefail +readonly POM_FILE="pom.xml" +readonly VELOCITY_FILE="src/main/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProvider.java" + +die() { + echo "Error: $*" >&2 + exit 1 +} + usage() { - echo "Usage: $0 " >&2 + cat >&2 <<'USAGE' +Usage: ./update_version.sh + +Bumps the Maven project version in pom.xml and keeps release metadata in sync. +Then creates a local commit and a local git tag vX.Y.Z. +USAGE } -if [[ $# -ne 1 ]]; then - usage - exit 1 -fi +require_file() { + local path="$1" + [[ -f "$path" ]] || die "${path} not found." +} -if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then - echo "This script must be run inside a git repository." >&2 - exit 1 -fi +require_clean_worktree() { + [[ -z "$(git status --porcelain)" ]] || die "Working tree is not clean. Commit or stash changes first." +} -bump_type="$1" -if [[ "$bump_type" != "major" && "$bump_type" != "minor" && "$bump_type" != "patch" ]]; then - usage - exit 1 -fi +resolve_maven_version() { + local version + version="$( + mvn -q -ntp -DforceStdout help:evaluate -Dexpression=project.version \ + | awk '/^[0-9]+\.[0-9]+\.[0-9]+$/ { print; exit }' + )" + [[ -n "$version" ]] || die "Unable to resolve a release semantic version from Maven." + echo "$version" +} -if [[ ! -f version.txt ]]; then - echo "version.txt not found." >&2 - exit 1 -fi +bump_semver() { + local semver="$1" + local bump_type="$2" + local major minor patch + + [[ "$semver" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || die "Current version must be semantic (X.Y.Z), got '${semver}'." + + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + patch="${BASH_REMATCH[3]}" + + case "$bump_type" in + major) + major=$((major + 1)) + minor=0 + patch=0 + ;; + minor) + minor=$((minor + 1)) + patch=0 + ;; + patch) + patch=$((patch + 1)) + ;; + *) + usage + exit 1 + ;; + esac + + echo "${major}.${minor}.${patch}" +} -if [[ ! -f pom.xml ]]; then - echo "pom.xml not found." >&2 - exit 1 -fi +update_velocity_plugin_annotation() { + local new_version="$1" + local tmp_file + tmp_file="$(mktemp)" + + awk -v v="$new_version" ' + BEGIN { replaced = 0 } + { + if (!replaced && $0 ~ /version = "[^"]+"/) { + sub(/version = "[^"]+"/, "version = \"" v "\"") + replaced = 1 + } + print + } + END { + if (!replaced) { + exit 2 + } + } + ' "$VELOCITY_FILE" > "$tmp_file" || { + rm -f "$tmp_file" + die "Could not update Velocity @Plugin version in ${VELOCITY_FILE}." + } -if [[ ! -f README.md ]]; then - echo "README.md not found." >&2 - exit 1 -fi + mv "$tmp_file" "$VELOCITY_FILE" +} -velocity_file="src/main/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProvider.java" -if [[ ! -f "$velocity_file" ]]; then - echo "${velocity_file} not found." >&2 - exit 1 +if [[ $# -eq 1 && ( "$1" == "--help" || "$1" == "-h" ) ]]; then + usage + exit 0 fi -if [[ -n "$(git status --porcelain)" ]]; then - echo "Working tree is not clean. Commit or stash changes before bumping version." >&2 +if [[ $# -ne 1 ]]; then + usage exit 1 fi -current_version="$(&2 - exit 1 +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + die "This script must be run inside a git repository." fi -major="${BASH_REMATCH[1]}" -minor="${BASH_REMATCH[2]}" -patch="${BASH_REMATCH[3]}" +command -v mvn >/dev/null 2>&1 || die "Maven (mvn) is required." -current_raw_version="${major}.${minor}.${patch}" -pom_raw_version="$(grep -m1 -oE '[0-9]+\.[0-9]+\.[0-9]+' pom.xml | sed -E 's###g')" -if [[ "$pom_raw_version" != "$current_raw_version" ]]; then - echo "version.txt (${current_version}) does not match pom.xml (${pom_raw_version})." >&2 - exit 1 -fi +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +require_file "$POM_FILE" +require_file "$VELOCITY_FILE" +require_clean_worktree -case "$bump_type" in - major) - major=$((major + 1)) - minor=0 - patch=0 - ;; - minor) - minor=$((minor + 1)) - patch=0 - ;; - patch) - patch=$((patch + 1)) - ;; -esac - -new_version="v${major}.${minor}.${patch}" -new_raw_version="${major}.${minor}.${patch}" - -if git rev-parse -q --verify "refs/tags/${new_version}" >/dev/null 2>&1; then - echo "Tag ${new_version} already exists." >&2 +bump_type="$1" +[[ "$bump_type" == "major" || "$bump_type" == "minor" || "$bump_type" == "patch" ]] || { + usage exit 1 +} + +current_version="$(resolve_maven_version)" +new_version="$(bump_semver "$current_version" "$bump_type")" +new_tag="v${new_version}" + +if git rev-parse -q --verify "refs/tags/${new_tag}" >/dev/null 2>&1; then + die "Tag ${new_tag} already exists." fi -echo "New version: $new_version" -printf '%s\n' "$new_version" > version.txt +echo "Current version: ${current_version}" +echo "Bumping to: ${new_version}" -# Update project version (first in pom.xml = project version). -awk -v v="$new_raw_version" ' - BEGIN { replaced = 0 } - { - if (!replaced && $0 ~ /[0-9]+\.[0-9]+\.[0-9]+<\/version>/) { - sub(/[0-9]+\.[0-9]+\.[0-9]+<\/version>/, "" v "") - replaced = 1 - } - print - } -' pom.xml > pom.xml.tmp -mv pom.xml.tmp pom.xml +# Use Maven's versions plugin so pom.xml remains the single source of truth. +mvn -B -ntp versions:set -DnewVersion="${new_version}" -DgenerateBackupPoms=false -DprocessAllModules=true -# Keep runtime metadata in sync for Velocity. -sed -E -i "s/(version = \")[0-9]+\.[0-9]+\.[0-9]+(\")/\1${new_raw_version}\2/" "$velocity_file" +resolved_after_bump="$(resolve_maven_version)" +[[ "$resolved_after_bump" == "$new_version" ]] || { + die "Maven version after bump is '${resolved_after_bump}', expected '${new_version}'." +} -# Keep dependency examples in README in sync. -sed -i "s/${current_raw_version}/${new_raw_version}/g" README.md +update_velocity_plugin_annotation "$new_version" -git add version.txt pom.xml README.md "$velocity_file" -git commit -m "Bump version to $new_version for release" -git tag "$new_version" +git add "$POM_FILE" "$VELOCITY_FILE" +git commit -m "Bump version to ${new_tag} for release" +git tag "$new_tag" -echo "Version updated locally. Push the branch and tag when ready:" -echo " git push && git push origin $new_version" +echo "Version updated locally." +echo "Next step: git push && git push origin ${new_tag}" diff --git a/version.txt b/version.txt deleted file mode 100644 index a68c29a..0000000 --- a/version.txt +++ /dev/null @@ -1 +0,0 @@ -v1.21.0 From cf80d51631c7789ec3703ab665ed158a3e96580e Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Sun, 29 Mar 2026 19:17:17 +0200 Subject: [PATCH 14/17] Update security.md --- SECURITY.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 60d6048..6a8d0f9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,12 +2,9 @@ ## Supported Versions -Security fixes are generally provided for the latest stable release line. +Security fixes are prioritized for the latest stable release line. +Older versions may receive fixes on a best-effort basis. -| Version | Supported | -| --- | --- | -| Latest stable release | Yes | -| Older releases | Best effort / No guarantee | ## Reporting a Vulnerability @@ -25,11 +22,13 @@ Include: - Impact assessment - Any proposed mitigation -## Response Expectations -- Initial triage acknowledgement: target within 72 hours -- Severity assessment and fix planning: as soon as reproducible -- Patch release timing: based on severity and exploitability +## What to Expect + +- We acknowledge reports as quickly as practical. +- We validate impact, prioritize by severity, and prepare a fix. +- We coordinate disclosure after a fix or mitigation is available. + ## Disclosure From dcb5b46d9935d5225d03b6148959afe007e25d92 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Sun, 29 Mar 2026 19:35:26 +0200 Subject: [PATCH 15/17] Update readme feature section --- README.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0719a85..f500af8 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,11 @@ It gives you one clean API for MySQL, MongoDB, Redis, and Redis messaging so you - Cleaner codebase: typed APIs reduce casting and repetitive boilerplate. - Better runtime behavior: connection reuse and lifecycle cleanup are handled centrally. -## What You Get +## Features -- Unified backend support: `MYSQL`, `MONGODB`, `REDIS`, `REDIS_MESSAGING` +- Following data backends are implemented: `MYSQL`, `MONGODB`, `REDIS`, `REDIS_MESSAGING` - Platform support: Velocity + Bukkit/Paper -- Reference-counted connection lifecycle management -- Optional ORM support for relational workflows (`ORMContext`) +- Optional ORM (through hibernate) support for relational workflows (`ORMContext`) ## Requirements From 5921015bf5134946e50af0f94685b7a54175d730 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Sun, 29 Mar 2026 19:36:42 +0200 Subject: [PATCH 16/17] Fix versioning --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 512ffac..50b34b7 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ nl.hauntedmc.dataprovider dataprovider DataProvider - 2.0.0 + 1.21.0 jar Plugin-scoped data access layer for Velocity and Bukkit/Paper plugins. https://github.com/HauntedMC/DataProvider From a82d7334faaed5ffeddfec8671e3ecf3f8c6285d Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Sun, 29 Mar 2026 19:36:50 +0200 Subject: [PATCH 17/17] Bump version to v2.0.0 for release --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 50b34b7..512ffac 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ nl.hauntedmc.dataprovider dataprovider DataProvider - 1.21.0 + 2.0.0 jar Plugin-scoped data access layer for Velocity and Bukkit/Paper plugins. https://github.com/HauntedMC/DataProvider