perf: eliminate synchronous MySQL calls on server main thread

Root cause of lag (TPS 9-16, MSPT spikes to 4846ms):
PlayerEvent.SaveToFile triggered synchronous JDBC writes on the
server main thread every Minecraft autosave cycle. With 35 players
this caused hundreds of network round-trips to MySQL blocking the
tick loop for up to 4846ms (97x the 50ms limit).

Fixes applied:
- onPlayerSaveToFile: now fully async. Entity state is snapshotted
  on the main thread (pure memory ops, <1ms), then ALL DB writes are
  submitted to the background executor. Main thread never blocks on
  MySQL again.

- snapshotPlayerData: now captures ALL entity-dependent mod data
  (Curios, Accessories, CosmeticArmor, NeoForge attachments) on the
  main thread. Previously these were read from a background thread
  which is not thread-safe and could cause data corruption.

- writeSnapshotToDB: single method that writes all player data in one
  background pass: player_data + curios + mod_player_data.

- Auto-save background task: removed ModCompatSync.storeAll(player),
  storeSophisticatedBackpacks, storeSophisticatedStorageItems,
  storeRefinedStorageDisks from background thread. These all accessed
  entity state off-thread. Mod compat data is now in the main-thread
  snapshot; backpack/SS/RS2 contents are saved on logout/shutdown.

- Added ModCompatSync snapshot API: snapshotAccessories(),
  snapshotCosmeticArmor(), snapshotAttachments(), writeModSnapshot()
  for clean separation of entity reads vs DB writes.
This commit is contained in:
laforetbrut 2026-03-27 14:15:29 +01:00
parent 04a1f0128e
commit 4999c372ec
2 changed files with 210 additions and 50 deletions

View File

@ -686,21 +686,72 @@ public class VanillaSync {
return "B64:" + Base64.getEncoder().encodeToString(object.getBytes(StandardCharsets.UTF_8)); return "B64:" + Base64.getEncoder().encodeToString(object.getBytes(StandardCharsets.UTF_8));
} }
public static void doPlayerSaveToFile(PlayerEvent.SaveToFile event) throws SQLException, IOException { /**
JDBCsetUp.executePreparedUpdate("UPDATE server_info SET last_update=? WHERE id=?", System.currentTimeMillis(), JdbcConfig.SERVER_ID.get()); * FIX CRITICAL (performance): PlayerEvent.SaveToFile fires on the MAIN THREAD
if (!event.getEntity().getTags().contains("player_synced")) return; * during Minecraft's own autosave cycle (every 6000 ticks) and on player logout.
store(event.getEntity(), false); * The previous implementation called store() synchronously, which includes:
} * - Full inventory serialization
* - Multiple JDBC UPDATE/INSERT statements (each one a synchronous network round-trip
// FIX: SaveToFile already fires on the main thread. Running store() off-thread via * to MySQL 5ms to 4846ms depending on network latency)
// executorService read player entity state (inventory, armor, effects) from a background * With 35 players this caused MSPT spikes of up to 4846ms (97× the 50ms limit).
// thread, causing duplication/corruption. Run directly on the main thread. *
* NEW APPROACH:
* 1. Update server heartbeat ASYNCHRONOUSLY (no main-thread DB call).
* 2. If the player has been synced, snapshot all entity state on the main thread
* (fast pure memory serialization, no I/O).
* 3. Submit all DB writes to the background executor thread pool.
* 4. The main thread NEVER waits for MySQL it returns immediately.
*
* Safety: backpack / SophisticatedStorage / RS2 contents are NOT saved here
* (they are saved completely on logout and shutdown, which is the correct moment).
* The snapshot covers inventory, effects, XP, curios, accessories, cosmetic armor,
* and NeoForge attachments everything that changes frequently during gameplay.
*/
@SubscribeEvent @SubscribeEvent
public static void onPlayerSaveToFile(PlayerEvent.SaveToFile event) { public static void onPlayerSaveToFile(PlayerEvent.SaveToFile event) {
// Always update server heartbeat async, never blocks main thread
executorService.submit(() -> {
try {
JDBCsetUp.executePreparedUpdate("UPDATE server_info SET last_update=? WHERE id=?",
System.currentTimeMillis(), JdbcConfig.SERVER_ID.get());
} catch (SQLException e) {
PlayerSync.LOGGER.error("Error updating server heartbeat on SaveToFile", e);
}
});
Player player = event.getEntity();
String puuid = player.getUUID().toString();
if (!player.getTags().contains("player_synced")) return;
if (syncNotCompletedPlayer.contains(puuid)) return;
if (player.isDeadOrDying()) return;
// Use tryLock: if a logout save or another SaveToFile save is already writing
// this player's data, skip the other operation already has fresh data.
ReentrantLock lock = getPlayerLock(puuid);
if (!lock.tryLock()) return;
try { try {
doPlayerSaveToFile(event); // === MAIN THREAD: snapshot all entity state (no DB I/O, pure memory ops) ===
final PlayerDataSnapshot snapshot = snapshotPlayerData(player);
// === BACKGROUND THREAD: all DB writes main thread continues immediately ===
executorService.submit(() -> {
ReentrantLock bgLock = getPlayerLock(puuid);
if (!bgLock.tryLock()) return; // another save started, skip
try {
writeSnapshotToDB(snapshot);
} catch (Exception e) {
PlayerSync.LOGGER.error("Error writing async SaveToFile snapshot for player {}", puuid, e);
} finally {
bgLock.unlock();
}
});
} catch (Exception e) { } catch (Exception e) {
PlayerSync.LOGGER.error("Error during player save-to-file", e); PlayerSync.LOGGER.error("Error snapshotting player {} for SaveToFile", puuid, e);
} finally {
lock.unlock(); // main thread releases background thread can now acquire
} }
} }
@ -1037,29 +1088,47 @@ public class VanillaSync {
} }
} }
// NOTE: Sophisticated Backpacks/Storage/RS2 saves are NOT done here anymore. // Mod data snapshots entity reads, MUST be on main thread.
// They are done in the background thread (their entity reads are on SavedData which is thread-safe, // These are included in the snapshot so the background writer can persist them
// and their DB writes should not block the main thread). // without touching the entity again.
String curiosData = ModList.get().isLoaded("curios") && !player.isDeadOrDying()
? ModsSupport.snapshotCuriosData(player) : null;
String accessoriesData = ModCompatSync.snapshotAccessories(player);
String cosmeticArmorData = ModCompatSync.snapshotCosmeticArmor(player);
String attachmentsData = ModCompatSync.snapshotAttachments(player);
// NOTE: Sophisticated Backpacks/Storage/RS2 saves are intentionally NOT in the
// periodic snapshot their contents live in server-side SavedData and are
// always saved completely on logout / server shutdown.
return new PlayerDataSnapshot( return new PlayerDataSnapshot(
uuid, XP, score, foodLevel, health, uuid, XP, score, foodLevel, health,
leftHand, cursors, leftHand, cursors,
equipmentMap.toString(), inventoryMap.toString(), enderChestMap.toString(), effectMap.toString(), equipmentMap.toString(), inventoryMap.toString(), enderChestMap.toString(), effectMap.toString(),
advancements, advancements,
null, null, null, null // Curios/Accessories/CosmeticArmor/Attachments handled by their own DB writes curiosData, accessoriesData, cosmeticArmorData, attachmentsData
); );
} }
/** /**
* Writes a snapshot to the DB. Runs on BACKGROUND THREAD (no entity access). * Writes a snapshot to the DB. Runs on BACKGROUND THREAD no entity access.
* All data (basic + curios + mod compat) is written here in one pass.
*/ */
private static void writeSnapshotToDB(PlayerDataSnapshot s) throws Exception { private static void writeSnapshotToDB(PlayerDataSnapshot s) throws Exception {
// Core player data
JDBCsetUp.executePreparedUpdate( JDBCsetUp.executePreparedUpdate(
"UPDATE player_data SET inventory=?, armor=?, xp=?, effects=?, enderchest=?, score=?, food_level=?, health=?, advancements=?, left_hand=?, cursors=? WHERE uuid=?", "UPDATE player_data SET inventory=?, armor=?, xp=?, effects=?, enderchest=?, score=?, food_level=?, health=?, advancements=?, left_hand=?, cursors=? WHERE uuid=?",
s.inventory(), s.equipment(), s.xp(), s.effects(), s.enderChest(), s.score(), s.foodLevel(), s.health(), s.advancements(), s.leftHand(), s.cursors(), s.uuid()); s.inventory(), s.equipment(), s.xp(), s.effects(), s.enderChest(), s.score(), s.foodLevel(), s.health(), s.advancements(), s.leftHand(), s.cursors(), s.uuid());
// Curios, Accessories, CosmeticArmor, Attachments are already written by their own store methods // Curios (snapshotted on main thread, written here off-thread)
// during the snapshot phase (they do their own DB writes internally) if (s.curiosData() != null) {
JDBCsetUp.executePreparedUpdate(
"REPLACE INTO curios (uuid, curios_item) VALUES (?, ?)",
s.uuid(), s.curiosData());
}
// Mod compat: Accessories + CosmeticArmor + NeoForge attachments
ModCompatSync.writeModSnapshot(s.uuid(), s.accessoriesData(), s.cosmeticArmorData(), s.attachmentsData());
} }
private static String getSyncWorldForServer() { private static String getSyncWorldForServer() {
@ -1120,9 +1189,18 @@ public class VanillaSync {
}); });
} }
// Auto-save: Snapshot entity data on MAIN THREAD (fast), then write to DB on BACKGROUND THREAD. // Auto-save: snapshot ALL entity data on MAIN THREAD (fast, no I/O), then write
// Previously, store() ran entirely on main thread including DB writes, blocking the tick loop // to DB on a BACKGROUND THREAD.
// for ~5ms per player per save (~5.66% server thread usage from Spark profiling). //
// FIX: Previously the background task called ModCompatSync.storeAll(player),
// storeSophisticatedBackpacks(player), etc. from off-thread accessing entity
// state (inventory, Accessories API, CosmeticArmor, NeoForge attachments) in a
// non-thread-safe way. All entity reads are now done in snapshotPlayerData()
// on the main thread, and the background task only does DB writes.
//
// Backpack / SophisticatedStorage / RS2 contents live in server-side SavedData
// and are always saved completely on player logout + server shutdown no need
// to include them in the periodic auto-save.
if (autoSaveTickCounter >= AUTO_SAVE_INTERVAL_TICKS) { if (autoSaveTickCounter >= AUTO_SAVE_INTERVAL_TICKS) {
autoSaveTickCounter = 0; autoSaveTickCounter = 0;
MinecraftServer server = ServerLifecycleHooks.getCurrentServer(); MinecraftServer server = ServerLifecycleHooks.getCurrentServer();
@ -1135,40 +1213,17 @@ public class VanillaSync {
ReentrantLock lock = getPlayerLock(puuid); ReentrantLock lock = getPlayerLock(puuid);
if (!lock.tryLock()) continue; if (!lock.tryLock()) continue;
try { try {
// === MAIN THREAD: Snapshot ALL data (entity reads only, no DB I/O) === // === MAIN THREAD: snapshot ALL entity state (no DB I/O) ===
// snapshotPlayerData now includes curios, accessories,
// cosmeticarmor, and neoforge attachments.
final PlayerDataSnapshot snapshot = snapshotPlayerData(player); final PlayerDataSnapshot snapshot = snapshotPlayerData(player);
// Snapshot Curios data on main thread (entity read), DB write deferred // === BACKGROUND THREAD: DB writes only (no entity access) ===
final String curiosSnapshot;
if (ModList.get().isLoaded("curios") && !player.isDeadOrDying()) {
curiosSnapshot = ModsSupport.snapshotCuriosData(player);
} else {
curiosSnapshot = null;
}
// === BACKGROUND THREAD: ALL DB writes in one batch ===
executorService.submit(() -> { executorService.submit(() -> {
ReentrantLock bgLock = getPlayerLock(puuid); ReentrantLock bgLock = getPlayerLock(puuid);
if (!bgLock.tryLock()) return; if (!bgLock.tryLock()) return;
try { try {
writeSnapshotToDB(snapshot); writeSnapshotToDB(snapshot);
// Write curios data
if (curiosSnapshot != null) {
JDBCsetUp.executePreparedUpdate(
"REPLACE INTO curios (uuid, curios_item) VALUES (?, ?)",
puuid, curiosSnapshot);
}
// Mod compat + storage saves (all DB writes, off main thread)
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);
}
} catch (Exception e) { } catch (Exception e) {
PlayerSync.LOGGER.error("Error auto-saving player {}", puuid, e); PlayerSync.LOGGER.error("Error auto-saving player {}", puuid, e);
} finally { } finally {

View File

@ -339,13 +339,118 @@ public class ModCompatSync {
} }
} }
// ============================
// Snapshot methods (main thread - entity reads only, NO DB writes)
// These are used by auto-save and SaveToFile to capture entity state on the
// main thread, then the actual DB writes happen on a background thread.
// ============================
/**
* Captures Accessories slot data on the main thread.
* Returns serialized string or null if mod not loaded / no data.
*/
public static String snapshotAccessories(Player player) {
if (!ModList.get().isLoaded("accessories")) return null;
try {
io.wispforest.accessories.api.AccessoriesCapability cap =
io.wispforest.accessories.api.AccessoriesCapability.get(player);
if (cap == null) return null;
Map<String, String> flatMap = new HashMap<>();
for (Map.Entry<String, io.wispforest.accessories.api.AccessoriesContainer> entry : cap.getContainers().entrySet()) {
String slotType = entry.getKey();
var accessories = entry.getValue().getAccessories();
for (int i = 0; i < accessories.getContainerSize(); i++) {
ItemStack stack = accessories.getItem(i);
if (!stack.isEmpty()) {
flatMap.put(slotType + ":" + i, VanillaSync.getNbtForStorage(stack));
}
}
}
return flatMap.isEmpty() ? null : flatMap.toString();
} catch (Exception e) {
PlayerSync.LOGGER.error("Error snapshotting Accessories for player {}", player.getUUID(), e);
return null;
}
}
/**
* Captures Cosmetic Armor slot data on the main thread.
* Returns serialized string or null if mod not loaded / no data.
*/
public static String snapshotCosmeticArmor(Player player) {
if (!ModList.get().isLoaded("cosmeticarmorreworked")) return null;
try {
lain.mods.cos.impl.inventory.InventoryCosArmor cosInv =
lain.mods.cos.impl.ModObjects.invMan.getCosArmorInventory(player.getUUID());
if (cosInv == null) return null;
Map<Integer, String> flatMap = new HashMap<>();
for (int i = 0; i < cosInv.getContainerSize(); i++) {
ItemStack stack = cosInv.getItem(i);
if (!stack.isEmpty()) {
flatMap.put(i, VanillaSync.getNbtForStorage(stack));
}
}
return flatMap.isEmpty() ? null : flatMap.toString();
} catch (Exception e) {
PlayerSync.LOGGER.error("Error snapshotting CosmeticArmor for player {}", player.getUUID(), e);
return null;
}
}
/**
* Captures NeoForge attachment data on the main thread via reflection.
* Returns BNBT-serialized string or null if no data.
*/
public static String snapshotAttachments(Player player) {
try {
if (!(player instanceof net.minecraft.server.level.ServerPlayer serverPlayer)) return null;
java.lang.reflect.Method serializeMethod = net.neoforged.neoforge.attachment.AttachmentHolder.class
.getDeclaredMethod("serializeAttachments", net.minecraft.core.HolderLookup.Provider.class);
serializeMethod.setAccessible(true);
net.minecraft.nbt.CompoundTag attachments = (net.minecraft.nbt.CompoundTag)
serializeMethod.invoke(player, serverPlayer.getServer().registryAccess());
if (attachments == null || attachments.isEmpty()) return null;
return VanillaSync.serializeTagToBinaryBase64(attachments);
} catch (Exception e) {
PlayerSync.LOGGER.error("Error snapshotting NeoForge attachments for player {}", player.getUUID(), e);
return null;
}
}
/**
* Writes pre-snapshotted mod data to the DB.
* NO entity access safe to call from a background thread.
*
* @param uuid player UUID string
* @param accessoriesData serialized Accessories slots (may be null skipped)
* @param cosmeticArmor serialized Cosmetic Armor slots (may be null skipped)
* @param attachments serialized NeoForge attachments (may be null skipped)
*/
public static void writeModSnapshot(String uuid, String accessoriesData, String cosmeticArmor, String attachments) throws SQLException {
if (accessoriesData != null) {
JDBCsetUp.executePreparedUpdate(
"REPLACE INTO mod_player_data (uuid, mod_id, data_value) VALUES (?, ?, ?)",
uuid, "accessories", accessoriesData);
}
if (cosmeticArmor != null) {
JDBCsetUp.executePreparedUpdate(
"REPLACE INTO mod_player_data (uuid, mod_id, data_value) VALUES (?, ?, ?)",
uuid, "cosmeticarmor", cosmeticArmor);
}
if (attachments != null) {
JDBCsetUp.executePreparedUpdate(
"REPLACE INTO mod_player_data (uuid, mod_id, data_value) VALUES (?, ?, ?)",
uuid, "neoforge_attachments", attachments);
}
}
// ============================ // ============================
// Convenience methods // Convenience methods
// ============================ // ============================
/** /**
* Saves all mod-specific data for a player. * Saves all mod-specific data for a player synchronously.
* Called on logout and auto-save. * Called on logout and server shutdown (main thread entity reads are safe here).
*/ */
public static void storeAll(Player player) { public static void storeAll(Player player) {
storeAccessories(player); storeAccessories(player);