diff --git a/README.md b/README.md index 04be8fa..f500af8 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. @@ -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 @@ -34,9 +33,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 = 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 +RegisteredServiceProvider registration = + Bukkit.getServicesManager().getRegistration(DataProviderAPI.class); +if (registration == null) { + return; +} +DataProviderAPI api = registration.getProvider(); +``` +```java Optional mysql = api.registerDatabaseAs( DatabaseType.MYSQL, "default", @@ -53,6 +75,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/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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9585241..baae422 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.internal`: shared platform runtime lifecycle and command behavior +- `platform.bukkit` / `platform.velocity`: platform adapters (bootstrap, command wiring, caller context resolution) ## Registration Model @@ -30,9 +31,22 @@ 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 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. - 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 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). ## ORM Integration diff --git a/docs/BEST_PRACTICES.md b/docs/BEST_PRACTICES.md index 1b0e9e4..32c61f5 100644 --- a/docs/BEST_PRACTICES.md +++ b/docs/BEST_PRACTICES.md @@ -3,21 +3,30 @@ ## 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. ## 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. +- `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. - 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/DEVELOPMENT.md b/docs/DEVELOPMENT.md index c962a17..d83fd49 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -23,6 +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/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 @@ -32,6 +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.internal` before adding platform-local duplication. - Avoid leaking plugin context across module boundaries. ## Manual Validation Checklist 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/docs/SCOPED_LIFECYCLE.md b/docs/SCOPED_LIFECYCLE.md new file mode 100644 index 0000000..e624271 --- /dev/null +++ b/docs/SCOPED_LIFECYCLE.md @@ -0,0 +1,52 @@ +# Scoped Lifecycle (Optional) + +`DataProviderScope` is an advanced ownership option. +Use it only when one plugin/software process contains multiple independently managed components. + +## Create a Scope + +```java +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 + +```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 b17b446..10292ec 100644 --- a/docs/USAGE_GUIDE.md +++ b/docs/USAGE_GUIDE.md @@ -5,17 +5,50 @@ 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. +## 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. + +## 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: @@ -60,6 +93,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 @@ -75,12 +111,20 @@ Release a specific connection: api.unregisterDatabase(DatabaseType.MYSQL, "example"); ``` -Release all connections for your plugin context: +Release all connections for your default plugin/software scope: ```java api.unregisterAllDatabases(); ``` +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/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/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/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/DataProviderAPI.java b/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderAPI.java index 24ab7fa..2af6ffc 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderAPI.java +++ b/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderAPI.java @@ -3,15 +3,31 @@ 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). + * + * For most integrations, the primary lifecycle is: + * register -> use provider/data access -> unregister. + * Optional scoped ownership is available through {@link #scope(String)} for advanced cases + * where one plugin/software process needs isolated ownership domains for independently + * managed components. */ public class DataProviderAPI { @@ -28,13 +44,29 @@ 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 - * @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)); + } + + /** + * Creates an optional scoped lifecycle facade. + * 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); } /** @@ -70,6 +102,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. @@ -79,12 +112,20 @@ public void unregisterDatabase(DatabaseType databaseType, String connectionIdent } /** - * Unregisters all database connections for the resolved caller plugin. + * Unregisters all database connections for the resolved caller plugin default owner scope. */ public void unregisterAllDatabases() { handler.unregisterAllDatabases(); } + /** + * Unregisters all database connections for the caller plugin across all caller scopes. + * Use this for deterministic full-plugin shutdown cleanup. + */ + public void unregisterAllDatabasesForPlugin() { + handler.unregisterAllDatabasesForPlugin(); + } + /** * Retrieves a registered database connection for the resolved caller plugin. * @@ -93,7 +134,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)); } /** @@ -127,7 +168,7 @@ public Optional getRegisteredDataAccess( .flatMap(provider -> provider.getDataAccessOptional(expectedDataAccessType)); } - private static Optional castProvider( + static Optional castProvider( DatabaseProvider provider, Class expectedProviderType ) { @@ -137,4 +178,125 @@ private static Optional castProvider( } return Optional.of(expectedProviderType.cast(provider)); } + + 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/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/DataProviderScope.java b/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderScope.java new file mode 100644 index 0000000..fb6bd05 --- /dev/null +++ b/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderScope.java @@ -0,0 +1,93 @@ +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; + +/** + * 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 final DataProviderHandler handler; + private final OwnerScope ownerScope; + + DataProviderScope(DataProviderHandler handler, OwnerScope ownerScope) { + this.handler = Objects.requireNonNull(handler, "DataProviderHandler cannot be null."); + this.ownerScope = Objects.requireNonNull(ownerScope, "Owner scope cannot be null."); + } + + /** + * Returns the normalized scope identifier used for ownership tracking. + */ + public OwnerScope 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(); + } +} 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/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 9bb49cf..5ed2d0f 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/config/ConfigHandler.java +++ b/src/main/java/nl/hauntedmc/dataprovider/config/ConfigHandler.java @@ -1,7 +1,8 @@ package nl.hauntedmc.dataprovider.config; import nl.hauntedmc.dataprovider.database.DatabaseType; -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; +import nl.hauntedmc.dataprovider.internal.security.FilePermissionHardening; +import nl.hauntedmc.dataprovider.logging.LoggerAdapter; import org.spongepowered.configurate.CommentedConfigurationNode; import org.spongepowered.configurate.loader.ConfigurationLoader; import org.spongepowered.configurate.serialize.SerializationException; @@ -20,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; @@ -28,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"); @@ -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/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/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 d9db187..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 @@ -5,10 +5,11 @@ 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; -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; +import nl.hauntedmc.dataprovider.logging.LoggerAdapter; import org.spongepowered.configurate.CommentedConfigurationNode; import org.bson.Document; @@ -18,28 +19,32 @@ import java.util.Objects; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; /** * 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 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; - private ExecutorService executor; - private MongoDBDataAccess dataAccess; - private boolean connected; - private String databaseName; - - public MongoDBDatabase(CommentedConfigurationNode config, 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, LoggerAdapter logger) { this.config = config; this.logger = Objects.requireNonNull(logger, "Logger cannot be null."); } @Override - public void connect() { + public synchronized void connect() { if (connected && mongoClient != null) { logger.info("[MongoDBDatabase] Already connected; skipping re–initialization."); return; @@ -47,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); @@ -60,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"); @@ -67,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; @@ -93,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 -> { @@ -108,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; @@ -119,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) { @@ -140,7 +202,7 @@ public void connect() { } @Override - public void disconnect() { + public synchronized void disconnect() { if (executor != null && !executor.isShutdown()) { executor.shutdown(); try { @@ -166,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; @@ -185,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..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 @@ -1,190 +1,293 @@ 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); + } + String[] safeFields = fields.clone(); + for (String field : safeFields) { + requireKey(field); + } + return AsyncTaskSupport.runAsync(executor, "redis.hdel", () -> { try (Jedis jedis = jedisPool.getResource()) { - jedis.hdel(hashKey, fields); + jedis.hdel(validatedHashKey, safeFields); } - }, 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); + } + 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(key, members); + jedis.sadd(validatedKey, safeMembers); } - }, 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); + } + 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(key, members); + jedis.srem(validatedKey, safeMembers); } - }, 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 431e799..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 @@ -1,10 +1,11 @@ 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; -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; @@ -15,26 +16,29 @@ 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 { +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; - private ExecutorService executor; - private RedisDataAccess dataAccess; - private boolean connected; + 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."); } @Override - public void connect() { + public synchronized void connect() { if (connected && jedisPool != null) { logger.info("[RedisDatabase] Already connected; skipping re–initialization."); return; @@ -42,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); @@ -64,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); @@ -93,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) { @@ -124,7 +184,7 @@ public void connect() { } @Override - public void disconnect() { + public synchronized void disconnect() { if (executor != null && !executor.isShutdown()) { executor.shutdown(); try { @@ -149,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; @@ -163,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/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 a1b66f4..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 @@ -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.platform.common.logger.ILoggerAdapter; +import nl.hauntedmc.dataprovider.internal.concurrent.AsyncTaskSupport; +import nl.hauntedmc.dataprovider.logging.LoggerAdapter; 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; @@ -25,10 +28,11 @@ 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; + private final int maxQueuedMessagesPerHandler; private final Map channelSubscriptions = new ConcurrentHashMap<>(); private final Object subscriptionLock = new Object(); private final AtomicBoolean shuttingDown = new AtomicBoolean(false); @@ -36,10 +40,11 @@ final class RedisMessagingDataAccess implements MessagingDataAccess { RedisMessagingDataAccess( JedisPool pool, ExecutorService workers, - ILoggerAdapter logger, + LoggerAdapter 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 ed2f0f8..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 @@ -1,11 +1,12 @@ 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; 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.*; @@ -13,21 +14,24 @@ 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 { +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 LoggerAdapter 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) { + public RedisMessagingDatabase(CommentedConfigurationNode cfg, LoggerAdapter logger) { this.cfg = cfg; this.logger = Objects.requireNonNull(logger, "Logger cannot be null."); this.messageRegistry = new MessageRegistry(logger); @@ -37,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); @@ -61,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); @@ -89,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; @@ -127,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; @@ -162,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 a6d4c5a..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 @@ -3,40 +3,47 @@ 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; -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; +import nl.hauntedmc.dataprovider.logging.LoggerAdapter; import org.spongepowered.configurate.CommentedConfigurationNode; import javax.sql.DataSource; 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. */ -public class MySQLDatabase implements RelationalDatabaseProvider { +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 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; - private HikariDataSource dataSource; - private ExecutorService executor; - private RelationalDataAccess dataAccess; - private SchemaManager schemaManager; + 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."); } @Override - public void connect() { + public synchronized void connect() { if (dataSource != null && !dataSource.isClosed()) { logger.info("[MySQLDatabase] Already connected, skipping re–initialization."); return; @@ -47,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 " @@ -65,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", @@ -75,18 +166,29 @@ public void connect() { allowPublicKeyRetrieval ); hikariConfig.setJdbcUrl(jdbcUrl); + 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 = new HikariDataSource(hikariConfig); + createdDataSource = createDataSource(hikariConfig); createdExecutor = BoundedExecutorFactory.create("dataprovider-mysql", poolSize, queueCapacity); try (var connection = createdDataSource.getConnection()) { @@ -97,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) { @@ -122,7 +225,7 @@ public void connect() { } @Override - public void disconnect() { + public synchronized void disconnect() { if (executor != null && !executor.isShutdown()) { executor.shutdown(); try { @@ -147,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); @@ -178,4 +282,53 @@ public RelationalDataAccess getDataAccess() { public DataSource getDataSource() { return dataSource; } + + 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/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 d727111..b0bf7cc 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; @@ -7,13 +8,12 @@ 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; import java.util.Map; import java.util.concurrent.ConcurrentMap; -import java.util.regex.Pattern; /** * Public entry point for plugin-scoped database operations. @@ -22,11 +22,12 @@ 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; - private final ILoggerAdapter logger; + private final LoggerAdapter logger; private final ClassLoader ownClassLoader; public DataProviderHandler( @@ -34,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."); @@ -51,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."); @@ -62,30 +63,148 @@ 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); + PluginId pluginId = PluginId.of(caller.pluginId()); + ConnectionIdentifier identifier = ConnectionIdentifier.of(connectionIdentifier); + return registry.registerDatabase( + pluginId, + OwnerScopeId.of(pluginId.value()), + databaseType, + identifier + ); + } + + /** + * Registers a database connection under an explicit owner scope. + * Used by the optional scoped API facade. + */ + 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"); + 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( + pluginId, + OwnerScopeId.from(ownerScope), + databaseType, + identifier + ); } /** * 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"); + CallerContext caller = resolveCallerContext(); + PluginId pluginId = PluginId.of(caller.pluginId()); + ConnectionIdentifier identifier = ConnectionIdentifier.of(connectionIdentifier); + registry.unregisterDatabase( + pluginId, + OwnerScopeId.of(pluginId.value()), + databaseType, + identifier + ); + } + + /** + * Unregisters a specific database connection under an explicit owner scope. + * Used by the optional scoped API facade. + */ + 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); + Objects.requireNonNull(ownerScope, "Owner scope cannot be null."); CallerContext caller = resolveCallerContext(); - registry.unregisterDatabase(caller.pluginId(), databaseType, connectionIdentifier); + PluginId pluginId = PluginId.of(caller.pluginId()); + ConnectionIdentifier identifier = ConnectionIdentifier.of(connectionIdentifier); + registry.unregisterDatabase( + pluginId, + OwnerScopeId.from(ownerScope), + databaseType, + identifier + ); } /** - * 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(); + PluginId pluginId = PluginId.of(caller.pluginId()); + registry.unregisterAllDatabases(pluginId, OwnerScopeId.of(pluginId.value())); + } + + /** + * Unregisters all database connections under an explicit owner scope. + * 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(); + Objects.requireNonNull(ownerScope, "Owner scope cannot be null."); + CallerContext caller = resolveCallerContext(); + PluginId pluginId = PluginId.of(caller.pluginId()); + registry.unregisterAllDatabases(pluginId, OwnerScopeId.from(ownerScope)); + } + + /** + * 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.unregisterAllDatabases(caller.pluginId()); + registry.unregisterAllDatabasesForPlugin(PluginId.of(caller.pluginId())); } /** @@ -100,16 +219,21 @@ 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(); - return registry.getDatabase(caller.pluginId(), databaseType, connectionIdentifier); + return registry.getDatabase( + PluginId.of(caller.pluginId()), + databaseType, + ConnectionIdentifier.of(connectionIdentifier) + ); } /** * Returns a snapshot of active database connections. */ public ConcurrentMap getActiveDatabases() { + requireOpen(); requireInternalCaller(); return registry.getActiveDatabases(); } @@ -118,10 +242,38 @@ public ConcurrentMap getActiveDatabases * Returns active connection reference counts per database key. */ public Map getActiveDatabaseReferenceCounts() { + requireOpen(); requireInternalCaller(); 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) { @@ -131,15 +283,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 void requireInternalCaller() { ClassLoader callerLoader = StackCallerClassLoaderResolver.resolveNearestCallerOutsidePackage(INTERNAL_PACKAGE_PREFIX); if (callerLoader == null || callerLoader != ownClassLoader) { @@ -147,4 +290,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 8069eab..5327a98 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistry.java +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/DataProviderRegistry.java @@ -4,111 +4,163 @@ 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.EnumMap; import java.util.HashMap; import java.util.Map; 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; class DataProviderRegistry { - private final ConcurrentMap activeDatabases = new ConcurrentHashMap<>(); + private static final String SHUTDOWN_MESSAGE = + "DataProvider is shut down. Obtain a fresh API instance after plugin enable."; + + /** + * 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; - 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."); } - 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) { - DatabaseProvider 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."); - } + protected DatabaseProvider registerDatabase( + String pluginName, + String ownerScope, + DatabaseType databaseType, + String connectionIdentifier + ) { + return registerDatabase( + PluginId.of(pluginName), + OwnerScopeId.of(ownerScope), + databaseType, + ConnectionIdentifier.of(connectionIdentifier) + ); + } - 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; - } + 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(); - DatabaseProvider 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(ownerScope)) { + int references = existingRegistration.referenceCount(); + logger.info(pluginName + " reused " + databaseType.name() + " connection (" + identifierValue + + "), 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 + + " (" + identifierValue + ") 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() + + " (" + identifierValue + ")"); + return null; + } - DatabaseProvider 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, + ownerScope + ); + ActiveDatabaseRegistration raceWinner = activeDatabases.putIfAbsent(key, createdRegistration); + if (raceWinner == null) { + logger.info(pluginName + " registered " + databaseType.name() + " connection (" + identifierValue + + "), 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(ownerScope)) { + int references = raceWinner.referenceCount(); + logger.info(pluginName + " already has " + databaseType.name() + " connection (" + identifierValue + + "), 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(); } } - private boolean isProviderHealthy(DatabaseProvider provider, DatabaseConnectionKey key) { + private boolean isProviderHealthy(DatabaseProvider provider, RegistrationKey key) { try { return provider.isConnected(); } catch (Exception e) { @@ -117,7 +169,7 @@ private boolean isProviderHealthy(DatabaseProvider provider, DatabaseConnectionK } } - private void disconnectQuietly(DatabaseProvider provider, DatabaseConnectionKey key, String reason) { + private void disconnectQuietly(ManagedDatabaseProvider provider, RegistrationKey key, String reason) { try { provider.disconnect(); } catch (Exception e) { @@ -126,144 +178,377 @@ private void disconnectQuietly(DatabaseProvider provider, DatabaseConnectionKey } 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; - } - - DatabaseProvider 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."); - } - return null; + return getDatabase( + PluginId.of(pluginName), + databaseType, + ConnectionIdentifier.of(connectionIdentifier) + ); } - 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; - } + 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(); + ActiveDatabaseRegistration registration = activeDatabases.get(key); + if (registration == null) { + return null; + } - int references = registration.releaseReference(); - if (references > 0) { - logger.info(pluginName + " released " + databaseType.name() + " connection (" + connectionIdentifier - + "), remaining references=" + references); - return; - } + ManagedDatabaseProvider provider = registration.provider(); + if (isProviderHealthy(provider, key)) { + return provider; + } - if (!activeDatabases.remove(key, registration)) { - return; + if (activeDatabases.remove(key, registration)) { + disconnectQuietly(provider, key, "stale connection during lookup"); + logger.warn("Removed stale " + databaseType.name() + " connection for " + pluginName + + " (" + identifierValue + ") while retrieving the provider."); + } + return null; + } finally { + readLock.unlock(); } + } - try { - registration.provider().disconnect(); - } catch (Exception e) { - logger.error("Error disconnecting " + key, e); - } - logger.info(pluginName + " unregistered " + databaseType.name() + " connection (" + connectionIdentifier + ")"); + protected void unregisterDatabase( + String pluginName, + String ownerScope, + DatabaseType databaseType, + String connectionIdentifier + ) { + unregisterDatabase( + PluginId.of(pluginName), + OwnerScopeId.of(ownerScope), + databaseType, + ConnectionIdentifier.of(connectionIdentifier) + ); } - protected void unregisterAllDatabases(String pluginName) { - for (Map.Entry entry : activeDatabases.entrySet()) { - DatabaseConnectionKey key = entry.getKey(); - if (!key.pluginName().equals(pluginName)) { - continue; + 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(); + ActiveDatabaseRegistration registration = activeDatabases.get(key); + if (registration == null) { + return; + } + + ReferenceReleaseResult releaseResult = registration.releaseReference(ownerScope); + if (!releaseResult.ownerHadReference()) { + logger.warn(pluginName + " attempted to release " + databaseType.name() + " connection (" + + identifierValue + ") from unregistered scope " + ownerScope.value()); + return; + } + int references = releaseResult.totalReferences(); + if (references > 0) { + logger.info(pluginName + " released " + databaseType.name() + " connection (" + identifierValue + + "), 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 (" + identifierValue + ")"); + } finally { + readLock.unlock(); + } + } + + /** + * Releases registrations for a specific plugin + owner scope pair. + */ + protected void unregisterAllDatabases(String pluginName, String ownerScope) { + 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()) { + RegistrationKey key = entry.getKey(); + if (!key.pluginId().equals(pluginId)) { + 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) { + 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()) { + RegistrationKey key = entry.getKey(); + if (!key.pluginId().equals(pluginId)) { + 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 { + writeLock.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().toExternalKey(), 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().toExternalKey(), entry.getValue().referenceCount()); + } + return snapshot; + } finally { + readLock.unlock(); + } + } + + 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; + } + + private void ensureOpen() { + if (closed) { + throw new IllegalStateException(SHUTDOWN_MESSAGE); + } + } + + /** + * 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()); } - return snapshot; } private static final class ActiveDatabaseRegistration { - private final DatabaseProvider provider; - private final AtomicInteger referenceCount; + private final ManagedDatabaseProvider provider; + // 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(DatabaseProvider provider) { + private ActiveDatabaseRegistration(ManagedDatabaseProvider provider, OwnerScopeId initialOwnerScope) { this.provider = Objects.requireNonNull(provider, "Database provider cannot be null."); - this.referenceCount = new AtomicInteger(1); + Objects.requireNonNull(initialOwnerScope, "Initial owner scope cannot be null."); + this.referenceCount = 1; + ownerReferenceCounts.put(initialOwnerScope, 1); } - private DatabaseProvider provider() { + 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(OwnerScopeId ownerScope) { + if (ownerScope == null) { + 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(OwnerScopeId ownerScope) { + if (ownerScope == null || 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(OwnerScopeId ownerScope) { + if (ownerScope == null || 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 void forceReleaseAll() { - referenceCount.set(0); + private synchronized int referenceCount() { + return Math.max(referenceCount, 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..ffdf9bc 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/internal/DatabaseConfigMap.java +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/DatabaseConfigMap.java @@ -1,7 +1,8 @@ package nl.hauntedmc.dataprovider.internal; import nl.hauntedmc.dataprovider.database.DatabaseType; -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; +import nl.hauntedmc.dataprovider.internal.security.FilePermissionHardening; +import nl.hauntedmc.dataprovider.logging.LoggerAdapter; import org.spongepowered.configurate.CommentedConfigurationNode; import org.spongepowered.configurate.loader.ConfigurationLoader; import org.spongepowered.configurate.yaml.YamlConfigurationLoader; @@ -21,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."); @@ -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) { @@ -97,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 004102d..579151b 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/internal/DatabaseFactory.java +++ b/src/main/java/nl/hauntedmc/dataprovider/internal/DatabaseFactory.java @@ -1,12 +1,11 @@ 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; 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; @@ -14,17 +13,26 @@ 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."); } - protected DatabaseProvider createDatabaseProvider(DatabaseType type, String connectionIdentifier) { + 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/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/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/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/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/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..76d8f6b --- /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.logging.LoggerAdapter; + +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, LoggerAdapter logger, String description) { + restrictToOwner(directory, OWNER_DIRECTORY_PERMISSIONS, logger, 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, + LoggerAdapter 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/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 0bd9846..4015927 100644 --- a/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/BukkitDataProvider.java +++ b/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/BukkitDataProvider.java @@ -2,51 +2,67 @@ 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.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; -import java.util.Objects; +public final class BukkitDataProvider extends JavaPlugin { -public class BukkitDataProvider extends JavaPlugin { - - private static DataProvider dataProvider; + private static final String COMMAND_NAME = "dataprovider"; + private final PlatformDataProviderRuntime runtime = new PlatformDataProviderRuntime(); @Override public void onEnable() { - - BukkitLoggerAdapter logInstance = new BukkitLoggerAdapter(getLogger()); - dataProvider = new DataProvider( - logInstance, - getDataPath(), - this.getClassLoader(), - new BukkitCallerContextResolver(this.getClassLoader()) + JulLoggerAdapter loggerAdapter = new JulLoggerAdapter(getLogger()); + runtime.start( + () -> new DataProvider( + loggerAdapter, + getDataPath(), + getClassLoader(), + new BukkitCallerContextResolver(getClassLoader()) + ), + this::initializeBindings, + loggerAdapter ); - // Init Bukkit Command - DataProviderCommand commandExecutor = new DataProviderCommand(dataProvider.getDataProviderHandler()); - Objects.requireNonNull(getCommand("dataprovider")).setExecutor(commandExecutor); - Objects.requireNonNull(getCommand("dataprovider")).setTabCompleter(commandExecutor); - getLogger().info("Enabled (v" + getDescription().getVersion() + ")."); } @Override public void onDisable() { - if (dataProvider != null) { - dataProvider.shutdownAllDatabases(); - } + getServer().getServicesManager().unregisterAll(this); + runtime.stop(new JulLoggerAdapter(getLogger())); getLogger().info("Disabled."); } - // START EXTERNALLY ACCESSIBLE - public static DataProviderAPI getDataProviderAPI() { - if (dataProvider == null) { - throw new IllegalStateException("DataProvider is not initialized yet."); + private void initializeBindings(DataProvider provider) { + registerCommand(provider.getDataProviderHandler()); + registerApiService(new DataProviderAPI(provider.getDataProviderHandler())); + } + + private void registerCommand(DataProviderHandler handler) { + PluginCommand command = getCommand(COMMAND_NAME); + if (command == null) { + throw new IllegalStateException("Command '" + COMMAND_NAME + "' is missing from plugin.yml."); } - return new DataProviderAPI(dataProvider.getDataProviderHandler()); + + DataProviderCommand commandExecutor = new DataProviderCommand(handler); + command.setExecutor(commandExecutor); + command.setTabCompleter(commandExecutor); + } + + private void registerApiService(DataProviderAPI dataProviderAPI) { + getServer().getServicesManager().register( + DataProviderAPI.class, + dataProviderAPI, + this, + ServicePriority.Normal + ); } - // END EXTERNALLY ACCESSIBLE } 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..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 @@ -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.internal.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, sender::hasPermission); } } 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/bukkit/logger/BukkitLoggerAdapter.java b/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/logger/BukkitLoggerAdapter.java deleted file mode 100644 index c6de300..0000000 --- a/src/main/java/nl/hauntedmc/dataprovider/platform/bukkit/logger/BukkitLoggerAdapter.java +++ /dev/null @@ -1,45 +0,0 @@ -package nl.hauntedmc.dataprovider.platform.bukkit.logger; - -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; - -import java.util.logging.Level; -import java.util.logging.Logger; - -public class BukkitLoggerAdapter implements ILoggerAdapter { - - private final Logger logger; - - public BukkitLoggerAdapter(Logger logger) { - this.logger = logger; - } - - @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/internal/command/DataProviderCommandService.java b/src/main/java/nl/hauntedmc/dataprovider/platform/internal/command/DataProviderCommandService.java new file mode 100644 index 0000000..da78fc8 --- /dev/null +++ b/src/main/java/nl/hauntedmc/dataprovider/platform/internal/command/DataProviderCommandService.java @@ -0,0 +1,586 @@ +package nl.hauntedmc.dataprovider.platform.internal.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.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; + +/** + * Shared command behavior used by all platform command adapters. + */ +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 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 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( + 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, String.CASE_INSENSITIVE_ORDER) + .thenComparing(key -> key.type().name()) + .thenComparing(DatabaseConnectionKey::connectionIdentifier, String.CASE_INSENSITIVE_ORDER); + + 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])) { + sendHelp(messageSink); + 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(noPermissionMessage(STATUS_PERMISSION)); + return; + } + + StatusOptions statusOptions = parseStatusOptions(args, messageSink); + if (statusOptions == null) { + return; + } + + List allConnections = listConnectionStatuses(messageSink); + if (allConnections == null) { + return; + } + + if (allConnections.isEmpty()) { + messageSink.accept(NO_ACTIVE_CONNECTIONS_MESSAGE); + return; + } + + 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); + } + } + + 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; + } + } + + 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 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; + + 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/internal/lifecycle/PlatformDataProviderRuntime.java b/src/main/java/nl/hauntedmc/dataprovider/platform/internal/lifecycle/PlatformDataProviderRuntime.java new file mode 100644 index 0000000..2f964f2 --- /dev/null +++ b/src/main/java/nl/hauntedmc/dataprovider/platform/internal/lifecycle/PlatformDataProviderRuntime.java @@ -0,0 +1,97 @@ +package nl.hauntedmc.dataprovider.platform.internal.lifecycle; + +import nl.hauntedmc.dataprovider.DataProvider; +import nl.hauntedmc.dataprovider.api.DataProviderAPI; +import nl.hauntedmc.dataprovider.logging.LoggerAdapter; + +import java.util.Objects; +import java.util.function.Consumer; +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 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 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, + 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; + activeProvider = 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." + ); + + 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(LoggerAdapter logger) { + Objects.requireNonNull(logger, "Logger cannot be null."); + DataProvider providerToShutdown = activeProvider; + activeProvider = null; + if (providerToShutdown != null) { + shutdownProvider(providerToShutdown, logger, SHUTDOWN_FAILURE_MESSAGE); + } + } + + /** + * Resolves a new API facade for the currently active provider. + */ + 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, LoggerAdapter 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 26f231b..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,9 +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.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; @@ -21,16 +24,22 @@ @Plugin( id = "dataprovider", name = "DataProvider", - version = "1.21.0", + version = "2.0.0", description = "A cross-platform data provider plugin.", authors = {"HauntedMC"} ) -public 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 String NOT_INITIALIZED_MESSAGE = "DataProvider is not initialized yet."; private final ProxyServer proxyServer; private final Logger logger; private final Path dataDirectory; - private static DataProvider dataProvider; + private final PlatformDataProviderRuntime runtime = new PlatformDataProviderRuntime(); + private volatile DataProviderAPI dataProviderApi; @Inject public VelocityDataProvider(ProxyServer proxyServer, Logger logger, @DataDirectory Path dataDirectory) { @@ -39,42 +48,55 @@ public VelocityDataProvider(ProxyServer proxyServer, Logger logger, @DataDirecto this.dataDirectory = dataDirectory; } - @Subscribe + @Subscribe(priority = INITIALIZE_EVENT_PRIORITY) public void onProxyInitialize(ProxyInitializeEvent event) { - SLF4JLoggerAdapter logInstance = new SLF4JLoggerAdapter(logger); - dataProvider = new DataProvider( - logInstance, - dataDirectory, - getClass().getClassLoader(), - new VelocityCallerContextResolver(proxyServer, getClass().getClassLoader()) + Slf4jLoggerAdapter loggerAdapter = new Slf4jLoggerAdapter(logger); + runtime.start( + () -> new DataProvider( + loggerAdapter, + dataDirectory, + getClass().getClassLoader(), + new VelocityCallerContextResolver(proxyServer, getClass().getClassLoader()) + ), + this::initializeBindings, + loggerAdapter ); - CommandManager commandManager = proxyServer.getCommandManager(); - CommandMeta meta = commandManager.metaBuilder("dataprovider") - .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); } - @Subscribe + @Subscribe(priority = SHUTDOWN_EVENT_PRIORITY) public void onProxyShutdown(ProxyShutdownEvent event) { - if (dataProvider != null) { - dataProvider.shutdownAllDatabases(); - } + dataProviderApi = null; + 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."); + @Override + public DataProviderAPI dataProviderApi() { + DataProviderAPI api = dataProviderApi; + if (api == null) { + throw new IllegalStateException(NOT_INITIALIZED_MESSAGE); } - return new DataProviderAPI(dataProvider.getDataProviderHandler()); + return api; + } + + private void initializeBindings(DataProvider provider) { + registerCommand(provider.getDataProviderHandler()); + dataProviderApi = new DataProviderAPI(provider.getDataProviderHandler()); + } + + 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) + .flatMap(container -> container.getDescription().getVersion()) + .orElse("unknown"); } - // END EXTERNALLY ACCESSIBLE } 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..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 @@ -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.internal.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(), invocation.source()::hasPermission)); } } 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/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 3ce4ad9..0000000 --- a/src/main/java/nl/hauntedmc/dataprovider/platform/velocity/logger/SLF4JLoggerAdapter.java +++ /dev/null @@ -1,42 +0,0 @@ -package nl.hauntedmc.dataprovider.platform.velocity.logger; - -import nl.hauntedmc.dataprovider.platform.common.logger.ILoggerAdapter; -import org.slf4j.Logger; - -public class SLF4JLoggerAdapter implements ILoggerAdapter { - private final Logger logger; - - public SLF4JLoggerAdapter(Logger logger) { - this.logger = logger; - } - - @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/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/main/resources/plugin.yml b/src/main/resources/plugin.yml index 7478e2a..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 \ No newline at end of file + 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/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/api/DataProviderAPITest.java b/src/test/java/nl/hauntedmc/dataprovider/api/DataProviderAPITest.java index 0ba07b5..73eacdc 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,65 @@ 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 scopeFacadeDelegatesScopedOperations() { + DataProviderHandler handler = mock(DataProviderHandler.class); + StubMessagingDatabaseProvider provider = new StubMessagingDatabaseProvider(new StubMessagingDataAccess()); + when(handler.registerDatabaseForScope(OwnerScope.of("component.scope"), DatabaseType.REDIS, "cache")) + .thenReturn(provider); + + DataProviderAPI api = new DataProviderAPI(handler); + DataProviderScope scope = api.scope("component.scope"); + + assertEquals(OwnerScope.of("component.scope"), scope.ownerScope()); + assertNotNull(scope.registerDatabase(DatabaseType.REDIS, "cache")); + scope.unregisterDatabase(DatabaseType.REDIS, "cache"); + scope.unregisterAllDatabases(); + + 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 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 +117,11 @@ void providerCastingAndDataAccessViewsReturnEmptyWhenTypeMismatches() { "cache", OtherDatabaseProvider.class ); + Optional managedView = api.registerDatabaseAs( + DatabaseType.REDIS, + "cache", + StubDatabaseProvider.class + ); Optional dataAccessView = api.getRegisteredDataAccess( DatabaseType.REDIS, "cache", @@ -94,6 +129,7 @@ void providerCastingAndDataAccessViewsReturnEmptyWhenTypeMismatches() { ); assertFalse(providerView.isPresent()); + assertFalse(managedView.isPresent()); assertFalse(dataAccessView.isPresent()); } @@ -104,9 +140,11 @@ void unregisterOperationsDelegateToHandler() { api.unregisterDatabase(DatabaseType.MYSQL, "default"); api.unregisterAllDatabases(); + api.unregisterAllDatabasesForPlugin(); verify(handler).unregisterDatabase(DatabaseType.MYSQL, "default"); verify(handler).unregisterAllDatabases(); + verify(handler).unregisterAllDatabasesForPlugin(); } @Test @@ -124,13 +162,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 +221,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/api/DataProviderScopeTest.java b/src/test/java/nl/hauntedmc/dataprovider/api/DataProviderScopeTest.java new file mode 100644 index 0000000..a351e98 --- /dev/null +++ b/src/test/java/nl/hauntedmc/dataprovider/api/DataProviderScopeTest.java @@ -0,0 +1,103 @@ +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(NullPointerException.class, () -> api.scope((String) 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(OwnerScope.of("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(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(OwnerScope.of("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; + } + } +} 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/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/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!"))); + } } diff --git a/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderHandlerTest.java b/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderHandlerTest.java index 2b3c795..96667e4 100644 --- a/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderHandlerTest.java +++ b/src/test/java/nl/hauntedmc/dataprovider/internal/DataProviderHandlerTest.java @@ -12,10 +12,12 @@ 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; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -24,7 +26,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(); @@ -37,34 +41,90 @@ 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.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")); 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).getDatabase("feature-plugin", DatabaseType.MYSQL, "default"); - verify(registry).unregisterDatabase("feature-plugin", DatabaseType.MYSQL, "default"); - verify(registry).unregisterAllDatabases("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 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, " ")); } @@ -102,7 +162,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() { }; @@ -111,24 +173,104 @@ 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 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()); 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 + void operationsFailFastWhenRegistryIsClosed() { + DataProviderRegistry registry = mock(DataProviderRegistry.class); + when(registry.isClosed()).thenReturn(true); + + ClassLoader pluginLoader = new ClassLoader() { + }; + CallerContextResolver resolver = () -> new CallerContext("plugin", pluginLoader); + DataProviderHandler handler = new DataProviderHandler( + registry, + resolver, + new RecordingLoggerAdapter(), + getClass().getClassLoader() + ); + + 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); + assertThrows(IllegalStateException.class, handler::getConfiguredDatabaseTypeStates); + assertThrows(IllegalStateException.class, handler::getConfiguredOrmSchemaMode); + assertThrows(IllegalStateException.class, handler::reloadConfiguration); + + 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")); + 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 5ae1403..a326864 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; @@ -42,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); @@ -58,10 +59,11 @@ 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", 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); @@ -70,15 +72,91 @@ 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, ConnectionIdentifier.of("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, ConnectionIdentifier.of("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, 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"); + 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); @@ -88,9 +166,10 @@ 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", DatabaseType.MYSQL, "default"); + registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); provider.connected = false; DatabaseProvider lookedUp = registry.getDatabase("plugin", DatabaseType.MYSQL, "default"); @@ -108,8 +187,9 @@ void providerHealthCheckExceptionsAreTreatedAsStaleConnections() { 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"); + 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"); assertNull(registry.getDatabase("plugin", DatabaseType.MYSQL, "default")); @@ -126,13 +206,13 @@ 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); - 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); @@ -149,13 +229,13 @@ 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", 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); @@ -174,21 +254,65 @@ 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", 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(); 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); + 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"))); } + @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, ConnectionIdentifier.of("default"))) + .thenReturn(provider); + registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); + + registry.shutdownAllDatabases(); + registry.shutdownAllDatabases(); + + assertEquals(1, provider.disconnectCalls); + assertTrue(registry.isClosed()); + + IllegalStateException registerFailure = assertThrows( + IllegalStateException.class, + () -> registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default") + ); + assertTrue(registerFailure.getMessage().contains("shut down")); + assertThrows( + IllegalStateException.class, + () -> registry.getDatabase("plugin", DatabaseType.MYSQL, "default") + ); + assertThrows( + IllegalStateException.class, + () -> registry.unregisterDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default") + ); + 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 void registerReturnsNullAndCleansUpWhenConnectThrows() { DatabaseFactory factory = mock(DatabaseFactory.class); @@ -199,9 +323,10 @@ 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", DatabaseType.MYSQL, "default"); + DatabaseProvider result = registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); assertNull(result); assertEquals(1, provider.connectCalls); @@ -216,9 +341,9 @@ 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", DatabaseType.MYSQL, "default"); + DatabaseProvider result = registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); assertNull(result); assertTrue(registry.getActiveDatabases().isEmpty()); } @@ -232,8 +357,9 @@ void getActiveSnapshotsExposeCurrentRegistryState() { 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"); + when(factory.createDatabaseProvider(DatabaseType.MYSQL, ConnectionIdentifier.of("default"))) + .thenReturn(provider); + registry.registerDatabase("plugin", "feature-a", DatabaseType.MYSQL, "default"); ConcurrentMap active = registry.getActiveDatabases(); Map refs = registry.getActiveDatabaseReferenceCounts(); @@ -243,7 +369,27 @@ void getActiveSnapshotsExposeCurrentRegistryState() { assertEquals(1, refs.get(key)); } - private static final class RecordingProvider implements DatabaseProvider { + @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; private int disconnectCalls; 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); 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/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/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); } + } 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/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 new file mode 100644 index 0000000..032b2b5 --- /dev/null +++ b/src/test/java/nl/hauntedmc/dataprovider/platform/internal/command/DataProviderCommandServiceTest.java @@ -0,0 +1,271 @@ +package nl.hauntedmc.dataprovider.platform.internal.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.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; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class DataProviderCommandServiceTest { + + @Test + void executeShowsHelpForEmptyArgsAndHelpSubcommand() { + DataProviderHandler handler = mock(DataProviderHandler.class); + DataProviderCommandService service = new DataProviderCommandService(handler); + RecordingOutput output = new RecordingOutput(); + + service.execute(new String[0], deniedPermissions(), output::record); + service.execute(new String[]{"help"}, deniedPermissions(), output::record); + + assertTrue(output.hasMessageContaining("DataProvider command help:")); + assertTrue(output.hasMessageContaining("/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"}, deniedPermissions(), output::record); + + assertTrue(output.hasMessageContaining("Missing permission: dataprovider.command.status")); + verify(handler, never()).getActiveDatabases(); + } + + @Test + void executeStatusShowsOverviewAggregatesAndConnectionRows() { + DataProviderHandler handler = mock(DataProviderHandler.class); + DataProviderCommandService service = new DataProviderCommandService(handler); + RecordingOutput output = new RecordingOutput(); + + DatabaseConnectionKey keyA = new DatabaseConnectionKey("APlugin", DatabaseType.REDIS, "cache"); + DatabaseConnectionKey keyB = new DatabaseConnectionKey("BPlugin", DatabaseType.MYSQL, "default"); + 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(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[]{"config"}, + permissions("dataprovider.command.config"), + output::record + ); + + 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 + void executeUnknownSubcommandShowsError() { + DataProviderCommandService service = new DataProviderCommandService(mock(DataProviderHandler.class)); + RecordingOutput output = new RecordingOutput(); + + service.execute(new String[]{"unknown"}, permissions("dataprovider.command.status"), output::record); + + assertTrue(output.hasMessageContaining("Unknown subcommand")); + } + + @Test + 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"))); + } + + private static DatabaseProvider connectedProvider() { + DatabaseProvider provider = mock(DatabaseProvider.class); + when(provider.isConnected()).thenReturn(true); + return provider; + } + + private static DatabaseProvider disconnectedProvider() { + DatabaseProvider provider = mock(DatabaseProvider.class); + when(provider.isConnected()).thenReturn(false); + return provider; + } + + 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<>(); + + 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 void clear() { + messages.clear(); + } + } +} diff --git a/src/test/java/nl/hauntedmc/dataprovider/platform/internal/lifecycle/PlatformDataProviderRuntimeTest.java b/src/test/java/nl/hauntedmc/dataprovider/platform/internal/lifecycle/PlatformDataProviderRuntimeTest.java new file mode 100644 index 0000000..4aeb18a --- /dev/null +++ b/src/test/java/nl/hauntedmc/dataprovider/platform/internal/lifecycle/PlatformDataProviderRuntimeTest.java @@ -0,0 +1,93 @@ +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.logging.LoggerAdapter; +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(); + LoggerAdapter logger = mock(LoggerAdapter.class); + DataProvider previousProvider = mock(DataProvider.class); + DataProvider replacementProvider = mock(DataProvider.class); + + 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(); + } + + @Test + void stopShutsDownActiveProviderAndMakesApiUnavailable() { + PlatformDataProviderRuntime runtime = new PlatformDataProviderRuntime(); + LoggerAdapter logger = mock(LoggerAdapter.class); + DataProvider provider = mock(DataProvider.class); + + runtime.start(() -> provider, created -> { + }, logger); + runtime.stop(logger); + + verify(provider).shutdownAllDatabases(); + assertThrows(IllegalStateException.class, runtime::getDataProviderAPI); + } + + @Test + void getDataProviderApiReturnsFacadeForActiveProvider() { + PlatformDataProviderRuntime runtime = new PlatformDataProviderRuntime(); + LoggerAdapter logger = mock(LoggerAdapter.class); + DataProvider provider = mock(DataProvider.class); + DataProviderHandler handler = mock(DataProviderHandler.class); + when(provider.getDataProviderHandler()).thenReturn(handler); + + runtime.start(() -> provider, created -> { + }, 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); + } + + @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 new file mode 100644 index 0000000..a32d404 --- /dev/null +++ b/src/test/java/nl/hauntedmc/dataprovider/platform/velocity/VelocityDataProviderTest.java @@ -0,0 +1,110 @@ +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; +import com.velocitypowered.api.proxy.ProxyServer; +import nl.hauntedmc.dataprovider.api.DataProviderAPI; +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; +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 { + + @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); + 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)); + } + + @Test + void dataProviderApiThrowsWhenNotInitialized() { + VelocityDataProvider provider = new VelocityDataProvider( + mock(ProxyServer.class), + mock(Logger.class), + Path.of(".") + ); + + assertThrows(IllegalStateException.class, provider::dataProviderApi); + } + + @Test + 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(".") + ); + + Field field = VelocityDataProvider.class.getDeclaredField("dataProviderApi"); + field.setAccessible(true); + 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/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()); 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); + } +} 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() { 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