diff --git a/src/main/java/org/mvplugins/multiverse/inventories/profile/FlatFileProfileDataSource.java b/src/main/java/org/mvplugins/multiverse/inventories/profile/FlatFileProfileDataSource.java index 1b026183..c8d7460f 100644 --- a/src/main/java/org/mvplugins/multiverse/inventories/profile/FlatFileProfileDataSource.java +++ b/src/main/java/org/mvplugins/multiverse/inventories/profile/FlatFileProfileDataSource.java @@ -9,12 +9,13 @@ import net.minidev.json.parser.ParseException; import org.bukkit.OfflinePlayer; import org.bukkit.configuration.InvalidConfigurationException; +import org.jetbrains.annotations.NotNull; import org.jvnet.hk2.annotations.Service; import org.mvplugins.multiverse.external.jakarta.inject.Inject; +import org.mvplugins.multiverse.external.vavr.control.Option; import org.mvplugins.multiverse.inventories.MultiverseInventories; import org.mvplugins.multiverse.inventories.share.ProfileEntry; import org.mvplugins.multiverse.inventories.share.Sharable; -import org.mvplugins.multiverse.inventories.share.SharableEntry; import org.mvplugins.multiverse.inventories.profile.container.ContainerType; import net.minidev.json.JSONObject; import org.bukkit.Bukkit; @@ -28,12 +29,8 @@ import java.util.LinkedHashMap; import java.util.Map; import java.util.UUID; -import java.util.concurrent.Callable; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; import java.util.logging.Level; @Service @@ -43,13 +40,17 @@ final class FlatFileProfileDataSource implements ProfileDataSource { private final JSONParser JSON_PARSER = new JSONParser(JSONParser.USE_INTEGER_STORAGE | JSONParser.ACCEPT_TAILLING_SPACE); - private final ExecutorService fileIOExecutorService = Executors.newSingleThreadExecutor(); - // TODO these probably need configurable max sizes + private final Cache configCache = CacheBuilder.newBuilder() + .expireAfterAccess(10, TimeUnit.MINUTES) + .maximumSize(1000) + .build(); + private final Cache profileCache = CacheBuilder.newBuilder() .expireAfterAccess(10, TimeUnit.MINUTES) .maximumSize(1000) .build(); + private final Cache globalProfileCache = CacheBuilder.newBuilder() .expireAfterAccess(10, TimeUnit.MINUTES) .maximumSize(500) @@ -59,8 +60,12 @@ final class FlatFileProfileDataSource implements ProfileDataSource { private final File groupFolder; private final File playerFolder; + private final ProfileFileIO profileFileIO; + @Inject - FlatFileProfileDataSource(MultiverseInventories plugin) throws IOException { + FlatFileProfileDataSource(@NotNull MultiverseInventories plugin, @NotNull ProfileFileIO profileFileIO) throws IOException { + this.profileFileIO = profileFileIO; + // Make the data folders plugin.getDataFolder().mkdirs(); @@ -85,57 +90,6 @@ final class FlatFileProfileDataSource implements ProfileDataSource { } } - private FileConfiguration waitForConfigHandle(File file) { - Future future = fileIOExecutorService.submit(new ConfigLoader(file)); - while (true) { - try { - return future.get(); - } catch (InterruptedException | ExecutionException e) { - e.printStackTrace(); - } - } - } - - private static FileConfiguration getConfigHandleNow(File file) throws IOException, InvalidConfigurationException { - JsonConfiguration jsonConfiguration = new JsonConfiguration(); - jsonConfiguration.options().continueOnSerializationError(true); - jsonConfiguration.load(file); - return jsonConfiguration; - } - - private static class ConfigLoader implements Callable { - private final File file; - - private ConfigLoader(File file) { - this.file = file; - } - - @Override - public FileConfiguration call() throws Exception { - return getConfigHandleNow(file); - } - } - - private File getFolder(ContainerType type, String folderName) { - File folder; - switch (type) { - case GROUP: - folder = new File(this.groupFolder, folderName); - break; - case WORLD: - folder = new File(this.worldFolder, folderName); - break; - default: - folder = new File(this.worldFolder, folderName); - break; - } - - if (!folder.exists()) { - folder.mkdirs(); - } - return folder; - } - /** * Retrieves the data file for a player based on a given world/group name, creating it if necessary. * @@ -143,117 +97,100 @@ private File getFolder(ContainerType type, String folderName) { * @param dataName The name of the group or world. * @param playerName The name of the player. * @return The data file for a player. - * @throws IOException if there was a problem creating the file. */ - private File getPlayerFile(ContainerType type, String dataName, String playerName) throws IOException { - return getPlayerFile(type, dataName, playerName, true); - } - - /** - * Retrieves the data file for a player based on a given world/group name, creating it if necessary. - * - * @param type Indicates whether data is for group or world. - * @param dataName The name of the group or world. - * @param playerName The name of the player. - * @return The data file for a player. - * @throws IOException if there was a problem creating the file. - */ - private File getPlayerFile(ContainerType type, String dataName, String playerName, boolean createNew) throws IOException { - File jsonPlayerFile = new File(this.getFolder(type, dataName), playerName + JSON); - if (!jsonPlayerFile.exists()) { - try { - if (createNew) { - jsonPlayerFile.createNewFile(); - } - } catch (IOException e) { - throw new IOException("Could not create necessary player data file: " + jsonPlayerFile.getPath() - + ". Data for " + playerName + " in " + type.name().toLowerCase() + " " + dataName - + " may not be saved.", e); - } - } + private File getPlayerFile(ContainerType type, String dataName, String playerName) { + File jsonPlayerFile = new File(getProfileContainerFolder(type, dataName), playerName + JSON); Logging.finer("got data file: %s. Type: %s, DataName: %s, PlayerName: %s", jsonPlayerFile.getPath(), type, dataName, playerName); return jsonPlayerFile; } - /** - * Retrieves the data file for a player for their global data, creating it if necessary. - * - * @param fileName The name of the file (player name or UUID) without extension. - * @param createIfMissing If true, the file will be created it it does not exist. - * @return The data file for a player. - * @throws IOException if there was a problem creating the file. - */ - File getGlobalFile(String fileName, boolean createIfMissing) throws IOException { - File jsonPlayerFile = new File(playerFolder, fileName + JSON); - if (createIfMissing && !jsonPlayerFile.exists()) { - try { - jsonPlayerFile.createNewFile(); - } catch (IOException e) { - throw new IOException("Could not create necessary player file: " + jsonPlayerFile.getPath() + ". " - + "There may be issues with " + fileName + "'s metadata", e); - } - } - return jsonPlayerFile; - } - - private void queueWrite(PlayerProfile profile) { - fileIOExecutorService.submit(new FileWriter(profile.clone())); - } + private File getProfileContainerFolder(ContainerType type, String folderName) { + File folder = switch (type) { + case GROUP -> new File(this.groupFolder, folderName); + case WORLD -> new File(this.worldFolder, folderName); + default -> new File(this.worldFolder, folderName); + }; - private class FileWriter implements Callable { - private final PlayerProfile profile; - - private FileWriter(PlayerProfile profile) { - this.profile = profile; + if (!folder.exists()) { + folder.mkdirs(); } + return folder; + } - @Override - public Void call() throws Exception { - processProfileWrite(profile); - return null; - } + /** + * {@inheritDoc} + */ + @Override + public void updatePlayerData(PlayerProfile playerProfile) { + profileFileIO.queueAction(() -> processProfileWrite(playerProfile.clone())); } private void processProfileWrite(PlayerProfile playerProfile) { + File playerFile = getPlayerFile( + playerProfile.getContainerType(), + playerProfile.getContainerName(), + playerProfile.getPlayer().getName() + ); try { - File playerFile = this.getPlayerFile(playerProfile.getContainerType(), - playerProfile.getContainerName(), playerProfile.getPlayer().getName()); - FileConfiguration playerData = getConfigHandleNow(playerFile); - playerData.createSection(playerProfile.getProfileType().getName(), serializePlayerProfile(playerProfile)); - try { - playerData.save(playerFile); - } catch (IOException e) { - Logging.severe("Could not save data for player: " + playerProfile.getPlayer().getName() - + " for " + playerProfile.getContainerType().toString() + ": " + playerProfile.getContainerName()); - Logging.severe(e.getMessage()); + ProfileKey fileProfileKey = ProfileKey.createProfileKey( + playerProfile.getContainerType(), + playerProfile.getContainerName(), + null, + playerProfile.getPlayer().getUniqueId(), + playerProfile.getPlayer().getName()); + FileConfiguration playerData = configCache.getIfPresent(fileProfileKey); + if (playerData == null) { + playerData = playerFile.exists() + ? profileFileIO.getConfigHandleNow(playerFile) + : new JsonConfiguration(); + configCache.put(fileProfileKey, playerData); } - } catch (final Exception e) { - Logging.getLogger().log(Level.WARNING, "Error while attempting to write profile data.", e); + Map serializedData = serializePlayerProfile(playerProfile); + if (serializedData.isEmpty()) { + return; + } + playerData.createSection(playerProfile.getProfileType().getName(), serializedData); + playerData.save(playerFile); + } catch (IOException e) { + Logging.severe("Could not save data for player: " + playerProfile.getPlayer().getName() + + " for " + playerProfile.getContainerType() + ": " + playerProfile.getContainerName()); + Logging.severe(e.getMessage()); + } catch (Exception e) { + Logging.severe("Unknown error while attempting to write profile data.", e); } } private Map serializePlayerProfile(PlayerProfile playerProfile) { - Map playerData = new LinkedHashMap(); + Map playerData = new LinkedHashMap<>(); JSONObject jsonStats = new JSONObject(); - for (SharableEntry entry : playerProfile) { - if (entry.getValue() != null) { - if (entry.getSharable().getSerializer() == null) { - continue; - } - Sharable sharable = entry.getSharable(); - if (sharable.getProfileEntry().isStat()) { - jsonStats.put(sharable.getProfileEntry().getFileTag(), - sharable.getSerializer().serialize(entry.getValue())); - } else { - playerData.put(sharable.getProfileEntry().getFileTag(), - sharable.getSerializer().serialize(entry.getValue())); - } + + for (var entry : playerProfile.getData().entrySet()) { + Sharable sharable = entry.getKey(); + Object sharableValue = entry.getValue(); + if (sharableValue == null) { + continue; + } + + var serializer = sharable.getSerializer(); + var profileEntry = sharable.getProfileEntry(); + if (serializer == null || profileEntry == null) { + continue; + } + + String fileTag = profileEntry.getFileTag(); + Object serializedValue = serializer.serialize(sharableValue); + if (profileEntry.isStat()) { + jsonStats.put(fileTag, serializedValue); + } else { + playerData.put(fileTag, serializedValue); } } + if (!jsonStats.isEmpty()) { playerData.put(DataStrings.PLAYER_STATS, jsonStats); } + return playerData; } @@ -261,8 +198,8 @@ private Map serializePlayerProfile(PlayerProfile playerProfile) * {@inheritDoc} */ @Override - public void updatePlayerData(PlayerProfile playerProfile) { - queueWrite(playerProfile); + public PlayerProfile getPlayerData(ContainerType containerType, String dataName, ProfileType profileType, UUID playerUUID) { + return getPlayerData(ProfileKey.createProfileKey(containerType, dataName, profileType, playerUUID)); } private PlayerProfile getPlayerData(ProfileKey key) { @@ -270,16 +207,21 @@ private PlayerProfile getPlayerData(ProfileKey key) { if (cached != null) { return cached; } - File playerFile = null; - try { - playerFile = getPlayerFile(key.getContainerType(), key.getDataName(), key.getPlayerName()); - } catch (IOException e) { - e.printStackTrace(); - // Return an empty profile - return PlayerProfile.createPlayerProfile(key.getContainerType(), key.getDataName(), key.getProfileType(), - Bukkit.getOfflinePlayer(key.getPlayerUUID())); + File playerFile = getPlayerFile(key.getContainerType(), key.getDataName(), key.getPlayerName()); + if (!playerFile.exists()) { + PlayerProfile playerProfile = PlayerProfile.createPlayerProfile(key.getContainerType(), key.getDataName(), + key.getProfileType(), Bukkit.getOfflinePlayer(key.getPlayerUUID())); + profileCache.put(key, playerProfile); + return playerProfile; + } + + // Migrate from none profile-type data + ProfileKey fileProfileKey = ProfileKey.createProfileKey(key, (ProfileType) null); + FileConfiguration playerData = configCache.getIfPresent(fileProfileKey); + if (playerData == null) { + playerData = profileFileIO.waitForConfigHandle(playerFile); + configCache.put(fileProfileKey, playerData); } - FileConfiguration playerData = this.waitForConfigHandle(playerFile); if (convertConfig(playerData)) { try { playerData.save(playerFile); @@ -289,18 +231,14 @@ private PlayerProfile getPlayerData(ProfileKey key) { Logging.severe(e.getMessage()); } } + ConfigurationSection section = playerData.getConfigurationSection(key.getProfileType().getName()); if (section == null) { section = playerData.createSection(key.getProfileType().getName()); } - PlayerProfile result = deserializePlayerProfile(key, convertSection(section)); - profileCache.put(key, result); - return result; - } - - @Override - public PlayerProfile getPlayerData(ContainerType containerType, String dataName, ProfileType profileType, UUID playerUUID) { - return getPlayerData(ProfileKey.createProfileKey(containerType, dataName, profileType, playerUUID)); + PlayerProfile playerProfile = deserializePlayerProfile(key, convertSection(section)); + profileCache.put(key, playerProfile); + return playerProfile; } private PlayerProfile deserializePlayerProfile(ProfileKey pKey, Map playerData) { @@ -308,34 +246,36 @@ private PlayerProfile deserializePlayerProfile(ProfileKey pKey, Map playerData) pKey.getProfileType(), Bukkit.getOfflinePlayer(pKey.getPlayerUUID())); for (Object keyObj : playerData.keySet()) { String key = keyObj.toString(); + final Object value = playerData.get(key); + if (value == null) { + Logging.fine("Player data '" + key + "' is null for: " + pKey.getPlayerName()); + continue; + } + if (key.equalsIgnoreCase(DataStrings.PLAYER_STATS)) { - final Object statsObject = playerData.get(key); - if (statsObject instanceof String) { - parseJsonPlayerStatsIntoProfile(statsObject.toString(), profile); + if (value instanceof String) { + parseJsonPlayerStatsIntoProfile((String) value, profile); + continue; + } + if (value instanceof Map) { + parsePlayerStatsIntoProfile((Map) value, profile); } else { - if (statsObject instanceof Map) { - parsePlayerStatsIntoProfile((Map) statsObject, profile); - } else { - Logging.warning("Could not parse stats for " + pKey.getPlayerName()); - } + Logging.warning("Could not parse stats for " + pKey.getPlayerName()); } - } else { - if (playerData.get(key) == null) { - Logging.fine("Player data '" + key + "' is null for: " + pKey.getPlayerName()); + continue; + } + + try { + Sharable sharable = ProfileEntry.lookup(false, key); + if (sharable == null) { + Logging.fine("Player fileTag '" + key + "' is unrecognized!"); continue; } - try { - Sharable sharable = ProfileEntry.lookup(false, key); - if (sharable == null) { - Logging.fine("Player fileTag '" + key + "' is unrecognized!"); - continue; - } - profile.set(sharable, sharable.getSerializer().deserialize(playerData.get(key))); - } catch (Exception e) { - Logging.fine("Could not parse fileTag: '" + key + "' with value '" + playerData.get(key) + "'"); - Logging.getLogger().log(Level.FINE, "Exception: ", e); - e.printStackTrace(); - } + profile.set(sharable, sharable.getSerializer().deserialize(playerData.get(key))); + } catch (Exception e) { + Logging.fine("Could not parse fileTag: '" + key + "' with value '" + playerData.get(key) + "'"); + Logging.getLogger().log(Level.FINE, "Exception: ", e); + e.printStackTrace(); } } Logging.finer("Created player profile from map for '" + pKey.getPlayerName() + "'."); @@ -376,54 +316,68 @@ private void parseJsonPlayerStatsIntoProfile(String stats, PlayerProfile profile // TODO Remove this conversion private boolean convertConfig(FileConfiguration config) { - ConfigurationSection section = config.getConfigurationSection("playerData"); - if (section != null) { - config.set(ProfileTypes.SURVIVAL.getName(), section); - config.set(ProfileTypes.CREATIVE.getName(), section); - config.set(ProfileTypes.ADVENTURE.getName(), section); - config.set("playerData", null); - Logging.finer("Migrated old player data to new multi-profile format"); - return true; + ConfigurationSection section = config.getConfigurationSection(DataStrings.PLAYER_DATA); + if (section == null) { + return false; } - return false; + config.set(ProfileTypes.SURVIVAL.getName(), section); + config.set(ProfileTypes.CREATIVE.getName(), section); + config.set(ProfileTypes.ADVENTURE.getName(), section); + config.set(DataStrings.PLAYER_DATA, null); + Logging.finer("Migrated old player data to new multi-profile format"); + return true; } /** * {@inheritDoc} */ @Override - public boolean removePlayerData(ContainerType containerType, String dataName, ProfileType profileType, String playerName) { - if (profileType == null) { + public boolean removePlayerData(ContainerType containerType, String dataName, ProfileType profileType, UUID playerUUID) { + ProfileKey profileKey = ProfileKey.createProfileKey(containerType, dataName, profileType, playerUUID); + if (profileKey.getProfileType() == null) { try { - File playerFile = getPlayerFile(containerType, dataName, playerName); + File playerFile = getPlayerFile(containerType, dataName, profileKey.getPlayerName()); + configCache.invalidate(profileKey); + profileCache.invalidateAll(Sets.filter( + profileCache.asMap().keySet(), + key -> key.getPlayerUUID().equals(playerUUID) + && key.getContainerType().equals(containerType) + && key.getDataName().equals(dataName) + )); return playerFile.delete(); - } catch (IOException ignore) { - Logging.warning("Attempted to delete file that did not exist for player " + playerName + } catch (Exception ignore) { + Logging.warning("Attempted to delete file that did not exist for player " + profileKey.getPlayerName() + " in " + containerType.name().toLowerCase() + " " + dataName); return false; } - } else { - File playerFile; - try { - playerFile = getPlayerFile(containerType, dataName, playerName); - } catch (IOException e) { - Logging.warning("Attempted to delete " + playerName + "'s data for " - + profileType.getName().toLowerCase() + " mode in " + containerType.name().toLowerCase() - + " " + dataName + " but the file did not exist."); - return false; + } + try { + File playerFile = getPlayerFile(containerType, dataName, profileKey.getPlayerName()); + ProfileKey fileProfileKey = ProfileKey.createProfileKey(profileKey, (ProfileType) null); + FileConfiguration playerData = configCache.getIfPresent(fileProfileKey); + if (playerData == null) { + if (!playerFile.exists()) { + return false; + } + playerData = profileFileIO.getConfigHandleNow(playerFile); } - FileConfiguration playerData = this.waitForConfigHandle(playerFile); playerData.set(profileType.getName(), null); - try { - playerData.save(playerFile); - } catch (IOException e) { - Logging.severe("Could not delete data for player: " + playerName - + " for " + containerType.toString() + ": " + dataName); - Logging.severe(e.getMessage()); - return false; - } - return true; + profileCache.invalidate(profileKey); + FileConfiguration finalPlayerData = playerData; + profileFileIO.queueAction(() -> { + try { + finalPlayerData.save(playerFile); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } catch (InvalidConfigurationException | IOException e) { + Logging.severe("Could not delete data for player: " + profileKey.getPlayerName() + + " for " + containerType.toString() + ": " + dataName); + Logging.severe(e.getMessage()); + return false; } + return true; } private Map convertSection(ConfigurationSection section) { @@ -450,69 +404,45 @@ public GlobalProfile getGlobalProfile(OfflinePlayer player) { } @Override - public GlobalProfile getGlobalProfile(String playerName, UUID playerUUID) { + public @NotNull GlobalProfile getGlobalProfile(String playerName, UUID playerUUID) { + return getExistingGlobalProfile(playerName, playerUUID) + .getOrElse(() -> GlobalProfile.createGlobalProfile(playerName, playerUUID)); + } + + @Override + public @NotNull Option getExistingGlobalProfile(String playerName, UUID playerUUID) { GlobalProfile cached = globalProfileCache.getIfPresent(playerUUID); if (cached != null) { - return cached; + return Option.of(cached); } - File playerFile; - // Migrate old data if necessary - try { - playerFile = getGlobalFile(playerName, false); - } catch (IOException e) { - // This won't ever happen - e.printStackTrace(); - return GlobalProfile.createGlobalProfile(playerName, playerUUID); - } - if (playerFile.exists()) { - GlobalProfile profile = loadGlobalProfile(playerFile, playerName, playerUUID); - if (!migrateGlobalProfileToUUID(profile, playerFile)) { - Logging.warning("Could not properly migrate player global data file for " + playerName); - } - globalProfileCache.put(playerUUID, profile); - return profile; + // Migrate from player name to uuid profile file + File legacyFile = getGlobalFile(playerName); + if (legacyFile.exists() && !migrateGlobalProfileToUUID(legacyFile, playerUUID)) { + Logging.warning("Could not properly migrate player global data file for " + playerName); } - // Load current format - try { - playerFile = getGlobalFile(playerUUID.toString(), true); - } catch (IOException e) { - e.printStackTrace(); - return GlobalProfile.createGlobalProfile(playerName, playerUUID); + // Load from existing profile file + File uuidFile = getGlobalFile(playerUUID.toString()); + if (uuidFile.exists()) { + GlobalProfile globalProfile = loadGlobalProfile(uuidFile, playerName, playerUUID); + globalProfileCache.put(playerUUID, globalProfile); + return Option.of(globalProfile); } - GlobalProfile profile = loadGlobalProfile(playerFile, playerName, playerUUID); - globalProfileCache.put(playerUUID, profile); - return profile; + return Option.none(); } - private boolean migrateGlobalProfileToUUID(GlobalProfile profile, File playerFile) { - updateGlobalProfile(profile); - return playerFile.delete(); + private boolean migrateGlobalProfileToUUID(File legacyFile, UUID playerUUID) { + return legacyFile.renameTo(getGlobalFile(playerUUID.toString())); } - private GlobalProfile loadGlobalProfile(File playerFile, String playerName, UUID playerUUID) { - FileConfiguration playerData = this.waitForConfigHandle(playerFile); - ConfigurationSection section = playerData.getConfigurationSection("playerData"); + private GlobalProfile loadGlobalProfile(File globalFile, String playerName, UUID playerUUID) { + FileConfiguration playerData = profileFileIO.waitForConfigHandle(globalFile); + ConfigurationSection section = playerData.getConfigurationSection(DataStrings.PLAYER_DATA); if (section == null) { - section = playerData.createSection("playerData"); - } - return deserializeGlobalProfile(playerName, playerUUID, convertSection(section)); - } - - private GlobalProfile deserializeGlobalProfile(String playerName, UUID playerUUID, - Map playerData) { - GlobalProfile globalProfile = GlobalProfile.createGlobalProfile(playerName, playerUUID); - for (String key : playerData.keySet()) { - if (key.equalsIgnoreCase(DataStrings.PLAYER_LAST_WORLD)) { - globalProfile.setLastWorld(playerData.get(key).toString()); - } else if (key.equalsIgnoreCase(DataStrings.PLAYER_SHOULD_LOAD)) { - globalProfile.setLoadOnLogin(Boolean.valueOf(playerData.get(key).toString())); - } else if (key.equalsIgnoreCase(DataStrings.PLAYER_LAST_KNOWN_NAME)) { - globalProfile.setLastKnownName(playerData.get(key).toString()); - } + section = playerData.createSection(DataStrings.PLAYER_DATA); } - return globalProfile; + return GlobalProfile.deserialize(playerName, playerUUID, section); } /** @@ -520,15 +450,9 @@ private GlobalProfile deserializeGlobalProfile(String playerName, UUID playerUUI */ @Override public boolean updateGlobalProfile(GlobalProfile globalProfile) { - File playerFile = null; - try { - playerFile = this.getGlobalFile(globalProfile.getPlayerUUID().toString(), true); - } catch (IOException e) { - e.printStackTrace(); - return false; - } - FileConfiguration playerData = this.waitForConfigHandle(playerFile); - playerData.createSection("playerData", serializeGlobalProfile(globalProfile)); + File playerFile = getGlobalFile(globalProfile.getPlayerUUID().toString()); + FileConfiguration playerData = new JsonConfiguration(); + playerData.createSection(DataStrings.PLAYER_DATA, globalProfile.serialize(globalProfile)); try { playerData.save(playerFile); } catch (IOException e) { @@ -539,14 +463,14 @@ public boolean updateGlobalProfile(GlobalProfile globalProfile) { return true; } - private Map serializeGlobalProfile(GlobalProfile profile) { - Map result = new HashMap(2); - if (profile.getLastWorld() != null) { - result.put(DataStrings.PLAYER_LAST_WORLD, profile.getLastWorld()); - } - result.put(DataStrings.PLAYER_SHOULD_LOAD, profile.shouldLoadOnLogin()); - result.put(DataStrings.PLAYER_LAST_KNOWN_NAME, profile.getLastKnownName()); - return result; + /** + * Retrieves the data file for a player for their global data. + * + * @param fileName The name of the file (player name or UUID) without extension. + * @return The data file for a player. + */ + private File getGlobalFile(String fileName) { + return new File(playerFolder, fileName + JSON); } @Override @@ -582,8 +506,8 @@ public void migratePlayerData(String oldName, String newName, UUID uuid) throws private void migrateForContainerType(File[] folders, ContainerType containerType, String oldName, String newName) throws IOException { for (File folder : folders) { - File oldNameFile = getPlayerFile(containerType, folder.getName(), oldName, false); - File newNameFile = getPlayerFile(containerType, folder.getName(), newName, false); + File oldNameFile = getPlayerFile(containerType, folder.getName(), oldName); + File newNameFile = getPlayerFile(containerType, folder.getName(), newName); if (!oldNameFile.exists()) { Logging.fine("No old data for player %s in %s %s to migrate.", oldName, containerType.name(), folder.getName()); @@ -605,21 +529,25 @@ private void migrateForContainerType(File[] folders, ContainerType containerType } void clearPlayerCache(UUID playerUUID) { - profileCache.invalidateAll(Sets.filter( - profileCache.asMap().keySet(), - key -> key.getPlayerUUID().equals(playerUUID) - )); + clearProfileCache(key -> key.getPlayerUUID().equals(playerUUID)); } @Override public void clearProfileCache(ProfileKey key) { + configCache.invalidate(key); profileCache.invalidate(key); } + @Override + public void clearProfileCache(Predicate predicate) { + configCache.invalidateAll(Sets.filter(configCache.asMap().keySet(), predicate::test)); + profileCache.invalidateAll(Sets.filter(profileCache.asMap().keySet(), predicate::test)); + } + @Override public void clearAllCache() { + configCache.invalidateAll(); globalProfileCache.invalidateAll(); profileCache.invalidateAll(); } } - diff --git a/src/main/java/org/mvplugins/multiverse/inventories/profile/GlobalProfile.java b/src/main/java/org/mvplugins/multiverse/inventories/profile/GlobalProfile.java index 4dac573b..fb80d856 100644 --- a/src/main/java/org/mvplugins/multiverse/inventories/profile/GlobalProfile.java +++ b/src/main/java/org/mvplugins/multiverse/inventories/profile/GlobalProfile.java @@ -1,7 +1,11 @@ package org.mvplugins.multiverse.inventories.profile; import org.bukkit.OfflinePlayer; +import org.bukkit.configuration.ConfigurationSection; +import org.mvplugins.multiverse.inventories.util.DataStrings; +import java.util.HashMap; +import java.util.Map; import java.util.UUID; /** @@ -15,7 +19,7 @@ public final class GlobalProfile { * @param player the player to create the profile object for. * @return a new GlobalProfile for the given player. */ - public static GlobalProfile createGlobalProfile(OfflinePlayer player) { + static GlobalProfile createGlobalProfile(OfflinePlayer player) { return new GlobalProfile(player.getName(), player.getUniqueId()); } @@ -26,7 +30,7 @@ public static GlobalProfile createGlobalProfile(OfflinePlayer player) { * @param playerUUID the UUID of the player to create the profile for. * @return a new GlobalProfile for the given player. */ - public static GlobalProfile createGlobalProfile(String playerName, UUID playerUUID) { + static GlobalProfile createGlobalProfile(String playerName, UUID playerUUID) { return new GlobalProfile(playerName, playerUUID); } @@ -40,6 +44,13 @@ private GlobalProfile(String name, UUID uuid) { this.lastKnownName = name; } + public GlobalProfile(UUID uuid, String lastWorld, String lastKnownName, boolean loadOnLogin) { + this.uuid = uuid; + this.lastWorld = lastWorld; + this.lastKnownName = lastKnownName; + this.loadOnLogin = loadOnLogin; + } + /** * Returns the name of the player. * @@ -127,4 +138,37 @@ public String toString() { ", loadOnLogin=" + loadOnLogin + '}'; } + + /** + * Converts a global profile to a map that can be serialized into the profile data file. + * + * @param profile The global profile data. + * @return The serialized profile map. + */ + Map serialize(GlobalProfile profile) { + Map result = new HashMap<>(3); + if (profile.getLastWorld() != null) { + result.put(DataStrings.PLAYER_LAST_WORLD, profile.getLastWorld()); + } + result.put(DataStrings.PLAYER_SHOULD_LOAD, profile.shouldLoadOnLogin()); + result.put(DataStrings.PLAYER_LAST_KNOWN_NAME, profile.getLastKnownName()); + return result; + } + + /** + * Converts a configuration section to a global profile. + * + * @param playerName The player name. + * @param playerUUID The player UUID. + * @param data The configuration section to convert. + * @return The global profile. + */ + static GlobalProfile deserialize(String playerName, UUID playerUUID, ConfigurationSection data) { + return new GlobalProfile( + playerUUID, + data.getString(DataStrings.PLAYER_LAST_WORLD, null), + data.getString(DataStrings.PLAYER_LAST_KNOWN_NAME, playerName), + data.getBoolean(DataStrings.PLAYER_SHOULD_LOAD, false) + ); + } } diff --git a/src/main/java/org/mvplugins/multiverse/inventories/profile/PlayerProfile.java b/src/main/java/org/mvplugins/multiverse/inventories/profile/PlayerProfile.java index 88e82d5c..57d253ec 100644 --- a/src/main/java/org/mvplugins/multiverse/inventories/profile/PlayerProfile.java +++ b/src/main/java/org/mvplugins/multiverse/inventories/profile/PlayerProfile.java @@ -1,25 +1,23 @@ package org.mvplugins.multiverse.inventories.profile; import org.mvplugins.multiverse.inventories.share.Sharable; -import org.mvplugins.multiverse.inventories.share.SharableEntry; import org.mvplugins.multiverse.inventories.profile.container.ContainerType; import org.bukkit.OfflinePlayer; import java.util.HashMap; -import java.util.Iterator; import java.util.Map; /** * Contains all the world/group specific data for a player. */ -public final class PlayerProfile implements Cloneable, Iterable { +public final class PlayerProfile implements Cloneable { - public static PlayerProfile createPlayerProfile(ContainerType containerType, String containerName, + static PlayerProfile createPlayerProfile(ContainerType containerType, String containerName, ProfileType profileType, OfflinePlayer player) { return new PlayerProfile(containerType, containerName, profileType, player); } - private Map data = new HashMap(); + private final Map data = new HashMap<>(); private final OfflinePlayer player; private final ContainerType containerType; @@ -69,8 +67,7 @@ public ProfileType getProfileType() { * @return The value of the sharable for this profile. Null if no value is set. */ public T get(Sharable sharable) { - SharableEntry entry = this.data.get(sharable); - return sharable.getType().cast(entry != null ? entry.getValue() : null); + return sharable.getType().cast(this.data.get(sharable)); } /** @@ -81,7 +78,7 @@ public T get(Sharable sharable) { * @param The type of value to be expected. */ public void set(Sharable sharable, T value) { - this.data.put(sharable, new SharableEntry(sharable, value)); + this.data.put(sharable, value); } public PlayerProfile clone() { @@ -92,33 +89,7 @@ public PlayerProfile clone() { } } - @Override - public Iterator iterator() { - return new SharablesIterator(data.values().iterator()); - } - - private static class SharablesIterator implements Iterator { - - private final Iterator backingIterator; - - private SharablesIterator(Iterator backingIterator) { - this.backingIterator = backingIterator; - } - - @Override - public boolean hasNext() { - return backingIterator.hasNext(); - } - - @Override - public SharableEntry next() { - return backingIterator.next(); - } - - @Override - public void remove() { - throw new UnsupportedOperationException(); - } + public Map getData() { + return data; } } - diff --git a/src/main/java/org/mvplugins/multiverse/inventories/profile/ProfileDataSource.java b/src/main/java/org/mvplugins/multiverse/inventories/profile/ProfileDataSource.java index c3354c75..91875707 100644 --- a/src/main/java/org/mvplugins/multiverse/inventories/profile/ProfileDataSource.java +++ b/src/main/java/org/mvplugins/multiverse/inventories/profile/ProfileDataSource.java @@ -1,11 +1,14 @@ package org.mvplugins.multiverse.inventories.profile; import org.bukkit.OfflinePlayer; +import org.jetbrains.annotations.NotNull; import org.jvnet.hk2.annotations.Contract; +import org.mvplugins.multiverse.external.vavr.control.Option; import org.mvplugins.multiverse.inventories.profile.container.ContainerType; import java.io.IOException; import java.util.UUID; +import java.util.function.Predicate; /** * A source for updating and retrieving player profiles via persistence. @@ -40,10 +43,10 @@ public sealed interface ProfileDataSource permits FlatFileProfileDataSource { * @param dataName The name of the world/group the player's data is associated with. * @param profileType The type of profile we're removing, as per {@link ProfileType}. If null, this will remove * remove all profile types. - * @param playerName The name of the player whose data is being removed. + * @param playerUUID The UUID of the player whose data is being removed. * @return True if successfully removed. */ - boolean removePlayerData(ContainerType containerType, String dataName, ProfileType profileType, String playerName); + boolean removePlayerData(ContainerType containerType, String dataName, ProfileType profileType, UUID playerUUID); /** * Retrieves the global profile for a player which contains meta-data for the player. @@ -63,12 +66,22 @@ public sealed interface ProfileDataSource permits FlatFileProfileDataSource { /** * Retrieves the global profile for a player which contains meta-data for the player. + * Creates the profile if it doesn't exist. * * @param playerName The name of the player. * @param playerUUID The UUID of the player. * @return The global profile for the specified player. */ - GlobalProfile getGlobalProfile(String playerName, UUID playerUUID); + @NotNull GlobalProfile getGlobalProfile(String playerName, UUID playerUUID); + + /** + * Retrieves the global profile for a player which contains meta-data for the player if it exists. + * + * @param playerName The name of the player. + * @param playerUUID The UUID of the player. + * @return The global profile for the specified player or empty if it doesn't exist. + */ + @NotNull Option getExistingGlobalProfile(String playerName, UUID playerUUID); /** * Update the file for a player's global profile. @@ -109,6 +122,11 @@ public sealed interface ProfileDataSource permits FlatFileProfileDataSource { */ void clearProfileCache(ProfileKey key); + /** + * Clears a single profile in cache. + */ + void clearProfileCache(Predicate predicate); + /** * Clears all profiles in cache. */ diff --git a/src/main/java/org/mvplugins/multiverse/inventories/profile/ProfileFileIO.java b/src/main/java/org/mvplugins/multiverse/inventories/profile/ProfileFileIO.java new file mode 100644 index 00000000..f0fbca29 --- /dev/null +++ b/src/main/java/org/mvplugins/multiverse/inventories/profile/ProfileFileIO.java @@ -0,0 +1,60 @@ +package org.mvplugins.multiverse.inventories.profile; + +import com.dumptruckman.bukkit.configuration.json.JsonConfiguration; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.FileConfiguration; +import org.jvnet.hk2.annotations.Service; +import org.mvplugins.multiverse.external.jakarta.inject.Inject; + +import java.io.File; +import java.io.IOException; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +@Service +final class ProfileFileIO { + + private final ExecutorService fileIOExecutorService = Executors.newSingleThreadExecutor(); + + @Inject + public ProfileFileIO() { + } + + FileConfiguration waitForConfigHandle(File file) { + Future future = fileIOExecutorService.submit(new ConfigLoader(file)); + try { + return future.get(10, TimeUnit.SECONDS); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + throw new RuntimeException(e); + } + } + + FileConfiguration getConfigHandleNow(File file) throws IOException, InvalidConfigurationException { + JsonConfiguration jsonConfiguration = new JsonConfiguration(); + jsonConfiguration.options().continueOnSerializationError(true); + jsonConfiguration.load(file); + return jsonConfiguration; + } + + private class ConfigLoader implements Callable { + private final File file; + + private ConfigLoader(File file) { + this.file = file; + } + + @Override + public FileConfiguration call() throws Exception { + return getConfigHandleNow(file); + } + } + + void queueAction(Runnable action) { + fileIOExecutorService.submit(action); + } +} diff --git a/src/main/java/org/mvplugins/multiverse/inventories/profile/container/ProfileContainer.java b/src/main/java/org/mvplugins/multiverse/inventories/profile/container/ProfileContainer.java index 97bcd957..9fc234c1 100644 --- a/src/main/java/org/mvplugins/multiverse/inventories/profile/container/ProfileContainer.java +++ b/src/main/java/org/mvplugins/multiverse/inventories/profile/container/ProfileContainer.java @@ -101,7 +101,7 @@ public void addPlayerData(PlayerProfile playerProfile) { */ public void removeAllPlayerData(OfflinePlayer player) { this.getPlayerData(player.getName()).clear(); - profileDataSource.removePlayerData(getContainerType(), getContainerName(), null, player.getName()); + profileDataSource.removePlayerData(getContainerType(), getContainerName(), null, player.getUniqueId()); } /** @@ -112,7 +112,7 @@ public void removeAllPlayerData(OfflinePlayer player) { */ public void removePlayerData(ProfileType profileType, OfflinePlayer player) { this.getPlayerData(player.getName()).remove(profileType); - profileDataSource.removePlayerData(getContainerType(), getContainerName(), profileType, player.getName()); + profileDataSource.removePlayerData(getContainerType(), getContainerName(), profileType, player.getUniqueId()); } /** @@ -139,11 +139,8 @@ public ContainerType getContainerType() { * Clears all cached data in the container. */ public void clearContainer() { - for (Map profiles : playerData.values()) { - for (PlayerProfile profile : profiles.values()) { - profileDataSource.clearProfileCache(ProfileKey.createProfileKey(profile)); - } - } + profileDataSource.clearProfileCache(key -> + key.getContainerType().equals(type) && key.getDataName().equals(name)); this.playerData.clear(); } } diff --git a/src/main/java/org/mvplugins/multiverse/inventories/util/DataStrings.java b/src/main/java/org/mvplugins/multiverse/inventories/util/DataStrings.java index cdf2eff3..2d72b1ac 100644 --- a/src/main/java/org/mvplugins/multiverse/inventories/util/DataStrings.java +++ b/src/main/java/org/mvplugins/multiverse/inventories/util/DataStrings.java @@ -52,6 +52,10 @@ public class DataStrings { * Player last location identifier. */ public static final String PLAYER_LAST_LOCATION = "lastLocation"; + /** + * Player global profile data + */ + public static final String PLAYER_DATA = "playerData"; /** * Player last world identifier. */ diff --git a/src/test/java/org/mvplugins/multiverse/inventories/profile/FilePerformanceTest.kt b/src/test/java/org/mvplugins/multiverse/inventories/profile/FilePerformanceTest.kt index 4346d78b..28193a49 100644 --- a/src/test/java/org/mvplugins/multiverse/inventories/profile/FilePerformanceTest.kt +++ b/src/test/java/org/mvplugins/multiverse/inventories/profile/FilePerformanceTest.kt @@ -1,10 +1,21 @@ package org.mvplugins.multiverse.inventories.profile import com.dumptruckman.minecraft.util.Logging +import org.bukkit.GameMode +import org.bukkit.Material +import org.bukkit.enchantments.Enchantment +import org.bukkit.inventory.ItemStack +import org.bukkit.potion.PotionEffect +import org.bukkit.potion.PotionEffectType import org.junit.jupiter.api.Test import org.mvplugins.multiverse.inventories.TestWithMockBukkit +import org.mvplugins.multiverse.inventories.profile.container.ContainerType +import org.mvplugins.multiverse.inventories.share.Sharables import java.util.* +import java.util.function.Consumer import kotlin.test.BeforeTest +import kotlin.test.assertEquals +import kotlin.test.assertNull class FilePerformanceTest : TestWithMockBukkit() { @@ -14,12 +25,13 @@ class FilePerformanceTest : TestWithMockBukkit() { fun setUp() { profileDataSource = serviceLocator.getService(ProfileDataSource::class.java).takeIf { it != null } ?: run { throw IllegalStateException("ProfileDataSource is not available as a service") } + Logging.setDebugLevel(0) } @Test - fun `Test 20K global profiles`() { + fun `Test 10K global profiles`() { val startTime = System.nanoTime() - for (i in 1..20000) { + for (i in 0..9999) { val globalProfile = profileDataSource.getGlobalProfile("player-$i", UUID.randomUUID()) globalProfile.setLoadOnLogin(true) profileDataSource.updateGlobalProfile(globalProfile) @@ -27,13 +39,81 @@ class FilePerformanceTest : TestWithMockBukkit() { Logging.info("Time taken: " + (System.nanoTime() - startTime) / 1000000 + "ms") profileDataSource.clearAllCache(); + Thread.sleep(800) // Wait for files to write finish val startTime2 = System.nanoTime() - for (i in 1..20000) { + for (i in 0..9999) { val globalProfile = profileDataSource.getGlobalProfile("player-$i", UUID.randomUUID()) globalProfile.setLoadOnLogin(false) profileDataSource.updateGlobalProfile(globalProfile) } Logging.info("Time taken: " + (System.nanoTime() - startTime2) / 1000000 + "ms") } -} \ No newline at end of file + + @Test + fun `Test 1K player profiles`() { + server.setPlayers(1000) + val startTime = System.nanoTime() + for (i in 0..999) { + val player = server.getPlayer(i) + for (gameMode in GameMode.entries) { + val playerProfile = profileDataSource.getPlayerData( + ContainerType.WORLD, "world", ProfileTypes.forGameMode(gameMode), player.uniqueId) + playerProfile.set(Sharables.HEALTH, 5.0) + playerProfile.set(Sharables.OFF_HAND, ItemStack(Material.STONE_BRICKS, 10)) + playerProfile.set(Sharables.INVENTORY, arrayOf( + ItemStack(Material.STONE_BRICKS, 10), + ItemStack(Material.ACACIA_LOG, 10), + createItemStack(Material.BOW, 1, { itemStack -> + itemStack.addEnchantment(Enchantment.UNBREAKING, 2) + }), + ItemStack(Material.WATER_BUCKET, 64) + )) + playerProfile.set(Sharables.POTIONS, arrayOf( + PotionEffect(PotionEffectType.POISON, 100, 1), + PotionEffect(PotionEffectType.SPEED, 50, 1), + )) + profileDataSource.updatePlayerData(playerProfile) + } + } + Logging.info("Time taken: " + (System.nanoTime() - startTime) / 1000000 + "ms") + + profileDataSource.clearAllCache() + Thread.sleep(800) // Wait for files to write finish + + val startTime2 = System.nanoTime() + for (i in 0..999) { + val player = server.getPlayer(i) + for (gameMode in GameMode.entries) { + val playerProfile = profileDataSource.getPlayerData( + ContainerType.WORLD, "world", ProfileTypes.forGameMode(gameMode), player.uniqueId + ) + assertEquals(5.0, playerProfile.get(Sharables.HEALTH)) + assertEquals(ItemStack(Material.STONE_BRICKS, 10), playerProfile.get(Sharables.OFF_HAND)) + } + } + Logging.info("Time taken: " + (System.nanoTime() - startTime2) / 1000000 + "ms") + + val startTime3 = System.nanoTime() + for (i in 0..999) { + val player = server.getPlayer(i) + for (gameMode in GameMode.entries) { + profileDataSource.removePlayerData( + ContainerType.WORLD, "world", ProfileTypes.forGameMode(gameMode), player.uniqueId + ) + val playerProfile = profileDataSource.getPlayerData( + ContainerType.WORLD, "world", ProfileTypes.forGameMode(gameMode), player.uniqueId + ) + assertNull(playerProfile.get(Sharables.HEALTH)) + assertNull(playerProfile.get(Sharables.OFF_HAND)) + } + } + Logging.info("Time taken: " + (System.nanoTime() - startTime3) / 1000000 + "ms") + } + + fun createItemStack(material: Material, amount: Int = 1, modify: Consumer): ItemStack { + val itemStack = ItemStack(material, amount) + modify.accept(itemStack) + return itemStack + } +}