From 107ae7954f024b1fb250373a1929766561c5a8ed Mon Sep 17 00:00:00 2001 From: hayanesuru Date: Sun, 8 Jun 2025 12:18:01 +0900 Subject: [PATCH] async saving player stats and advancements (#334) * async saving player stats and advancements * remove thread check * fix interrupt * longer wait IO tasks time * safe replace * delay join while saving player * mark as experimental --------- Co-authored-by: Taiyou06 --- .../server/0100-Smooth-teleport-config.patch | 4 +- .../0086-Nitori-Async-playerdata-saving.patch | 337 +++++++++++++----- ...p-dirty-stats-copy-when-requesting-p.patch | 6 +- ...-SparklyPaper-Parallel-world-ticking.patch | 18 +- .../0163-Async-switch-connection-state.patch | 4 +- .../0033-Async-playerdata-saving.patch | 6 +- .../leaf/async/AsyncPlayerDataSaving.java | 37 -- .../dreeam/leaf/async/ShutdownExecutors.java | 1 + .../async/storage/AsyncPlayerDataSaving.java | 307 ++++++++++++++++ .../modules/async/AsyncPlayerDataSave.java | 11 + .../leavesmc/leaves/bot/BotStatsCounter.java | 2 +- 11 files changed, 583 insertions(+), 150 deletions(-) delete mode 100644 leaf-server/src/main/java/org/dreeam/leaf/async/AsyncPlayerDataSaving.java create mode 100644 leaf-server/src/main/java/org/dreeam/leaf/async/storage/AsyncPlayerDataSaving.java diff --git a/leaf-archived-patches/removed/hardfork/server/0100-Smooth-teleport-config.patch b/leaf-archived-patches/removed/hardfork/server/0100-Smooth-teleport-config.patch index 30429f8d..b5c7d20c 100644 --- a/leaf-archived-patches/removed/hardfork/server/0100-Smooth-teleport-config.patch +++ b/leaf-archived-patches/removed/hardfork/server/0100-Smooth-teleport-config.patch @@ -32,10 +32,10 @@ index 4f01b53bf801f99253efd27df6216912705d18af..82a1715fea41e6a41c4ff441ea89f424 level.addDuringTeleport(this); this.triggerDimensionChangeTriggers(serverLevel); diff --git a/net/minecraft/server/players/PlayerList.java b/net/minecraft/server/players/PlayerList.java -index 75fb49f1596f475278d12c8c7aea9ad4952b6056..b17c8a2f5294ac28cc05fb05c84a041b2c6c8721 100644 +index 52a0fa425a30caa2e592c0fdda44800da169c2a0..3f5c5b6234eb400838973c37e5a48bb121d1ff16 100644 --- a/net/minecraft/server/players/PlayerList.java +++ b/net/minecraft/server/players/PlayerList.java -@@ -955,11 +955,11 @@ public abstract class PlayerList { +@@ -997,11 +997,11 @@ public abstract class PlayerList { byte b = (byte)(keepInventory ? 1 : 0); ServerLevel serverLevel = serverPlayer.serverLevel(); LevelData levelData = serverLevel.getLevelData(); diff --git a/leaf-server/minecraft-patches/features/0086-Nitori-Async-playerdata-saving.patch b/leaf-server/minecraft-patches/features/0086-Nitori-Async-playerdata-saving.patch index 337ffac9..5fdb3805 100644 --- a/leaf-server/minecraft-patches/features/0086-Nitori-Async-playerdata-saving.patch +++ b/leaf-server/minecraft-patches/features/0086-Nitori-Async-playerdata-saving.patch @@ -6,15 +6,213 @@ Subject: [PATCH] Nitori: Async playerdata saving Original license: GPL v3 Original project: https://github.com/Gensokyo-Reimagined/Nitori +diff --git a/net/minecraft/network/Connection.java b/net/minecraft/network/Connection.java +index 00a82873d226f113278632a53c0faca420dd67d4..2c4423eb2d465c2782a8dab851619ce539f69ae8 100644 +--- a/net/minecraft/network/Connection.java ++++ b/net/minecraft/network/Connection.java +@@ -586,7 +586,7 @@ public class Connection extends SimpleChannelInboundHandler> { + // Paper end - Optimize network + + private static final int MAX_PER_TICK = io.papermc.paper.configuration.GlobalConfiguration.get().misc.maxJoinsPerTick; // Paper - Buffer joins to world +- private static int joinAttemptsThisTick; // Paper - Buffer joins to world ++ public static int joinAttemptsThisTick; // Paper - Buffer joins to world // Leaf - Async player IO + private static int currTick; // Paper - Buffer joins to world + private static int tickSecond; // Purpur - Max joins per second + public void tick() { +diff --git a/net/minecraft/server/PlayerAdvancements.java b/net/minecraft/server/PlayerAdvancements.java +index d2159a747fe42aa95cfc6bca0e55e3f4485847bb..d5196b181a0a633cb04ce18b0471cda2dcaa8816 100644 +--- a/net/minecraft/server/PlayerAdvancements.java ++++ b/net/minecraft/server/PlayerAdvancements.java +@@ -111,6 +111,7 @@ public class PlayerAdvancements { + } + + private void load(ServerAdvancementManager manager) { ++ org.dreeam.leaf.async.storage.AsyncPlayerDataSaving.INSTANCE.blockAdvancements(player.getUUID(), player.getScoreboardName()); // Leaf - Async player IO + if (Files.isRegularFile(this.playerSavePath)) { + try (JsonReader jsonReader = new JsonReader(Files.newBufferedReader(this.playerSavePath, StandardCharsets.UTF_8))) { + jsonReader.setLenient(false); +@@ -133,12 +134,18 @@ public class PlayerAdvancements { + JsonElement jsonElement = this.codec.encodeStart(JsonOps.INSTANCE, this.asData()).getOrThrow(); + + try { +- FileUtil.createDirectoriesSafe(this.playerSavePath.getParent()); +- +- try (Writer bufferedWriter = Files.newBufferedWriter(this.playerSavePath, StandardCharsets.UTF_8)) { +- GSON.toJson(jsonElement, GSON.newJsonWriter(bufferedWriter)); +- } +- } catch (JsonIOException | IOException var7) { ++ // Leaf start - Async player IO ++ String content = GSON.toJson(jsonElement); ++ final Path path = this.playerSavePath; ++ org.dreeam.leaf.async.storage.AsyncPlayerDataSaving.INSTANCE.submitAdvancements( ++ this.player.getUUID(), ++ this.player.getScoreboardName(), ++ () -> { ++ org.dreeam.leaf.async.storage.AsyncPlayerDataSaving.safeReplace(path, content); ++ return null; ++ }); ++ } catch (JsonIOException /*| IOException*/ var7) { ++ // Leaf end - Async player IO + LOGGER.error("Couldn't save player advancements to {}", this.playerSavePath, var7); + } + } +diff --git a/net/minecraft/server/network/ServerLoginPacketListenerImpl.java b/net/minecraft/server/network/ServerLoginPacketListenerImpl.java +index 114b25f933c6a1b011523581a5a02a5a2c1e827e..3da6dad3dd0f4c5750609b382f47a6cd14f18e7a 100644 +--- a/net/minecraft/server/network/ServerLoginPacketListenerImpl.java ++++ b/net/minecraft/server/network/ServerLoginPacketListenerImpl.java +@@ -79,6 +79,7 @@ public class ServerLoginPacketListenerImpl implements ServerLoginPacketListener, + .expireAfterWrite(org.dreeam.leaf.config.modules.misc.Cache.cachePlayerProfileResultTimeout, java.util.concurrent.TimeUnit.MINUTES) + .build(); + // Leaf end - Cache player profileResult ++ @Nullable public java.util.UUID[] duplicateDisconnect = null; // Leaf - Async player IO + + public ServerLoginPacketListenerImpl(MinecraftServer server, Connection connection, boolean transferred) { + this.server = server; +diff --git a/net/minecraft/server/players/PlayerList.java b/net/minecraft/server/players/PlayerList.java +index 75fb49f1596f475278d12c8c7aea9ad4952b6056..52a0fa425a30caa2e592c0fdda44800da169c2a0 100644 +--- a/net/minecraft/server/players/PlayerList.java ++++ b/net/minecraft/server/players/PlayerList.java +@@ -782,6 +782,31 @@ public abstract class PlayerList { + // UserBanListEntry userBanListEntry = this.bans.get(gameProfile); + // Moved from processLogin + UUID uuid = gameProfile.getId(); ++ ++ // Leaf start - Async player IO ++ if (org.dreeam.leaf.config.modules.async.AsyncPlayerDataSave.enabled) { ++ if (org.dreeam.leaf.async.storage.AsyncPlayerDataSaving.INSTANCE.isSaving(uuid)) { ++ if (Connection.joinAttemptsThisTick != 0) Connection.joinAttemptsThisTick -= 1; ++ return null; ++ } ++ if (loginlistener.duplicateDisconnect != null ++ && loginlistener.duplicateDisconnect.length != 0) { ++ // check last one ++ var last = loginlistener.duplicateDisconnect[loginlistener.duplicateDisconnect.length - 1]; ++ if (org.dreeam.leaf.async.storage.AsyncPlayerDataSaving.INSTANCE.isSaving(last)) { ++ if (Connection.joinAttemptsThisTick != 0) Connection.joinAttemptsThisTick -= 1; ++ return null; ++ } ++ for (UUID uuid1 : loginlistener.duplicateDisconnect) { ++ if (org.dreeam.leaf.async.storage.AsyncPlayerDataSaving.INSTANCE.isSaving(uuid1)) { ++ if (Connection.joinAttemptsThisTick != 0) Connection.joinAttemptsThisTick -= 1; ++ return null; ++ } ++ } ++ loginlistener.duplicateDisconnect = null; ++ } ++ } ++ // Leaf end - Async player IO + List list = Lists.newArrayList(); + + ServerPlayer entityplayer; +@@ -793,6 +818,23 @@ public abstract class PlayerList { + } + } + ++ // Leaf start - Async player IO ++ if (org.dreeam.leaf.config.modules.async.AsyncPlayerDataSave.enabled && !list.isEmpty()) { ++ loginlistener.duplicateDisconnect = new UUID[list.size()]; ++ java.util.Iterator iterator = list.iterator(); ++ ++ int index = 0; ++ while (iterator.hasNext()) { ++ entityplayer = iterator.next(); ++ // this.save(entityplayer); // CraftBukkit - Force the player's inventory to be saved ++ entityplayer.connection.disconnect(Component.translatable("multiplayer.disconnect.duplicate_login"), org.bukkit.event.player.PlayerKickEvent.Cause.DUPLICATE_LOGIN); // Paper - kick event cause ++ loginlistener.duplicateDisconnect[index] = entityplayer.getUUID(); ++ index++; ++ } ++ if (Connection.joinAttemptsThisTick != 0) Connection.joinAttemptsThisTick -= 1; ++ return null; ++ } ++ // Leaf end - Async player IO + java.util.Iterator iterator = list.iterator(); + + while (iterator.hasNext()) { +@@ -1582,7 +1624,7 @@ public abstract class PlayerList { + */ + // Leaf end - Remove useless creating stats json bases on player name logic + +- serverStatsCounter = new ServerStatsCounter(this.server, file1); ++ serverStatsCounter = new ServerStatsCounter(this.server, file1, displayName, uuid); + // this.stats.put(uuid, serverStatsCounter); // CraftBukkit + } + +diff --git a/net/minecraft/stats/ServerStatsCounter.java b/net/minecraft/stats/ServerStatsCounter.java +index b26dbe807e5cb0a42f6c06b933397902310e5616..35ad7f249cfb6f5c779136d96f3698ea4de1eb7c 100644 +--- a/net/minecraft/stats/ServerStatsCounter.java ++++ b/net/minecraft/stats/ServerStatsCounter.java +@@ -39,12 +39,23 @@ public class ServerStatsCounter extends StatsCounter { + private final File file; + private final Set> dirty = Sets.newHashSet(); + ++ // Leaf start - Async player IO ++ private final String name; ++ private final java.util.UUID uuid; ++ @Deprecated(forRemoval = true) + public ServerStatsCounter(MinecraftServer server, File file) { ++ throw new UnsupportedOperationException(); ++ } ++ public ServerStatsCounter(MinecraftServer server, File file, String name, java.util.UUID uuid) { ++ this.name = name; ++ this.uuid = uuid; ++ org.dreeam.leaf.async.storage.AsyncPlayerDataSaving.INSTANCE.blockStats(uuid, name); ++ // Leaf end - Async player IO + this.server = server; + this.file = file; + if (file.isFile()) { + try { +- this.parseLocal(server.getFixerUpper(), FileUtils.readFileToString(file)); ++ this.parseLocal(server.getFixerUpper(), FileUtils.readFileToString(file, java.nio.charset.StandardCharsets.UTF_8)); // Leaf - UTF-8 + } catch (IOException var4) { + LOGGER.error("Couldn't read statistics file {}", file, var4); + } catch (JsonParseException var5) { +@@ -66,11 +77,37 @@ public class ServerStatsCounter extends StatsCounter { + + public void save() { + if (org.spigotmc.SpigotConfig.disableStatSaving) return; // Spigot ++ // Leaf start - Async player IO ++ Map, JsonObject> map = Maps.newHashMap(); ++ for (it.unimi.dsi.fastutil.objects.Object2IntMap.Entry> entry : this.stats.object2IntEntrySet()) { ++ Stat stat = entry.getKey(); ++ map.computeIfAbsent(stat.getType(), type -> new JsonObject()).addProperty(getKey(stat).toString(), entry.getIntValue()); ++ } ++ final File file = this.file; ++ org.dreeam.leaf.async.storage.AsyncPlayerDataSaving.INSTANCE.submitStats( ++ uuid, ++ name, ++ () -> { ++ JsonObject jsonObject = new JsonObject(); ++ ++ for (Entry, JsonObject> entry1 : map.entrySet()) { ++ jsonObject.add(BuiltInRegistries.STAT_TYPE.getKey(entry1.getKey()).toString(), entry1.getValue()); ++ } ++ ++ JsonObject jsonObject1 = new JsonObject(); ++ jsonObject1.add("stats", jsonObject); ++ jsonObject1.addProperty("DataVersion", SharedConstants.getCurrentVersion().getDataVersion().getVersion()); ++ org.dreeam.leaf.async.storage.AsyncPlayerDataSaving.safeReplace(file.toPath(), jsonObject1.toString()); ++ return null; ++ }); ++ /* + try { + FileUtils.writeStringToFile(this.file, this.toJson()); + } catch (IOException var2) { + LOGGER.error("Couldn't save stats", (Throwable)var2); + } ++ */ ++ // Leaf end - Async player IO + } + + @Override diff --git a/net/minecraft/world/level/storage/LevelStorageSource.java b/net/minecraft/world/level/storage/LevelStorageSource.java -index de43e54698125ce9f319d4889dd49f7029fe95e0..742bd4b60321adc9e63c3de910ea95f4990b618d 100644 +index de43e54698125ce9f319d4889dd49f7029fe95e0..360e54b87db68fad60cdec63af466765baae0a07 100644 --- a/net/minecraft/world/level/storage/LevelStorageSource.java +++ b/net/minecraft/world/level/storage/LevelStorageSource.java -@@ -520,15 +520,26 @@ public class LevelStorageSource { +@@ -520,15 +520,24 @@ public class LevelStorageSource { private void saveLevelData(CompoundTag tag) { Path path = this.levelDirectory.path(); -+ // Leaf start - Async playerdata saving ++ // Leaf start - Async player IO + // Save level.dat asynchronously + var nbtBytes = new it.unimi.dsi.fastutil.io.FastByteArrayOutputStream(65536); try { @@ -28,38 +226,36 @@ index de43e54698125ce9f319d4889dd49f7029fe95e0..742bd4b60321adc9e63c3de910ea95f4 - LevelStorageSource.LOGGER.error("Failed to save level {}", path, var6); + LevelStorageSource.LOGGER.error("Failed to encode level {}", path, var6); } -+ org.dreeam.leaf.async.AsyncPlayerDataSaving.submit(() -> { ++ org.dreeam.leaf.async.storage.AsyncPlayerDataSaving.INSTANCE.saveLevelData(path, () -> { + try { -+ Path path1 = Files.createTempFile(path, "level", ".dat"); -+ org.apache.commons.io.FileUtils.writeByteArrayToFile(path1.toFile(), nbtBytes.array, 0, nbtBytes.length, false); -+ Path path2 = this.levelDirectory.oldDataFile(); -+ Path path3 = this.levelDirectory.dataFile(); -+ Util.safeReplaceFile(path3, path1, path2); ++ Path old = this.levelDirectory.oldDataFile(); ++ Path current = this.levelDirectory.dataFile(); ++ org.dreeam.leaf.async.storage.AsyncPlayerDataSaving.safeReplaceBackup(current, old, nbtBytes.array, 0, nbtBytes.length); + } catch (Exception var6) { -+ LevelStorageSource.LOGGER.error("Failed to save level {}", path, var6); ++ LevelStorageSource.LOGGER.error("Failed to save level.dat {}", path, var6); + } + }); -+ // Leaf end - Async playerdata saving ++ // Leaf end - Async player IO } public Optional getIconFile() { +@@ -645,6 +654,7 @@ public class LevelStorageSource { + + @Override + public void close() throws IOException { ++ org.dreeam.leaf.async.storage.AsyncPlayerDataSaving.INSTANCE.saveLevelData(this.levelDirectory.path(), null); // Leaf - Async player IO + this.lock.close(); + } + diff --git a/net/minecraft/world/level/storage/PlayerDataStorage.java b/net/minecraft/world/level/storage/PlayerDataStorage.java -index c44110b123ba5912af18faf0065e9ded780da9b7..fd8b4832c8b4a52bd8f9b3ea59111af85127b573 100644 +index c44110b123ba5912af18faf0065e9ded780da9b7..2eae5ccb37b942b94964c28391b96989ae85b072 100644 --- a/net/minecraft/world/level/storage/PlayerDataStorage.java +++ b/net/minecraft/world/level/storage/PlayerDataStorage.java -@@ -25,6 +25,7 @@ public class PlayerDataStorage { - private final File playerDir; - protected final DataFixer fixerUpper; - private static final DateTimeFormatter FORMATTER = FileNameDateFormatter.create(); -+ private final java.util.Map> savingLocks = new it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap<>(); // Leaf - Async playerdata saving - - public PlayerDataStorage(LevelStorageSource.LevelStorageAccess levelStorageAccess, DataFixer fixerUpper) { - this.fixerUpper = fixerUpper; -@@ -34,19 +35,82 @@ public class PlayerDataStorage { +@@ -34,19 +34,37 @@ public class PlayerDataStorage { public void save(Player player) { if (org.spigotmc.SpigotConfig.disablePlayerDataSaving) return; // Spigot -+ // Leaf start - Async playerdata saving ++ // Leaf start - Async player IO + CompoundTag compoundTag; try { - CompoundTag compoundTag = player.saveWithoutId(new CompoundTag()); @@ -77,105 +273,60 @@ index c44110b123ba5912af18faf0065e9ded780da9b7..fd8b4832c8b4a52bd8f9b3ea59111af8 + return; } + save(player.getScoreboardName(), player.getUUID(), player.getStringUUID(), compoundTag); -+ // Leaf end - Async playerdata saving ++ // Leaf end - Async player IO } -+ // Leaf start - Async playerdata saving -+ public void save(String playerName, java.util.UUID uniqueId, String stringId, CompoundTag compoundTag) { -+ var nbtBytes = new it.unimi.dsi.fastutil.io.FastByteArrayOutputStream(65536); -+ try { -+ NbtIo.writeCompressed(compoundTag, nbtBytes); -+ } catch (Exception exception) { -+ LOGGER.warn("Failed to encode player data for {}", stringId, exception); -+ } -+ lockFor(uniqueId, playerName); -+ synchronized (PlayerDataStorage.this) { -+ org.dreeam.leaf.async.AsyncPlayerDataSaving.submit(() -> { -+ try { -+ Path path = this.playerDir.toPath(); -+ Path path1 = Files.createTempFile(path, stringId + "-", ".dat"); -+ org.apache.commons.io.FileUtils.writeByteArrayToFile(path1.toFile(), nbtBytes.array, 0, nbtBytes.length, false); -+ Path path2 = path.resolve(stringId + ".dat"); -+ Path path3 = path.resolve(stringId + ".dat_old"); -+ Util.safeReplaceFile(path2, path1, path3); -+ } catch (Exception var7) { -+ LOGGER.warn("Failed to save player data for {}", playerName, var7); -+ } finally { -+ synchronized (PlayerDataStorage.this) { -+ savingLocks.remove(uniqueId); -+ } -+ } -+ }).ifPresent(future -> savingLocks.put(uniqueId, future)); -+ } -+ } ++ // Leaf start - Async player IO ++ public void save(String playerName, java.util.UUID uuid, String stringId, CompoundTag compoundTag) { ++ final File playerDir = this.playerDir; ++ org.dreeam.leaf.async.storage.AsyncPlayerDataSaving.INSTANCE.submitEntity( ++ uuid, ++ playerName, ++ () -> { ++ var nbtBytes = new it.unimi.dsi.fastutil.io.FastByteArrayOutputStream(65536); ++ NbtIo.writeCompressed(compoundTag, nbtBytes); ++ Path path = playerDir.toPath(); + -+ private void lockFor(java.util.UUID uniqueId, String playerName) { -+ java.util.concurrent.Future fut; -+ synchronized (this) { -+ fut = savingLocks.get(uniqueId); -+ } -+ if (fut == null) { -+ return; -+ } -+ while (true) { -+ try { -+ fut.get(10_000L, java.util.concurrent.TimeUnit.MILLISECONDS); -+ break; -+ } catch (InterruptedException ignored) { -+ } catch (java.util.concurrent.ExecutionException -+ | java.util.concurrent.TimeoutException exception) { -+ LOGGER.warn("Failed to save player data for {}", playerName, exception); -+ -+ String threadDump = ""; -+ var threadMXBean = java.lang.management.ManagementFactory.getThreadMXBean(); -+ for (var threadInfo : threadMXBean.dumpAllThreads(true, true)) { -+ if (threadInfo.getThreadName().equals("Leaf IO Thread")) { -+ threadDump = threadInfo.toString(); -+ break; -+ } -+ } -+ LOGGER.warn(threadDump); -+ fut.cancel(true); -+ break; -+ } finally { -+ savingLocks.remove(uniqueId); -+ } -+ } ++ Path current = path.resolve(stringId + ".dat"); ++ Path old = path.resolve(stringId + ".dat_old"); ++ org.dreeam.leaf.async.storage.AsyncPlayerDataSaving.safeReplaceBackup(current, old, nbtBytes.array, 0, nbtBytes.length); ++ return null; ++ }); + } -+ // Leaf end - Async playerdata saving ++ // Leaf end - Async player IO + private void backup(String name, String stringUuid, String suffix) { // CraftBukkit Path path = this.playerDir.toPath(); Path path1 = path.resolve(stringUuid + suffix); // CraftBukkit -@@ -60,7 +124,13 @@ public class PlayerDataStorage { +@@ -60,7 +78,13 @@ public class PlayerDataStorage { } } - private Optional load(String name, String stringUuid, String suffix) { // CraftBukkit -+ // Leaf start - Async playerdata saving ++ // Leaf start - Async player IO + private Optional load(String name, String stringUuid, String suffix) { + return load(name, stringUuid, suffix, java.util.UUID.fromString(stringUuid)); + } + private Optional load(String name, String stringUuid, String suffix, java.util.UUID playerUuid) { // CraftBukkit -+ lockFor(playerUuid, name); -+ // Leaf end - Async playerdata saving ++ org.dreeam.leaf.async.storage.AsyncPlayerDataSaving.INSTANCE.blockEntity(playerUuid, name); ++ // Leaf end - Async player IO File file = new File(this.playerDir, stringUuid + suffix); // CraftBukkit // Spigot start boolean usingWrongFile = false; -@@ -91,7 +161,7 @@ public class PlayerDataStorage { +@@ -91,7 +115,7 @@ public class PlayerDataStorage { public Optional load(Player player) { // CraftBukkit start - return this.load(player.getName().getString(), player.getStringUUID()).map((tag) -> { -+ return this.load(player.getName().getString(), player.getStringUUID(), player.getUUID()).map((tag) -> { // Leaf - Async playerdata saving ++ return this.load(player.getName().getString(), player.getStringUUID(), player.getUUID()).map((tag) -> { // Leaf - Async player IO if (player instanceof ServerPlayer serverPlayer) { CraftPlayer craftPlayer = serverPlayer.getBukkitEntity(); // Only update first played if it is older than the one we have -@@ -106,20 +176,25 @@ public class PlayerDataStorage { +@@ -106,20 +130,25 @@ public class PlayerDataStorage { }); } -+ // Leaf start - Async playerdata saving ++ // Leaf start - Async player IO public Optional load(String name, String uuid) { + return this.load(name, uuid, java.util.UUID.fromString(uuid)); + } @@ -195,7 +346,7 @@ index c44110b123ba5912af18faf0065e9ded780da9b7..fd8b4832c8b4a52bd8f9b3ea59111af8 return compoundTag; }); } -+ // Leaf end - Async playerdata saving ++ // Leaf end - Async player IO // CraftBukkit start public File getPlayerDir() { diff --git a/leaf-server/minecraft-patches/features/0110-SparklyPaper-Skip-dirty-stats-copy-when-requesting-p.patch b/leaf-server/minecraft-patches/features/0110-SparklyPaper-Skip-dirty-stats-copy-when-requesting-p.patch index ea9f51d9..9b461562 100644 --- a/leaf-server/minecraft-patches/features/0110-SparklyPaper-Skip-dirty-stats-copy-when-requesting-p.patch +++ b/leaf-server/minecraft-patches/features/0110-SparklyPaper-Skip-dirty-stats-copy-when-requesting-p.patch @@ -6,10 +6,10 @@ Subject: [PATCH] SparklyPaper: Skip dirty stats copy when requesting player diff --git a/net/minecraft/stats/ServerStatsCounter.java b/net/minecraft/stats/ServerStatsCounter.java -index b26dbe807e5cb0a42f6c06b933397902310e5616..ce89060bd01b253af7577fd0e6c03fc95f046b91 100644 +index 35ad7f249cfb6f5c779136d96f3698ea4de1eb7c..523dc12a8866a199eac1b2f418bf206f068ba80c 100644 --- a/net/minecraft/stats/ServerStatsCounter.java +++ b/net/minecraft/stats/ServerStatsCounter.java -@@ -81,11 +81,15 @@ public class ServerStatsCounter extends StatsCounter { +@@ -118,11 +118,15 @@ public class ServerStatsCounter extends StatsCounter { this.dirty.add(stat); } @@ -25,7 +25,7 @@ index b26dbe807e5cb0a42f6c06b933397902310e5616..ce89060bd01b253af7577fd0e6c03fc9 public void parseLocal(DataFixer fixerUpper, String json) { try { -@@ -194,10 +198,12 @@ public class ServerStatsCounter extends StatsCounter { +@@ -231,10 +235,12 @@ public class ServerStatsCounter extends StatsCounter { public void sendStats(ServerPlayer player) { Object2IntMap> map = new Object2IntOpenHashMap<>(); diff --git a/leaf-server/minecraft-patches/features/0132-SparklyPaper-Parallel-world-ticking.patch b/leaf-server/minecraft-patches/features/0132-SparklyPaper-Parallel-world-ticking.patch index 9f02149e..71743a1a 100644 --- a/leaf-server/minecraft-patches/features/0132-SparklyPaper-Parallel-world-ticking.patch +++ b/leaf-server/minecraft-patches/features/0132-SparklyPaper-Parallel-world-ticking.patch @@ -458,7 +458,7 @@ index 63ff20f467c7508486a8f274442269b90faea108..15de8904a43c0ee1e6d55d511ebd84df } // CraftBukkit end diff --git a/net/minecraft/server/PlayerAdvancements.java b/net/minecraft/server/PlayerAdvancements.java -index d2159a747fe42aa95cfc6bca0e55e3f4485847bb..abe7ffd48766c48fab091947f34db436b3c883d0 100644 +index d5196b181a0a633cb04ce18b0471cda2dcaa8816..46433f0e6b37d31ec5468b3f4a5b2524d3cb29ed 100644 --- a/net/minecraft/server/PlayerAdvancements.java +++ b/net/minecraft/server/PlayerAdvancements.java @@ -53,8 +53,11 @@ public class PlayerAdvancements { @@ -483,7 +483,7 @@ index d2159a747fe42aa95cfc6bca0e55e3f4485847bb..abe7ffd48766c48fab091947f34db436 this.isFirstPacket = true; this.lastSelectedTab = null; this.tree = manager.tree(); -@@ -151,7 +155,7 @@ public class PlayerAdvancements { +@@ -158,7 +162,7 @@ public class PlayerAdvancements { if (org.galemc.gale.configuration.GaleGlobalConfiguration.get().logToConsole.ignoredAdvancements) LOGGER.warn("Ignored advancement '{}' in progress file {} - it doesn't exist anymore?", path, this.playerSavePath); // Gale - Purpur - do not log ignored advancements } else { this.startProgress(advancementHolder, progress); @@ -492,7 +492,7 @@ index d2159a747fe42aa95cfc6bca0e55e3f4485847bb..abe7ffd48766c48fab091947f34db436 this.markForVisibilityUpdate(advancementHolder); } }); -@@ -183,10 +187,12 @@ public class PlayerAdvancements { +@@ -190,10 +194,12 @@ public class PlayerAdvancements { return false; } // Paper end - Add PlayerAdvancementCriterionGrantEvent @@ -509,7 +509,7 @@ index d2159a747fe42aa95cfc6bca0e55e3f4485847bb..abe7ffd48766c48fab091947f34db436 // Paper start - Add Adventure message to PlayerAdvancementDoneEvent final net.kyori.adventure.text.Component message = advancement.value().display().flatMap(info -> { return java.util.Optional.ofNullable( -@@ -220,12 +226,14 @@ public class PlayerAdvancements { +@@ -227,12 +233,14 @@ public class PlayerAdvancements { AdvancementProgress orStartProgress = this.getOrStartProgress(advancement); boolean isDone = orStartProgress.isDone(); if (orStartProgress.revokeProgress(criterionKey)) { @@ -527,7 +527,7 @@ index d2159a747fe42aa95cfc6bca0e55e3f4485847bb..abe7ffd48766c48fab091947f34db436 this.markForVisibilityUpdate(advancement); } -@@ -271,7 +279,10 @@ public class PlayerAdvancements { +@@ -278,7 +286,10 @@ public class PlayerAdvancements { } public void flushDirty(ServerPlayer serverPlayer) { @@ -539,7 +539,7 @@ index d2159a747fe42aa95cfc6bca0e55e3f4485847bb..abe7ffd48766c48fab091947f34db436 Map map = new HashMap<>(); Set set = new java.util.TreeSet<>(java.util.Comparator.comparing(adv -> adv.id().toString())); // Paper - Changed from HashSet to TreeSet ordered alphabetically. Set set1 = new HashSet<>(); -@@ -279,16 +290,24 @@ public class PlayerAdvancements { +@@ -286,16 +297,24 @@ public class PlayerAdvancements { for (AdvancementNode advancementNode : this.rootsToUpdate) { this.updateTreeVisibility(advancementNode, set, set1); } @@ -568,7 +568,7 @@ index d2159a747fe42aa95cfc6bca0e55e3f4485847bb..abe7ffd48766c48fab091947f34db436 if (!map.isEmpty() || !set.isEmpty() || !set1.isEmpty()) { serverPlayer.connection.send(new ClientboundUpdateAdvancementsPacket(this.isFirstPacket, set, set1, map)); } -@@ -331,10 +350,13 @@ public class PlayerAdvancements { +@@ -338,10 +357,13 @@ public class PlayerAdvancements { AdvancementHolder advancementHolder = node.holder(); if (visible) { if (this.visible.add(advancementHolder)) { @@ -900,7 +900,7 @@ index 75fb49f1596f475278d12c8c7aea9ad4952b6056..de601491b7ecb83f1bb64a95989d6ed4 player.isRealPlayer = true; // Paper player.loginTime = System.currentTimeMillis(); // Paper - Replace OfflinePlayer#getLastPlayed GameProfile gameProfile = player.getGameProfile(); -@@ -891,6 +893,15 @@ public abstract class PlayerList { +@@ -933,6 +935,15 @@ public abstract class PlayerList { return this.respawn(player, keepInventory, reason, eventReason, null); } public ServerPlayer respawn(ServerPlayer player, boolean keepInventory, Entity.RemovalReason reason, org.bukkit.event.player.PlayerRespawnEvent.RespawnReason eventReason, org.bukkit.Location location) { @@ -916,7 +916,7 @@ index 75fb49f1596f475278d12c8c7aea9ad4952b6056..de601491b7ecb83f1bb64a95989d6ed4 player.stopRiding(); // CraftBukkit this.players.remove(player); this.playersByName.remove(player.getScoreboardName().toLowerCase(java.util.Locale.ROOT)); // Spigot -@@ -902,6 +913,7 @@ public abstract class PlayerList { +@@ -944,6 +955,7 @@ public abstract class PlayerList { ServerPlayer serverPlayer = player; Level fromWorld = player.level(); player.wonGame = false; diff --git a/leaf-server/minecraft-patches/features/0163-Async-switch-connection-state.patch b/leaf-server/minecraft-patches/features/0163-Async-switch-connection-state.patch index 16dfdbb5..1cd528ac 100644 --- a/leaf-server/minecraft-patches/features/0163-Async-switch-connection-state.patch +++ b/leaf-server/minecraft-patches/features/0163-Async-switch-connection-state.patch @@ -110,10 +110,10 @@ index 2e9eb04c7c4342393c05339906c267bca9ff29b1..53b9daa909c2b89046d5af515e17afe0 try { PlayerList playerList = this.server.getPlayerList(); diff --git a/net/minecraft/server/network/ServerLoginPacketListenerImpl.java b/net/minecraft/server/network/ServerLoginPacketListenerImpl.java -index 114b25f933c6a1b011523581a5a02a5a2c1e827e..5907f1c75002be5e2ef1f9875974e665f964db7a 100644 +index 3da6dad3dd0f4c5750609b382f47a6cd14f18e7a..c1e4ea2f28aba688b5b61e5bea2c295e9f219aba 100644 --- a/net/minecraft/server/network/ServerLoginPacketListenerImpl.java +++ b/net/minecraft/server/network/ServerLoginPacketListenerImpl.java -@@ -494,11 +494,31 @@ public class ServerLoginPacketListenerImpl implements ServerLoginPacketListener, +@@ -495,11 +495,31 @@ public class ServerLoginPacketListenerImpl implements ServerLoginPacketListener, this.disconnect(ServerCommonPacketListenerImpl.DISCONNECT_UNEXPECTED_QUERY); } diff --git a/leaf-server/paper-patches/features/0033-Async-playerdata-saving.patch b/leaf-server/paper-patches/features/0033-Async-playerdata-saving.patch index 8eae877f..5c6eb13d 100644 --- a/leaf-server/paper-patches/features/0033-Async-playerdata-saving.patch +++ b/leaf-server/paper-patches/features/0033-Async-playerdata-saving.patch @@ -5,7 +5,7 @@ Subject: [PATCH] Async playerdata saving diff --git a/src/main/java/org/bukkit/craftbukkit/CraftOfflinePlayer.java b/src/main/java/org/bukkit/craftbukkit/CraftOfflinePlayer.java -index f2d87c12dd19210ce7e2147fada5c10191008632..14da4c731391f69fef104b6b3b7f2f977fe5ee95 100644 +index f2d87c12dd19210ce7e2147fada5c10191008632..ad66046d31c24ba2a7d2b115f6c70adb95b9735b 100644 --- a/src/main/java/org/bukkit/craftbukkit/CraftOfflinePlayer.java +++ b/src/main/java/org/bukkit/craftbukkit/CraftOfflinePlayer.java @@ -207,7 +207,7 @@ public class CraftOfflinePlayer implements OfflinePlayer, ConfigurationSerializa @@ -13,7 +13,7 @@ index f2d87c12dd19210ce7e2147fada5c10191008632..14da4c731391f69fef104b6b3b7f2f97 private CompoundTag getData() { - return this.storage.load(this.profile.getName(), this.profile.getId().toString()).orElse(null); -+ return this.storage.load(this.profile.getName(), this.profile.getId().toString(), this.profile.getId()).orElse(null); // Leaf - Async playerdata saving ++ return this.storage.load(this.profile.getName(), this.profile.getId().toString(), this.profile.getId()).orElse(null); // Leaf - Async player IO } private CompoundTag getBukkitData() { @@ -31,7 +31,7 @@ index f2d87c12dd19210ce7e2147fada5c10191008632..14da4c731391f69fef104b6b3b7f2f97 - } catch (java.io.IOException e) { - e.printStackTrace(); - } -+ server.console.playerDataStorage.save(this.getName(), this.getUniqueId(), this.getUniqueId().toString(), compoundTag); // Leaf - Async playerdata saving ++ server.console.playerDataStorage.save(this.getName(), this.getUniqueId(), this.getUniqueId().toString(), compoundTag); // Leaf - Async player IO } // Purpur end - OfflinePlayer API } diff --git a/leaf-server/src/main/java/org/dreeam/leaf/async/AsyncPlayerDataSaving.java b/leaf-server/src/main/java/org/dreeam/leaf/async/AsyncPlayerDataSaving.java deleted file mode 100644 index 3df32778..00000000 --- a/leaf-server/src/main/java/org/dreeam/leaf/async/AsyncPlayerDataSaving.java +++ /dev/null @@ -1,37 +0,0 @@ -package org.dreeam.leaf.async; - -import net.minecraft.Util; -import org.dreeam.leaf.config.modules.async.AsyncPlayerDataSave; - -import java.util.Optional; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Future; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; - -public class AsyncPlayerDataSaving { - - public static final ExecutorService IO_POOL = new ThreadPoolExecutor( - 1, 1, 0L, TimeUnit.MILLISECONDS, - new LinkedBlockingQueue<>(), - new com.google.common.util.concurrent.ThreadFactoryBuilder() - .setPriority(Thread.NORM_PRIORITY - 2) - .setNameFormat("Leaf IO Thread") - .setUncaughtExceptionHandler(Util::onThreadException) - .build(), - new ThreadPoolExecutor.DiscardPolicy() - ); - - private AsyncPlayerDataSaving() { - } - - public static Optional> submit(Runnable runnable) { - if (!AsyncPlayerDataSave.enabled) { - runnable.run(); - return Optional.empty(); - } else { - return Optional.of(IO_POOL.submit(runnable)); - } - } -} diff --git a/leaf-server/src/main/java/org/dreeam/leaf/async/ShutdownExecutors.java b/leaf-server/src/main/java/org/dreeam/leaf/async/ShutdownExecutors.java index ee6d490d..df2d6ef6 100644 --- a/leaf-server/src/main/java/org/dreeam/leaf/async/ShutdownExecutors.java +++ b/leaf-server/src/main/java/org/dreeam/leaf/async/ShutdownExecutors.java @@ -1,6 +1,7 @@ package org.dreeam.leaf.async; import net.minecraft.server.MinecraftServer; +import org.dreeam.leaf.async.storage.AsyncPlayerDataSaving; import org.dreeam.leaf.async.tracker.MultithreadedTracker; public class ShutdownExecutors { diff --git a/leaf-server/src/main/java/org/dreeam/leaf/async/storage/AsyncPlayerDataSaving.java b/leaf-server/src/main/java/org/dreeam/leaf/async/storage/AsyncPlayerDataSaving.java new file mode 100644 index 00000000..7e5060d7 --- /dev/null +++ b/leaf-server/src/main/java/org/dreeam/leaf/async/storage/AsyncPlayerDataSaving.java @@ -0,0 +1,307 @@ +package org.dreeam.leaf.async.storage; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import it.unimi.dsi.fastutil.objects.Object2ObjectMap; +import it.unimi.dsi.fastutil.objects.Object2ObjectMaps; +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; +import net.minecraft.Util; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.dreeam.leaf.config.modules.async.AsyncPlayerDataSave; +import org.jetbrains.annotations.Nullable; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.format.SignStyle; +import java.time.temporal.ChronoField; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.*; + +public class AsyncPlayerDataSaving { + public static final AsyncPlayerDataSaving INSTANCE = new AsyncPlayerDataSaving(); + private static final Logger LOGGER = LogManager.getLogger("Leaf Async Player IO"); + public static ExecutorService IO_POOL = null; + private static final DateTimeFormatter FORMATTER = new DateTimeFormatterBuilder() + .appendValue(ChronoField.YEAR, 4, 10, SignStyle.EXCEEDS_PAD) + .appendValue(ChronoField.MONTH_OF_YEAR, 2) + .appendValue(ChronoField.DAY_OF_MONTH, 2) + .appendValue(ChronoField.HOUR_OF_DAY, 2) + .appendValue(ChronoField.MINUTE_OF_HOUR, 2) + .appendValue(ChronoField.SECOND_OF_MINUTE, 2) + .appendValue(ChronoField.NANO_OF_SECOND, 9) + .toFormatter(); + + private record SaveTask(Ty ty, Callable callable, String name, UUID uuid) implements Runnable { + @Override + public void run() { + try { + callable.call(); + } catch (Exception e) { + LOGGER.error("Failed to save player {} data for {}", ty, name, e); + } finally { + switch (ty) { + case ENTITY -> INSTANCE.entityFut.remove(uuid); + case STATS -> INSTANCE.statsFut.remove(uuid); + case ADVANCEMENTS -> INSTANCE.advancementsFut.remove(uuid); + } + } + } + } + + private enum Ty { + ENTITY, + STATS, + ADVANCEMENTS, + } + + // use same lock + private final Object2ObjectMap> entityFut = Object2ObjectMaps.synchronize(new Object2ObjectOpenHashMap<>(), this); + private final Object2ObjectMap> statsFut = Object2ObjectMaps.synchronize(new Object2ObjectOpenHashMap<>(), this); + private final Object2ObjectMap> advancementsFut = Object2ObjectMaps.synchronize(new Object2ObjectOpenHashMap<>(), this); + + private final Object2ObjectMap> levelDatFut = Object2ObjectMaps.synchronize(new Object2ObjectOpenHashMap<>(), this); + + private AsyncPlayerDataSaving() { + } + + public static void init() { + if (AsyncPlayerDataSaving.IO_POOL != null) { + throw new IllegalStateException("Already initialized"); + } + AsyncPlayerDataSaving.IO_POOL = new ThreadPoolExecutor( + 1, 1, 0L, TimeUnit.MILLISECONDS, + new LinkedBlockingQueue<>(), + new ThreadFactoryBuilder() + .setPriority(Thread.NORM_PRIORITY - 2) + .setNameFormat("Leaf Async Player IO Thread") + .setUncaughtExceptionHandler(Util::onThreadException) + .build(), + new ThreadPoolExecutor.DiscardPolicy() + ); + } + + + public void saveLevelData(Path path, @Nullable Runnable runnable) { + if (!AsyncPlayerDataSave.enabled) { + if (runnable != null) { + runnable.run(); + } + return; + } + var fut = levelDatFut.get(path); + if (fut != null) { + try { + while (true) { + try { + fut.get(); + break; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } catch (ExecutionException e) { + LOGGER.error("Failed to save level.dat for {}", path, e); + } finally { + levelDatFut.remove(path); + } + } + if (runnable != null) { + levelDatFut.put(path, IO_POOL.submit(() -> { + try { + runnable.run(); + } catch (Exception e) { + LOGGER.error(e); + } finally { + levelDatFut.remove(path); + } + })); + } + } + + public boolean isSaving(UUID uuid) { + var entity = entityFut.get(uuid); + var advancements = advancementsFut.get(uuid); + var stats = statsFut.get(uuid); + return entity != null || advancements != null || stats != null; + } + + public void submitStats(UUID uuid, String playerName, Callable callable) { + submit(Ty.STATS, uuid, playerName, callable); + } + + public void submitEntity(UUID uuid, String playerName, Callable callable) { + submit(Ty.ENTITY, uuid, playerName, callable); + } + + public void submitAdvancements(UUID uuid, String playerName, Callable callable) { + submit(Ty.ADVANCEMENTS, uuid, playerName, callable); + } + + private void submit(Ty type, UUID uuid, String playerName, Callable callable) { + if (!AsyncPlayerDataSave.enabled) { + try { + callable.call(); + } catch (Exception e) { + LOGGER.error("Failed to save player {} data for {}", type, playerName, e); + } + return; + } + block(type, uuid, playerName); + var fut = IO_POOL.submit(new SaveTask(type, callable, playerName, uuid)); + switch (type) { + case ENTITY -> entityFut.put(uuid, fut); + case ADVANCEMENTS -> advancementsFut.put(uuid, fut); + case STATS -> statsFut.put(uuid, fut); + } + } + + public void blockStats(UUID uuid, String playerName) { + block(Ty.STATS, uuid, playerName); + } + + public void blockEntity(UUID uuid, String playerName) { + block(Ty.ENTITY, uuid, playerName); + } + + public void blockAdvancements(UUID uuid, String playerName) { + block(Ty.ADVANCEMENTS, uuid, playerName); + } + + private void block(Ty type, UUID uuid, String playerName) { + if (!AsyncPlayerDataSave.enabled) { + return; + } + + Future fut = switch (type) { + case ENTITY -> entityFut.get(uuid); + case ADVANCEMENTS -> advancementsFut.get(uuid); + case STATS -> statsFut.get(uuid); + }; + if (fut == null) { + return; + } + try { + while (true) { + try { + fut.get(); + break; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } catch (ExecutionException exception) { + LOGGER.warn("Failed to save player {} data for {}", type, playerName, exception); + fut.cancel(true); + } finally { + switch (type) { + case ENTITY -> entityFut.remove(uuid); + case ADVANCEMENTS -> advancementsFut.remove(uuid); + case STATS -> statsFut.remove(uuid); + } + } + } + + private static final StandardCopyOption[] ATOMIC_MOVE = new StandardCopyOption[]{StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING}; + private static final StandardCopyOption[] NO_ATOMIC_MOVE = new StandardCopyOption[]{StandardCopyOption.REPLACE_EXISTING}; + + public static void safeReplace(Path current, String content) { + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + safeReplace(current, bytes, 0, bytes.length); + } + + @SuppressWarnings("unused") + public static void safeReplaceBackup(Path current, Path old, String content) { + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + safeReplaceBackup(current, old, bytes, 0, bytes.length); + } + + public static void safeReplace(Path current, byte[] bytes, int offset, int length) { + File latest = writeTempFile(current, bytes, offset, length); + Objects.requireNonNull(latest); + for (int i = 1; i <= 10; i++) { + try { + try { + Files.move(latest.toPath(), current, ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(latest.toPath(), current, NO_ATOMIC_MOVE); + } + break; + } catch (IOException e) { + LOGGER.error("Failed move {} to {} retries ({} / 10)", latest, current, i, e); + } + } + } + + public static void safeReplaceBackup(Path current, Path backup, byte[] bytes, int offset, int length) { + File latest = writeTempFile(current, bytes, offset, length); + Objects.requireNonNull(latest); + for (int i = 1; i <= 10; i++) { + try { + try { + Files.move(current, backup, ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(current, backup, NO_ATOMIC_MOVE); + } + break; + } catch (IOException e) { + LOGGER.error("Failed move {} to {} retries ({} / 10)", current, backup, i, e); + } + } + for (int i = 1; i <= 10; i++) { + try { + try { + Files.move(latest.toPath(), current, ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(latest.toPath(), current, NO_ATOMIC_MOVE); + } + break; + } catch (IOException e) { + LOGGER.error("Failed move {} to {} retries ({} / 10)", latest, current, i, e); + } + } + } + + private static File writeTempFile(Path current, byte[] bytes, int offset, int length) { + Path dir = current.getParent(); + for (int i = 1; i <= 10; i++) { + File temp = null; + try { + if (!dir.toFile().isDirectory()) { + Files.createDirectories(dir); + } + temp = tempFileDateTime(current).toFile(); + if (temp.exists()) { + throw new FileAlreadyExistsException(temp.getPath()); + } + // sync content and metadata to device + try (RandomAccessFile stream = new RandomAccessFile(temp, "rws")) { + stream.write(bytes, offset, length); + } + return temp; + } catch (IOException e) { + LOGGER.error("Failed write {} retries ({} / 10)", temp == null ? current : temp, i, e); + } + } + return null; + } + + private static Path tempFileDateTime(Path path) { + String now = LocalDateTime.now().format(FORMATTER); + String last = path.getFileName().toString(); + int dot = last.lastIndexOf('.'); + + String base = (dot == -1) ? last : last.substring(0, dot); + String ext = (dot == -1) ? "" : last.substring(dot); + + String newExt = switch (ext) { + case ".json", ".dat" -> ext; + default -> ".temp"; + }; + return path.resolveSibling(base + "-" + now + newExt); + } +} diff --git a/leaf-server/src/main/java/org/dreeam/leaf/config/modules/async/AsyncPlayerDataSave.java b/leaf-server/src/main/java/org/dreeam/leaf/config/modules/async/AsyncPlayerDataSave.java index 6d555ce0..e8ac71f7 100644 --- a/leaf-server/src/main/java/org/dreeam/leaf/config/modules/async/AsyncPlayerDataSave.java +++ b/leaf-server/src/main/java/org/dreeam/leaf/config/modules/async/AsyncPlayerDataSave.java @@ -1,7 +1,9 @@ package org.dreeam.leaf.config.modules.async; +import org.dreeam.leaf.async.storage.AsyncPlayerDataSaving; import org.dreeam.leaf.config.ConfigModules; import org.dreeam.leaf.config.EnumConfigCategory; +import org.dreeam.leaf.config.annotations.Experimental; public class AsyncPlayerDataSave extends ConfigModules { @@ -9,7 +11,9 @@ public class AsyncPlayerDataSave extends ConfigModules { return EnumConfigCategory.ASYNC.getBaseKeyName() + ".async-playerdata-save"; } + @Experimental public static boolean enabled = false; + private static boolean asyncPlayerDataSaveInitialized; @Override public void onLoaded() { @@ -18,6 +22,13 @@ public class AsyncPlayerDataSave extends ConfigModules { """ 异步保存玩家数据."""); + if (asyncPlayerDataSaveInitialized) { + config.getConfigSection(getBasePath()); + return; + } + asyncPlayerDataSaveInitialized = true; + enabled = config.getBoolean(getBasePath() + ".enabled", enabled); + if (enabled) AsyncPlayerDataSaving.init(); } } diff --git a/leaf-server/src/main/java/org/leavesmc/leaves/bot/BotStatsCounter.java b/leaf-server/src/main/java/org/leavesmc/leaves/bot/BotStatsCounter.java index 10494446..6dfb82e4 100644 --- a/leaf-server/src/main/java/org/leavesmc/leaves/bot/BotStatsCounter.java +++ b/leaf-server/src/main/java/org/leavesmc/leaves/bot/BotStatsCounter.java @@ -14,7 +14,7 @@ public class BotStatsCounter extends ServerStatsCounter { private static final File UNKOWN_FILE = new File("BOT_STATS_REMOVE_THIS"); public BotStatsCounter(MinecraftServer server) { - super(server, UNKOWN_FILE); + super(server, UNKOWN_FILE, "", net.minecraft.Util.NIL_UUID); // Leaf } @Override