From c70ca9f4644a1d2503570d565d5d7ebb4cde2b01 Mon Sep 17 00:00:00 2001 From: laforetbrut Date: Wed, 22 Apr 2026 06:01:55 +0200 Subject: [PATCH] Phase 4: 10-min periodic save + dimension-change trigger Adds two new triggers that complement NeoForge's vanilla SaveToFile event: PeriodicSaveService.java - Dedicated single-thread daemon scheduler, started after server boot. - Ticks every 'auto_save_interval_minutes' (config, default 10 min). - On each tick: hops to main thread, snapshots every online synced player via VanillaSync.snapshotAndQueueSave, async BG writes with full P0 guard stack (pendingLogoutSaves + online=0 + bgLock tryLock). - Set interval to 0 to disable. VanillaSync.snapshotAndQueueSave(Player, String label) - Extracted from onPlayerSaveToFile body; public entry point shared by PeriodicSaveService, onPlayerChangeDimension, and the existing SaveToFile event. Label flows into logs for traceability (SaveToFile / PERIODIC / DIMENSION). VanillaSync.onPlayerChangeDimension - New @SubscribeEvent on PlayerChangedDimensionEvent, gated by 'save_on_dimension_change' config (default false). Queues a full save when a player teleports across dimensions, protecting against mid- teleport crashes. JdbcConfig - Added AUTO_SAVE_INTERVAL_MINUTES (int, 0-1440, default 10) - Added SAVE_ON_DIMENSION_CHANGE (bool, default false) VanillaSync.onServerShutdown also stops PeriodicSaveService before the pool close, same pattern as HeartbeatService. --- .../vip/fubuki/playersync/PlayerSync.java | 3 + .../fubuki/playersync/config/JdbcConfig.java | 26 ++++++ .../fubuki/playersync/sync/VanillaSync.java | 39 +++++++- .../playersync/util/PeriodicSaveService.java | 92 +++++++++++++++++++ 4 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 src/main/java/vip/fubuki/playersync/util/PeriodicSaveService.java diff --git a/src/main/java/vip/fubuki/playersync/PlayerSync.java b/src/main/java/vip/fubuki/playersync/PlayerSync.java index 9479ad0..b73e49b 100644 --- a/src/main/java/vip/fubuki/playersync/PlayerSync.java +++ b/src/main/java/vip/fubuki/playersync/PlayerSync.java @@ -219,6 +219,9 @@ public class PlayerSync { vip.fubuki.playersync.sync.VanillaSync.emergencyFlushAll()); vip.fubuki.playersync.util.HeartbeatService.start(); + // Phase 4: periodic full-flush scheduler (default 10 min). + vip.fubuki.playersync.util.PeriodicSaveService.start(); + LOGGER.info("PlayerSync is ready!"); } diff --git a/src/main/java/vip/fubuki/playersync/config/JdbcConfig.java b/src/main/java/vip/fubuki/playersync/config/JdbcConfig.java index 9bf400d..14b8d89 100644 --- a/src/main/java/vip/fubuki/playersync/config/JdbcConfig.java +++ b/src/main/java/vip/fubuki/playersync/config/JdbcConfig.java @@ -33,6 +33,24 @@ public class JdbcConfig { */ public static ModConfigSpec.ConfigValue TABLE_PREFIX; + /** + * Periodic full-flush interval in minutes. Triggers a complete save + * (player data + backpacks + SS + RS2 disks) for every online player at + * this cadence — independent of NeoForge's PlayerEvent.SaveToFile which + * only fires on vanilla world-save ticks. Set to 0 to disable. + * Default 10 minutes is a reasonable trade-off between data-loss window + * on crash and DB load. Minimum 1 minute to avoid accidental DB hammering. + */ + public static ModConfigSpec.IntValue AUTO_SAVE_INTERVAL_MINUTES; + + /** + * Whether to trigger a full snapshot save on PlayerChangeDimensionEvent. + * Prevents data loss if the player crashes mid-teleport between dimensions. + * Disabled by default — enable if your server has frequent cross-dimension + * travel (ex-Twilight Forest heavy modpacks). + */ + public static ModConfigSpec.BooleanValue SAVE_ON_DIMENSION_CHANGE; + static { ModConfigSpec.Builder COMMON_BUILDER = new ModConfigSpec.Builder(); @@ -67,6 +85,14 @@ public class JdbcConfig { ITEM_PLACEHOLDER_DESCRIPTION_OVERRIDE = COMMON_BUILDER .comment("Override the description of placeholder items which are unavailable on the current server.") .define("item_placeholder_description_override", ""); + AUTO_SAVE_INTERVAL_MINUTES = COMMON_BUILDER.comment( + "Periodic full-flush interval (minutes). Triggers a complete save (player data +", + "backpacks + SS + RS2) for every online player. Set to 0 to disable. Default 10." + ).defineInRange("auto_save_interval_minutes", 10, 0, 1440); + SAVE_ON_DIMENSION_CHANGE = COMMON_BUILDER.comment( + "Trigger a full save when a player changes dimension. Protects against mid-teleport", + "crashes. Adds DB load proportional to travel frequency. Default false." + ).define("save_on_dimension_change", false); COMMON_BUILDER.pop(); COMMON_CONFIG = COMMON_BUILDER.build(); diff --git a/src/main/java/vip/fubuki/playersync/sync/VanillaSync.java b/src/main/java/vip/fubuki/playersync/sync/VanillaSync.java index 9bad5ac..656e21d 100644 --- a/src/main/java/vip/fubuki/playersync/sync/VanillaSync.java +++ b/src/main/java/vip/fubuki/playersync/sync/VanillaSync.java @@ -910,17 +910,48 @@ public class VanillaSync { */ @SubscribeEvent public static void onPlayerSaveToFile(PlayerEvent.SaveToFile event) { - // Always update server heartbeat — async, never blocks main thread + snapshotAndQueueSave(event.getEntity(), "SaveToFile"); + } + + /** + * Phase 4: optional save on dimension change — gated by + * {@code save_on_dimension_change} config. Protects against mid-teleport + * crashes when the player is about to serialize into a new world file. + */ + @SubscribeEvent + public static void onPlayerChangeDimension(PlayerEvent.PlayerChangedDimensionEvent event) { + try { + if (!JdbcConfig.SAVE_ON_DIMENSION_CHANGE.get()) return; + PlayerSync.LOGGER.debug("[dimension-change] queuing save for {} ({} -> {})", + event.getEntity().getUUID(), event.getFrom().location(), event.getTo().location()); + SyncLogger.playerEvent(event.getEntity().getUUID().toString(), "DIMENSION_CHANGE", + event.getFrom().location() + " -> " + event.getTo().location()); + snapshotAndQueueSave(event.getEntity(), "DIMENSION"); + } catch (Exception e) { + PlayerSync.LOGGER.warn("[dimension-change] save trigger failed: {}", e.getMessage()); + } + } + + /** + * Phase 4: public entry point used by PeriodicSaveService and dimension-change + * handler. Snapshots on main thread, queues async DB write with the full P0 + * guard stack (pendingLogoutSaves + online=0 + bgLock tryLock). + * + * @param player the player to snapshot — MUST be called on the server main thread + * @param label a short tag used in log lines for diagnosis (e.g. "SaveToFile", + * "PERIODIC", "DIMENSION") + */ + public static void snapshotAndQueueSave(Player player, String label) { + // Heartbeat piggyback — cheap, keeps server_info fresh even if no SaveToFile ticks. executorService.submit(() -> { try { JDBCsetUp.executePreparedUpdate("UPDATE " + Tables.serverInfo() + " SET last_update=? WHERE id=?", System.currentTimeMillis(), JdbcConfig.SERVER_ID.get()); } catch (SQLException e) { - PlayerSync.LOGGER.error("Error updating server heartbeat on SaveToFile", e); + PlayerSync.LOGGER.error("Error updating server heartbeat on {}", label, e); } }); - Player player = event.getEntity(); String puuid = player.getUUID().toString(); if (!player.getTags().contains("player_synced")) return; @@ -1069,6 +1100,8 @@ public class VanillaSync { // Phase 3: stop heartbeat before pool shutdown so its tick doesn't race with pool close. vip.fubuki.playersync.util.HeartbeatService.stop(); + // Phase 4: stop periodic-save scheduler before pool shutdown. + vip.fubuki.playersync.util.PeriodicSaveService.stop(); // Shut down the background executor — no new tasks after this point executorService.shutdown(); diff --git a/src/main/java/vip/fubuki/playersync/util/PeriodicSaveService.java b/src/main/java/vip/fubuki/playersync/util/PeriodicSaveService.java new file mode 100644 index 0000000..8e9e914 --- /dev/null +++ b/src/main/java/vip/fubuki/playersync/util/PeriodicSaveService.java @@ -0,0 +1,92 @@ +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. + * + *

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. + 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()); + } + } +}