From 5714bec389ccf954cacb0902965d8284a94e03fb Mon Sep 17 00:00:00 2001 From: ehko Date: Tue, 26 May 2026 00:47:01 -0400 Subject: [PATCH] sql setup --- docs/03_Configuration.md | 32 ++++++ docs/10_Database.md | 100 ++++++++++++++++++ docs/README.md | 1 + pom.xml | 16 +++ .../net/kewwbec/networkcore/NetworkCore.java | 25 +++++ .../networkcore/api/DatabaseService.java | 26 +++++ .../command/NetworkCoreCommand.java | 14 +++ .../networkcore/config/ConfigLoader.java | 5 + .../networkcore/config/CoreConfig.java | 17 +++ .../networkcore/db/MySqlDatabaseService.java | 89 ++++++++++++++++ 10 files changed, 325 insertions(+) create mode 100644 docs/10_Database.md create mode 100644 src/main/java/net/kewwbec/networkcore/api/DatabaseService.java create mode 100644 src/main/java/net/kewwbec/networkcore/db/MySqlDatabaseService.java diff --git a/docs/03_Configuration.md b/docs/03_Configuration.md index 4f6bb79..d267149 100644 --- a/docs/03_Configuration.md +++ b/docs/03_Configuration.md @@ -32,6 +32,20 @@ }, "rpc": { "timeout_ms": 5000 + }, + "mysql": { + "enabled": false, + "host": "127.0.0.1", + "port": 3306, + "database": "kweebec", + "user": "kweebec", + "password": null, + "max_pool_size": 10, + "min_idle": 2, + "connection_timeout_ms": 5000, + "idle_timeout_ms": 600000, + "max_lifetime_ms": 1800000, + "use_ssl": false } } ``` @@ -78,6 +92,23 @@ |---|---|---| | `timeout_ms` | `5000` | How long an RPC request waits for a response before completing the future exceptionally with `TimeoutException`. | +### mysql + +| Field | Default | Meaning | +|---|---|---| +| `enabled` | `false` | Master switch. When false, `getDatabase()` returns null and no pool is created. Set to true once you've put real credentials in place. | +| `host` | `"127.0.0.1"` | MySQL host. | +| `port` | `3306` | MySQL port. | +| `database` | `"kweebec"` | Database (schema) name. Must exist before NetworkCore boots; we don't auto-create databases. | +| `user` | `"kweebec"` | MySQL user. | +| `password` | `null` | MySQL password. Prefer the env var. | +| `max_pool_size` | `10` | Hikari `maximumPoolSize`. | +| `min_idle` | `2` | Hikari `minimumIdle`. | +| `connection_timeout_ms` | `5000` | Hikari `connectionTimeout`. | +| `idle_timeout_ms` | `600000` (10 min) | Hikari `idleTimeout`. | +| `max_lifetime_ms` | `1800000` (30 min) | Hikari `maxLifetime`. Keep below MySQL's `wait_timeout`. | +| `use_ssl` | `false` | Adds `useSSL=true` to the JDBC URL. Turn on whenever MySQL is on a different host than the Hytale server. | + ## Environment variable overrides These take precedence over the config file at boot time. Use them for secrets so the config file stays safe to check into version control. @@ -86,6 +117,7 @@ These take precedence over the config file at boot time. Use them for secrets so |---|---| | `REDIS_PASSWORD` | `redis.password` | | `NETWORK_AUTH_TOKEN` | `network.auth_token` | +| `MYSQL_PASSWORD` | `mysql.password` | ## Recommended setup per environment diff --git a/docs/10_Database.md b/docs/10_Database.md new file mode 100644 index 0000000..3a88b11 --- /dev/null +++ b/docs/10_Database.md @@ -0,0 +1,100 @@ +# Database + +Shared MySQL pool, exposed to all plugins via `DatabaseService` from NetworkCore. Use this for durable state that needs to survive restarts and be queried later: player profiles, punishment history, currency balances, leaderboards, anything you'd run a SQL query against. + +For ephemeral observability data (counters, latency), use the metrics layer instead. See [04_Metrics.md](04_Metrics.md). + +## API + +```java +public interface DatabaseService { + DataSource getDataSource(); + boolean isHealthy(); + int getActiveConnections(); + int getIdleConnections(); + int getTotalConnections(); +} +``` + +That's it. We expose the `DataSource` and stay out of your way. Use JDBC directly, jOOQ, MyBatis, JdbcTemplate, whatever you like. + +## Getting a connection + +Always use try-with-resources so connections return to the pool: + +```java +DatabaseService db = NetworkCore.getInstance().getDatabase(); +if (db == null) { + // MySQL disabled in NetworkCore config; degrade gracefully + return; +} + +try (Connection c = db.getDataSource().getConnection(); + PreparedStatement ps = c.prepareStatement("SELECT name, level FROM players WHERE uuid = ?")) { + ps.setString(1, uuid.toString()); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) { + return new PlayerRecord(rs.getString("name"), rs.getInt("level")); + } + } +} +``` + +Never hold a connection across an RPC call, a thread hop, or a wait. Pool exhaustion is the most common way Hikari-backed plugins fall over. + +## Threading + +The pool is fully thread-safe. Get a connection from whichever thread needs it. JDBC calls themselves block, so: + +- Don't run heavy queries on the Hytale world thread - it stops world ticking. +- Don't run heavy queries on the MessageBus worker pool - it backs up the bus. +- Use a dedicated executor or `CompletableFuture.supplyAsync` for I/O-heavy work. + +## Schema management + +NetworkCore owns the *pool*, not your schema. Each plugin is responsible for creating and migrating its own tables. + +Conventions: +- **Table prefix per plugin.** The Staff plugin prefixes everything with `staff_`. Pick something unique to your plugin so two plugins don't fight over `players`. +- **Bootstrap on startup.** In your plugin's `start()`, run a `CREATE TABLE IF NOT EXISTS ...` for every table you need. This makes a fresh install just-work without manual setup. +- **Migrations as numbered scripts.** When you change a schema, add a `migrations/002_add_email_column.sql` and a tiny runner that tracks applied migrations in a `_schema_version` table. Don't reach for Flyway/Liquibase until you've added a third migration - those frameworks earn their weight on real projects, not on the second migration. + +## Configuration + +See the [mysql section in 03_Configuration.md](03_Configuration.md#mysql). Default is `enabled: false` so a server that doesn't have MySQL configured doesn't crash on boot. Flip to `true` once you've put credentials in place via `MYSQL_PASSWORD` env var. + +## What you see in /networkcore status + +When MySQL is healthy: +``` +Database: ok (active=2 idle=8 total=10) +``` + +When disabled: +``` +Database: disabled +``` + +When configured but failing: +``` +Database: unhealthy +``` + +If you ever see "unhealthy", check the boot log for the connection error and verify host/port/credentials. + +## What is *not* in the box + +- **No ORM.** Plain `DataSource` only. Pick your own. +- **No migration framework.** Bootstrap with `CREATE TABLE IF NOT EXISTS` per plugin. +- **No automatic encryption at rest.** That's MySQL's job, not the plugin's. Use MySQL Enterprise's TDE or AWS RDS encryption. +- **No read replicas.** Single pool to a single MySQL endpoint. Read replicas are an MySQL Router or ProxySQL concern, not a Hytale plugin concern. + +## Sizing the pool + +`max_pool_size = 10` is fine for a single server with a handful of plugins. Rules of thumb: + +- Each plugin doing heavy queries needs maybe 2-4 connections worth of headroom. +- The pool is per Hytale-server-instance, not network-wide. If you have 5 servers each with pool=10, MySQL needs to handle 50 concurrent connections. +- Make sure MySQL's `max_connections` is comfortably above the *sum* of all pools that point at it. + +Don't crank `max_pool_size` to "fix" pool exhaustion. Pool exhaustion is almost always a leaked connection or a query that's holding the connection too long. Find the leak, then re-evaluate sizing. diff --git a/docs/README.md b/docs/README.md index 430fbdb..dc42c3e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ If you're building a plugin that needs to talk to other servers, record stats, o 7. [07_RPC.md](07_RPC.md) - request/response between servers 8. [08_Commands.md](08_Commands.md) - the /networkcore admin command 9. [09_Operations_And_Security.md](09_Operations_And_Security.md) - auth token, what's not secured, ops checks +10. [10_Database.md](10_Database.md) - shared MySQL pool (HikariCP) ## Quick reference diff --git a/pom.xml b/pom.xml index 4870fee..5ed9941 100644 --- a/pom.xml +++ b/pom.xml @@ -41,6 +41,18 @@ 2.11.0 + + com.zaxxer + HikariCP + 5.1.0 + + + + com.mysql + mysql-connector-j + 8.4.0 + + com.google.code.findbugs jsr305 @@ -99,6 +111,10 @@ org.reactivestreams ${shade.base}.reactivestreams + + com.zaxxer.hikari + ${shade.base}.hikari + diff --git a/src/main/java/net/kewwbec/networkcore/NetworkCore.java b/src/main/java/net/kewwbec/networkcore/NetworkCore.java index 6ef9674..0bb95c4 100644 --- a/src/main/java/net/kewwbec/networkcore/NetworkCore.java +++ b/src/main/java/net/kewwbec/networkcore/NetworkCore.java @@ -3,6 +3,7 @@ package net.kewwbec.networkcore; import com.hypixel.hytale.logger.HytaleLogger; import com.hypixel.hytale.server.core.plugin.JavaPlugin; import com.hypixel.hytale.server.core.plugin.JavaPluginInit; +import net.kewwbec.networkcore.api.DatabaseService; import net.kewwbec.networkcore.api.MessageBus; import net.kewwbec.networkcore.api.MetricsService; import net.kewwbec.networkcore.api.RpcClient; @@ -11,6 +12,7 @@ import net.kewwbec.networkcore.bus.RedisMessageBus; import net.kewwbec.networkcore.command.NetworkCoreCommand; import net.kewwbec.networkcore.config.ConfigLoader; import net.kewwbec.networkcore.config.CoreConfig; +import net.kewwbec.networkcore.db.MySqlDatabaseService; import net.kewwbec.networkcore.internal.ServerIdGenerator; import net.kewwbec.networkcore.internal.ServiceRegistry; import net.kewwbec.networkcore.metrics.BuiltInMetrics; @@ -39,6 +41,7 @@ public final class NetworkCore extends JavaPlugin { @Nullable private RedisMessageBus messageBus; @Nullable private RedisServerRegistry serverRegistry; @Nullable private RpcClientImpl rpcClient; + @Nullable private MySqlDatabaseService database; public NetworkCore(@Nonnull JavaPluginInit init) { super(init); @@ -101,6 +104,22 @@ public final class NetworkCore extends JavaPlugin { this.rpcClient = new RpcClientImpl(messageBus, serverId, config.rpc.timeout_ms, metricsImpl); services.register(RpcClient.class, rpcClient); + if (config.mysql.enabled) { + try { + this.database = new MySqlDatabaseService(config.mysql); + if (database.isHealthy()) { + services.register(DatabaseService.class, database); + } else { + LOGGER.at(Level.WARNING).log("MySQL DatabaseService initialized but unhealthy; not registered"); + } + } catch (RuntimeException e) { + ((HytaleLogger.Api) LOGGER.at(Level.SEVERE).withCause(e)).log("Failed to initialize MySQL DatabaseService"); + this.database = null; + } + } else { + LOGGER.at(Level.INFO).log("MySQL disabled in config (mysql.enabled=false); no DatabaseService registered"); + } + LOGGER.at(Level.INFO).log("NetworkCore started"); } @@ -118,6 +137,10 @@ public final class NetworkCore extends JavaPlugin { try { messageBus.close(); } catch (RuntimeException ignored) {} messageBus = null; } + if (database != null) { + try { database.close(); } catch (RuntimeException ignored) {} + database = null; + } if (exporter != null) { try { exporter.stop(); } catch (RuntimeException ignored) {} exporter = null; @@ -144,6 +167,8 @@ public final class NetworkCore extends JavaPlugin { public ServerRegistry getServerRegistry() { return services.get(ServerRegistry.class); } @Nullable public RpcClient getRpc() { return services.get(RpcClient.class); } + @Nullable + public DatabaseService getDatabase() { return services.get(DatabaseService.class); } @Nonnull public CoreConfig getConfigSnapshot() { diff --git a/src/main/java/net/kewwbec/networkcore/api/DatabaseService.java b/src/main/java/net/kewwbec/networkcore/api/DatabaseService.java new file mode 100644 index 0000000..2f29ae8 --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/api/DatabaseService.java @@ -0,0 +1,26 @@ +package net.kewwbec.networkcore.api; + +import javax.annotation.Nonnull; +import javax.sql.DataSource; + +public interface DatabaseService { + + /** + * The pooled DataSource. Callers should always use try-with-resources: + * + * try (Connection c = service.getDataSource().getConnection()) { + * ... + * } + */ + @Nonnull + DataSource getDataSource(); + + /** + * True if the pool is open and at least one connection has been established successfully. + */ + boolean isHealthy(); + + int getActiveConnections(); + int getIdleConnections(); + int getTotalConnections(); +} diff --git a/src/main/java/net/kewwbec/networkcore/command/NetworkCoreCommand.java b/src/main/java/net/kewwbec/networkcore/command/NetworkCoreCommand.java index e56728c..c3f8b81 100644 --- a/src/main/java/net/kewwbec/networkcore/command/NetworkCoreCommand.java +++ b/src/main/java/net/kewwbec/networkcore/command/NetworkCoreCommand.java @@ -15,6 +15,7 @@ import com.hypixel.hytale.server.core.universe.PlayerRef; import com.hypixel.hytale.server.core.universe.world.World; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; import net.kewwbec.networkcore.NetworkCore; +import net.kewwbec.networkcore.api.DatabaseService; import net.kewwbec.networkcore.api.MessageBus; import net.kewwbec.networkcore.api.ServerInfo; import net.kewwbec.networkcore.api.ServerRegistry; @@ -65,6 +66,19 @@ public final class NetworkCoreCommand extends AbstractCommandCollection { } else { context.sendMessage(Message.raw("Metrics: disabled").color(Color.WHITE)); } + + DatabaseService db = plugin.getDatabase(); + if (db == null) { + context.sendMessage(Message.raw("Database: disabled").color(Color.WHITE)); + } else if (!db.isHealthy()) { + context.sendMessage(Message.raw("Database: unhealthy").color(Color.RED)); + } else { + context.sendMessage(Message.raw( + "Database: ok (active=" + db.getActiveConnections() + + " idle=" + db.getIdleConnections() + + " total=" + db.getTotalConnections() + ")" + ).color(Color.WHITE)); + } } } diff --git a/src/main/java/net/kewwbec/networkcore/config/ConfigLoader.java b/src/main/java/net/kewwbec/networkcore/config/ConfigLoader.java index c221ea5..6a43dea 100644 --- a/src/main/java/net/kewwbec/networkcore/config/ConfigLoader.java +++ b/src/main/java/net/kewwbec/networkcore/config/ConfigLoader.java @@ -35,6 +35,7 @@ public final class ConfigLoader { if (parsed.network == null) parsed.network = new CoreConfig.NetworkSection(); if (parsed.metrics == null) parsed.metrics = new CoreConfig.MetricsSection(); if (parsed.rpc == null) parsed.rpc = new CoreConfig.RpcSection(); + if (parsed.mysql == null) parsed.mysql = new CoreConfig.MysqlSection(); return applyEnvOverrides(parsed); } @@ -48,6 +49,10 @@ public final class ConfigLoader { if (envToken != null && !envToken.isEmpty()) { config.network.auth_token = envToken; } + String envMysqlPw = System.getenv("MYSQL_PASSWORD"); + if (envMysqlPw != null && !envMysqlPw.isEmpty()) { + config.mysql.password = envMysqlPw; + } return config; } } diff --git a/src/main/java/net/kewwbec/networkcore/config/CoreConfig.java b/src/main/java/net/kewwbec/networkcore/config/CoreConfig.java index 8919a86..091309b 100644 --- a/src/main/java/net/kewwbec/networkcore/config/CoreConfig.java +++ b/src/main/java/net/kewwbec/networkcore/config/CoreConfig.java @@ -9,6 +9,7 @@ public final class CoreConfig { public NetworkSection network = new NetworkSection(); public MetricsSection metrics = new MetricsSection(); public RpcSection rpc = new RpcSection(); + public MysqlSection mysql = new MysqlSection(); public static final class ServerSection { @Nullable @@ -42,4 +43,20 @@ public final class CoreConfig { public static final class RpcSection { public long timeout_ms = 5000L; } + + public static final class MysqlSection { + public boolean enabled = false; + public String host = "127.0.0.1"; + public int port = 3306; + public String database = "kweebec"; + public String user = "kweebec"; + @Nullable + public String password = null; + public int max_pool_size = 10; + public int min_idle = 2; + public long connection_timeout_ms = 5_000L; + public long idle_timeout_ms = 600_000L; + public long max_lifetime_ms = 1_800_000L; + public boolean use_ssl = false; + } } diff --git a/src/main/java/net/kewwbec/networkcore/db/MySqlDatabaseService.java b/src/main/java/net/kewwbec/networkcore/db/MySqlDatabaseService.java new file mode 100644 index 0000000..ce9c400 --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/db/MySqlDatabaseService.java @@ -0,0 +1,89 @@ +package net.kewwbec.networkcore.db; + +import com.hypixel.hytale.logger.HytaleLogger; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import net.kewwbec.networkcore.api.DatabaseService; +import net.kewwbec.networkcore.config.CoreConfig; + +import javax.annotation.Nonnull; +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.logging.Level; + +public final class MySqlDatabaseService implements DatabaseService { + + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + + private final HikariDataSource dataSource; + private volatile boolean healthy; + + public MySqlDatabaseService(@Nonnull CoreConfig.MysqlSection cfg) { + HikariConfig hc = new HikariConfig(); + String jdbcUrl = String.format( + "jdbc:mysql://%s:%d/%s?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC&useSSL=%s&allowPublicKeyRetrieval=true", + cfg.host, cfg.port, cfg.database, cfg.use_ssl + ); + hc.setJdbcUrl(jdbcUrl); + hc.setUsername(cfg.user); + if (cfg.password != null) { + hc.setPassword(cfg.password); + } + hc.setDriverClassName("com.mysql.cj.jdbc.Driver"); + hc.setMaximumPoolSize(cfg.max_pool_size); + hc.setMinimumIdle(cfg.min_idle); + hc.setConnectionTimeout(cfg.connection_timeout_ms); + hc.setIdleTimeout(cfg.idle_timeout_ms); + hc.setMaxLifetime(cfg.max_lifetime_ms); + hc.setPoolName("networkcore-mysql"); + hc.setAutoCommit(true); + + this.dataSource = new HikariDataSource(hc); + + try (Connection c = dataSource.getConnection()) { + if (!c.isValid(2)) { + throw new SQLException("Connection is not valid"); + } + this.healthy = true; + LOGGER.at(Level.INFO).log("Connected to MySQL at %s:%d/%s (pool=%d)", + cfg.host, cfg.port, cfg.database, cfg.max_pool_size); + } catch (SQLException e) { + this.healthy = false; + ((HytaleLogger.Api) LOGGER.at(Level.SEVERE).withCause(e)).log( + "Failed initial connectivity check to MySQL at %s:%d/%s", cfg.host, cfg.port, cfg.database); + } + } + + @Override + @Nonnull + public DataSource getDataSource() { + return dataSource; + } + + @Override + public boolean isHealthy() { + return healthy && !dataSource.isClosed(); + } + + @Override + public int getActiveConnections() { + return dataSource.getHikariPoolMXBean() == null ? 0 : dataSource.getHikariPoolMXBean().getActiveConnections(); + } + + @Override + public int getIdleConnections() { + return dataSource.getHikariPoolMXBean() == null ? 0 : dataSource.getHikariPoolMXBean().getIdleConnections(); + } + + @Override + public int getTotalConnections() { + return dataSource.getHikariPoolMXBean() == null ? 0 : dataSource.getHikariPoolMXBean().getTotalConnections(); + } + + public void close() { + if (!dataSource.isClosed()) { + dataSource.close(); + } + } +}