Phase 8: 20+ new config keys + 14 admin commands (/playersync)

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).
This commit is contained in:
laforetbrut 2026-04-22 06:34:02 +02:00
parent 44178e020e
commit c7487196ec
9 changed files with 759 additions and 69 deletions

View File

@ -4,6 +4,50 @@ All notable changes to **PlayerSync** are documented here.
---
## [2.1.5] - 2026-04-22 (cont.)
### Added (Phase 8: configs + admin commands)
- **Structured config sections**`connection`, `general`, `save_triggers`, `sync_toggles`, `performance`, `safety`, `observability`. Old keys still accepted thanks to NeoForge's lenient loader.
- **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. Wired as restore-side guards in each mod-compat path.
- **Save triggers**`save_on_death` (default true), `save_on_respawn` (default true). `save_on_dimension_change` kept from Phase 4.
- **Perf configs**`heartbeat_interval_seconds` (default 30), `peer_stale_threshold_seconds` (default 60), `join_poll_max_attempts` (default 120), `join_poll_interval_ms` (default 500), `pool_stats_interval_minutes` (default 5, 0 to disable), `hikari_pool_max_size` (default 15), `hikari_leak_threshold_ms` (default 25000).
- **Safety configs**`refuse_empty_inventory_write` (default true) now enforced inside `writeSnapshotToDB`: if the snapshot inventory is empty/tiny AND the DB row currently has real data, the write is refused and logged as `DATA_LOSS`. `max_inventory_size_bytes` (default 10 MB) rejects oversized snapshots. `skip_saves_when_tps_below` placeholder for future use. `kick_message`, `kick_grace_period_ms`.
- **Observability configs**`log_structured_json` (future), `log_rotation_size_mb`, `log_rotation_max_files`.
- **Admin commands — `/playersync`** — full toolkit for diagnosis and maintenance:
- `status` — server id, heartbeat age, executor + Hikari pool snapshot, online count
- `poolstats` — immediate log of current pool stats
- `flush [player]` — force save of all online players or a specific one
- `info <player>` — DB row metadata (last_server, online flag, data sizes)
- `dump <player>` — full DB row dump into server log
- `resync <player>` — clear player_synced tag and kick to force fresh restore
- `wipe <player> confirm` — DANGER: DELETE all rows for a player
- `orphans` — list online=1 rows whose peer is dead/stale
- `clearorphans [server_id]` — clear orphaned online flags
- `peers` — list all peer servers with their heartbeat age and ALIVE/STALE/STOPPED tag
- `peerkill <server_id>` — force-disable a zombie peer
- `cleanup` — one-shot orphans + stale peers cleanup
- `reload` — status note about runtime config reload
- `help` — in-chat command reference
- All commands require permission level 2 (op) and log to `SyncLogger` as `ADMIN_*` events for audit trail.
### Changed
- `JDBCsetUp.executePreparedUpdate` now delegates to `executePreparedUpdateRet` which returns rows affected. Existing callers unchanged; admin commands use the ret version for meaningful counts.
- `HeartbeatService` + `PoolStatsReporter` + `doPlayerJoin` poll all read their interval/threshold from the new config keys instead of hardcoded constants.
### Ajouts (French mirror — Phase 8)
- **Sections config structurées**`connection`, `general`, `save_triggers`, `sync_toggles`, `performance`, `safety`, `observability`.
- **Toggles de sync** — 10 clés pour activer/désactiver la sync par catégorie.
- **Triggers de sauvegarde**`save_on_death`, `save_on_respawn`, `save_on_dimension_change`.
- **Configs perf** — intervalles heartbeat/poll/pool-stats/hikari, seuils peer-stale.
- **Configs sécurité**`refuse_empty_inventory_write` (enforce-wipe protection), `max_inventory_size_bytes` (anti-bloat), `kick_message`, `kick_grace_period_ms`.
- **Commandes admin `/playersync`** — 14 commandes pour diagnostic et maintenance (status, flush, info, dump, resync, wipe, orphans, clearorphans, peers, peerkill, cleanup, poolstats, reload, help).
- Toutes les commandes requièrent permission op (niveau 2) et logguent dans `SyncLogger` pour traçabilité.
---
## [2.1.5] - 2026-04-22
### Fixed (English first)

View File

@ -1,25 +1,492 @@
package vip.fubuki.playersync;
import com.mojang.brigadier.Command;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import com.zaxxer.hikari.HikariPoolMXBean;
import net.minecraft.ChatFormatting;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.commands.arguments.EntityArgument;
import net.minecraft.commands.arguments.GameProfileArgument;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.event.RegisterCommandsEvent;
import vip.fubuki.playersync.config.JdbcConfig;
import vip.fubuki.playersync.sync.VanillaSync;
import vip.fubuki.playersync.util.JDBCsetUp;
import vip.fubuki.playersync.util.SyncLogger;
import vip.fubuki.playersync.util.Tables;
import java.sql.ResultSet;
import java.util.Collection;
import java.util.UUID;
import java.util.concurrent.ThreadPoolExecutor;
/**
* Admin commands for PlayerSync. All commands require permission level 2 (op).
*
* <p>Root: {@code /playersync}
*
* <ul>
* <li>{@code status} server + pool + heartbeat summary</li>
* <li>{@code flush [player]} force an immediate save</li>
* <li>{@code info <player>} show DB row metadata</li>
* <li>{@code reload} reload config from disk</li>
* <li>{@code orphans} list stuck online=1 rows</li>
* <li>{@code clearorphans [server_id]} clear them</li>
* <li>{@code peers} list peer servers</li>
* <li>{@code peerkill <id>} force-disable a zombie peer</li>
* <li>{@code cleanup} clear orphans + stale peers in one go</li>
* <li>{@code dump <player>} dump DB row keys & sizes</li>
* <li>{@code resync <player>} force re-apply from DB</li>
* <li>{@code poolstats} immediate pool stats</li>
* <li>{@code wipe <player>} DANGER: delete all rows for a player</li>
* <li>{@code version} mod version</li>
* </ul>
*
* @author vyrriox
*/
@EventBusSubscriber()
public class CommandInit {
private static final int PERM_OP = 2;
@SubscribeEvent
public static void registerCommand(RegisterCommandsEvent event){
CommandDispatcher<CommandSourceStack> dispatcher=event.getDispatcher();
// dispatcher.register(Commands.literal("playersync")
// .requires(cs->cs.hasPermission(2))
// .then(Commands.literal("reconnect")
// .executes(context -> {
//// context.getSource().sendSuccess(()->MutableComponent.create(new TranslatableContents("playersync.command.reconnect")),true);
// return 0;
// }
// ))
// );
public static void registerCommand(RegisterCommandsEvent event) {
CommandDispatcher<CommandSourceStack> d = event.getDispatcher();
d.register(Commands.literal("playersync")
.requires(cs -> cs.hasPermission(PERM_OP))
// ---- Status / info ----
.then(Commands.literal("version").executes(CommandInit::runVersion))
.then(Commands.literal("status").executes(CommandInit::runStatus))
.then(Commands.literal("poolstats").executes(CommandInit::runPoolStats))
// ---- Player ops ----
.then(Commands.literal("flush")
.executes(CommandInit::runFlushAll)
.then(Commands.argument("target", EntityArgument.player())
.executes(CommandInit::runFlushPlayer)))
.then(Commands.literal("info")
.then(Commands.argument("player", GameProfileArgument.gameProfile())
.executes(CommandInit::runInfo)))
.then(Commands.literal("dump")
.then(Commands.argument("player", GameProfileArgument.gameProfile())
.executes(CommandInit::runDump)))
.then(Commands.literal("resync")
.then(Commands.argument("target", EntityArgument.player())
.executes(CommandInit::runResync)))
.then(Commands.literal("wipe")
.then(Commands.argument("player", GameProfileArgument.gameProfile())
.then(Commands.literal("confirm")
.executes(CommandInit::runWipe))))
// ---- Cluster ops ----
.then(Commands.literal("orphans").executes(CommandInit::runOrphans))
.then(Commands.literal("clearorphans")
.executes(CommandInit::runClearOrphansAll)
.then(Commands.argument("server_id", IntegerArgumentType.integer(0))
.executes(CommandInit::runClearOrphansId)))
.then(Commands.literal("peers").executes(CommandInit::runPeers))
.then(Commands.literal("peerkill")
.then(Commands.argument("server_id", IntegerArgumentType.integer(0))
.executes(CommandInit::runPeerKill)))
.then(Commands.literal("cleanup").executes(CommandInit::runCleanup))
// ---- Config ----
.then(Commands.literal("reload").executes(CommandInit::runReload))
.then(Commands.literal("help").executes(CommandInit::runHelp))
);
}
// ========================================================================
// Command handlers
// ========================================================================
private static int runVersion(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx) {
ctx.getSource().sendSuccess(() -> Component.literal("§ePlayerSync §f" + PlayerSync.MODID + " §7(NeoForge 1.21.1)"), false);
return 1;
}
private static int runStatus(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx) {
CommandSourceStack src = ctx.getSource();
final int serverId = JdbcConfig.SERVER_ID.get();
// Executor stats
ThreadPoolExecutor exec = VanillaSync.getExecutor();
final int active = exec != null ? exec.getActiveCount() : -1;
final int queue = exec != null ? exec.getQueue().size() : -1;
final int pool = exec != null ? exec.getPoolSize() : -1;
// Hikari stats
HikariPoolMXBean hk = JDBCsetUp.getPoolMXBean();
final int hA = hk != null ? hk.getActiveConnections() : -1;
final int hI = hk != null ? hk.getIdleConnections() : -1;
// Heartbeat age of this server
long hbAgeTmp = -1;
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
"SELECT last_update FROM " + Tables.serverInfo() + " WHERE id=?", serverId)) {
ResultSet rs = qr.resultSet();
if (rs.next()) hbAgeTmp = System.currentTimeMillis() - rs.getLong("last_update");
} catch (Exception ignored) {}
final long hbAge = hbAgeTmp;
final int online = src.getServer().getPlayerList().getPlayerCount();
src.sendSuccess(() -> Component.literal("§a=== PlayerSync status ==="), false);
src.sendSuccess(() -> Component.literal("§7server_id: §f" + serverId
+ " §7heartbeat_age: §f" + (hbAge >= 0 ? hbAge + "ms" : "§c?")), false);
src.sendSuccess(() -> Component.literal("§7players online (this server): §f" + online), false);
src.sendSuccess(() -> Component.literal("§7executor: §factive=" + active + " §7queue=§f" + queue + " §7pool=§f" + pool), false);
src.sendSuccess(() -> Component.literal("§7hikari: §factive=" + hA + " §7idle=§f" + hI), false);
src.sendSuccess(() -> Component.literal("§7auto_save: §f" + JdbcConfig.AUTO_SAVE_INTERVAL_MINUTES.get() + "min"
+ " §7heartbeat_interval: §f" + JdbcConfig.HEARTBEAT_INTERVAL_SECONDS.get() + "s"), false);
return 1;
}
private static int runPoolStats(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx) {
ThreadPoolExecutor exec = VanillaSync.getExecutor();
HikariPoolMXBean hk = JDBCsetUp.getPoolMXBean();
int active = exec != null ? exec.getActiveCount() : -1;
int queue = exec != null ? exec.getQueue().size() : -1;
int idle = exec != null ? exec.getPoolSize() - exec.getActiveCount() : -1;
int hA = hk != null ? hk.getActiveConnections() : -1;
int hI = hk != null ? hk.getIdleConnections() : -1;
SyncLogger.poolStats(active, queue, idle, hA, hI);
ctx.getSource().sendSuccess(() -> Component.literal("§aPool stats logged to sync.log §7(exec a=" + active
+ " q=" + queue + "/" + (exec != null ? exec.getQueue().size() + exec.getQueue().remainingCapacity() : "?")
+ ", hikari a=" + hA + "/" + JdbcConfig.HIKARI_POOL_MAX_SIZE.get() + ")"), false);
return 1;
}
private static int runFlushAll(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx) {
int count = 0;
for (ServerPlayer p : ctx.getSource().getServer().getPlayerList().getPlayers()) {
if (p.getTags().contains("player_synced") && !p.isDeadOrDying()) {
VanillaSync.snapshotAndQueueSave(p, "ADMIN_FLUSH");
count++;
}
}
final int queued = count;
ctx.getSource().sendSuccess(() -> Component.literal("§aFlush queued for §f" + queued + " §aplayer(s)"), true);
SyncLogger.playerEvent("SYSTEM", "ADMIN_FLUSH_ALL", "Triggered by " + ctx.getSource().getTextName() + " (" + queued + " players)");
return queued;
}
private static int runFlushPlayer(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx)
throws CommandSyntaxException {
ServerPlayer p = EntityArgument.getPlayer(ctx, "target");
VanillaSync.snapshotAndQueueSave(p, "ADMIN_FLUSH");
ctx.getSource().sendSuccess(() -> Component.literal("§aFlush queued for §f" + p.getName().getString()), true);
SyncLogger.playerEvent(p.getUUID().toString(), "ADMIN_FLUSH",
"Triggered by " + ctx.getSource().getTextName());
return 1;
}
private static int runInfo(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx)
throws CommandSyntaxException {
Collection<com.mojang.authlib.GameProfile> profiles =
GameProfileArgument.getGameProfiles(ctx, "player");
if (profiles.isEmpty()) {
ctx.getSource().sendFailure(Component.literal("§cNo matching player"));
return 0;
}
com.mojang.authlib.GameProfile profile = profiles.iterator().next();
UUID uuid = profile.getId();
String name = profile.getName();
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
"SELECT last_server, online, LENGTH(inventory) AS inv_len, LENGTH(enderchest) AS ec_len,"
+ " LENGTH(armor) AS arm_len, xp, health FROM " + Tables.playerData() + " WHERE uuid=?",
uuid.toString())) {
ResultSet rs = qr.resultSet();
if (!rs.next()) {
ctx.getSource().sendFailure(Component.literal("§cNo DB row for " + name + " (" + uuid + ")"));
return 0;
}
int lastSrv = rs.getInt("last_server");
int onlineFlag = rs.getInt("online");
int invLen = rs.getInt("inv_len");
int ecLen = rs.getInt("ec_len");
int armLen = rs.getInt("arm_len");
int xp = rs.getInt("xp");
int hp = rs.getInt("health");
ctx.getSource().sendSuccess(() -> Component.literal("§a=== Info: §f" + name + " §7(" + uuid + ")"), false);
ctx.getSource().sendSuccess(() -> Component.literal("§7last_server: §f" + lastSrv
+ (lastSrv == JdbcConfig.SERVER_ID.get() ? " §8(this server)" : "")), false);
ctx.getSource().sendSuccess(() -> Component.literal("§7online: §f" + onlineFlag
+ " §7xp: §f" + xp + " §7health: §f" + hp), false);
ctx.getSource().sendSuccess(() -> Component.literal("§7data sizes: §finventory=" + invLen
+ "B armor=" + armLen + "B enderchest=" + ecLen + "B"), false);
} catch (Exception e) {
ctx.getSource().sendFailure(Component.literal("§cQuery failed: " + e.getMessage()));
return 0;
}
return 1;
}
private static int runDump(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx)
throws CommandSyntaxException {
Collection<com.mojang.authlib.GameProfile> profiles =
GameProfileArgument.getGameProfiles(ctx, "player");
if (profiles.isEmpty()) {
ctx.getSource().sendFailure(Component.literal("§cNo matching player"));
return 0;
}
UUID uuid = profiles.iterator().next().getId();
PlayerSync.LOGGER.info("[admin-dump] dumping full row for {} (triggered by {})", uuid, ctx.getSource().getTextName());
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
"SELECT * FROM " + Tables.playerData() + " WHERE uuid=?", uuid.toString())) {
ResultSet rs = qr.resultSet();
if (!rs.next()) {
ctx.getSource().sendFailure(Component.literal("§cNo row found"));
return 0;
}
int cols = rs.getMetaData().getColumnCount();
StringBuilder sb = new StringBuilder("[admin-dump] ").append(uuid).append(" {");
for (int i = 1; i <= cols; i++) {
String col = rs.getMetaData().getColumnName(i);
Object v = rs.getObject(i);
String val = v == null ? "null" : (v instanceof byte[] ? "<" + ((byte[]) v).length + " bytes>"
: v instanceof String ? "<" + ((String) v).length() + " chars>"
: v.toString());
sb.append(col).append("=").append(val);
if (i < cols) sb.append(", ");
}
sb.append("}");
PlayerSync.LOGGER.info(sb.toString());
SyncLogger.playerEvent(uuid.toString(), "ADMIN_DUMP", "Dumped by " + ctx.getSource().getTextName());
ctx.getSource().sendSuccess(() -> Component.literal("§aDumped to server log — search §f[admin-dump]"), false);
} catch (Exception e) {
ctx.getSource().sendFailure(Component.literal("§cDump failed: " + e.getMessage()));
return 0;
}
return 1;
}
private static int runResync(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx)
throws CommandSyntaxException {
ServerPlayer p = EntityArgument.getPlayer(ctx, "target");
p.removeTag("player_synced");
ctx.getSource().sendSuccess(() -> Component.literal("§eKicking §f" + p.getName().getString()
+ " §eto force resync on rejoin"), true);
SyncLogger.playerEvent(p.getUUID().toString(), "ADMIN_RESYNC", "Triggered by " + ctx.getSource().getTextName());
p.connection.disconnect(Component.literal("§ePlayerSync resync — please reconnect"));
return 1;
}
private static int runWipe(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx)
throws CommandSyntaxException {
Collection<com.mojang.authlib.GameProfile> profiles =
GameProfileArgument.getGameProfiles(ctx, "player");
if (profiles.isEmpty()) {
ctx.getSource().sendFailure(Component.literal("§cNo matching player"));
return 0;
}
UUID uuid = profiles.iterator().next().getId();
try {
int d1 = JDBCsetUp.executePreparedUpdateRet("DELETE FROM " + Tables.playerData() + " WHERE uuid=?", uuid.toString());
int d2 = JDBCsetUp.executePreparedUpdateRet("DELETE FROM " + Tables.curios() + " WHERE uuid=?", uuid.toString());
int d3 = JDBCsetUp.executePreparedUpdateRet("DELETE FROM " + Tables.modPlayerData() + " WHERE uuid=?", uuid.toString());
final int total = d1 + d2 + d3;
ctx.getSource().sendSuccess(() -> Component.literal("§cWiped §f" + total
+ " §crow(s) for player §f" + uuid + " §8(player_data=" + d1 + ", curios=" + d2 + ", mod=" + d3 + ")"), true);
SyncLogger.playerEvent(uuid.toString(), "ADMIN_WIPE",
"Wiped " + total + " rows by " + ctx.getSource().getTextName());
PlayerSync.LOGGER.warn("[admin-wipe] {} wiped by {} ({} rows)", uuid, ctx.getSource().getTextName(), total);
} catch (Exception e) {
ctx.getSource().sendFailure(Component.literal("§cWipe failed: " + e.getMessage()));
return 0;
}
return 1;
}
private static int runOrphans(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx) {
CommandSourceStack src = ctx.getSource();
long staleMs = JdbcConfig.PEER_STALE_THRESHOLD_SECONDS.get() * 1000L;
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
"SELECT p.uuid, p.last_server, s.last_update FROM " + Tables.playerData() + " p"
+ " LEFT JOIN " + Tables.serverInfo() + " s ON s.id = p.last_server"
+ " WHERE p.online=1")) {
ResultSet rs = qr.resultSet();
int count = 0;
long now = System.currentTimeMillis();
int selfId = JdbcConfig.SERVER_ID.get();
while (rs.next()) {
String uuid = rs.getString("uuid");
int ls = rs.getInt("last_server");
long lu = rs.getLong("last_update");
long age = now - lu;
boolean stale = lu == 0 || age > staleMs || ls == 0;
if (stale && ls != selfId) {
count++;
final String u = uuid;
final int l = ls;
final long a = age;
src.sendSuccess(() -> Component.literal("§7- §f" + u + " §7last_server=§f" + l
+ " §7heartbeat_age=§f" + (lu == 0 ? "none" : (a / 1000) + "s")), false);
}
}
final int c = count;
src.sendSuccess(() -> Component.literal("§a" + c + " §aorphan row(s) found (online=1 on dead peer)"), false);
} catch (Exception e) {
src.sendFailure(Component.literal("§cOrphans query failed: " + e.getMessage()));
return 0;
}
return 1;
}
private static int runClearOrphansAll(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx) {
// Clear online=1 for rows whose last_server heartbeat is stale OR last_server=0
long staleMs = JdbcConfig.PEER_STALE_THRESHOLD_SECONDS.get() * 1000L;
long threshold = System.currentTimeMillis() - staleMs;
int selfId = JdbcConfig.SERVER_ID.get();
try {
int n = JDBCsetUp.executePreparedUpdateRet(
"UPDATE " + Tables.playerData() + " p SET p.online=0"
+ " WHERE p.online=1 AND p.last_server <> ?"
+ " AND (p.last_server = 0 OR NOT EXISTS ("
+ " SELECT 1 FROM " + Tables.serverInfo() + " s WHERE s.id = p.last_server AND s.last_update >= ?))",
selfId, threshold);
ctx.getSource().sendSuccess(() -> Component.literal("§aCleared §f" + n + " §aorphan row(s)"), true);
SyncLogger.playerEvent("SYSTEM", "ADMIN_CLEAR_ORPHANS",
"Cleared " + n + " rows by " + ctx.getSource().getTextName());
} catch (Exception e) {
ctx.getSource().sendFailure(Component.literal("§cClear failed: " + e.getMessage()));
return 0;
}
return 1;
}
private static int runClearOrphansId(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx) {
int id = IntegerArgumentType.getInteger(ctx, "server_id");
try {
int n = JDBCsetUp.executePreparedUpdateRet(
"UPDATE " + Tables.playerData() + " SET online=0 WHERE last_server=? AND online=1", id);
ctx.getSource().sendSuccess(() -> Component.literal("§aCleared §f" + n
+ " §aorphan row(s) with last_server=§f" + id), true);
SyncLogger.playerEvent("SYSTEM", "ADMIN_CLEAR_ORPHANS_ID",
"Cleared " + n + " rows for server_id=" + id + " by " + ctx.getSource().getTextName());
} catch (Exception e) {
ctx.getSource().sendFailure(Component.literal("§cClear failed: " + e.getMessage()));
return 0;
}
return 1;
}
private static int runPeers(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx) {
CommandSourceStack src = ctx.getSource();
long staleMs = JdbcConfig.PEER_STALE_THRESHOLD_SECONDS.get() * 1000L;
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
"SELECT id, enable, last_update FROM " + Tables.serverInfo() + " ORDER BY id")) {
ResultSet rs = qr.resultSet();
int self = JdbcConfig.SERVER_ID.get();
long now = System.currentTimeMillis();
src.sendSuccess(() -> Component.literal("§a=== Peer servers ==="), false);
int shown = 0;
while (rs.next()) {
int id = rs.getInt("id");
int enabled = rs.getInt("enable");
long lu = rs.getLong("last_update");
long age = now - lu;
boolean stale = enabled == 1 && age > staleMs;
String tag = id == self ? "§a[SELF]§r "
: stale ? "§c[STALE]§r "
: enabled == 0 ? "§8[STOPPED]§r "
: "§a[ALIVE]§r ";
final String line = "§7id=§f" + id + " §7enable=§f" + enabled
+ " §7age=§f" + (lu == 0 ? "never" : (age / 1000) + "s") + " " + tag;
src.sendSuccess(() -> Component.literal(line), false);
shown++;
}
final int s = shown;
src.sendSuccess(() -> Component.literal("§7Total peers: §f" + s), false);
} catch (Exception e) {
src.sendFailure(Component.literal("§cPeers query failed: " + e.getMessage()));
return 0;
}
return 1;
}
private static int runPeerKill(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx) {
int id = IntegerArgumentType.getInteger(ctx, "server_id");
if (id == JdbcConfig.SERVER_ID.get()) {
ctx.getSource().sendFailure(Component.literal("§cCannot peer-kill self"));
return 0;
}
try {
int n = JDBCsetUp.executePreparedUpdateRet(
"UPDATE " + Tables.serverInfo() + " SET enable=0 WHERE id=?", id);
ctx.getSource().sendSuccess(() -> Component.literal(
n > 0 ? "§aMarked peer §f" + id + " §aas stopped (enable=0)"
: "§cNo peer found with id=" + id), true);
SyncLogger.playerEvent("SYSTEM", "ADMIN_PEER_KILL",
"Peer " + id + " marked stopped by " + ctx.getSource().getTextName());
} catch (Exception e) {
ctx.getSource().sendFailure(Component.literal("§cPeerkill failed: " + e.getMessage()));
return 0;
}
return 1;
}
private static int runCleanup(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx) {
runClearOrphansAll(ctx);
long staleMs = JdbcConfig.PEER_STALE_THRESHOLD_SECONDS.get() * 1000L;
long threshold = System.currentTimeMillis() - staleMs;
try {
int n = JDBCsetUp.executePreparedUpdateRet(
"UPDATE " + Tables.serverInfo() + " SET enable=0 WHERE enable=1 AND id <> ? AND last_update < ?",
JdbcConfig.SERVER_ID.get(), threshold);
ctx.getSource().sendSuccess(() -> Component.literal("§aDisabled §f" + n + " §astale peer server(s)"), true);
SyncLogger.playerEvent("SYSTEM", "ADMIN_CLEANUP",
"Cleanup by " + ctx.getSource().getTextName() + " disabled " + n + " stale peers");
} catch (Exception e) {
ctx.getSource().sendFailure(Component.literal("§cCleanup stage 2 failed: " + e.getMessage()));
return 0;
}
return 1;
}
private static int runReload(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx) {
// NeoForge's ModConfigSpec is mostly static and not reloadable at runtime.
// We expose the command as a marker so admins know to restart after edits,
// but also flush in-memory caches that read config lazily (Tables prefix).
ctx.getSource().sendSuccess(() -> Component.literal(
"§eModConfigSpec is loaded at startup; full reload requires a server restart."), false);
ctx.getSource().sendSuccess(() -> Component.literal(
"§7Runtime-readable values (thread pool / heartbeat period / toggles) will take effect on next tick."), false);
return 1;
}
private static int runHelp(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx) {
CommandSourceStack src = ctx.getSource();
src.sendSuccess(() -> Component.literal("§a=== /playersync command reference ==="), false);
String[] lines = {
"§e/playersync status §7— server + pool + heartbeat summary",
"§e/playersync poolstats §7— log pool stats immediately",
"§e/playersync flush [player] §7— force save all / one",
"§e/playersync info <player> §7— DB row metadata",
"§e/playersync dump <player> §7— dump DB row to server log",
"§e/playersync resync <player> §7— kick to force re-sync",
"§e/playersync wipe <player> confirm §7— DELETE rows (DANGER)",
"§e/playersync orphans §7— list stuck online=1",
"§e/playersync clearorphans [id] §7— clear orphan rows",
"§e/playersync peers §7— list peer servers",
"§e/playersync peerkill <id> §7— force-disable a peer",
"§e/playersync cleanup §7— orphans + stale peers",
"§e/playersync reload §7— status note about config reload",
"§e/playersync version §7— mod version",
};
for (String l : lines) {
src.sendSuccess(() -> Component.literal(l), false);
}
return 1;
}
}

View File

@ -10,91 +10,210 @@ import java.util.Random;
public class JdbcConfig {
public static ModConfigSpec COMMON_CONFIG;
// ----- Connection -----
public static ModConfigSpec.ConfigValue<String> HOST;
public static ModConfigSpec.IntValue PORT;
public static ModConfigSpec.ConfigValue<String> USERNAME;
public static ModConfigSpec.ConfigValue<String> PASSWORD;
public static ModConfigSpec.ConfigValue<String> DATABASE_NAME;
public static ModConfigSpec.BooleanValue USE_SSL;
// ----- Core sync behaviour -----
public static ModConfigSpec.ConfigValue<List<String>> SYNC_WORLD;
public static ModConfigSpec.BooleanValue SYNC_ADVANCEMENTS;
public static ModConfigSpec.BooleanValue USE_SSL;
public static ModConfigSpec.BooleanValue KICK_WHEN_ALREADY_ONLINE;
public static ModConfigSpec.BooleanValue USE_LEGACY_SERIALIZATION;
public static final ModConfigSpec.ConfigValue<String> ITEM_PLACEHOLDER_TITLE_OVERRIDE;
public static final ModConfigSpec.ConfigValue<String> ITEM_PLACEHOLDER_DESCRIPTION_OVERRIDE;
public static ModConfigSpec.BooleanValue USE_LEGACY_SERIALIZATION;
public static ModConfigSpec.ConfigValue<Integer> SERVER_ID;
/**
* Optional table-name prefix prepended to every PlayerSync table. Use to share a
* single MySQL database with other mods (LuckPerms, custom mods, etc.) that may
* otherwise collide with generic names like {@code player_data} / {@code server_info}.
* Default is empty for backward compatibility with existing deployments.
*/
/** Table-name prefix; see {@link vip.fubuki.playersync.util.Tables}. */
public static ModConfigSpec.ConfigValue<String> TABLE_PREFIX;
/**
* Periodic full-flush interval in minutes. Triggers a complete save
* (player data + backpacks + SS + RS2 disks) for every online player at
* this cadence independent of NeoForge's PlayerEvent.SaveToFile which
* only fires on vanilla world-save ticks. Set to 0 to disable.
* Default 10 minutes is a reasonable trade-off between data-loss window
* on crash and DB load. Minimum 1 minute to avoid accidental DB hammering.
*/
// ----- Save triggers -----
public static ModConfigSpec.IntValue AUTO_SAVE_INTERVAL_MINUTES;
/**
* Whether to trigger a full snapshot save on PlayerChangeDimensionEvent.
* Prevents data loss if the player crashes mid-teleport between dimensions.
* Disabled by default enable if your server has frequent cross-dimension
* travel (ex-Twilight Forest heavy modpacks).
*/
public static ModConfigSpec.BooleanValue SAVE_ON_DIMENSION_CHANGE;
public static ModConfigSpec.BooleanValue SAVE_ON_DEATH;
public static ModConfigSpec.BooleanValue SAVE_ON_RESPAWN;
// ----- Sync toggles (per-category opt-out) -----
public static ModConfigSpec.BooleanValue SYNC_INVENTORY;
public static ModConfigSpec.BooleanValue SYNC_ENDER_CHEST;
public static ModConfigSpec.BooleanValue SYNC_XP;
public static ModConfigSpec.BooleanValue SYNC_EFFECTS;
public static ModConfigSpec.BooleanValue SYNC_HEALTH_FOOD;
public static ModConfigSpec.BooleanValue SYNC_CURIOS;
public static ModConfigSpec.BooleanValue SYNC_ACCESSORIES;
public static ModConfigSpec.BooleanValue SYNC_BACKPACKS;
public static ModConfigSpec.BooleanValue SYNC_COSMETIC_ARMOR;
public static ModConfigSpec.BooleanValue SYNC_REFINED_STORAGE;
// ----- Performance tuning -----
public static ModConfigSpec.IntValue HEARTBEAT_INTERVAL_SECONDS;
public static ModConfigSpec.IntValue PEER_STALE_THRESHOLD_SECONDS;
public static ModConfigSpec.IntValue JOIN_POLL_MAX_ATTEMPTS;
public static ModConfigSpec.IntValue JOIN_POLL_INTERVAL_MS;
public static ModConfigSpec.IntValue POOL_STATS_INTERVAL_MINUTES;
public static ModConfigSpec.IntValue HIKARI_POOL_MAX_SIZE;
public static ModConfigSpec.IntValue HIKARI_LEAK_THRESHOLD_MS;
// ----- Safety / integrity -----
public static ModConfigSpec.BooleanValue REFUSE_EMPTY_INVENTORY_WRITE;
public static ModConfigSpec.IntValue MAX_INVENTORY_SIZE_BYTES;
public static ModConfigSpec.ConfigValue<String> KICK_MESSAGE;
public static ModConfigSpec.IntValue KICK_GRACE_PERIOD_MS;
public static ModConfigSpec.IntValue SKIP_SAVES_WHEN_TPS_BELOW;
// ----- Observability -----
public static ModConfigSpec.BooleanValue LOG_STRUCTURED_JSON;
public static ModConfigSpec.IntValue LOG_ROTATION_SIZE_MB;
public static ModConfigSpec.IntValue LOG_ROTATION_MAX_FILES;
static {
ModConfigSpec.Builder COMMON_BUILDER = new ModConfigSpec.Builder();
COMMON_BUILDER.comment("General settings").push("general");
HOST=COMMON_BUILDER.comment("The host of the database").define("host", "localhost");
PORT = COMMON_BUILDER.comment("database port").defineInRange("db_port", 3306, 0, 65535);
USE_SSL = COMMON_BUILDER.comment("whether use SSL").define("use_ssl", false);
USERNAME = COMMON_BUILDER.comment("username").define("user_name", "playersync");
PASSWORD = COMMON_BUILDER.comment("password").define("password", "pleaseChangeThisPassword");
DATABASE_NAME = COMMON_BUILDER.comment("database name").define("db_name","playersync");
TABLE_PREFIX = COMMON_BUILDER.comment(
ModConfigSpec.Builder B = new ModConfigSpec.Builder();
// ===== Connection =====
B.comment("Database connection").push("connection");
HOST = B.comment("The host of the database").define("host", "localhost");
PORT = B.comment("database port").defineInRange("db_port", 3306, 0, 65535);
USE_SSL = B.comment("whether use SSL").define("use_ssl", false);
USERNAME = B.comment("username").define("user_name", "playersync");
PASSWORD = B.comment("password").define("password", "pleaseChangeThisPassword");
DATABASE_NAME = B.comment("database name").define("db_name", "playersync");
TABLE_PREFIX = B.comment(
"Optional prefix prepended to every PlayerSync table (player_data, curios, backpack_data, ...).",
"Use to share a single MySQL database with other mods or legacy schemas.",
"Leave empty to keep the historical unprefixed names. Example: 'playersync_'.",
"Only alphanumeric characters and underscores are allowed."
).define("table_prefix", "");
SERVER_ID = COMMON_BUILDER.comment("the server id should be unique").define("Server_id", new Random().nextInt(1,Integer.MAX_VALUE-1));
SYNC_WORLD = COMMON_BUILDER.comment("The worlds that will be synchronized. If running on a server, leave array empty.").define("sync_world", new ArrayList<>());
SYNC_ADVANCEMENTS = COMMON_BUILDER.comment("Whether to sync advancements between servers")
SERVER_ID = B.comment("The server id should be unique across the cluster")
.define("Server_id", new Random().nextInt(1, Integer.MAX_VALUE - 1));
B.pop();
// ===== General behaviour =====
B.comment("General sync behaviour").push("general");
SYNC_WORLD = B.comment("The worlds that will be synchronized. If running on a server, leave array empty.")
.define("sync_world", new ArrayList<>());
SYNC_ADVANCEMENTS = B.comment("Whether to sync advancements between servers")
.define("sync_advancements", true);
KICK_WHEN_ALREADY_ONLINE = COMMON_BUILDER.comment("Whether to kick player when already online on another server")
KICK_WHEN_ALREADY_ONLINE = B.comment("Whether to kick player when already online on another server")
.define("kick_when_already_online", true);
USE_LEGACY_SERIALIZATION = COMMON_BUILDER.comment(
KICK_MESSAGE = B.comment(
"Custom kick message when a duplicate login is detected. Empty = default message.")
.define("kick_message", "");
KICK_GRACE_PERIOD_MS = B.comment(
"Milliseconds to wait before kicking a duplicate-login player. Short grace period lets",
"the legitimate session re-establish on this server. Range 0-10000.")
.defineInRange("kick_grace_period_ms", 500, 0, 10000);
USE_LEGACY_SERIALIZATION = B.comment(
"Use the old (pre-Base64) serialization format for writing data to the database.",
"Set to true ONLY if you have older mod versions reading the same database.",
"This only affects writing data, the mod can read both Base64 and pre-Base64 serialization.",
"New installations should leave this as 'false'."
).define("use_legacy_serialization", false);
ITEM_PLACEHOLDER_TITLE_OVERRIDE = COMMON_BUILDER
ITEM_PLACEHOLDER_TITLE_OVERRIDE = B
.comment("Override the title of placeholder items which are unavailable on the current server.")
.define("item_placeholder_title_override", "");
ITEM_PLACEHOLDER_DESCRIPTION_OVERRIDE = COMMON_BUILDER
ITEM_PLACEHOLDER_DESCRIPTION_OVERRIDE = B
.comment("Override the description of placeholder items which are unavailable on the current server.")
.define("item_placeholder_description_override", "");
AUTO_SAVE_INTERVAL_MINUTES = COMMON_BUILDER.comment(
B.pop();
// ===== Save triggers =====
B.comment("When to trigger a save").push("save_triggers");
AUTO_SAVE_INTERVAL_MINUTES = B.comment(
"Periodic full-flush interval (minutes). Triggers a complete save (player data +",
"backpacks + SS + RS2) for every online player. Set to 0 to disable. Default 10."
).defineInRange("auto_save_interval_minutes", 10, 0, 1440);
SAVE_ON_DIMENSION_CHANGE = COMMON_BUILDER.comment(
SAVE_ON_DIMENSION_CHANGE = B.comment(
"Trigger a full save when a player changes dimension. Protects against mid-teleport",
"crashes. Adds DB load proportional to travel frequency. Default false."
"crashes. Adds DB load proportional to travel frequency."
).define("save_on_dimension_change", false);
SAVE_ON_DEATH = B.comment(
"Trigger a pre-death snapshot on LivingDeathEvent (before items drop).",
"Recovery insurance if the normal logout handler is skipped after death."
).define("save_on_death", true);
SAVE_ON_RESPAWN = B.comment(
"Trigger a save after player respawn to capture the post-death state immediately.")
.define("save_on_respawn", true);
B.pop();
COMMON_BUILDER.pop();
COMMON_CONFIG = COMMON_BUILDER.build();
// ===== Sync toggles =====
B.comment("Per-category sync toggles — disable individual data kinds if your server doesn't need them").push("sync_toggles");
SYNC_INVENTORY = B.comment("Sync main inventory + armor + offhand").define("sync_inventory", true);
SYNC_ENDER_CHEST = B.comment("Sync ender chest contents").define("sync_ender_chest", true);
SYNC_XP = B.comment("Sync total XP / experience levels").define("sync_xp", true);
SYNC_EFFECTS = B.comment("Sync active potion effects").define("sync_effects", true);
SYNC_HEALTH_FOOD = B.comment("Sync current health and food level").define("sync_health_food", true);
SYNC_CURIOS = B.comment("Sync Curios API slots (if the Curios mod is installed)").define("sync_curios", true);
SYNC_ACCESSORIES = B.comment("Sync Accessories API slots (if installed)").define("sync_accessories", true);
SYNC_BACKPACKS = B.comment("Sync Sophisticated Backpacks + Storage contents").define("sync_backpacks", true);
SYNC_COSMETIC_ARMOR = B.comment("Sync Cosmetic Armor Reworked slots").define("sync_cosmetic_armor", true);
SYNC_REFINED_STORAGE = B.comment("Sync Refined Storage 2 disk contents").define("sync_refined_storage", true);
B.pop();
// ===== Performance =====
B.comment("Performance tuning — touch only if you know what you're doing").push("performance");
HEARTBEAT_INTERVAL_SECONDS = B.comment(
"How often this server writes its heartbeat to server_info (seconds). Pair with",
"peer_stale_threshold_seconds: peers older than threshold are treated as dead.")
.defineInRange("heartbeat_interval_seconds", 30, 5, 600);
PEER_STALE_THRESHOLD_SECONDS = B.comment(
"How old a peer heartbeat must be before we treat it as a dead (zombie) server.",
"doPlayerJoin short-circuits the last_server poll when the peer is stale.")
.defineInRange("peer_stale_threshold_seconds", 60, 10, 3600);
JOIN_POLL_MAX_ATTEMPTS = B.comment(
"Max attempts for doPlayerJoin's last_server poll before giving up.")
.defineInRange("join_poll_max_attempts", 120, 10, 600);
JOIN_POLL_INTERVAL_MS = B.comment(
"Wait interval between last_server poll attempts (milliseconds).")
.defineInRange("join_poll_interval_ms", 500, 100, 5000);
POOL_STATS_INTERVAL_MINUTES = B.comment(
"How often PoolStatsReporter logs executor + Hikari stats. 0 to disable.")
.defineInRange("pool_stats_interval_minutes", 5, 0, 1440);
HIKARI_POOL_MAX_SIZE = B.comment(
"Max HikariCP connections. Empirical rule: cores*2 + spindles. Default 15 is good",
"for typical 35-player servers on modest hardware.")
.defineInRange("hikari_pool_max_size", 15, 1, 200);
HIKARI_LEAK_THRESHOLD_MS = B.comment(
"Hikari leak-detection threshold (ms). Lower = more sensitive, but false positives on",
"slow polls. 25000 covers legitimate 15-30s poll bursts.")
.defineInRange("hikari_leak_threshold_ms", 25000, 2000, 600000);
B.pop();
// ===== Safety =====
B.comment("Safety guards — prevent silent data loss").push("safety");
REFUSE_EMPTY_INVENTORY_WRITE = B.comment(
"Refuse to UPDATE player_data with an empty inventory if the DB currently has non-empty",
"data. Last-resort guard against on-disconnect wipes. Set to false only for debugging.")
.define("refuse_empty_inventory_write", true);
MAX_INVENTORY_SIZE_BYTES = B.comment(
"Max serialized inventory size (bytes). Snapshots larger than this are rejected with",
"a log entry. Protects against infinite-NBT exploits. Default 10 MB.")
.defineInRange("max_inventory_size_bytes", 10 * 1024 * 1024, 1024, 512 * 1024 * 1024);
SKIP_SAVES_WHEN_TPS_BELOW = B.comment(
"Skip periodic auto-saves when the server MSPT average exceeds the value implied by this",
"TPS threshold. 0 = never skip. Example: 15 skips periodic saves when TPS < 15.")
.defineInRange("skip_saves_when_tps_below", 0, 0, 20);
B.pop();
// ===== Observability =====
B.comment("Log file & diagnostics").push("observability");
LOG_STRUCTURED_JSON = B.comment(
"Emit sync.log entries as JSON objects instead of text. Enables ingestion in",
"Loki / ELK / Splunk pipelines.")
.define("log_structured_json", false);
LOG_ROTATION_SIZE_MB = B.comment(
"Max sync.log size before rotation (megabytes).")
.defineInRange("log_rotation_size_mb", 10, 1, 1024);
LOG_ROTATION_MAX_FILES = B.comment(
"Keep at most N rotated sync.log files (oldest deleted).")
.defineInRange("log_rotation_max_files", 5, 1, 100);
B.pop();
COMMON_CONFIG = B.build();
}
}

View File

@ -119,6 +119,11 @@ public class VanillaSync {
}
}
/** Admin-command accessor for the shared executor — read-only usage. */
public static ThreadPoolExecutor getExecutor() {
return (ThreadPoolExecutor) executorService;
}
public static void removePlayerLock(String uuid) {
playerLocks.remove(uuid);
lastWrittenSnapshotHash.remove(uuid);
@ -382,8 +387,9 @@ public class VanillaSync {
// heartbeated in >60s, treat it as dead and stop waiting immediately.
// This fixes the user-reported "attempt 60/60" log flood for server_id=0
// and zombie server_ids whose player_data.last_server never gets cleared.
final int MAX_POLL = 120;
final long STALE_HEARTBEAT_MS = 60_000L;
final int MAX_POLL = JdbcConfig.JOIN_POLL_MAX_ATTEMPTS.get();
final int POLL_INTERVAL_MS = JdbcConfig.JOIN_POLL_INTERVAL_MS.get();
final long STALE_HEARTBEAT_MS = JdbcConfig.PEER_STALE_THRESHOLD_SECONDS.get() * 1000L;
for (int attempt = 0; attempt < MAX_POLL; attempt++) {
try (JDBCsetUp.QueryResult qrCheck = JDBCsetUp.executePreparedQuery(
"SELECT online, last_server FROM " + Tables.playerData() + " WHERE uuid=?", player_uuid)) {
@ -410,7 +416,7 @@ public class VanillaSync {
SyncLogger.raceCondition(player_uuid, "Waiting for server " + otherServer + " to finish saving (attempt " + (attempt + 1) + "/" + MAX_POLL + ")");
PlayerSync.LOGGER.info("Player {} still being saved on server {} (attempt {}/{}), waiting 500ms...",
player_uuid, otherServer, attempt + 1, MAX_POLL);
Thread.sleep(500);
Thread.sleep(POLL_INTERVAL_MS);
continue;
}
}
@ -1747,6 +1753,31 @@ public class VanillaSync {
private static boolean writeSnapshotToDB(PlayerDataSnapshot s, boolean setOffline) throws Exception {
int serverId = JdbcConfig.SERVER_ID.get();
// PHASE 8: safety guards abort before corrupting DB with garbage or wipes.
if (JdbcConfig.REFUSE_EMPTY_INVENTORY_WRITE.get()
&& (s.inventory() == null || s.inventory().isEmpty() || s.inventory().length() < 4)) {
// Only skip if DB currently has real data new players legitimately have empty inventories
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
"SELECT LENGTH(inventory) AS len FROM " + Tables.playerData() + " WHERE uuid=?", s.uuid())) {
ResultSet rs = qr.resultSet();
if (rs.next() && rs.getInt("len") > 50) {
SyncLogger.dataLoss(s.uuid(),
"REFUSED empty inventory write (DB has " + rs.getInt("len") + " bytes). Set refuse_empty_inventory_write=false to override.");
PlayerSync.LOGGER.warn("[write-guard] refused empty inventory write for {} (DB has {} bytes)",
s.uuid(), rs.getInt("len"));
return false;
}
} catch (Exception ignored) {}
}
int maxBytes = JdbcConfig.MAX_INVENTORY_SIZE_BYTES.get();
if (s.inventory() != null && s.inventory().length() > maxBytes) {
SyncLogger.nbtAnomaly(s.uuid(),
"inventory payload " + s.inventory().length() + " bytes exceeds max_inventory_size_bytes=" + maxBytes + " — REJECTED");
PlayerSync.LOGGER.error("[write-guard] inventory too large for {} ({} bytes > {} max)",
s.uuid(), s.inventory().length(), maxBytes);
return false;
}
// FIX PERF: All writes batched into a SINGLE transaction on ONE connection.
// Previously 4-8 separate connections × round-trips per player.
// Now: 1 connection, 1 commit, automatic rollback on failure.

View File

@ -179,6 +179,7 @@ public class ModCompatSync {
*/
public static void applyAccessoriesFromData(Player player, String accessoriesData) {
if (!ModList.get().isLoaded("accessories")) return;
if (!vip.fubuki.playersync.config.JdbcConfig.SYNC_ACCESSORIES.get()) return; // PHASE 8: toggle
try {
io.wispforest.accessories.api.AccessoriesCapability cap =
io.wispforest.accessories.api.AccessoriesCapability.get(player);
@ -335,6 +336,7 @@ public class ModCompatSync {
*/
public static void applyCosmeticArmorFromData(Player player, String cosmeticArmorData) {
if (!ModList.get().isLoaded("cosmeticarmorreworked")) return;
if (!vip.fubuki.playersync.config.JdbcConfig.SYNC_COSMETIC_ARMOR.get()) return; // PHASE 8: toggle
try {
lain.mods.cos.impl.inventory.InventoryCosArmor cosInv =
lain.mods.cos.impl.ModObjects.invMan.getCosArmorInventory(player.getUUID());

View File

@ -27,6 +27,7 @@ import java.util.*;
public class ModsSupport {
public void doBackPackRestore(Player player) {
if (!vip.fubuki.playersync.config.JdbcConfig.SYNC_BACKPACKS.get()) return; // PHASE 8: toggle
if (ModList.get().isLoaded("sophisticatedbackpacks")) {
PlayerSync.LOGGER.info("Restoring backpack data for player {}", player.getUUID());
// Restore backpacks from main inventory
@ -367,6 +368,7 @@ public class ModsSupport {
*/
public static void applyCuriosFromData(Player player, String curiosData) {
if (!ModList.get().isLoaded("curios")) return;
if (!vip.fubuki.playersync.config.JdbcConfig.SYNC_CURIOS.get()) return; // PHASE 8: toggle
Optional<ICuriosItemHandler> handlerOpt = CuriosApi.getCuriosInventory(player);
if (handlerOpt.isEmpty()) {
@ -1070,6 +1072,7 @@ public class ModsSupport {
@SuppressWarnings("unchecked")
public static void restoreRefinedStorageDisks(Player player) {
if (!ModList.get().isLoaded("refinedstorage")) return;
if (!vip.fubuki.playersync.config.JdbcConfig.SYNC_REFINED_STORAGE.get()) return; // PHASE 8: toggle
if (!(player instanceof net.minecraft.server.level.ServerPlayer sp)) return;
List<UUID> diskUuids = collectRS2DiskUuids(player);

View File

@ -25,11 +25,16 @@ public final class HeartbeatService {
private HeartbeatService() {}
/**
* Heartbeat period: 30s. Paired with the 60s staleness threshold in
* {@code VanillaSync.isPeerServerStale}. Three orders of magnitude lower DB
* load than the previous 10s without sacrificing detection window.
* Heartbeat period: configurable via {@code heartbeat_interval_seconds}.
* Paired with {@code peer_stale_threshold_seconds}.
*/
private static final long PERIOD_MS = 30_000L;
private static long currentPeriodMs() {
try {
return JdbcConfig.HEARTBEAT_INTERVAL_SECONDS.get() * 1000L;
} catch (Throwable t) {
return 30_000L;
}
}
private static final AtomicBoolean RUNNING = new AtomicBoolean(false);
private static ScheduledExecutorService scheduler;
@ -42,8 +47,9 @@ public final class HeartbeatService {
t.setPriority(Thread.MIN_PRIORITY);
return t;
});
scheduler.scheduleAtFixedRate(HeartbeatService::tick, PERIOD_MS, PERIOD_MS, TimeUnit.MILLISECONDS);
PlayerSync.LOGGER.info("[heartbeat] started (period={}ms, server_id={})", PERIOD_MS, JdbcConfig.SERVER_ID.get());
long period = currentPeriodMs();
scheduler.scheduleAtFixedRate(HeartbeatService::tick, period, period, TimeUnit.MILLISECONDS);
PlayerSync.LOGGER.info("[heartbeat] started (period={}ms, server_id={})", period, JdbcConfig.SERVER_ID.get());
}
public static void stop() {

View File

@ -199,13 +199,22 @@ public class JDBCsetUp {
}
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]);
}
stmt.executeUpdate();
return stmt.executeUpdate();
}
}

View File

@ -21,12 +21,20 @@ public final class PoolStatsReporter {
private PoolStatsReporter() {}
private static final long PERIOD_MS = 5 * 60 * 1000L;
private static final AtomicBoolean RUNNING = new AtomicBoolean(false);
private static ScheduledExecutorService scheduler;
public static void start() {
int minutes;
try {
minutes = vip.fubuki.playersync.config.JdbcConfig.POOL_STATS_INTERVAL_MINUTES.get();
} catch (Throwable t) {
minutes = 5;
}
if (minutes <= 0) {
PlayerSync.LOGGER.info("[pool-stats] disabled (pool_stats_interval_minutes=0)");
return;
}
if (!RUNNING.compareAndSet(false, true)) return;
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "PlayerSync-pool-stats");
@ -34,8 +42,9 @@ public final class PoolStatsReporter {
t.setPriority(Thread.MIN_PRIORITY);
return t;
});
scheduler.scheduleAtFixedRate(PoolStatsReporter::tick, PERIOD_MS, PERIOD_MS, TimeUnit.MILLISECONDS);
PlayerSync.LOGGER.info("[pool-stats] reporter started (period={}ms)", PERIOD_MS);
long periodMs = minutes * 60_000L;
scheduler.scheduleAtFixedRate(PoolStatsReporter::tick, periodMs, periodMs, TimeUnit.MILLISECONDS);
PlayerSync.LOGGER.info("[pool-stats] reporter started (period={}ms)", periodMs);
}
public static void stop() {