diff --git a/build.gradle b/build.gradle index f2babce..2d70645 100644 --- a/build.gradle +++ b/build.gradle @@ -142,6 +142,18 @@ dependencies { jarJar "com.mysql:mysql-connector-j:${jdbc_version}" additionalRuntimeClasspath "com.mysql:mysql-connector-j:${jdbc_version}" + // HikariCP connection pool — eliminates isValid() ping on every query (no more pingInternal in Spark) + // Exclude slf4j-api: NeoForge already ships it + implementation("com.zaxxer:HikariCP:${hikari_version}") { + exclude group: "org.slf4j", module: "slf4j-api" + } + jarJar("com.zaxxer:HikariCP:${hikari_version}") { + exclude group: "org.slf4j", module: "slf4j-api" + } + additionalRuntimeClasspath("com.zaxxer:HikariCP:${hikari_version}") { + exclude group: "org.slf4j", module: "slf4j-api" + } + // For more info: // http://www.gradle.org/docs/current/userguide/artifact_dependencies_tutorial.html // http://www.gradle.org/docs/current/userguide/dependency_management.html diff --git a/gradle.properties b/gradle.properties index 80f8314..606f08b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -43,3 +43,6 @@ mod_description=make multiserver players' data sync # JDBC driver version # see https://dev.mysql.com/doc/relnotes/connector-j/en/ for latest version jdbc_version=9.3.0 + +# HikariCP connection pool version +hikari_version=5.1.0 diff --git a/src/main/java/vip/fubuki/playersync/PlayerSync.java b/src/main/java/vip/fubuki/playersync/PlayerSync.java index c91c019..60a9250 100644 --- a/src/main/java/vip/fubuki/playersync/PlayerSync.java +++ b/src/main/java/vip/fubuki/playersync/PlayerSync.java @@ -56,10 +56,19 @@ public class PlayerSync { return; } - // Step 1: Create the database using a connection that does not select a database. + // Step 1: Create the database using a raw DriverManager connection (no pool yet). JDBCsetUp.executeUpdate("CREATE DATABASE IF NOT EXISTS `" + dbName + "`", 1); - // Step 2: Explicitly select the database on a connection obtained without default database. + // Step 2: Initialise HikariCP pool now that the database exists. + // All subsequent queries use the pool — no more isValid() ping on every borrow. + try { + JDBCsetUp.initPool(); + } catch (Exception e) { + LOGGER.error("[PlayerSync] Failed to initialise connection pool — check MySQL config.", e); + return; + } + + // Step 3: Explicitly select the database on a raw connection (DDL only). try (Connection conn = JDBCsetUp.getConnection(false); Statement st = conn.createStatement()) { st.execute("USE `" + dbName + "`"); @@ -68,7 +77,7 @@ public class PlayerSync { throw e; } - // Step 3: Create and alter tables using fully qualified names. + // Step 4: Create and alter tables using fully qualified names. // Create player_data table JDBCsetUp.executeUpdate( "CREATE TABLE IF NOT EXISTS `" + dbName + "`.`player_data` (" + @@ -204,7 +213,7 @@ public class PlayerSync { ); try { - JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE last_server=? AND online=1 LIMIT 1000", JdbcConfig.SERVER_ID.get()); + JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE last_server=? AND online=1", JdbcConfig.SERVER_ID.get()); } catch (Exception e) { LOGGER.error("An exception occurred while trying change wrong player-status\n" + e.getMessage()); } @@ -212,8 +221,12 @@ public class PlayerSync { } @SubscribeEvent - public void onServerStopping(ServerStoppingEvent event){ + public void onServerStopping(ServerStoppingEvent event) { ChatSync.shutdown(); + // DO NOT call JDBCsetUp.shutdownPool() here! + // VanillaSync.onServerShutdown also subscribes to ServerStoppingEvent and + // needs the pool to save all player data. Event firing order is not guaranteed. + // The pool is shut down at the very end of VanillaSync.onServerShutdown instead. } } diff --git a/src/main/java/vip/fubuki/playersync/sync/VanillaSync.java b/src/main/java/vip/fubuki/playersync/sync/VanillaSync.java index 21ae501..da49037 100644 --- a/src/main/java/vip/fubuki/playersync/sync/VanillaSync.java +++ b/src/main/java/vip/fubuki/playersync/sync/VanillaSync.java @@ -312,6 +312,46 @@ public class VanillaSync { effectData = rs2.getString("effects"); } + // FIX PERF: Pre-read ALL mod data on BACKGROUND THREAD (no entity access). + // Previously these DB reads happened inside server.execute() on the main thread, + // blocking it for 5-200ms per query × 4-7 queries per player login. + final String curiosData; + if (ModList.get().isLoaded("curios")) { + try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery( + "SELECT curios_item FROM curios WHERE uuid=?", player_uuid)) { + ResultSet rs = qr.resultSet(); + curiosData = rs.next() ? rs.getString("curios_item") : null; + } + } else { curiosData = null; } + + final String accessoriesData; + if (ModList.get().isLoaded("accessories")) { + try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery( + "SELECT data_value FROM mod_player_data WHERE uuid=? AND mod_id=?", + player_uuid, "accessories")) { + ResultSet rs = qr.resultSet(); + accessoriesData = rs.next() ? rs.getString("data_value") : null; + } + } else { accessoriesData = null; } + + final String cosmeticArmorData; + if (ModList.get().isLoaded("cosmeticarmorreworked")) { + try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery( + "SELECT data_value FROM mod_player_data WHERE uuid=? AND mod_id=?", + player_uuid, "cosmeticarmor")) { + ResultSet rs = qr.resultSet(); + cosmeticArmorData = rs.next() ? rs.getString("data_value") : null; + } + } else { cosmeticArmorData = null; } + + final String attachmentsData; + try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery( + "SELECT data_value FROM mod_player_data WHERE uuid=? AND mod_id=?", + player_uuid, "neoforge_attachments")) { + ResultSet rs = qr.resultSet(); + attachmentsData = rs.next() ? rs.getString("data_value") : null; + } + // === PHASE 2: Apply to player on MAIN SERVER THREAD === // Minecraft entities are NOT thread-safe. Modifying inventory/health/effects // from a background thread causes duplication exploits and corruption. @@ -369,17 +409,22 @@ public class VanillaSync { } } - // Restore mod data (these do their own DB reads internally, acceptable on main thread) - ModsSupport modsSupport = new ModsSupport(); - modsSupport.doCuriosRestore(serverPlayer); - modsSupport.doBackPackRestore(serverPlayer); + // FIX PERF: Apply mod data from pre-read strings (NO DB calls on main thread). + // All DB reads were done in Phase 1 on the background thread. + ModsSupport.applyCuriosFromData(serverPlayer, curiosData); + ModCompatSync.applyAccessoriesFromData(serverPlayer, accessoriesData); + ModCompatSync.applyCosmeticArmorFromData(serverPlayer, cosmeticArmorData); + ModCompatSync.applyAttachmentsFromData(serverPlayer, attachmentsData); + + // Backpacks/SS/RS2: need inventory items to know UUIDs, so DB reads + // happen here (1-5 fast queries per player, acceptable with HikariCP). + new ModsSupport().doBackPackRestore(serverPlayer); if (ModList.get().isLoaded("sophisticatedstorage")) { ModsSupport.restoreSophisticatedStorageItems(serverPlayer); } if (ModList.get().isLoaded("refinedstorage")) { ModsSupport.restoreRefinedStorageDisks(serverPlayer); } - ModCompatSync.restoreAll(serverPlayer); serverPlayer.addTag("player_synced"); PlayerSync.LOGGER.info("Sync data for player {} completed.", player_uuid); @@ -737,6 +782,10 @@ public class VanillaSync { // === BACKGROUND THREAD: all DB writes — main thread continues immediately === executorService.submit(() -> { + // FIX: If the player already logged out (removePlayerLock was called), + // this snapshot is stale and must NOT overwrite the fresher logout snapshot. + if (!playerLocks.containsKey(puuid)) return; + ReentrantLock bgLock = getPlayerLock(puuid); if (!bgLock.tryLock()) return; // another save started, skip try { @@ -757,54 +806,100 @@ public class VanillaSync { @SubscribeEvent public static void onServerShutdown(ServerStoppingEvent event) throws SQLException { - // Save ALL online players before shutdown to prevent data loss - // Uses ServerStoppingEvent (not ServerStoppedEvent) because players are still connected + // FIX PERF: Snapshot ALL players on main thread (fast, no DB I/O), then write + // ALL saves in PARALLEL on background threads. Previously this was sequential: + // 35 players × 200ms = 7 seconds blocking the main thread → watchdog "server thread stuck". + // Now: snapshot 35 players (~50ms total), then 35 parallel DB writes (~500ms total). MinecraftServer server = ServerLifecycleHooks.getCurrentServer(); if (server != null) { + List> futures = new ArrayList<>(); + for (ServerPlayer player : server.getPlayerList().getPlayers()) { - if (player.getTags().contains("player_synced") && !player.isDeadOrDying()) { - String puuid = player.getUUID().toString(); - // FIX: Acquire per-player lock to prevent race with queued logout save - ReentrantLock lock = getPlayerLock(puuid); - lock.lock(); - try { - store(player, false); - if (ModList.get().isLoaded("curios")) { - new ModsSupport().StoreCurios(player, false); - } - ModCompatSync.storeAll(player); - if (ModList.get().isLoaded("sophisticatedbackpacks")) { - ModsSupport.storeSophisticatedBackpacks(player); - } - if (ModList.get().isLoaded("sophisticatedstorage")) { - ModsSupport.storeSophisticatedStorageItems(player); - } - if (ModList.get().isLoaded("refinedstorage")) { - ModsSupport.storeRefinedStorageDisks(player); - } - PlayerSync.LOGGER.info("Saved player {} data on server shutdown", player.getUUID()); - } catch (Exception e) { - PlayerSync.LOGGER.error("Error saving player {} on shutdown", player.getUUID(), e); - } finally { - // CRITICAL: online=0 MUST be in finally - if any save throws, - // player gets permanently locked as online=1 - try { - JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=?", puuid); - } catch (Exception e2) { - PlayerSync.LOGGER.error("CRITICAL: Failed to mark player {} offline on shutdown", puuid, e2); - } - lock.unlock(); + if (!player.getTags().contains("player_synced") || player.isDeadOrDying()) continue; + + String puuid = player.getUUID().toString(); + try { + // Cache curios before snapshot + if (ModList.get().isLoaded("curios")) { + CuriosCache.tryStoreCuriosToCache(player); } + + // === MAIN THREAD: Snapshot (entity reads, fast) === + final PlayerDataSnapshot snapshot = snapshotPlayerData(player); + final List backpackUuids = ModsSupport.collectBackpackUuids(player); + final List ssUuids = ModsSupport.collectSSUuids(player); + final List rs2DiskUuids; + final ServerLevel rs2Level; + final HolderLookup.Provider rs2Registry; + if (ModList.get().isLoaded("refinedstorage")) { + rs2DiskUuids = ModsSupport.collectRS2DiskUuids(player); + rs2Level = player.serverLevel(); + rs2Registry = player.getServer().registryAccess(); + } else { + rs2DiskUuids = List.of(); + rs2Level = null; + rs2Registry = null; + } + + // === BACKGROUND THREAD: DB writes (parallel across all players) === + futures.add(CompletableFuture.runAsync(() -> { + try { + writeSnapshotToDB(snapshot); + ModsSupport.saveBackpacksByUuids(backpackUuids); + ModsSupport.saveSSByUuids(ssUuids); + if (!rs2DiskUuids.isEmpty() && rs2Level != null) { + ModsSupport.saveRS2DisksByLevel(rs2DiskUuids, rs2Level, rs2Registry); + } + PlayerSync.LOGGER.info("Saved player {} data on server shutdown", puuid); + } catch (Exception e) { + PlayerSync.LOGGER.error("Error saving player {} on shutdown", puuid, e); + } finally { + try { + JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=?", puuid); + } catch (Exception e2) { + PlayerSync.LOGGER.error("CRITICAL: Failed to mark player {} offline on shutdown", puuid, e2); + } + } + }, executorService)); + + } catch (Exception e) { + PlayerSync.LOGGER.error("Error snapshotting player {} on shutdown", puuid, e); + try { JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=?", puuid); } + catch (Exception ignored) {} } } + + // Wait for all parallel saves to complete (30s max to avoid watchdog kill) + try { + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .get(30, TimeUnit.SECONDS); + } catch (TimeoutException e) { + PlayerSync.LOGGER.error("Timeout waiting for shutdown saves — {} tasks may not have completed", futures.size()); + } catch (Exception e) { + PlayerSync.LOGGER.error("Error waiting for shutdown saves", e); + } } JDBCsetUp.executePreparedUpdate("UPDATE server_info SET enable=0 WHERE id=?", JdbcConfig.SERVER_ID.get()); + + // Shut down the background executor — no new tasks after this point + executorService.shutdown(); + try { + if (!executorService.awaitTermination(5, TimeUnit.SECONDS)) { + executorService.shutdownNow(); + } + } catch (InterruptedException ignored) { + executorService.shutdownNow(); + } + + // Close the HikariCP pool LAST — after all DB writes are guaranteed complete. + // Previously this was in PlayerSync.onServerStopping which could fire BEFORE + // this handler, closing the pool while shutdown saves were still running. + JDBCsetUp.shutdownPool(); } /** - * FIX C-2: All save operations run on the MAIN THREAD (onPlayerLogout fires on main thread). + * FIX: Logout saves are now fully async (snapshot on main thread, DB writes on background). * Entity state (inventory, curios, effects) is read safely on the correct thread. - * DB writes block briefly but this is required for correctness. */ @SubscribeEvent public static void onPlayerLogout(PlayerEvent.PlayerLoggedOutEvent event) { @@ -838,29 +933,65 @@ public class VanillaSync { ReentrantLock lock = getPlayerLock(player_uuid); lock.lock(); try { - // Save curios (main thread - safe to read Curios API) - if (ModList.get().isLoaded("curios")) { - ModsSupport modsSupport = new ModsSupport(); - if (player.isDeadOrDying()) { - modsSupport.saveCuriosFromCacheOrApi(player); - } else { - modsSupport.onPlayerLeave(player); - } + // === MAIN THREAD: Snapshot ALL entity state (fast, no DB I/O) === + + // Cache curios before snapshot (safety for dead/dying players) + if (ModList.get().isLoaded("curios") && !player.isDeadOrDying()) { + CuriosCache.tryStoreCuriosToCache((ServerPlayer) player); + } + + final PlayerDataSnapshot snapshot = snapshotPlayerData(player); + + // Collect backpack/SS/RS2 UUIDs (inventory reads, must be main thread) + final List backpackUuids = ModsSupport.collectBackpackUuids(player); + final List ssUuids = ModsSupport.collectSSUuids(player); + final List rs2DiskUuids; + final ServerLevel rs2Level; + final HolderLookup.Provider rs2RegistryAccess; + if (ModList.get().isLoaded("refinedstorage") && player instanceof ServerPlayer sp) { + rs2DiskUuids = ModsSupport.collectRS2DiskUuids(player); + rs2Level = sp.serverLevel(); + rs2RegistryAccess = sp.getServer().registryAccess(); + } else { + rs2DiskUuids = List.of(); + rs2Level = null; + rs2RegistryAccess = null; + } + + // === BACKGROUND THREAD: ALL DB writes — main thread returns immediately === + CountDownLatch saveLatch = new CountDownLatch(1); + executorService.submit(() -> { + try { + writeSnapshotToDB(snapshot); + ModsSupport.saveBackpacksByUuids(backpackUuids); + ModsSupport.saveSSByUuids(ssUuids); + if (!rs2DiskUuids.isEmpty() && rs2Level != null) { + ModsSupport.saveRS2DisksByLevel(rs2DiskUuids, rs2Level, rs2RegistryAccess); + } + } catch (Exception e) { + PlayerSync.LOGGER.error("Error saving player {} data on logout", player_uuid, e); + } finally { + // CRITICAL: online=0 MUST always execute, even if saves fail + try { + JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=?", player_uuid); + } catch (Exception e2) { + PlayerSync.LOGGER.error("CRITICAL: Failed to mark player {} offline", player_uuid, e2); + } + saveLatch.countDown(); + } + }); + + // Wait for background save to complete (data must be in DB before player can rejoin) + if (!saveLatch.await(15, TimeUnit.SECONDS)) { + PlayerSync.LOGGER.error("Timeout saving player {} on logout — forcing offline", player_uuid); + try { JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=?", player_uuid); } + catch (Exception ignored) {} } - // Save mod compat data (main thread - safe to read Accessories/CosmeticArmor) - ModCompatSync.storeAll(player); - // Save main inventory + effects + advancements (main thread - safe) - store(player, false); } catch (Exception e) { PlayerSync.LOGGER.error("Error during player logout save for {}", player_uuid, e); + try { JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=?", player_uuid); } + catch (Exception ignored) {} } finally { - // CRITICAL: online=0 MUST be in finally - if store() throws, player gets - // permanently locked as online=1 and can never reconnect. - try { - JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=?", player_uuid); - } catch (Exception e2) { - PlayerSync.LOGGER.error("CRITICAL: Failed to mark player {} offline", player_uuid, e2); - } lock.unlock(); removePlayerLock(player_uuid); } @@ -1166,7 +1297,7 @@ public class VanillaSync { private static int heartbeatTickCounter = 0; private static final int HEARTBEAT_INTERVAL_TICKS = 600; // Every 30 seconds (20 tps * 30s) private static int autoSaveTickCounter = 0; - private static final int AUTO_SAVE_INTERVAL_TICKS = 2400; // Every 2 minutes (was 1min, doubled to reduce main thread load) + private static final int AUTO_SAVE_INTERVAL_TICKS = 6000; // Every 5 minutes (20 tps × 300s) private static int autoCleanCuriosCacheTickCounter = 0; private static final int AUTO_CLEAN_CURIOS_CACHE_INTERVAL_TICKS = 36000; // Every 30 min diff --git a/src/main/java/vip/fubuki/playersync/sync/addons/ModCompatSync.java b/src/main/java/vip/fubuki/playersync/sync/addons/ModCompatSync.java index 328d1f3..bcaaa90 100644 --- a/src/main/java/vip/fubuki/playersync/sync/addons/ModCompatSync.java +++ b/src/main/java/vip/fubuki/playersync/sync/addons/ModCompatSync.java @@ -149,6 +149,57 @@ public class ModCompatSync { } } + /** + * Applies pre-read Accessories data to the player entity (NO DB access). + * Used by doPlayerJoin to avoid DB reads on the main thread. + */ + public static void applyAccessoriesFromData(Player player, String accessoriesData) { + if (!ModList.get().isLoaded("accessories")) return; + if (accessoriesData == null || accessoriesData.length() <= 2) return; + try { + io.wispforest.accessories.api.AccessoriesCapability cap = + io.wispforest.accessories.api.AccessoriesCapability.get(player); + if (cap == null) return; + + Map storedMap = LocalJsonUtil.StringToMap(accessoriesData); + if (storedMap.isEmpty()) return; + + Map containers = cap.getContainers(); + + for (io.wispforest.accessories.api.AccessoriesContainer container : containers.values()) { + var accessories = container.getAccessories(); + for (int i = 0; i < accessories.getContainerSize(); i++) { + accessories.setItem(i, ItemStack.EMPTY); + } + } + + for (Map.Entry entry : storedMap.entrySet()) { + String compositeKey = entry.getKey(); + int lastColon = compositeKey.lastIndexOf(':'); + if (lastColon < 0) continue; + String slotType = compositeKey.substring(0, lastColon); + int slotIndex; + try { slotIndex = Integer.parseInt(compositeKey.substring(lastColon + 1)); } + catch (NumberFormatException ex) { continue; } + + try { + ItemStack stack = VanillaSync.deserializeAndCreatePlaceholderIfNeeded(entry.getValue()); + if (containers.containsKey(slotType)) { + var acc = containers.get(slotType).getAccessories(); + if (slotIndex < acc.getContainerSize()) { + acc.setItem(slotIndex, stack); + } + } + } catch (Exception e) { + PlayerSync.LOGGER.error("Error applying Accessories data for key {}", compositeKey, e); + } + } + PlayerSync.LOGGER.info("Applied Accessories data for player {}", player.getUUID()); + } catch (Exception e) { + PlayerSync.LOGGER.error("Error applying Accessories data for player {}", player.getUUID(), e); + } + } + // ============================ // Cosmetic Armor Reworked // ============================ @@ -252,6 +303,42 @@ public class ModCompatSync { } } + /** + * Applies pre-read CosmeticArmor data to the player entity (NO DB access). + */ + public static void applyCosmeticArmorFromData(Player player, String cosmeticArmorData) { + if (!ModList.get().isLoaded("cosmeticarmorreworked")) return; + if (cosmeticArmorData == null || cosmeticArmorData.length() <= 2) return; + try { + lain.mods.cos.impl.inventory.InventoryCosArmor cosInv = + lain.mods.cos.impl.ModObjects.invMan.getCosArmorInventory(player.getUUID()); + if (cosInv == null) return; + + Map storedMap = LocalJsonUtil.StringToEntryMap(cosmeticArmorData); + if (storedMap.isEmpty()) return; + + for (int i = 0; i < cosInv.getContainerSize(); i++) { + cosInv.setItem(i, ItemStack.EMPTY); + } + + for (Map.Entry entry : storedMap.entrySet()) { + int slot = entry.getKey(); + try { + ItemStack stack = VanillaSync.deserializeAndCreatePlaceholderIfNeeded(entry.getValue()); + if (slot < cosInv.getContainerSize()) { + cosInv.setItem(slot, stack); + } + } catch (Exception e) { + PlayerSync.LOGGER.error("Error applying CosmeticArmor slot {}", slot, e); + } + } + cosInv.setChanged(); + PlayerSync.LOGGER.info("Applied CosmeticArmor data for player {}", player.getUUID()); + } catch (Exception e) { + PlayerSync.LOGGER.error("Error applying CosmeticArmor data for player {}", player.getUUID(), e); + } + } + // ============================ // Generic NeoForge Attachment Sync // ============================ @@ -339,6 +426,34 @@ public class ModCompatSync { } } + /** + * Applies pre-read NeoForge attachments data to the player entity (NO DB access). + */ + public static void applyAttachmentsFromData(Player player, String serialized) { + if (serialized == null || !serialized.startsWith("BNBT:")) return; + try { + if (!(player instanceof net.minecraft.server.level.ServerPlayer serverPlayer)) return; + + net.minecraft.nbt.CompoundTag attachments = VanillaSync.deserializeBinaryBase64Tag(serialized); + if (attachments.isEmpty()) return; + + net.minecraft.nbt.CompoundTag wrapper = new net.minecraft.nbt.CompoundTag(); + wrapper.put("neoforge:attachments", attachments); + + java.lang.reflect.Method deserializeMethod = net.neoforged.neoforge.attachment.AttachmentHolder.class + .getDeclaredMethod("deserializeAttachments", + net.minecraft.core.HolderLookup.Provider.class, + net.minecraft.nbt.CompoundTag.class); + deserializeMethod.setAccessible(true); + deserializeMethod.invoke(player, serverPlayer.getServer().registryAccess(), wrapper); + + PlayerSync.LOGGER.info("Applied NeoForge attachments for player {} ({} keys)", + player.getUUID(), attachments.getAllKeys().size()); + } catch (Exception e) { + PlayerSync.LOGGER.error("Error applying NeoForge attachments for player {}", player.getUUID(), e); + } + } + // ============================ // Snapshot methods (main thread - entity reads only, NO DB writes) // These are used by auto-save and SaveToFile to capture entity state on the diff --git a/src/main/java/vip/fubuki/playersync/sync/addons/ModsSupport.java b/src/main/java/vip/fubuki/playersync/sync/addons/ModsSupport.java index d97948d..cf5e477 100644 --- a/src/main/java/vip/fubuki/playersync/sync/addons/ModsSupport.java +++ b/src/main/java/vip/fubuki/playersync/sync/addons/ModsSupport.java @@ -97,14 +97,19 @@ public class ModsSupport { * wrapper state (common with Sophisticated Backpacks/Storage). */ private static void saveStorageContents(UUID contentsUuid, CompoundTag nbt) { - // Skip empty/minimal NBT to avoid overwriting real data in DB - if (nbt == null || nbt.isEmpty() || nbt.size() <= 1) { - // Check if DB already has data for this UUID - if so, don't overwrite with empty + // Only skip truly empty CompoundTag (no keys at all) — this happens when + // getOrCreateStorageContents() creates a blank entry because the wrapper + // hasn't flushed to SavedData yet. A backpack/shulker that the player + // legitimately emptied still has structural keys (e.g. empty "items" list), + // so nbt.isEmpty() is false and the save proceeds correctly. + // Previous guard used nbt.size() <= 1 which also blocked legitimately emptied + // containers, causing item duplication on the next login. + if (nbt == null || nbt.isEmpty()) { try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery( "SELECT LENGTH(backpack_nbt) AS len FROM backpack_data WHERE uuid=?", contentsUuid.toString())) { java.sql.ResultSet rs = qr.resultSet(); if (rs.next() && rs.getInt("len") > 50) { - PlayerSync.LOGGER.debug("Skipping save of empty/minimal NBT for UUID {} - DB has {} bytes of real data", + PlayerSync.LOGGER.debug("Skipping save of empty NBT for UUID {} - DB has {} bytes of real data", contentsUuid, rs.getInt("len")); return; } @@ -256,6 +261,59 @@ public class ModsSupport { return flatMap.toString(); } + /** + * Applies pre-read curios data to the player entity (NO DB access). + * Used by doPlayerJoin to avoid DB reads on the main thread. + */ + public static void applyCuriosFromData(Player player, String curiosData) { + if (!ModList.get().isLoaded("curios")) return; + if (curiosData == null || curiosData.length() <= 2) return; + + Optional handlerOpt = CuriosApi.getCuriosInventory(player); + if (handlerOpt.isEmpty()) { + PlayerSync.LOGGER.warn("Could not get Curios handler for player {} during apply", player.getUUID()); + return; + } + + Map storedMap = LocalJsonUtil.StringToMap(curiosData); + if (storedMap.isEmpty()) return; + + ICuriosItemHandler handler = handlerOpt.get(); + + // Clear all curios slots BEFORE restoring + for (Map.Entry entry : handler.getCurios().entrySet()) { + IDynamicStackHandler stacks = entry.getValue().getStacks(); + for (int i = 0; i < stacks.getSlots(); i++) { + stacks.setStackInSlot(i, ItemStack.EMPTY); + } + } + + // Restore items from pre-read data + for (Map.Entry entry : storedMap.entrySet()) { + String compositeKey = entry.getKey(); + int lastColon = compositeKey.lastIndexOf(':'); + if (lastColon < 0) continue; + String slotType = compositeKey.substring(0, lastColon); + int slotIndex; + try { slotIndex = Integer.parseInt(compositeKey.substring(lastColon + 1)); } + catch (NumberFormatException e) { continue; } + + try { + ItemStack stack = VanillaSync.deserializeAndCreatePlaceholderIfNeeded(entry.getValue()); + ICurioStacksHandler stacksHandler = handler.getCurios().get(slotType); + if (stacksHandler != null) { + IDynamicStackHandler stacks = stacksHandler.getStacks(); + if (slotIndex < stacks.getSlots()) { + stacks.setStackInSlot(slotIndex, stack); + } + } + } catch (Exception e) { + PlayerSync.LOGGER.error("Error applying curios slot {}:{}", slotType, slotIndex, e); + } + } + PlayerSync.LOGGER.info("Applied curios data for player {} from pre-read data", player.getUUID()); + } + public void StoreCurios(Player player, boolean init) throws SQLException { if (!ModList.get().isLoaded("curios")) return; @@ -316,6 +374,45 @@ public class ModsSupport { }); } + /** + * Collects Sophisticated Backpack UUIDs from the player's inventory. + * Must be called on the MAIN THREAD (reads inventory items). + * Also refreshes wrappers to flush in-memory state to SavedData. + */ + public static List collectBackpackUuids(Player player) { + List uuids = new ArrayList<>(); + if (!ModList.get().isLoaded("sophisticatedbackpacks")) return uuids; + try { + net.p3pp3rf1y.sophisticatedbackpacks.util.PlayerInventoryProvider.get().runOnBackpacks(player, + (ItemStack backpackItem, String handler, String identifier, int slot) -> { + net.p3pp3rf1y.sophisticatedbackpacks.backpack.wrapper.IBackpackWrapper wrapper = + net.p3pp3rf1y.sophisticatedbackpacks.backpack.wrapper.BackpackWrapper.fromStack(backpackItem); + try { wrapper.refreshInventoryForInputOutput(); } catch (Exception ignored) {} + wrapper.getContentsUuid().ifPresent(uuids::add); + return false; + }); + } catch (Exception e) { + PlayerSync.LOGGER.error("Error collecting backpack UUIDs for player {}", player.getUUID(), e); + } + return uuids; + } + + /** + * Saves backpack contents by UUID. Reads SavedData and writes to DB. + * Can be called from a background thread (no entity access). + */ + public static void saveBackpacksByUuids(List uuids) { + for (UUID uuid : uuids) { + try { + CompoundTag nbt = net.p3pp3rf1y.sophisticatedbackpacks.backpack.BackpackStorage.get() + .getOrCreateBackpackContents(uuid); + saveStorageContents(uuid, nbt); + } catch (Exception e) { + PlayerSync.LOGGER.error("Error saving backpack data for UUID {}", uuid, e); + } + } + } + // ============================ // Sophisticated Storage (barrels, shulkers, chests) // ============================ @@ -430,6 +527,59 @@ public class ModsSupport { } } + /** + * Collects Sophisticated Storage item UUIDs from the player's inventory and ender chest. + * Must be called on the MAIN THREAD (reads inventory items). + */ + public static List collectSSUuids(Player player) { + List uuids = new ArrayList<>(); + if (!ModList.get().isLoaded("sophisticatedstorage")) return uuids; + try { + var registryAccess = net.neoforged.neoforge.server.ServerLifecycleHooks.getCurrentServer().registryAccess(); + // Scan main inventory + for (int i = 0; i < player.getInventory().getContainerSize(); i++) { + ItemStack stack = player.getInventory().getItem(i); + if (stack.isEmpty() || !isSophisticatedStorageItem(stack)) continue; + try { + net.p3pp3rf1y.sophisticatedstorage.item.StackStorageWrapper wrapper = + net.p3pp3rf1y.sophisticatedstorage.item.StackStorageWrapper.fromStack(registryAccess, stack); + wrapper.getContentsUuid().ifPresent(uuids::add); + } catch (Exception ignored) {} + } + // Scan ender chest + for (int i = 0; i < player.getEnderChestInventory().getContainerSize(); i++) { + ItemStack stack = player.getEnderChestInventory().getItem(i); + if (stack.isEmpty() || !isSophisticatedStorageItem(stack)) continue; + try { + net.p3pp3rf1y.sophisticatedstorage.item.StackStorageWrapper wrapper = + net.p3pp3rf1y.sophisticatedstorage.item.StackStorageWrapper.fromStack(registryAccess, stack); + wrapper.getContentsUuid().ifPresent(uuids::add); + } catch (Exception ignored) {} + } + } catch (Exception e) { + PlayerSync.LOGGER.error("Error collecting SS UUIDs for player {}", player.getUUID(), e); + } + return uuids; + } + + /** + * Saves Sophisticated Storage contents by UUID. Reads SavedData and writes to DB. + * Can be called from a background thread (no entity access). + */ + public static void saveSSByUuids(List uuids) { + for (UUID uuid : uuids) { + try { + CompoundTag nbt = net.p3pp3rf1y.sophisticatedstorage.block.ItemContentsStorage.get() + .getOrCreateStorageContents(uuid); + if (nbt != null && !nbt.isEmpty()) { + saveStorageContents(uuid, nbt); + } + } catch (Exception e) { + PlayerSync.LOGGER.error("Error saving SS data for UUID {}", uuid, e); + } + } + } + /** * Extracts the contents UUID from an item's custom data. * Used by Sophisticated Backpacks (key: "contentsUuid"). @@ -563,6 +713,32 @@ public class ModsSupport { } } + /** + * Saves RS2 disk storage contents by UUID using a pre-captured ServerLevel reference. + * Can be called from a background thread (SavedData read + DB write, no entity access). + */ + public static void saveRS2DisksByLevel(List diskUuids, net.minecraft.server.level.ServerLevel level, + net.minecraft.core.HolderLookup.Provider registryAccess) { + if (diskUuids.isEmpty()) return; + try { + com.refinedmods.refinedstorage.common.api.storage.StorageRepository repo = + com.refinedmods.refinedstorage.common.api.RefinedStorageApi.INSTANCE.getStorageRepository(level); + if (!(repo instanceof net.minecraft.world.level.saveddata.SavedData sd)) return; + + net.minecraft.nbt.CompoundTag fullNbt = sd.save(new net.minecraft.nbt.CompoundTag(), registryAccess); + + for (UUID uuid : diskUuids) { + net.minecraft.nbt.CompoundTag entryNbt = findRS2EntryInNbt(fullNbt, uuid.toString()); + if (entryNbt != null && !entryNbt.isEmpty()) { + saveStorageContents(uuid, entryNbt); + PlayerSync.LOGGER.info("Saved RS2 disk data for UUID {} (async save)", uuid); + } + } + } catch (Exception e) { + PlayerSync.LOGGER.error("Error saving RS2 disks by level", e); + } + } + /** Describes the top-level NBT structure for debugging */ private static String describeNbtStructure(net.minecraft.nbt.CompoundTag tag) { StringBuilder sb = new StringBuilder("{"); @@ -674,7 +850,7 @@ public class ModsSupport { /** * Collects all RS2/ExtraDisks storage reference UUIDs from the player's inventory and ender chest. */ - private static List collectRS2DiskUuids(Player player) { + public static List collectRS2DiskUuids(Player player) { List uuids = new ArrayList<>(); // Check main inventory collectRS2DiskUuidsFromContainer(player.getInventory(), uuids); diff --git a/src/main/java/vip/fubuki/playersync/util/JDBCsetUp.java b/src/main/java/vip/fubuki/playersync/util/JDBCsetUp.java index e1a9134..ca5e711 100644 --- a/src/main/java/vip/fubuki/playersync/util/JDBCsetUp.java +++ b/src/main/java/vip/fubuki/playersync/util/JDBCsetUp.java @@ -1,25 +1,80 @@ 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.*; -import java.util.concurrent.LinkedBlockingQueue; /** - * JDBC utility with a simple connection pool. - * Previously, every single query opened a NEW MySQL connection (TCP handshake + auth + USE db), - * consuming ~10% of server thread time. Now connections are pooled and reused. + * 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; - // Simple connection pool - reuses connections instead of opening new ones every query - private static final int POOL_SIZE = 5; - private static final LinkedBlockingQueue connectionPool = new LinkedBlockingQueue<>(POOL_SIZE); - private static String cachedUrl = null; + // ------------------------------------------------------------------------- + // 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()); + + // Pool sizing: 2 warm connections, up to 10 under load + cfg.setMaximumPoolSize(10); + cfg.setMinimumIdle(2); + + // Connection lifecycle + cfg.setConnectionTimeout(30_000L); // 30 s – how long to wait for a free slot + cfg.setIdleTimeout(600_000L); // 10 min – evict idle connections + 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"); + + 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."); + } + } + + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- private static String buildUrl(boolean selectDatabase) { String dbName = JdbcConfig.DATABASE_NAME.get(); @@ -27,170 +82,149 @@ public class JDBCsetUp { if (selectDatabase && !dbName.isEmpty()) { url += "/" + dbName; } + // No autoReconnect — HikariCP handles reconnection transparently url += "?useUnicode=true&characterEncoding=utf-8&useSSL=" + JdbcConfig.USE_SSL.get() - + "&serverTimezone=UTC&allowPublicKeyRetrieval=true&autoReconnect=true"; + + "&serverTimezone=UTC&allowPublicKeyRetrieval=true"; return url; } /** - * Gets a connection from the pool, or creates a new one if pool is empty. - * Connections are validated before returning (checks if still alive). + * 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 { - // For non-default-database connections (startup DDL), always create fresh if (!selectDatabase) { - return DriverManager.getConnection(buildUrl(false), JdbcConfig.USERNAME.get(), JdbcConfig.PASSWORD.get()); + // Raw connection for DDL that runs before/without the pool database + return DriverManager.getConnection( + buildUrl(false), JdbcConfig.USERNAME.get(), JdbcConfig.PASSWORD.get()); } - - // Try to get a pooled connection - Connection conn = connectionPool.poll(); - if (conn != null) { - try { - if (!conn.isClosed() && conn.isValid(2)) { - return conn; - } - // Connection is dead, close it and create new - conn.close(); - } catch (SQLException e) { - // Connection is broken, ignore and create new - } + if (dataSource == null || dataSource.isClosed()) { + throw new SQLException("[PlayerSync] HikariCP pool is not initialised — call initPool() first."); } - - // Create a new connection - if (cachedUrl == null) { - cachedUrl = buildUrl(true); - } - conn = DriverManager.getConnection(cachedUrl, JdbcConfig.USERNAME.get(), JdbcConfig.PASSWORD.get()); - String dbName = JdbcConfig.DATABASE_NAME.get(); - if (!dbName.isEmpty()) { - try (Statement st = conn.createStatement()) { - st.execute("USE `" + dbName + "`"); - } - } - return conn; + return dataSource.getConnection(); } public static Connection getConnection() throws SQLException { return getConnection(true); } - /** - * Returns a connection to the pool instead of closing it. - * If the pool is full, the connection is closed normally. - */ - private static void returnConnection(Connection conn) { - if (conn == null) return; - try { - if (conn.isClosed()) return; - if (!connectionPool.offer(conn)) { - // Pool is full, close the connection - conn.close(); - } - } catch (SQLException e) { - try { conn.close(); } catch (SQLException ignored) {} - } - } + // ------------------------------------------------------------------------- + // Query helpers (API unchanged — callers need no modification) + // ------------------------------------------------------------------------- - /** - * Shuts down the pool, closing all connections. - */ - public static void shutdownPool() { - Connection conn; - while ((conn = connectionPool.poll()) != null) { - try { conn.close(); } catch (SQLException ignored) {} - } - } - - /** - * Executes a query using a connection that includes the database. - */ public static QueryResult executeQuery(String sqlFormatString, Object... args) throws SQLException { String sql = String.format(sqlFormatString, args); LOGGER.trace(sql); Connection connection = getConnection(); - PreparedStatement queryStatement = connection.prepareStatement(sql); - ResultSet resultSet = queryStatement.executeQuery(); - return new QueryResult(connection, queryStatement, resultSet); + 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 executeUpdate(boolean selectDatabase, String sqlFormatString, Object... args) throws SQLException { + private static void executeUpdateInternal(boolean selectDatabase, String sqlFormatString, Object... args) throws SQLException { String sql = String.format(sqlFormatString, args); LOGGER.trace(sql); - Connection connection = getConnection(selectDatabase); - try (PreparedStatement updateStatement = connection.prepareStatement(sql)) { - updateStatement.executeUpdate(); - } finally { - if (selectDatabase) { - returnConnection(connection); - } else { - connection.close(); - } + 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 { - executeUpdate(true, sqlFormatString, args); + 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 connection = getConnection(false); - PreparedStatement updateStatement = connection.prepareStatement(sql)) { - updateStatement.executeUpdate(); + 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); - Connection connection = getConnection(); - try (PreparedStatement updateStatement = connection.prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { for (int i = 0; i < argument.length; i++) { - updateStatement.setString(i + 1, argument[i]); + stmt.setString(i + 1, argument[i]); } - updateStatement.executeUpdate(); - } finally { - returnConnection(connection); + stmt.executeUpdate(); } } public static void executePreparedUpdate(String sql, Object... params) throws SQLException { LOGGER.trace(sql); - Connection connection = getConnection(); - try (PreparedStatement stmt = connection.prepareStatement(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(); - } finally { - returnConnection(connection); } } public static QueryResult executePreparedQuery(String sql, Object... params) throws SQLException { LOGGER.trace(sql); - Connection connection = getConnection(); - PreparedStatement stmt = connection.prepareStatement(sql); - for (int i = 0; i < params.length; i++) { - stmt.setObject(i + 1, params[i]); + 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; } - ResultSet rs = stmt.executeQuery(); - return new QueryResult(connection, stmt, rs); } + // ------------------------------------------------------------------------- + // QueryResult — holds connection open until caller closes it + // ------------------------------------------------------------------------- + /** - * QueryResult now returns the connection to the pool on close instead of closing 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 { + 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("Error closing ResultSet", e); } + try { resultSet.close(); } catch (SQLException e) { + LOGGER.error("[PlayerSync] Error closing ResultSet", e); + } } if (preparedStatement != null) { - try { preparedStatement.close(); } catch (SQLException e) { LOGGER.error("Error closing PreparedStatement", e); } + 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); + } } - // Return connection to pool instead of closing - returnConnection(connection); } } }