PlayerSync/src/main/java/vip/fubuki/playersync/util/HeartbeatService.java
laforetbrut c7487196ec 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).
2026-04-22 06:34:02 +02:00

76 lines
2.7 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: configurable via {@code heartbeat_interval_seconds}.
* Paired with {@code peer_stale_threshold_seconds}.
*/
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;
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;
});
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() {
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());
}
}
}