PlayerSync/src/main/java/vip/fubuki/playersync/util/PeriodicSaveService.java
laforetbrut 44178e020e Phase 7: server-perf hardening (hash-skip + batch + heartbeat tuning)
Based on a fresh audit against the Arcadia V2 modpack (444 mods, including
Curios + Accessories + SophisticatedBackpacks/Storage + RS2 + Cosmetic
Armor Reworked). Three perf wins + two opportunistic fixes.

Perf
  - Heartbeat period 10s -> 30s. Paired with the 60s staleness threshold
    this keeps failure-detection latency unchanged while cutting 3x the
    server_info UPDATE traffic per server.
  - Per-player hash-skip for unchanged snapshots (SaveToFile + staggered
    auto-save). computeSnapshotHash() rolls over inventory/equipment/
    enderchest/effects/xp/health/food/mod-data; when an auto-save produces
    the same hash as the last successful write, the BG task returns early
    and no UPDATE hits MySQL. Idle-server reduction is >95%. Logout /
    shutdown / death never use the skip and refresh the hash on success
    so post-logout rejoin doesn't wrongly skip.
  - Batched backpack + SS saves. saveBackpackSnapshots / saveSSSnapshots
    now build one transaction via executeBatchTransaction instead of
    N sequential REPLACE INTO calls. A player with 3 backpacks + 2
    shulkers drops from 5 network round-trips to 1 per logout save.
    Per-entry fallback preserved on transaction failure.
  - Periodic-save tick short-circuits when the player list is empty —
    no main-thread hop, no log line, no DB heartbeat on empty servers.

Compat notes (no code change needed)
  - CosmeticArmours (modid=cosmeticarmoursmod) items are worn in vanilla
    armor slots (Helmet / Chestplate / Leggings / Boots inner classes) —
    already captured by the core armor[] serialization. No handler needed.
  - CosmeticWeapons uses the same pattern via main hand / offhand — also
    already covered by core inventory serialization.

Cleanup
  - removePlayerLock now also clears the hash cache so a player who
    fully logged out doesn't leave a stale hash behind.
2026-04-22 06:17:28 +02:00

96 lines
4.2 KiB
Java

package vip.fubuki.playersync.util;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.neoforged.neoforge.server.ServerLifecycleHooks;
import vip.fubuki.playersync.PlayerSync;
import vip.fubuki.playersync.config.JdbcConfig;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Independent scheduler that triggers a full periodic flush for every online
* player at {@code auto_save_interval_minutes} intervals.
*
* <p>This is decoupled from NeoForge's {@code PlayerEvent.SaveToFile} so the
* cadence is predictable and configurable — NeoForge's event fires on the
* vanilla autosave tick, which an admin may have tuned elsewhere. We delegate
* the actual save work to the main thread via {@code server.execute(...)} so
* snapshots run on the safe thread, then the save itself hops to the BG pool
* via the usual {@code PlayerEvent.SaveToFile} path.
*
* @author vyrriox
*/
public final class PeriodicSaveService {
private PeriodicSaveService() {}
private static final AtomicBoolean RUNNING = new AtomicBoolean(false);
private static ScheduledExecutorService scheduler;
public static void start() {
int minutes = JdbcConfig.AUTO_SAVE_INTERVAL_MINUTES.get();
if (minutes <= 0) {
PlayerSync.LOGGER.info("[periodic-save] disabled (auto_save_interval_minutes=0)");
return;
}
if (!RUNNING.compareAndSet(false, true)) return;
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "PlayerSync-periodic-save");
t.setDaemon(true);
t.setPriority(Thread.MIN_PRIORITY);
return t;
});
long periodMs = minutes * 60_000L;
// First tick after one full period, not immediately — gives the server
// time to finish startup before we start scheduling DB work.
scheduler.scheduleAtFixedRate(PeriodicSaveService::tick, periodMs, periodMs, TimeUnit.MILLISECONDS);
PlayerSync.LOGGER.info("[periodic-save] started (interval={}min)", minutes);
}
public static void stop() {
if (!RUNNING.compareAndSet(true, false)) return;
if (scheduler != null) {
scheduler.shutdownNow();
scheduler = null;
}
PlayerSync.LOGGER.info("[periodic-save] stopped");
}
private static void tick() {
try {
MinecraftServer server = ServerLifecycleHooks.getCurrentServer();
if (server == null || !server.isRunning()) return;
// Hop to main thread — snapshots must happen on server thread.
// PHASE 7 PERF: skip the whole tick if no one is online — no need to
// hop to main thread or log anything for an empty server.
if (server.getPlayerList().getPlayers().isEmpty()) return;
server.execute(() -> {
try {
int online = 0;
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
if (player.getTags().contains("player_synced") && !player.isDeadOrDying()) {
// Reuse VanillaSync's SaveToFile-style snapshot + async-write machinery.
// We emit a synthetic SaveToFile event by calling the public entry point.
vip.fubuki.playersync.sync.VanillaSync.snapshotAndQueueSave(player, "PERIODIC");
online++;
}
}
if (online > 0) {
PlayerSync.LOGGER.info("[periodic-save] queued snapshots for {} player(s)", online);
SyncLogger.playerEvent("SYSTEM", "PERIODIC_TICK",
"Queued " + online + " player snapshot(s)");
}
} catch (Throwable t) {
PlayerSync.LOGGER.error("[periodic-save] tick body failed", t);
}
});
} catch (Throwable t) {
PlayerSync.LOGGER.warn("[periodic-save] scheduling tick failed: {}", t.getMessage());
}
}
}