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.
This commit is contained in:
laforetbrut 2026-04-22 06:01:55 +02:00
parent 746cb56275
commit c70ca9f464
4 changed files with 157 additions and 3 deletions

View File

@ -219,6 +219,9 @@ public class PlayerSync {
vip.fubuki.playersync.sync.VanillaSync.emergencyFlushAll()); vip.fubuki.playersync.sync.VanillaSync.emergencyFlushAll());
vip.fubuki.playersync.util.HeartbeatService.start(); 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!"); LOGGER.info("PlayerSync is ready!");
} }

View File

@ -33,6 +33,24 @@ public class JdbcConfig {
*/ */
public static ModConfigSpec.ConfigValue<String> TABLE_PREFIX; public static ModConfigSpec.ConfigValue<String> 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 { static {
ModConfigSpec.Builder COMMON_BUILDER = new ModConfigSpec.Builder(); ModConfigSpec.Builder COMMON_BUILDER = new ModConfigSpec.Builder();
@ -67,6 +85,14 @@ public class JdbcConfig {
ITEM_PLACEHOLDER_DESCRIPTION_OVERRIDE = COMMON_BUILDER ITEM_PLACEHOLDER_DESCRIPTION_OVERRIDE = COMMON_BUILDER
.comment("Override the description of placeholder items which are unavailable on the current server.") .comment("Override the description of placeholder items which are unavailable on the current server.")
.define("item_placeholder_description_override", ""); .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_BUILDER.pop();
COMMON_CONFIG = COMMON_BUILDER.build(); COMMON_CONFIG = COMMON_BUILDER.build();

View File

@ -910,17 +910,48 @@ public class VanillaSync {
*/ */
@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 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(() -> { executorService.submit(() -> {
try { try {
JDBCsetUp.executePreparedUpdate("UPDATE " + Tables.serverInfo() + " SET last_update=? WHERE id=?", JDBCsetUp.executePreparedUpdate("UPDATE " + Tables.serverInfo() + " SET last_update=? WHERE id=?",
System.currentTimeMillis(), JdbcConfig.SERVER_ID.get()); System.currentTimeMillis(), JdbcConfig.SERVER_ID.get());
} catch (SQLException e) { } 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(); String puuid = player.getUUID().toString();
if (!player.getTags().contains("player_synced")) return; 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. // Phase 3: stop heartbeat before pool shutdown so its tick doesn't race with pool close.
vip.fubuki.playersync.util.HeartbeatService.stop(); 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 // Shut down the background executor no new tasks after this point
executorService.shutdown(); executorService.shutdown();

View File

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