sql setup

This commit is contained in:
2026-05-26 00:47:01 -04:00
parent 981d26d003
commit 5714bec389
10 changed files with 325 additions and 0 deletions
+32
View File
@@ -32,6 +32,20 @@
}, },
"rpc": { "rpc": {
"timeout_ms": 5000 "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`. | | `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 ## 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. 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` | | `REDIS_PASSWORD` | `redis.password` |
| `NETWORK_AUTH_TOKEN` | `network.auth_token` | | `NETWORK_AUTH_TOKEN` | `network.auth_token` |
| `MYSQL_PASSWORD` | `mysql.password` |
## Recommended setup per environment ## Recommended setup per environment
+100
View File
@@ -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 `<plugin>_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.
+1
View File
@@ -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 7. [07_RPC.md](07_RPC.md) - request/response between servers
8. [08_Commands.md](08_Commands.md) - the /networkcore admin command 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 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 ## Quick reference
+16
View File
@@ -41,6 +41,18 @@
<version>2.11.0</version> <version>2.11.0</version>
</dependency> </dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>5.1.0</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.4.0</version>
</dependency>
<dependency> <dependency>
<groupId>com.google.code.findbugs</groupId> <groupId>com.google.code.findbugs</groupId>
<artifactId>jsr305</artifactId> <artifactId>jsr305</artifactId>
@@ -99,6 +111,10 @@
<pattern>org.reactivestreams</pattern> <pattern>org.reactivestreams</pattern>
<shadedPattern>${shade.base}.reactivestreams</shadedPattern> <shadedPattern>${shade.base}.reactivestreams</shadedPattern>
</relocation> </relocation>
<relocation>
<pattern>com.zaxxer.hikari</pattern>
<shadedPattern>${shade.base}.hikari</shadedPattern>
</relocation>
</relocations> </relocations>
</configuration> </configuration>
</execution> </execution>
@@ -3,6 +3,7 @@ package net.kewwbec.networkcore;
import com.hypixel.hytale.logger.HytaleLogger; import com.hypixel.hytale.logger.HytaleLogger;
import com.hypixel.hytale.server.core.plugin.JavaPlugin; import com.hypixel.hytale.server.core.plugin.JavaPlugin;
import com.hypixel.hytale.server.core.plugin.JavaPluginInit; 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.MessageBus;
import net.kewwbec.networkcore.api.MetricsService; import net.kewwbec.networkcore.api.MetricsService;
import net.kewwbec.networkcore.api.RpcClient; 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.command.NetworkCoreCommand;
import net.kewwbec.networkcore.config.ConfigLoader; import net.kewwbec.networkcore.config.ConfigLoader;
import net.kewwbec.networkcore.config.CoreConfig; import net.kewwbec.networkcore.config.CoreConfig;
import net.kewwbec.networkcore.db.MySqlDatabaseService;
import net.kewwbec.networkcore.internal.ServerIdGenerator; import net.kewwbec.networkcore.internal.ServerIdGenerator;
import net.kewwbec.networkcore.internal.ServiceRegistry; import net.kewwbec.networkcore.internal.ServiceRegistry;
import net.kewwbec.networkcore.metrics.BuiltInMetrics; import net.kewwbec.networkcore.metrics.BuiltInMetrics;
@@ -39,6 +41,7 @@ public final class NetworkCore extends JavaPlugin {
@Nullable private RedisMessageBus messageBus; @Nullable private RedisMessageBus messageBus;
@Nullable private RedisServerRegistry serverRegistry; @Nullable private RedisServerRegistry serverRegistry;
@Nullable private RpcClientImpl rpcClient; @Nullable private RpcClientImpl rpcClient;
@Nullable private MySqlDatabaseService database;
public NetworkCore(@Nonnull JavaPluginInit init) { public NetworkCore(@Nonnull JavaPluginInit init) {
super(init); super(init);
@@ -101,6 +104,22 @@ public final class NetworkCore extends JavaPlugin {
this.rpcClient = new RpcClientImpl(messageBus, serverId, config.rpc.timeout_ms, metricsImpl); this.rpcClient = new RpcClientImpl(messageBus, serverId, config.rpc.timeout_ms, metricsImpl);
services.register(RpcClient.class, rpcClient); 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"); LOGGER.at(Level.INFO).log("NetworkCore started");
} }
@@ -118,6 +137,10 @@ public final class NetworkCore extends JavaPlugin {
try { messageBus.close(); } catch (RuntimeException ignored) {} try { messageBus.close(); } catch (RuntimeException ignored) {}
messageBus = null; messageBus = null;
} }
if (database != null) {
try { database.close(); } catch (RuntimeException ignored) {}
database = null;
}
if (exporter != null) { if (exporter != null) {
try { exporter.stop(); } catch (RuntimeException ignored) {} try { exporter.stop(); } catch (RuntimeException ignored) {}
exporter = null; exporter = null;
@@ -144,6 +167,8 @@ public final class NetworkCore extends JavaPlugin {
public ServerRegistry getServerRegistry() { return services.get(ServerRegistry.class); } public ServerRegistry getServerRegistry() { return services.get(ServerRegistry.class); }
@Nullable @Nullable
public RpcClient getRpc() { return services.get(RpcClient.class); } public RpcClient getRpc() { return services.get(RpcClient.class); }
@Nullable
public DatabaseService getDatabase() { return services.get(DatabaseService.class); }
@Nonnull @Nonnull
public CoreConfig getConfigSnapshot() { public CoreConfig getConfigSnapshot() {
@@ -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();
}
@@ -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.World;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import net.kewwbec.networkcore.NetworkCore; import net.kewwbec.networkcore.NetworkCore;
import net.kewwbec.networkcore.api.DatabaseService;
import net.kewwbec.networkcore.api.MessageBus; import net.kewwbec.networkcore.api.MessageBus;
import net.kewwbec.networkcore.api.ServerInfo; import net.kewwbec.networkcore.api.ServerInfo;
import net.kewwbec.networkcore.api.ServerRegistry; import net.kewwbec.networkcore.api.ServerRegistry;
@@ -65,6 +66,19 @@ public final class NetworkCoreCommand extends AbstractCommandCollection {
} else { } else {
context.sendMessage(Message.raw("Metrics: disabled").color(Color.WHITE)); 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));
}
} }
} }
@@ -35,6 +35,7 @@ public final class ConfigLoader {
if (parsed.network == null) parsed.network = new CoreConfig.NetworkSection(); if (parsed.network == null) parsed.network = new CoreConfig.NetworkSection();
if (parsed.metrics == null) parsed.metrics = new CoreConfig.MetricsSection(); if (parsed.metrics == null) parsed.metrics = new CoreConfig.MetricsSection();
if (parsed.rpc == null) parsed.rpc = new CoreConfig.RpcSection(); if (parsed.rpc == null) parsed.rpc = new CoreConfig.RpcSection();
if (parsed.mysql == null) parsed.mysql = new CoreConfig.MysqlSection();
return applyEnvOverrides(parsed); return applyEnvOverrides(parsed);
} }
@@ -48,6 +49,10 @@ public final class ConfigLoader {
if (envToken != null && !envToken.isEmpty()) { if (envToken != null && !envToken.isEmpty()) {
config.network.auth_token = envToken; config.network.auth_token = envToken;
} }
String envMysqlPw = System.getenv("MYSQL_PASSWORD");
if (envMysqlPw != null && !envMysqlPw.isEmpty()) {
config.mysql.password = envMysqlPw;
}
return config; return config;
} }
} }
@@ -9,6 +9,7 @@ public final class CoreConfig {
public NetworkSection network = new NetworkSection(); public NetworkSection network = new NetworkSection();
public MetricsSection metrics = new MetricsSection(); public MetricsSection metrics = new MetricsSection();
public RpcSection rpc = new RpcSection(); public RpcSection rpc = new RpcSection();
public MysqlSection mysql = new MysqlSection();
public static final class ServerSection { public static final class ServerSection {
@Nullable @Nullable
@@ -42,4 +43,20 @@ public final class CoreConfig {
public static final class RpcSection { public static final class RpcSection {
public long timeout_ms = 5000L; 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;
}
} }
@@ -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();
}
}
}