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:
parent
746cb56275
commit
c70ca9f464
|
|
@ -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!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user