Config (JdbcConfig.java completely restructured into sections):
connection
host, port, use_ssl, user_name, password, db_name, table_prefix, Server_id
general
sync_world, sync_advancements, kick_when_already_online,
kick_message, kick_grace_period_ms, use_legacy_serialization,
item_placeholder_title_override, item_placeholder_description_override
save_triggers
auto_save_interval_minutes (0-1440, default 10)
save_on_dimension_change (default false)
save_on_death (default true)
save_on_respawn (default true)
sync_toggles
sync_inventory, sync_ender_chest, sync_xp, sync_effects,
sync_health_food, sync_curios, sync_accessories, sync_backpacks,
sync_cosmetic_armor, sync_refined_storage (all default true)
performance
heartbeat_interval_seconds (5-600, default 30)
peer_stale_threshold_seconds (10-3600, default 60)
join_poll_max_attempts (10-600, default 120)
join_poll_interval_ms (100-5000, default 500)
pool_stats_interval_minutes (0-1440, default 5)
hikari_pool_max_size (1-200, default 15)
hikari_leak_threshold_ms (2000-600000, default 25000)
safety
refuse_empty_inventory_write (default true) — enforced in writeSnapshotToDB
max_inventory_size_bytes (default 10 MB)
skip_saves_when_tps_below (0-20, default 0 = never)
observability
log_structured_json (future use)
log_rotation_size_mb (default 10)
log_rotation_max_files (default 5)
Wiring
- HeartbeatService reads heartbeat_interval_seconds at start.
- PoolStatsReporter reads pool_stats_interval_minutes (0 disables).
- doPlayerJoin poll uses join_poll_max_attempts + join_poll_interval_ms +
peer_stale_threshold_seconds.
- writeSnapshotToDB: refuse_empty guard + max_inventory_size_bytes guard
before core UPDATE. Both log via SyncLogger.dataLoss / .nbtAnomaly.
- Restore-side toggles: applyCuriosFromData, applyAccessoriesFromData,
applyCosmeticArmorFromData, doBackPackRestore, restoreRefinedStorageDisks
all short-circuit when their toggle is false.
Commands — new /playersync tree (perm level 2 required):
status — server id + heartbeat age + exec/Hikari stats + online
poolstats — log current stats immediately
flush [player] — force save all / one
info <player> — DB row metadata
dump <player> — dump full DB row to server log
resync <player> — clear synced tag + kick to force re-restore
wipe <player> confirm — DELETE all rows (DANGER, double-keyword required)
orphans — list stuck online=1 rows on dead peers
clearorphans [id] — clear orphans (global or by server_id)
peers — list peer servers with ALIVE/STALE/STOPPED tag
peerkill <id> — force-disable a zombie peer
cleanup — orphans + stale peers in one shot
reload — note about runtime reload scope
help — in-chat command reference
Every command logs to SyncLogger as ADMIN_<OP> for audit trail.
Infrastructure
- JDBCsetUp.executePreparedUpdateRet(String, Object...) returns rows-affected
for commands that need meaningful counts.
- VanillaSync.getExecutor() exposes the thread pool for read-only stats access
from admin commands (replaces reflection use in PoolStatsReporter eventually).
314 lines
13 KiB
Java
314 lines
13 KiB
Java
package vip.fubuki.playersync.util;
|
||
|
||
import com.mojang.logging.LogUtils;
|
||
import com.zaxxer.hikari.HikariConfig;
|
||
import com.zaxxer.hikari.HikariDataSource;
|
||
import org.slf4j.Logger;
|
||
import vip.fubuki.playersync.config.JdbcConfig;
|
||
|
||
import java.sql.*;
|
||
|
||
/**
|
||
* JDBC utility backed by HikariCP connection pool.
|
||
*
|
||
* Why HikariCP instead of the old manual pool?
|
||
* - Old pool called conn.isValid(2) on every borrow → SELECT 1 round-trip → visible as
|
||
* "pingInternal" in Spark profiler (~1% server thread constantly).
|
||
* - HikariCP uses TCP keepalive and only validates idle connections at a configurable
|
||
* interval (keepaliveTime=5min), never on hot-path queries.
|
||
* - Automatic reconnection, proper idle-connection eviction, and thread-safe internals
|
||
* are all handled by HikariCP without manual LinkedBlockingQueue management.
|
||
*/
|
||
public class JDBCsetUp {
|
||
|
||
private static final Logger LOGGER = LogUtils.getLogger();
|
||
private static volatile HikariDataSource dataSource;
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Pool lifecycle
|
||
// -------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Initialises the HikariCP pool. Must be called once after the MySQL database
|
||
* has been created (i.e. at the end of the CREATE DATABASE step in PlayerSync).
|
||
* Safe to call again on server-restart scenarios — closes the old pool first.
|
||
*/
|
||
public static void initPool() {
|
||
if (dataSource != null && !dataSource.isClosed()) {
|
||
dataSource.close();
|
||
}
|
||
|
||
HikariConfig cfg = new HikariConfig();
|
||
cfg.setJdbcUrl(buildUrl(true));
|
||
cfg.setUsername(JdbcConfig.USERNAME.get());
|
||
cfg.setPassword(JdbcConfig.PASSWORD.get());
|
||
|
||
// FIX PERF (C9): right-sized pool. 25 was oversized; empirical HikariCP rule is
|
||
// ~ cores*2 + spindles. 15 handles 35 concurrent players comfortably and reduces
|
||
// MySQL server-side context switching.
|
||
cfg.setMaximumPoolSize(15);
|
||
cfg.setMinimumIdle(4);
|
||
|
||
// Connection lifecycle
|
||
cfg.setConnectionTimeout(10_000L); // 10 s – fail fast on MySQL outage
|
||
cfg.setIdleTimeout(300_000L); // 5 min – evict idle connections sooner
|
||
cfg.setMaxLifetime(1_800_000L); // 30 min – recycle before MySQL wait_timeout
|
||
cfg.setKeepaliveTime(300_000L); // 5 min – ping idle connections (NOT hot path)
|
||
|
||
cfg.setAutoCommit(true);
|
||
cfg.setPoolName("PlayerSync");
|
||
|
||
// FIX PERF (C9): 25s threshold — covers worst-case doPlayerJoin poll bursts without
|
||
// flooding logs with false positives. Previous 10s fired during legitimate 15-30s polls.
|
||
cfg.setLeakDetectionThreshold(25_000L);
|
||
|
||
dataSource = new HikariDataSource(cfg);
|
||
LOGGER.info("[PlayerSync] HikariCP pool ready (maxPool={}, minIdle={})",
|
||
cfg.getMaximumPoolSize(), cfg.getMinimumIdle());
|
||
}
|
||
|
||
/**
|
||
* Closes all pooled connections. Called on server shutdown.
|
||
*/
|
||
public static void shutdownPool() {
|
||
if (dataSource != null && !dataSource.isClosed()) {
|
||
dataSource.close();
|
||
dataSource = null;
|
||
LOGGER.info("[PlayerSync] HikariCP pool closed.");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Exposes the HikariCP MBean for monitoring. Returns {@code null} if the
|
||
* pool is not initialised or already closed. Used by PoolStatsReporter.
|
||
*/
|
||
public static com.zaxxer.hikari.HikariPoolMXBean getPoolMXBean() {
|
||
try {
|
||
if (dataSource == null || dataSource.isClosed()) return null;
|
||
return dataSource.getHikariPoolMXBean();
|
||
} catch (Throwable t) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Internal helpers
|
||
// -------------------------------------------------------------------------
|
||
|
||
private static String buildUrl(boolean selectDatabase) {
|
||
String dbName = JdbcConfig.DATABASE_NAME.get();
|
||
String url = "jdbc:mysql://" + JdbcConfig.HOST.get() + ":" + JdbcConfig.PORT.get();
|
||
if (selectDatabase && !dbName.isEmpty()) {
|
||
url += "/" + dbName;
|
||
}
|
||
// No autoReconnect — HikariCP handles reconnection transparently.
|
||
// FIX PERF: Added MySQL performance parameters:
|
||
// - rewriteBatchedStatements: rewrites batch INSERTs into multi-row (5-30x faster)
|
||
// - cachePrepStmts + useServerPrepStmts: server-side prepared statement cache (15-25% CPU reduction)
|
||
// - prepStmtCacheSize=256: keeps compiled statements in cache across queries
|
||
// - useCompression: compresses network traffic (40-60% reduction for large NBT blobs)
|
||
// - tcpNoDelay: disable Nagle's algorithm for lower latency
|
||
url += "?useUnicode=true&characterEncoding=utf-8&useSSL=" + JdbcConfig.USE_SSL.get()
|
||
+ "&serverTimezone=UTC&allowPublicKeyRetrieval=true"
|
||
+ "&rewriteBatchedStatements=true"
|
||
+ "&cachePrepStmts=true"
|
||
+ "&useServerPrepStmts=true"
|
||
+ "&prepStmtCacheSize=256"
|
||
+ "&prepStmtCacheSqlLimit=2048"
|
||
+ "&useCompression=true"
|
||
+ "&tcpNoDelay=true";
|
||
return url;
|
||
}
|
||
|
||
/**
|
||
* Returns a connection from the HikariCP pool (selectDatabase=true)
|
||
* or a raw DriverManager connection (selectDatabase=false, used only for
|
||
* startup DDL that must run without a selected database).
|
||
*
|
||
* With HikariCP, calling connection.close() returns the connection to the
|
||
* pool — no separate returnConnection() call needed.
|
||
*/
|
||
public static Connection getConnection(boolean selectDatabase) throws SQLException {
|
||
if (!selectDatabase) {
|
||
// Raw connection for DDL that runs before/without the pool database
|
||
return DriverManager.getConnection(
|
||
buildUrl(false), JdbcConfig.USERNAME.get(), JdbcConfig.PASSWORD.get());
|
||
}
|
||
if (dataSource == null || dataSource.isClosed()) {
|
||
throw new SQLException("[PlayerSync] HikariCP pool is not initialised — call initPool() first.");
|
||
}
|
||
return dataSource.getConnection();
|
||
}
|
||
|
||
public static Connection getConnection() throws SQLException {
|
||
return getConnection(true);
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Query helpers (API unchanged — callers need no modification)
|
||
// -------------------------------------------------------------------------
|
||
|
||
public static QueryResult executeQuery(String sqlFormatString, Object... args) throws SQLException {
|
||
String sql = String.format(sqlFormatString, args);
|
||
LOGGER.trace(sql);
|
||
Connection connection = getConnection();
|
||
try {
|
||
PreparedStatement stmt = connection.prepareStatement(sql);
|
||
ResultSet rs = stmt.executeQuery();
|
||
return new QueryResult(connection, stmt, rs);
|
||
} catch (SQLException e) {
|
||
try { connection.close(); } catch (SQLException ignored) {}
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
private static void executeUpdateInternal(boolean selectDatabase, String sqlFormatString, Object... args) throws SQLException {
|
||
String sql = String.format(sqlFormatString, args);
|
||
LOGGER.trace(sql);
|
||
try (Connection conn = getConnection(selectDatabase);
|
||
PreparedStatement stmt = conn.prepareStatement(sql)) {
|
||
stmt.executeUpdate();
|
||
// conn.close() is called by try-with-resources:
|
||
// - pool connection → returned to HikariCP pool
|
||
// - raw connection → truly closed
|
||
}
|
||
}
|
||
|
||
public static void executeUpdate(String sqlFormatString, Object... args) throws SQLException {
|
||
executeUpdateInternal(true, sqlFormatString, args);
|
||
}
|
||
|
||
/** Overload used by startup DDL that must bypass the pool (selectDatabase=false). */
|
||
public static void executeUpdate(String sql, int dummy) throws SQLException {
|
||
LOGGER.trace(sql);
|
||
try (Connection conn = getConnection(false);
|
||
PreparedStatement stmt = conn.prepareStatement(sql)) {
|
||
stmt.executeUpdate();
|
||
}
|
||
}
|
||
|
||
public static void update(String sql, String... argument) throws SQLException {
|
||
LOGGER.trace(sql);
|
||
try (Connection conn = getConnection();
|
||
PreparedStatement stmt = conn.prepareStatement(sql)) {
|
||
for (int i = 0; i < argument.length; i++) {
|
||
stmt.setString(i + 1, argument[i]);
|
||
}
|
||
stmt.executeUpdate();
|
||
}
|
||
}
|
||
|
||
public static void executePreparedUpdate(String sql, Object... params) throws SQLException {
|
||
executePreparedUpdateRet(sql, params);
|
||
}
|
||
|
||
/**
|
||
* Variant of {@link #executePreparedUpdate(String, Object...)} that returns the
|
||
* number of rows affected. Used by admin commands (clearorphans, peerkill, wipe)
|
||
* to report meaningful counts to the operator.
|
||
*/
|
||
public static int executePreparedUpdateRet(String sql, Object... params) throws SQLException {
|
||
LOGGER.trace(sql);
|
||
try (Connection conn = getConnection();
|
||
PreparedStatement stmt = conn.prepareStatement(sql)) {
|
||
for (int i = 0; i < params.length; i++) {
|
||
stmt.setObject(i + 1, params[i]);
|
||
}
|
||
return stmt.executeUpdate();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* FIX PERF: Execute multiple SQL statements in a SINGLE transaction on ONE connection.
|
||
* Previously, writeSnapshotToDB called executePreparedUpdate 4-8 times per player,
|
||
* each opening a new connection from the pool. With 35 players: 140-280 connection
|
||
* borrows + network round-trips. This batches them into 1 connection + 1 commit.
|
||
*
|
||
* Each entry is {sql, params...}. All execute in order within one transaction.
|
||
* If any fails, the entire batch is rolled back.
|
||
*
|
||
* @return array of per-statement affected-row counts (parallel to {@code statements}).
|
||
* Callers can inspect the first entry to detect silent no-ops caused by
|
||
* {@code AND last_server=?} guards blocking a stale write.
|
||
*/
|
||
public static int[] executeBatchTransaction(Object[]... statements) throws SQLException {
|
||
int[] counts = new int[statements.length];
|
||
try (Connection conn = getConnection()) {
|
||
conn.setAutoCommit(false);
|
||
try {
|
||
for (int idx = 0; idx < statements.length; idx++) {
|
||
Object[] entry = statements[idx];
|
||
String sql = (String) entry[0];
|
||
LOGGER.trace(sql);
|
||
try (PreparedStatement stmt = conn.prepareStatement(sql)) {
|
||
for (int i = 1; i < entry.length; i++) {
|
||
stmt.setObject(i, entry[i]);
|
||
}
|
||
counts[idx] = stmt.executeUpdate();
|
||
}
|
||
}
|
||
conn.commit();
|
||
} catch (SQLException e) {
|
||
try { conn.rollback(); } catch (SQLException rbEx) {
|
||
LOGGER.error("[PlayerSync] Rollback failed while handling batch transaction error", rbEx);
|
||
}
|
||
throw e;
|
||
} finally {
|
||
conn.setAutoCommit(true);
|
||
}
|
||
}
|
||
return counts;
|
||
}
|
||
|
||
public static QueryResult executePreparedQuery(String sql, Object... params) throws SQLException {
|
||
LOGGER.trace(sql);
|
||
Connection conn = getConnection();
|
||
try {
|
||
PreparedStatement stmt = conn.prepareStatement(sql);
|
||
for (int i = 0; i < params.length; i++) {
|
||
stmt.setObject(i + 1, params[i]);
|
||
}
|
||
ResultSet rs = stmt.executeQuery();
|
||
return new QueryResult(conn, stmt, rs);
|
||
} catch (SQLException e) {
|
||
try { conn.close(); } catch (SQLException ignored) {}
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// QueryResult — holds connection open until caller closes it
|
||
// -------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Auto-closeable holder for a live query result.
|
||
* Closing it releases the ResultSet and PreparedStatement, then calls
|
||
* connection.close() which returns the connection to the HikariCP pool.
|
||
*/
|
||
public record QueryResult(
|
||
Connection connection,
|
||
PreparedStatement preparedStatement,
|
||
ResultSet resultSet
|
||
) implements AutoCloseable {
|
||
|
||
@Override
|
||
public void close() {
|
||
if (resultSet != null) {
|
||
try { resultSet.close(); } catch (SQLException e) {
|
||
LOGGER.error("[PlayerSync] Error closing ResultSet", e);
|
||
}
|
||
}
|
||
if (preparedStatement != null) {
|
||
try { preparedStatement.close(); } catch (SQLException e) {
|
||
LOGGER.error("[PlayerSync] Error closing PreparedStatement", e);
|
||
}
|
||
}
|
||
if (connection != null) {
|
||
try { connection.close(); } catch (SQLException e) {
|
||
LOGGER.error("[PlayerSync] Error returning connection to pool", e);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|