PlayerSync/src/main/java/vip/fubuki/playersync/util/HeartbeatService.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

70 lines
2.6 KiB
Java

package vip.fubuki.playersync.util;
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;
/**
* Periodic {@code server_info.last_update} heartbeat.
*
* <p>Runs on a dedicated single-threaded scheduler at a fixed interval so peer
* servers can detect this server as alive via {@code isPeerServerStale()} in
* {@code VanillaSync.doPlayerJoin}. Without this, a server that stops issuing
* updates (e.g. hung main thread) would be treated as alive indefinitely by
* rejoining players on other servers, causing the 30s poll timeouts seen in
* production logs.
*
* @author vyrriox
*/
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.
*/
private static final long PERIOD_MS = 30_000L;
private static final AtomicBoolean RUNNING = new AtomicBoolean(false);
private static ScheduledExecutorService scheduler;
public static void start() {
if (!RUNNING.compareAndSet(false, true)) return;
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "PlayerSync-heartbeat");
t.setDaemon(true);
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());
}
public static void stop() {
if (!RUNNING.compareAndSet(true, false)) return;
if (scheduler != null) {
scheduler.shutdownNow();
scheduler = null;
}
PlayerSync.LOGGER.info("[heartbeat] stopped");
}
private static void tick() {
try {
int serverId = JdbcConfig.SERVER_ID.get();
JDBCsetUp.executePreparedUpdate(
"UPDATE " + Tables.serverInfo() + " SET last_update=?, enable=1 WHERE id=?",
System.currentTimeMillis(), serverId);
} catch (Throwable t) {
// Do not kill the scheduler on a transient DB error — log and retry next tick.
PlayerSync.LOGGER.warn("[heartbeat] tick failed: {}", t.getMessage());
}
}
}