Add dedicated PlayerSync diagnostic log file (logs/playersync/sync.log)

New SyncLogger utility class:
- Writes to logs/playersync/sync.log (separate from MC console)
- Automatic rotation: 10MB max per file, 5 files kept
- Thread-safe: lock-free ConcurrentLinkedQueue + async flush
- Categorized log levels: INFO, WARN, ERROR, DUPE_RISK, DATA_LOSS,
  RACE, PERF_SLOW, SAVE, SAVE_FAIL, SAVE_SKIP, RESTORE, EVENT, GUARD

Tracked events:
- Every player join/leave with sync status
- Every save (logout, shutdown, death, auto-save) with duration
- Save failures with error details
- Saves skipped (uncompleted sync, dead player)
- Cross-server race conditions (poll loop waiting)
- Player disconnects before sync apply (potential data loss)
- Duplicate login kicks
- Slow operations (> 50ms threshold)

Usage: check logs/playersync/sync.log on your server for diagnostics.
Look for DUPE_RISK, DATA_LOSS, RACE, SAVE_FAIL entries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
laforetbrut 2026-04-15 14:12:31 +02:00
parent 57f7925c2f
commit edf63aeb8c
3 changed files with 237 additions and 0 deletions

View File

@ -68,6 +68,9 @@ public class PlayerSync {
return;
}
// Initialize dedicated PlayerSync log file (logs/playersync/sync.log)
vip.fubuki.playersync.util.SyncLogger.init();
// Step 3: Explicitly select the database on a raw connection (DDL only).
try (Connection conn = JDBCsetUp.getConnection(false);
Statement st = conn.createStatement()) {
@ -223,6 +226,7 @@ public class PlayerSync {
@SubscribeEvent
public void onServerStopping(ServerStoppingEvent event) {
ChatSync.shutdown();
vip.fubuki.playersync.util.SyncLogger.shutdown();
// DO NOT call JDBCsetUp.shutdownPool() here!
// VanillaSync.onServerShutdown also subscribes to ServerStoppingEvent and
// needs the pool to save all player data. Event firing order is not guaranteed.

View File

@ -1,5 +1,6 @@
package vip.fubuki.playersync.sync;
import vip.fubuki.playersync.util.SyncLogger;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import net.minecraft.ChatFormatting;
import net.minecraft.core.BlockPos;
@ -309,6 +310,7 @@ public class VanillaSync {
lock.lock();
try {
PlayerSync.LOGGER.info("Starting synchronization for player {}", player_uuid);
SyncLogger.restoreStarted(player_uuid);
// FIX ANTI-DUPLICATION: Wait for the PREVIOUS server to finish saving this player's data.
// The old server's writeSnapshotToDB uses AND last_server=? once we claim last_server,
@ -332,6 +334,7 @@ public class VanillaSync {
// if online went to 0 (old server finished) or if last_server changed.
boolean otherOnline = rsCheck.getBoolean("online");
if (otherOnline) {
SyncLogger.raceCondition(player_uuid, "Waiting for server " + otherServer + " to finish saving (attempt " + (attempt + 1) + "/60)");
PlayerSync.LOGGER.info("Player {} still being saved on server {} (attempt {}/60), waiting 500ms...",
player_uuid, otherServer, attempt + 1);
Thread.sleep(500);
@ -450,6 +453,7 @@ public class VanillaSync {
// it could interfere with the logout save or corrupt state.
if (!isPlayerOnline(server, player_uuid)) {
PlayerSync.LOGGER.warn("Player {} disconnected before sync apply, skipping", player_uuid);
SyncLogger.dataLoss(player_uuid, "Player disconnected before sync apply — .dat data may persist, DB data not applied");
return;
}
@ -522,6 +526,7 @@ public class VanillaSync {
serverPlayer.addTag("player_synced");
PlayerSync.LOGGER.info("Sync data for player {} completed.", player_uuid);
SyncLogger.restoreCompleted(player_uuid, 0);
} catch (Exception e) {
PlayerSync.LOGGER.error("Error applying sync data for player {}", player_uuid, e);
} finally {
@ -956,6 +961,7 @@ public class VanillaSync {
ModsSupport.saveRS2DisksByLevel(rs2DiskUuids, rs2Level, rs2Registry);
}
PlayerSync.LOGGER.info("Saved player {} data on server shutdown", puuid);
SyncLogger.saveCompleted(puuid, "SHUTDOWN", 0);
} catch (Exception e) {
PlayerSync.LOGGER.error("Error saving player {} on shutdown", puuid, e);
try {
@ -1024,6 +1030,7 @@ public class VanillaSync {
// online on the OTHER server.
if (kickedForDuplicateLogin.remove(player_uuid)) {
PlayerSync.LOGGER.info("Player {} was kicked for duplicate login, NOT marking offline (still on other server)", player_uuid);
SyncLogger.playerEvent(player_uuid, "KICKED_DUPLICATE", "Player on another server, not marking offline");
syncNotCompletedPlayer.remove(player_uuid);
removePlayerLock(player_uuid);
return;
@ -1046,6 +1053,7 @@ public class VanillaSync {
if (syncNotCompletedPlayer.remove(player_uuid)) {
PlayerSync.LOGGER.warn("Player {} logged out with uncompleted sync. Data won't be saved for safety.", player_uuid);
SyncLogger.saveSkipped(player_uuid, "LOGOUT", "Sync not completed — data preserved in DB, .dat data discarded");
try {
// FIX: No last_server guard same reason as above.
JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=?", player_uuid);
@ -1121,8 +1129,10 @@ public class VanillaSync {
ModsSupport.saveRS2DisksByLevel(rs2DiskUuids, rs2Level, rs2RegistryAccess);
}
PlayerSync.LOGGER.info("Logout save completed for player {}", player_uuid);
SyncLogger.saveCompleted(player_uuid, "LOGOUT", 0);
} catch (Exception e) {
PlayerSync.LOGGER.error("Error saving player {} data on logout", player_uuid, e);
SyncLogger.saveFailed(player_uuid, "LOGOUT", e.getMessage());
// If the atomic write failed, still try to set online=0
try {
JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=? AND last_server=?",
@ -1712,6 +1722,7 @@ public class VanillaSync {
ModsSupport.saveRS2DisksByLevel(rs2DiskUuids, rs2Level, rs2Registry);
}
PlayerSync.LOGGER.info("Death-save completed for player {}", puuid);
SyncLogger.saveCompleted(puuid, "DEATH", 0);
} catch (Exception e) {
PlayerSync.LOGGER.error("Error death-saving player {}", puuid, e);
} finally {

View File

@ -0,0 +1,222 @@
package vip.fubuki.playersync.util;
import vip.fubuki.playersync.config.JdbcConfig;
import java.io.*;
import java.nio.file.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Dedicated file logger for PlayerSync diagnostics.
* Writes to logs/playersync/sync.log with automatic rotation (max 10MB per file, 5 files kept).
*
* Tracks: saves, restores, errors, potential duplications, data loss warnings,
* cross-server race conditions, and performance metrics.
*
* Thread-safe: uses a lock-free queue + async flush to avoid blocking the main thread.
*
* @author vyrriox
*/
public class SyncLogger {
private static final String LOG_DIR = "logs/playersync";
private static final String LOG_FILE = "sync.log";
private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
private static final int MAX_FILES = 5;
private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
// Lock-free queue for async writes (no main thread blocking)
private static final ConcurrentLinkedQueue<String> writeQueue = new ConcurrentLinkedQueue<>();
private static final AtomicBoolean initialized = new AtomicBoolean(false);
private static Path logPath;
// -------------------------------------------------------------------------
// Initialization
// -------------------------------------------------------------------------
public static void init() {
if (initialized.getAndSet(true)) return;
try {
Path dir = Paths.get(LOG_DIR);
Files.createDirectories(dir);
logPath = dir.resolve(LOG_FILE);
rotateIfNeeded();
writeRaw("=".repeat(80));
writeRaw("PlayerSync Log — Server ID: " + JdbcConfig.SERVER_ID.get() + " — Started: " + LocalDateTime.now().format(TIME_FMT));
writeRaw("=".repeat(80));
} catch (Exception e) {
System.err.println("[PlayerSync] Failed to initialize SyncLogger: " + e.getMessage());
}
}
// -------------------------------------------------------------------------
// Public API categorized log methods
// -------------------------------------------------------------------------
/** Normal sync operations (save/restore completed successfully) */
public static void info(String message, Object... args) {
log("INFO", message, args);
}
/** Warnings that may indicate issues (timeouts, fallbacks, edge cases) */
public static void warn(String message, Object... args) {
log("WARN", message, args);
}
/** Errors that caused data loss or corruption */
public static void error(String message, Object... args) {
log("ERROR", message, args);
}
/** Potential duplication detected — highest severity */
public static void dupeRisk(String playerUuid, String detail) {
log("DUPE_RISK", "[{}] {}", playerUuid, detail);
}
/** Potential data loss detected */
public static void dataLoss(String playerUuid, String detail) {
log("DATA_LOSS", "[{}] {}", playerUuid, detail);
}
/** Cross-server race condition event */
public static void raceCondition(String playerUuid, String detail) {
log("RACE", "[{}] {}", playerUuid, detail);
}
/** Performance metric */
public static void perf(String operation, long durationMs) {
if (durationMs > 50) { // Only log slow operations (> 50ms)
log("PERF_SLOW", "{} took {}ms", operation, durationMs);
}
}
/** Player join/leave tracking */
public static void playerEvent(String playerUuid, String eventType, String detail) {
log("EVENT", "[{}] {} — {}", playerUuid, eventType, detail);
}
// -------------------------------------------------------------------------
// Save tracking logs every save with metadata for debugging
// -------------------------------------------------------------------------
public static void saveStarted(String playerUuid, String saveType) {
log("SAVE", "[{}] {} started", playerUuid, saveType);
}
public static void saveCompleted(String playerUuid, String saveType, long durationMs) {
log("SAVE", "[{}] {} completed in {}ms", playerUuid, saveType, durationMs);
}
public static void saveFailed(String playerUuid, String saveType, String reason) {
log("SAVE_FAIL", "[{}] {} FAILED: {}", playerUuid, saveType, reason);
}
public static void saveSkipped(String playerUuid, String saveType, String reason) {
log("SAVE_SKIP", "[{}] {} skipped: {}", playerUuid, saveType, reason);
}
/** Logs when a write was blocked by the last_server guard (stale server tried to write) */
public static void guardBlocked(String playerUuid, int thisServerId, String detail) {
log("GUARD", "[{}] Write blocked (server={}) — {}", playerUuid, thisServerId, detail);
}
// -------------------------------------------------------------------------
// Restore tracking
// -------------------------------------------------------------------------
public static void restoreStarted(String playerUuid) {
log("RESTORE", "[{}] Data restore started", playerUuid);
}
public static void restoreCompleted(String playerUuid, long durationMs) {
log("RESTORE", "[{}] Data restore completed in {}ms", playerUuid, durationMs);
}
public static void restoreFailed(String playerUuid, String reason) {
log("RESTORE_FAIL", "[{}] Data restore FAILED: {}", playerUuid, reason);
}
// -------------------------------------------------------------------------
// Internal async file writing
// -------------------------------------------------------------------------
private static void log(String level, String message, Object... args) {
if (!initialized.get()) return;
try {
String formatted = formatMessage(message, args);
String line = String.format("[%s] [%s] [%s] %s",
LocalDateTime.now().format(TIME_FMT),
Thread.currentThread().getName(),
level,
formatted);
writeQueue.add(line);
// Flush async to avoid blocking caller
flushQueue();
} catch (Exception ignored) {}
}
private static String formatMessage(String template, Object... args) {
if (args == null || args.length == 0) return template;
// Simple {} placeholder replacement (like SLF4J)
StringBuilder sb = new StringBuilder();
int argIdx = 0;
int i = 0;
while (i < template.length()) {
if (i < template.length() - 1 && template.charAt(i) == '{' && template.charAt(i + 1) == '}' && argIdx < args.length) {
sb.append(args[argIdx++]);
i += 2;
} else {
sb.append(template.charAt(i));
i++;
}
}
return sb.toString();
}
private static void flushQueue() {
if (logPath == null) return;
try (BufferedWriter writer = new BufferedWriter(new FileWriter(logPath.toFile(), true))) {
String line;
int count = 0;
while ((line = writeQueue.poll()) != null && count < 100) {
writer.write(line);
writer.newLine();
count++;
}
} catch (IOException ignored) {}
}
private static void writeRaw(String line) {
writeQueue.add(line);
flushQueue();
}
private static void rotateIfNeeded() {
if (logPath == null) return;
try {
if (Files.exists(logPath) && Files.size(logPath) > MAX_FILE_SIZE) {
// Rotate: sync.log sync.1.log sync.2.log ... delete oldest
for (int i = MAX_FILES - 1; i >= 1; i--) {
Path src = Paths.get(LOG_DIR, "sync." + i + ".log");
Path dst = Paths.get(LOG_DIR, "sync." + (i + 1) + ".log");
if (Files.exists(src)) {
if (i == MAX_FILES - 1) {
Files.delete(src);
} else {
Files.move(src, dst, StandardCopyOption.REPLACE_EXISTING);
}
}
}
Files.move(logPath, Paths.get(LOG_DIR, "sync.1.log"), StandardCopyOption.REPLACE_EXISTING);
}
} catch (IOException ignored) {}
}
/** Call on server shutdown to flush remaining entries */
public static void shutdown() {
flushQueue();
}
}