diff --git a/AreaShop/src/main/java/me/wiefferink/areashop/AreaShop.java b/AreaShop/src/main/java/me/wiefferink/areashop/AreaShop.java index 505a5be4..33feada6 100644 --- a/AreaShop/src/main/java/me/wiefferink/areashop/AreaShop.java +++ b/AreaShop/src/main/java/me/wiefferink/areashop/AreaShop.java @@ -16,9 +16,10 @@ import me.wiefferink.areashop.interfaces.WorldEditInterface; import me.wiefferink.areashop.interfaces.WorldGuardInterface; import me.wiefferink.areashop.listeners.PlayerLoginLogoutListener; +import me.wiefferink.areashop.managers.CacheManager; import me.wiefferink.areashop.managers.FeatureManager; -import me.wiefferink.areashop.managers.IFileManager; import me.wiefferink.areashop.managers.FileManager; +import me.wiefferink.areashop.managers.IFileManager; import me.wiefferink.areashop.managers.Manager; import me.wiefferink.areashop.managers.SignErrorLogger; import me.wiefferink.areashop.managers.SignLinkerManager; @@ -56,6 +57,7 @@ import javax.annotation.Nonnull; import java.io.File; import java.io.IOException; +import java.time.Duration; import java.util.HashSet; import java.util.List; import java.util.Optional; @@ -81,6 +83,7 @@ public final class AreaShop extends JavaPlugin implements AreaShopApi { private MessageBridge messageBridge; private IFileManager fileManager = null; private LanguageManager languageManager = null; + private CacheManager cacheManager = null; private SignLinkerManager signLinkerManager = null; private FeatureManager featureManager = null; private SignManager signManager; @@ -285,11 +288,17 @@ public void onEnable() { managers.add(featureManager); signManager = injector.getInstance(SignManager.class); managers.add(signManager); + cacheManager = injector.getInstance(CacheManager.class); + String rawExpiryDuration = fileManager.getConfig().getString("cacheExpiryDuration", "7d"); + long millis = Utils.durationStringToLong(rawExpiryDuration); + cacheManager.initialize(new File(getDataFolder(), "uuid-cache.bin"), Duration.ofMillis(millis)); + cacheManager.loadCache(); + managers.add(cacheManager); loadExtensions(); // Register the event listeners - getServer().getPluginManager().registerEvents(new PlayerLoginLogoutListener(this, messageBridge), this); + getServer().getPluginManager().registerEvents(new PlayerLoginLogoutListener(this, messageBridge, this.cacheManager), this); setupTasks(); @@ -384,6 +393,10 @@ private String cleanVersion(String version) { return version; } + public CacheManager getCacheManager() { + return cacheManager; + } + /** * Called on shutdown or reload of the server. */ diff --git a/AreaShop/src/main/java/me/wiefferink/areashop/listeners/PlayerLoginLogoutListener.java b/AreaShop/src/main/java/me/wiefferink/areashop/listeners/PlayerLoginLogoutListener.java index 7a46ea7e..704732eb 100644 --- a/AreaShop/src/main/java/me/wiefferink/areashop/listeners/PlayerLoginLogoutListener.java +++ b/AreaShop/src/main/java/me/wiefferink/areashop/listeners/PlayerLoginLogoutListener.java @@ -2,10 +2,12 @@ import me.wiefferink.areashop.AreaShop; import me.wiefferink.areashop.MessageBridge; +import me.wiefferink.areashop.managers.CacheManager; import me.wiefferink.areashop.regions.BuyRegion; import me.wiefferink.areashop.regions.GeneralRegion; import me.wiefferink.areashop.regions.RentRegion; import me.wiefferink.areashop.tools.Utils; +import me.wiefferink.areashop.tools.CacheWrapper; import me.wiefferink.bukkitdo.Do; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -27,14 +29,20 @@ public final class PlayerLoginLogoutListener implements Listener { private final AreaShop plugin; private final MessageBridge messageBridge; + private final CacheManager cacheManager; /** * Constructor. * @param plugin The AreaShop plugin */ - public PlayerLoginLogoutListener(@Nonnull AreaShop plugin, @Nonnull MessageBridge messageBridge) { + public PlayerLoginLogoutListener( + @Nonnull AreaShop plugin, + @Nonnull MessageBridge messageBridge, + @Nonnull CacheManager cacheManager + ) { this.plugin = plugin; this.messageBridge = messageBridge; + this.cacheManager = cacheManager; } /** @@ -48,6 +56,8 @@ public void onPlayerLogin(PlayerLoginEvent event) { } final Player player = event.getPlayer(); + this.cacheManager.computeIfAbsent(player.getUniqueId(), uuid -> new CacheWrapper(uuid, player.getName())); + // Schedule task to check for notifications, prevents a lag spike at login Do.syncTimerLater(25, 25, () -> { // Delay until all regions are loaded diff --git a/AreaShop/src/main/java/me/wiefferink/areashop/managers/CacheManager.java b/AreaShop/src/main/java/me/wiefferink/areashop/managers/CacheManager.java new file mode 100644 index 00000000..37dbe931 --- /dev/null +++ b/AreaShop/src/main/java/me/wiefferink/areashop/managers/CacheManager.java @@ -0,0 +1,247 @@ +package me.wiefferink.areashop.managers; + +import jakarta.inject.Inject; +import me.wiefferink.areashop.tools.CacheWrapper; +import org.bukkit.plugin.Plugin; + +import javax.annotation.Nonnull; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import java.util.logging.Level; + +public class CacheManager extends Manager { + + private final Plugin plugin; + private final Map cache; + + private File cacheFile; + private Duration expiryDuration; + + @Inject + CacheManager(@Nonnull Plugin plugin) { + this.plugin = plugin; + this.cache = new HashMap<>(); + } + + + @Override + public void shutdown() { + if (this.expiryDuration != null) { + trimCache(expiryDuration); + } + if (this.cacheFile != null) { + saveCache(cacheFile); + } + } + + public void initialize(@Nonnull File cacheFile, @Nonnull Duration expiryDuration) { + this.cacheFile = cacheFile; + this.expiryDuration = expiryDuration; + } + + public void loadCache() { + if (this.cacheFile != null) { + loadCache(this.cacheFile); + } + } + + /** + * Load the cache from the file + */ + public void loadCache(@Nonnull File cacheFile) { + if (Files.notExists(cacheFile.toPath())) { + this.plugin.getLogger().info("uuid cache file does not exist, loaded 0 entries"); + return; + } + this.cache.clear(); + + try (FileInputStream input = new FileInputStream(cacheFile)) { + byte[] bytes = input.readAllBytes(); + ByteBuffer buffer = ByteBuffer.wrap(bytes).asReadOnlyBuffer(); + while (buffer.hasRemaining()) { + long lastUsed = buffer.getLong(); + long lsb = buffer.getLong(); + long msb = buffer.getLong(); + int stringLen = buffer.getInt(); + byte[] stringBytes = new byte[stringLen]; + buffer.get(stringBytes); + String name = new String(stringBytes, StandardCharsets.UTF_8); + UUID uuid = new UUID(msb, lsb); + CacheWrapper wrapper = new CacheWrapper(uuid, name, lastUsed); + this.cache.put(wrapper.getUuid(), wrapper); + } + this.plugin.getLogger() + .info(String.format("Loaded %d cached uuid name entries", this.cache.size())); + } catch (IOException ex) { + ex.printStackTrace(); + this.plugin.getLogger().warning("Failed to load the uuid cache!"); + } + } + + public void saveCache() { + if (this.cacheFile != null) { + saveCache(this.cacheFile); + } + } + + public void saveCacheAsync() { + if (this.cacheFile != null) { + saveCacheAsync(this.cacheFile); + } + } + + + /** + * Save the cache to the file + */ + public void saveCache(@Nonnull File cacheFile) { + List copy = this.cache.values().stream().map(CacheWrapper::new).toList(); + try { + // Ensure parent directory exists + Path parentDir = cacheFile.toPath().getParent(); + if (parentDir != null) { // parent might be null if cacheFile is relative with no parent + Files.createDirectories(parentDir); + } + byte[] bytes = serialize(copy); + try (FileOutputStream fos = new FileOutputStream(cacheFile)) { + fos.write(bytes); + } + } catch (IOException e) { + plugin.getLogger().log(Level.SEVERE, "Failed to save cache.json", e); + } + } + + public void saveCacheAsync(@Nonnull File cacheFile) { + List copy = this.cache.values().stream().map(CacheWrapper::new).toList(); + CompletableFuture.runAsync(() -> { + try { + // Ensure parent directory exists + Path parentDir = cacheFile.toPath().getParent(); + if (parentDir != null) { // parent might be null if cacheFile is relative with no parent + Files.createDirectories(parentDir); + } + byte[] bytes = serialize(copy); + try (FileOutputStream fos = new FileOutputStream(cacheFile)) { + fos.write(bytes); + } + } catch (IOException e) { + plugin.getLogger().log(Level.SEVERE, "Failed to save cache.json", e); + } + }); + } + + private byte[] serialize(List wrappers) { + ByteArrayOutputStream bos = new ByteArrayOutputStream(256 * wrappers.size()); + for (CacheWrapper wrapper : wrappers) { + ByteBuffer buffer = ByteBuffer.allocate(256); + byte[] stringBytes = wrapper.getName().getBytes(StandardCharsets.UTF_8); + int stringLen = stringBytes.length; + long lsb = wrapper.getUuid().getLeastSignificantBits(); + long msb = wrapper.getUuid().getMostSignificantBits(); + long lastUsed = wrapper.getLastUsed(); + buffer.putLong(lastUsed); + buffer.putLong(lsb); + buffer.putLong(msb); + buffer.putInt(stringLen); + buffer.put(stringBytes); + + buffer.flip(); + + bos.write(buffer.array(), buffer.position(), buffer.remaining()); + } + return bos.toByteArray(); + } + + /** + * Get a cache entry + */ + public CacheWrapper get(UUID uuid) { + return cache.get(uuid); + } + + public CacheWrapper computeIfAbsent(@Nonnull UUID uuid, Function function) { + return this.cache.computeIfAbsent(uuid, function); + } + + /** + * Put a cache entry + */ + public void put(UUID uuid, CacheWrapper wrapper) { + cache.put(uuid, wrapper); + } + + /** + * Remove a cache entry + */ + public CacheWrapper remove(UUID uuid) { + return cache.remove(uuid); + } + + /** + * Check if cache contains a UUID + */ + public boolean contains(UUID uuid) { + return cache.containsKey(uuid); + } + + /** + * Get all cache entries + */ + public Map getAllEntries() { + return new HashMap<>(cache); + } + + /** + * Clear all cache entries + */ + public void clear() { + cache.clear(); + } + + /** + * Get the size of the cache + */ + public int size() { + return cache.size(); + } + + public void trimCache() { + if (this.expiryDuration != null) { + trimCache(this.expiryDuration); + } + } + + /** + * Trim the cache by removing entries older than 1 week + */ + public void trimCache(@Nonnull Duration expiryDuration) { + long expiryMillis = expiryDuration.toMillis(); + long currentTime = System.currentTimeMillis(); + + Iterator> iterator = cache.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + CacheWrapper wrapper = entry.getValue(); + + if (wrapper.getLastUsed() != -1 && (currentTime - wrapper.getLastUsed()) >= expiryMillis) { + iterator.remove(); + } + } + + } +} \ No newline at end of file diff --git a/AreaShop/src/main/java/me/wiefferink/areashop/tools/CacheWrapper.java b/AreaShop/src/main/java/me/wiefferink/areashop/tools/CacheWrapper.java new file mode 100644 index 00000000..ae029189 --- /dev/null +++ b/AreaShop/src/main/java/me/wiefferink/areashop/tools/CacheWrapper.java @@ -0,0 +1,67 @@ +package me.wiefferink.areashop.tools; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nonnull; +import java.util.Objects; +import java.util.UUID; + +public class CacheWrapper { + + private static final Logger log = LoggerFactory.getLogger(CacheWrapper.class); + private final UUID uuid; + private final String name; + private long lastUsed; + + public CacheWrapper(CacheWrapper other) { + this.uuid = other.uuid; + this.name = other.name; + this.lastUsed = other.lastUsed; + } + + public CacheWrapper(@Nonnull UUID uuid, @Nonnull String name) { + this(uuid, name, -1); + } + + public CacheWrapper(@Nonnull UUID uuid, @Nonnull String name, long lastUsed) { + this.uuid = uuid; + this.name = name; + this.lastUsed = lastUsed; + } + + public UUID getUuid() { + return this.uuid; + } + + public String getName() { + this.lastUsed = System.currentTimeMillis(); + return name; + } + + public long getLastUsed() { + return lastUsed; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + CacheWrapper that = (CacheWrapper) o; + return lastUsed == that.lastUsed && Objects.equals(uuid, + that.uuid) && Objects.equals(name, that.name); + } + + @Override + public int hashCode() { + return Objects.hash(uuid, name, lastUsed); + } + + @Override + public String toString() { + return "CacheWrapper{" + + "uuid=" + uuid + + ", name='" + name + '\'' + + ", lastUsed=" + lastUsed + + '}'; + } +} diff --git a/AreaShop/src/main/java/me/wiefferink/areashop/tools/Utils.java b/AreaShop/src/main/java/me/wiefferink/areashop/tools/Utils.java index 1a413c66..dddc0c4f 100644 --- a/AreaShop/src/main/java/me/wiefferink/areashop/tools/Utils.java +++ b/AreaShop/src/main/java/me/wiefferink/areashop/tools/Utils.java @@ -6,6 +6,7 @@ import me.wiefferink.areashop.AreaShop; import me.wiefferink.areashop.interfaces.WorldEditSelection; import me.wiefferink.areashop.interfaces.WorldGuardInterface; +import me.wiefferink.areashop.managers.CacheManager; import me.wiefferink.areashop.regions.BuyRegion; import me.wiefferink.areashop.regions.GeneralRegion; import me.wiefferink.areashop.regions.RentRegion; @@ -37,8 +38,6 @@ import java.util.Map; import java.util.Set; import java.util.UUID; -import java.util.function.Function; -import java.util.function.Supplier; public class Utils { @@ -807,10 +806,20 @@ public static String toName(UUID uuid) { if (uuid == null) { return ""; } - String name = Bukkit.getOfflinePlayer(uuid).getName(); + + final CacheManager cacheManager = plugin.getCacheManager(); + + if (cacheManager.contains(uuid)) + return cacheManager.get(uuid).getName(); + + + final String name = Bukkit.getOfflinePlayer(uuid).getName(); + if (name != null) { + cacheManager.put(uuid, new CacheWrapper(uuid, name, System.currentTimeMillis())); return name; } + return ""; } diff --git a/AreaShop/src/main/resources/config.yml b/AreaShop/src/main/resources/config.yml index 6829f2cf..ca626356 100644 --- a/AreaShop/src/main/resources/config.yml +++ b/AreaShop/src/main/resources/config.yml @@ -150,6 +150,7 @@ forceClearEntities: false # Maximum number of locations the teleport function should check to find a safe spot. maximumTries: 50000 +cacheExpiryDuration: '7d' # ┌────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ # │ VARIABLES: Variables that can be used in messages and settings where a region is available. │