From 438490adfd1bcadd671a6e26f3e4697f729d5290 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:52:12 -0600 Subject: [PATCH 01/13] Make placeholder cache updates storage-worker safe --- .../votingplugin/VotingPluginMain.java | 11 + .../listeners/PlayerJoinEvent.java | 14 +- .../placeholders/PlaceHolders.java | 326 +++++++++++++----- .../PlaceholderPlayerPresence.java | 85 +++++ .../votingplugin/user/VotingPluginUser.java | 44 ++- .../PlayerJoinEventPresenceTest.java | 49 +++ .../PlaceHoldersWorkerSafetyTest.java | 246 +++++++++++++ .../PlaceholderPlayerPresenceTest.java | 52 +++ 8 files changed, 722 insertions(+), 105 deletions(-) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresence.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerJoinEventPresenceTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresenceTest.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 05f3eb66fb..7a40b3e740 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -96,6 +96,7 @@ import com.bencodez.votingplugin.listeners.VotingPluginUpdateEvent; import com.bencodez.votingplugin.placeholders.MVdWPlaceholders; import com.bencodez.votingplugin.placeholders.PlaceHolders; +import com.bencodez.votingplugin.placeholders.PlaceholderPlayerPresence; import com.bencodez.votingplugin.placeholders.VotingPluginExpansion; import com.bencodez.votingplugin.presets.VoteSitePresetSetupHandler; import com.bencodez.votingplugin.proxy.control.HostedControlManager; @@ -231,6 +232,9 @@ public File getLoadedPluginJarFile() { @Getter private PlaceHolders placeholders; + @Getter + private final PlaceholderPlayerPresence placeholderPlayerPresence = new PlaceholderPlayerPresence(); + @Getter private VoteTester voteTester; @@ -649,6 +653,7 @@ public void onPostLoad() { registerCommands(); checkVotifier(); + refreshPlaceholderPlayerPresence(); registerEvents(); loadVoteBroadcast(); @@ -1791,6 +1796,7 @@ public void onFinish() { @Override public void onUnLoad() { + placeholderPlayerPresence.clear(); stopBackendHostedControlLifecycle(); stopBackendControlConnectorLifecycle(); if (getBackendProxyHandler() != null) { @@ -1929,6 +1935,11 @@ public void reloadAll() { reloadPlugin(true, true); } + /** Captures Bukkit presence while the lifecycle caller owns platform access. */ + public void refreshPlaceholderPlayerPresence() { + placeholderPlayerPresence.replace(Bukkit.getOnlinePlayers()); + } + /** Reloads configuration applied by Control before its result is acknowledged. */ public void reloadFromControl() { reloadPlugin(false, false); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerJoinEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerJoinEvent.java index 8a2a216c98..7ba5eccdf5 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerJoinEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerJoinEvent.java @@ -26,6 +26,12 @@ public PlayerJoinEvent(VotingPluginMain plugin) { this.plugin = plugin; } + /** Capture the entity owner before asynchronous user notifications can need it. */ + @EventHandler(priority = EventPriority.LOWEST) + public void onPlayerJoin(org.bukkit.event.player.PlayerJoinEvent event) { + if (event != null) plugin.getPlaceholderPlayerPresence().playerOnline(event.getPlayer()); + } + private static boolean isBlank(String s) { return s == null || s.trim().isEmpty() || "null".equalsIgnoreCase(s.trim()); } @@ -35,9 +41,9 @@ private static boolean isBlank(String s) { * * @param event the event */ - @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) - public void onPlayerLogin(AdvancedCoreLoginEvent event) { - if (event == null || !plugin.isMySQLOkay() || event.isCancelled() || event.getUser() == null) { + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onPlayerLogin(AdvancedCoreLoginEvent event) { + if (event == null || !plugin.isMySQLOkay() || event.isCancelled() || event.getUser() == null) { return; } @@ -94,6 +100,8 @@ public void onPlayerQuit(PlayerQuitEvent event) { if (player == null) { return; } + plugin.getPlaceholderPlayerPresence().playerOffline(player.getUniqueId()); + if (plugin.getPlaceholders() != null) plugin.getPlaceholders().onLogout(player.getUniqueId()); if (plugin.getBungeeSettings().isUseBungeecoord()) { plugin.getBackendProxyHandler().playerOffline(player.getName()); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java index 04e53559c3..eb04b40877 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java @@ -6,8 +6,11 @@ import java.time.temporal.TemporalField; import java.time.temporal.WeekFields; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.List; import java.util.Locale; import java.util.Map.Entry; import java.util.Set; @@ -49,6 +52,14 @@ public class PlaceHolders { private ArrayList cachedPlaceholders = new ArrayList<>(); private ConcurrentLinkedQueue placeholdersToSetCacheOn = new ConcurrentLinkedQueue<>(); + private final Set> platformOwnedPlaceholders = + Collections.newSetFromMap(new IdentityHashMap<>()); + private final Set> offlineWorkerPlaceholders = + Collections.newSetFromMap(new IdentityHashMap<>()); + private volatile List> userDataChangePlaceholders = List.of(); + private volatile Set> userDataChangePlatformPlaceholders = Set.of(); + private volatile Set> userDataChangeOfflineWorkerPlaceholders = Set.of(); + private boolean userDataChangeListenerRegistered; @Getter private PlaceholderCacheLevel cacheLevel; @@ -299,8 +310,10 @@ public String getPlaceholderValue(OfflinePlayer p, String identifier, boolean ja * Load all placeholders. */ public void load() { - placeholders.clear(); - nonPlayerPlaceholders.clear(); + placeholders.clear(); + nonPlayerPlaceholders.clear(); + platformOwnedPlaceholders.clear(); + offlineWorkerPlaceholders.clear(); // older placeholders, might be removed in the future placeholders.add(new PlaceHolder("total") { @@ -493,13 +506,13 @@ public String placeholderRequest(VotingPluginUser user, String identifier) { } }.withDescription("Return true/false if player can vote on all sites").updateDataKey("LastVotes")); - placeholders.add(new PlaceHolder("CanVoteSites") { + placeholders.add(platformOwnedWithOfflineWorkerFallback(new PlaceHolder("CanVoteSites") { @Override public String placeholderRequest(VotingPluginUser user, String identifier) { return "" + user.getSitesNotVotedOn(); } - }.withDescription("Return number of votesites available").updateDataKey("LastVotes")); + }.withDescription("Return number of votesites available").updateDataKey("LastVotes"))); placeholders.add(new PlaceHolder("Next_AnySite") { @@ -530,21 +543,21 @@ public String placeholderRequest(VotingPluginUser user, String identifier) { } }.withDescription("How long until user can vote on anysite").updateDataKey("LastVotes")); - placeholders.add(new PlaceHolder("SitesAvailable") { + placeholders.add(platformOwnedWithOfflineWorkerFallback(new PlaceHolder("SitesAvailable") { @Override public String placeholderRequest(VotingPluginUser user, String identifier) { return "" + user.getSitesNotVotedOn(); } - }.withDescription("Get number of sites available to be voted on").updateDataKey("LastVotes")); + }.withDescription("Get number of sites available to be voted on").updateDataKey("LastVotes"))); - placeholders.add(new PlaceHolder("SitesAvailableTotal") { - - @Override - public String placeholderRequest(VotingPluginUser user, String identifier) { - return "" + user.getTotalNumberOfSites(); - } - }.withDescription("Get total number of sites available to be voted on")); + placeholders.add(platformOwnedWithOfflineWorkerFallback(new PlaceHolder("SitesAvailableTotal") { + + @Override + public String placeholderRequest(VotingPluginUser user, String identifier) { + return "" + user.getTotalNumberOfSites(); + } + }.withDescription("Get total number of sites available to be voted on"))); for (final VoteSite voteSite : plugin.getVoteSiteManager().getVoteSitesEnabled()) { placeholders.add(new CalculatingPlaceholder("Next_" + voteSite.getKey()) { @@ -1174,55 +1187,175 @@ public String placeholderRequest(String identifier) { } } - plugin.getUserManager().getUserDataChange().add(new UserDataChanged() { + publishUserDataChangePlaceholders(); + if (!userDataChangeListenerRegistered) { + plugin.getUserManager().getUserDataChange().add(new UserDataChanged() { - @Override - public void onChange(AdvancedCoreUser user, String... keys) { - VotingPluginUser vpUser = plugin.getVotingPluginUserManager().getVotingPluginUser(user); - if (!vpUser.isCached()) { - vpUser.userDataFetechMode(UserDataFetchMode.NO_CACHE); + @Override + public void onChange(AdvancedCoreUser user, String... keys) { + onUserDataChange(user, keys); } - for (PlaceHolder placeholder : placeholders) { - if (placeholder.isUsesCache()) { - if (placeholder.isCached(placeholder.getIdentifier(), user.getJavaUUID()) - || !getCacheLevel().onlineOnly() || user.isOnline()) { - if (placeholder instanceof CalculatingPlaceholder) { - CalculatingPlaceholder cPlaceholder = (CalculatingPlaceholder) placeholder; - if (placeholder.isUsesCache()) { - for (String key : keys) { - if (placeholder.getUpdateDataKey().equalsIgnoreCase(key)) { - for (String ident : placeholder.getCache().keySet()) { - String value = placeholder.placeholderRequest(vpUser, ident); - cPlaceholder.getCacheData().put(user.getJavaUUID(), - cPlaceholder.placeholderDataRequest(vpUser, ident)); - placeholder.getCache().get(ident).put(vpUser.getJavaUUID(), value); - plugin.devDebug("Updated calculating placeholder cache for " - + vpUser.getUUID() + " on " + key + " with " + value); - } - } - } - } - } else { - if (placeholder.isUsesCache()) { - for (String key : keys) { - if (placeholder.getUpdateDataKey().equalsIgnoreCase(key)) { - for (String ident : placeholder.getCache().keySet()) { - String value = placeholder.placeholderRequest(vpUser, ident); - placeholder.getCache().get(ident).put(vpUser.getJavaUUID(), value); - plugin.devDebug("Updated placeholder cache for " + vpUser.getUUID() - + " on " + key + " with " + value); - - } - - } - } - } - } - } + }); + userDataChangeListenerRegistered = true; + } + } + + PlaceHolder platformOwned(PlaceHolder placeholder) { + platformOwnedPlaceholders.add(placeholder); + return placeholder; + } + + PlaceHolder platformOwnedWithOfflineWorkerFallback( + PlaceHolder placeholder) { + offlineWorkerPlaceholders.add(placeholder); + return platformOwned(placeholder); + } + + void publishUserDataChangePlaceholders() { + userDataChangePlaceholders = List.copyOf(placeholders); + userDataChangePlatformPlaceholders = Set.copyOf(platformOwnedPlaceholders); + userDataChangeOfflineWorkerPlaceholders = Set.copyOf(offlineWorkerPlaceholders); + } + + void onUserDataChange(AdvancedCoreUser user, String... keys) { + if (user == null || keys == null) return; + VotingPluginUser vpUser = plugin.getVotingPluginUserManager().getVotingPluginUser(user); + if (vpUser == null) return; + if (!vpUser.isCached()) vpUser.userDataFetechMode(UserDataFetchMode.NO_CACHE); + + UUID uuid = user.getJavaUUID(); + List> platformUpdates = new ArrayList<>(); + List> offlineUpdates = new ArrayList<>(); + Set> platformSnapshot = userDataChangePlatformPlaceholders; + Set> offlineSnapshot = userDataChangeOfflineWorkerPlaceholders; + Player owner = plugin.getPlaceholderPlayerPresence().schedulerOwner(uuid); + for (PlaceHolder placeholder : userDataChangePlaceholders) { + if (!shouldRefresh(placeholder, uuid, keys)) continue; + if (platformSnapshot.contains(placeholder)) { + if (owner != null) platformUpdates.add(placeholder); + else if (offlineSnapshot.contains(placeholder)) offlineUpdates.add(placeholder); + } + else updateCachedPlaceholder(placeholder, vpUser, uuid, keys); + } + + if (!offlineUpdates.isEmpty()) updateOfflinePlatformPlaceholders(uuid, vpUser, offlineUpdates, keys); + if (platformUpdates.isEmpty() || !plugin.isEnabled()) return; + schedulePlatformUpdates(uuid, vpUser, List.copyOf(platformUpdates), keys.clone()); + } + + private void schedulePlatformUpdates(UUID uuid, VotingPluginUser user, + List> platformUpdates, String[] keys) { + Player owner = plugin.getPlaceholderPlayerPresence().schedulerOwner(uuid); + if (owner == null) { + dispatchOfflinePlatformUpdates(uuid, user, platformUpdates, keys); + return; + } + Runnable update = () -> { + if (!plugin.isEnabled()) return; + if (plugin.getPlaceholderPlayerPresence().schedulerOwner(uuid) != owner) { + schedulePlatformUpdates(uuid, user, platformUpdates, keys); + return; + } + if (getCacheLevel().onlineOnly() && !plugin.getPlaceholderPlayerPresence().isOnline(uuid)) return; + for (PlaceHolder placeholder : platformUpdates) { + if (shouldRefresh(placeholder, uuid, keys)) updateCachedPlaceholder(placeholder, user, uuid, keys); + } + }; + plugin.getBukkitScheduler().runTask(plugin, update, owner); + } + + private void dispatchOfflinePlatformUpdates(UUID uuid, VotingPluginUser user, + List> platformUpdates, String[] keys) { + plugin.getUserManager().getDataManager().dispatchSharedUserDataNotification(() -> { + if (!plugin.isEnabled()) return; + if (plugin.getPlaceholderPlayerPresence().schedulerOwner(uuid) != null) { + schedulePlatformUpdates(uuid, user, platformUpdates, keys); + return; + } + if (getCacheLevel().onlineOnly()) return; + Set> offlineSnapshot = userDataChangeOfflineWorkerPlaceholders; + List> offlineUpdates = platformUpdates.stream() + .filter(offlineSnapshot::contains).filter(placeholder -> shouldRefresh(placeholder, uuid, keys)) + .toList(); + if (!offlineUpdates.isEmpty()) updateOfflinePlatformPlaceholders(uuid, user, offlineUpdates, keys); + }); + } + + private void updateOfflinePlatformPlaceholders(UUID uuid, VotingPluginUser user, + List> placeholdersToUpdate, String... keys) { + IdentityHashMap, String> values = new IdentityHashMap<>(); + for (PlaceHolder placeholder : placeholdersToUpdate) { + String value = placeholder.getIdentifier().equalsIgnoreCase("SitesAvailableTotal") + ? Integer.toString(user.getTotalNumberOfSitesWithoutOnlinePermissions()) + : Integer.toString(user.getSitesNotVotedOnWithoutOnlinePermissions()); + values.put(placeholder, value); + } + boolean published = plugin.getPlaceholderPlayerPresence().runIfOffline(uuid, () -> { + for (PlaceHolder placeholder : placeholdersToUpdate) { + for (String ident : Set.copyOf(placeholder.getCache().keySet())) { + ConcurrentHashMap cachedValues = placeholder.getCache().get(ident); + if (cachedValues != null) cachedValues.put(uuid, values.get(placeholder)); + } + } + }); + if (published) { + for (PlaceHolder placeholder : placeholdersToUpdate) { + plugin.devDebug("Updated offline placeholder cache for " + user.getUUID() + " on " + + matchingKey(placeholder, keys) + " with " + values.get(placeholder)); + } + } else if (plugin.isEnabled()) { + schedulePlatformUpdates(uuid, user, List.copyOf(placeholdersToUpdate), keys.clone()); + } + } + + private boolean shouldRefresh(PlaceHolder placeholder, UUID uuid, String... keys) { + if (!placeholder.isUsesCache() || placeholder.getCache() == null) return false; + if (getCacheLevel().onlineOnly() && !plugin.getPlaceholderPlayerPresence().isOnline(uuid)) return false; + for (String key : keys) { + if (key != null && placeholder.getUpdateDataKey().equalsIgnoreCase(key)) return true; + } + return false; + } + + @SuppressWarnings("unchecked") + private void updateCachedPlaceholder(PlaceHolder placeholder, VotingPluginUser user, + UUID uuid, String... keys) { + for (String ident : Set.copyOf(placeholder.getCache().keySet())) { + ConcurrentHashMap values = placeholder.getCache().get(ident); + if (values == null) continue; + if (placeholder instanceof CalculatingPlaceholder) { + CalculatingPlaceholder calculating = + (CalculatingPlaceholder) placeholder; + synchronized (calculating) { + calculating.getCacheData().put(uuid, calculating.placeholderDataRequest(user, ident)); + String value = calculating.placeholderRequest(user, ident); + values.put(uuid, value); + if (getCacheLevel().onlineOnly() && !plugin.getPlaceholderPlayerPresence().isOnline(uuid)) { + values.remove(uuid); + calculating.getCacheData().remove(uuid); + continue; } + plugin.devDebug("Updated calculating placeholder cache for " + user.getUUID() + " on " + + matchingKey(placeholder, keys) + " with " + value); } - } - }); + } else { + String value = placeholder.placeholderRequest(user, ident); + values.put(uuid, value); + if (getCacheLevel().onlineOnly() && !plugin.getPlaceholderPlayerPresence().isOnline(uuid)) { + values.remove(uuid); + continue; + } + plugin.devDebug("Updated placeholder cache for " + user.getUUID() + " on " + + matchingKey(placeholder, keys) + " with " + value); + } + } + } + + private String matchingKey(PlaceHolder placeholder, String... keys) { + for (String key : keys) { + if (key != null && placeholder.getUpdateDataKey().equalsIgnoreCase(key)) return key; + } + return placeholder.getUpdateDataKey(); } /** @@ -1230,12 +1363,19 @@ public void onChange(AdvancedCoreUser user, String... keys) { * @param user the voting plugin user */ public void onLogout(VotingPluginUser user) { - if (plugin.getPlaceholders().getCacheLevel().onlineOnly()) { - for (PlaceHolder placeholder : placeholders) { - if (placeholder.isUsesCache()) { - placeholder.clearCachePlayer(user.getJavaUUID()); - } - } + if (user != null) onLogout(user.getJavaUUID()); + } + + /** Clears online-only cache entries without resolving user storage. */ + public void onLogout(UUID uuid) { + if (getCacheLevel().onlineOnly()) { + for (PlaceHolder placeholder : userDataChangePlaceholders) { + if (placeholder.isUsesCache()) { + if (placeholder instanceof CalculatingPlaceholder) { + synchronized (placeholder) { placeholder.clearCachePlayer(uuid); } + } else placeholder.clearCachePlayer(uuid); + } + } } } @@ -1263,34 +1403,33 @@ public void onUpdate() { * @param user the voting plugin user * @param login whether this is a login update */ - public void onUpdate(VotingPluginUser user, boolean login) { - for (PlaceHolder placeholder : placeholders) { - if (placeholder.isUsesCache()) { - if (placeholder.isCached(placeholder.getIdentifier(), user.getJavaUUID()) || login) { - if (placeholder instanceof CalculatingPlaceholder) { - - CalculatingPlaceholder cPlaceholder = (CalculatingPlaceholder) placeholder; - if (placeholder.isUsesCache()) { - for (String ident : placeholder.getCache().keySet()) { - cPlaceholder.getCacheData().put(user.getJavaUUID(), - cPlaceholder.placeholderDataRequest(user, ident)); - placeholder.getCache().get(ident).put(user.getJavaUUID(), - placeholder.placeholderRequest(user, ident)); - } - - } - } else { - if (placeholder.isUsesCache()) { - for (String ident : placeholder.getCache().keySet()) { - placeholder.getCache().get(ident).put(user.getJavaUUID(), - placeholder.placeholderRequest(user, ident)); - } - } - } - } - } - } - } + public void onUpdate(VotingPluginUser user, boolean login) { + if (user == null) return; + UUID uuid = user.getJavaUUID(); + Player owner = plugin.getPlaceholderPlayerPresence().schedulerOwner(uuid); + Set> platformSnapshot = userDataChangePlatformPlaceholders; + Set> offlineSnapshot = userDataChangeOfflineWorkerPlaceholders; + List> platformUpdates = new ArrayList<>(); + List> offlineUpdates = new ArrayList<>(); + for (PlaceHolder placeholder : List.copyOf(placeholders)) { + if (!placeholder.isUsesCache() || placeholder.getCache() == null + || (!placeholder.isCached(placeholder.getIdentifier(), uuid) && !login)) continue; + if (platformSnapshot.contains(placeholder)) { + if (owner != null) platformUpdates.add(placeholder); + else if (!getCacheLevel().onlineOnly() && offlineSnapshot.contains(placeholder)) { + offlineUpdates.add(placeholder); + } + } else updateCachedPlaceholder(placeholder, user, uuid, placeholder.getUpdateDataKey()); + } + if (!offlineUpdates.isEmpty()) { + updateOfflinePlatformPlaceholders(uuid, user, offlineUpdates, + offlineUpdates.stream().map(PlaceHolder::getUpdateDataKey).toArray(String[]::new)); + } + if (!platformUpdates.isEmpty() && plugin.isEnabled()) { + schedulePlatformUpdates(uuid, user, List.copyOf(platformUpdates), + platformUpdates.stream().map(PlaceHolder::getUpdateDataKey).toArray(String[]::new)); + } + } /** * Handle vote party update. @@ -1309,6 +1448,7 @@ public void onVotePartyUpdate() { * Reload placeholders. */ public void reload() { + plugin.refreshPlaceholderPlayerPresence(); cacheLevel = plugin.getConfigFile().getPlaceholderCacheLevel(); onUpdate(); if (!cacheLevel.equals(PlaceholderCacheLevel.NONE)) { @@ -1328,7 +1468,7 @@ public void schedulePlaceholderCheck(VotingPluginUser user) { @Override public void run() { checkNonCachedPlaceholders(); - onUpdate(user, user.isOnline()); + onUpdate(user, plugin.getPlaceholderPlayerPresence().isOnline(user.getJavaUUID())); } }); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresence.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresence.java new file mode 100644 index 0000000000..88d23dd6b1 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresence.java @@ -0,0 +1,85 @@ +package com.bencodez.votingplugin.placeholders; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; + +import org.bukkit.entity.Player; + +/** + * Immutable snapshots of live Bukkit players captured at player-owned lifecycle + * boundaries. Storage workers may inspect membership and pass the captured owner + * to the Folia-aware scheduler, but must not call methods on the player directly. + */ +public final class PlaceholderPlayerPresence { + private final AtomicReference> onlinePlayers = new AtomicReference<>(Map.of()); + private final Object lifecycleLock = new Object(); + + public boolean isOnline(UUID uuid) { + return uuid != null && onlinePlayers.get().containsKey(uuid); + } + + public Player schedulerOwner(UUID uuid) { + return uuid == null ? null : onlinePlayers.get().get(uuid); + } + + public void playerOnline(Player player) { + if (player == null) return; + UUID uuid = player.getUniqueId(); + synchronized (lifecycleLock) { + update(current -> { + Map next = new HashMap<>(current); + next.put(uuid, player); + return Map.copyOf(next); + }); + } + } + + public void playerOffline(UUID uuid) { + if (uuid == null) return; + synchronized (lifecycleLock) { + update(current -> { + if (!current.containsKey(uuid)) return current; + Map next = new HashMap<>(current); + next.remove(uuid); + return Map.copyOf(next); + }); + } + } + + public void replace(Collection players) { + Map next = new HashMap<>(); + if (players != null) { + for (Player player : players) { + if (player != null) next.put(player.getUniqueId(), player); + } + } + synchronized (lifecycleLock) { onlinePlayers.set(Map.copyOf(next)); } + } + + public void clear() { + synchronized (lifecycleLock) { onlinePlayers.set(Map.of()); } + } + + /** Publish an offline-only result atomically with player lifecycle capture. */ + public boolean runIfOffline(UUID uuid, Runnable publication) { + if (uuid == null || publication == null) return false; + synchronized (lifecycleLock) { + if (onlinePlayers.get().containsKey(uuid)) return false; + publication.run(); + return true; + } + } + + private void update(java.util.function.Function, Map> operation) { + Map current; + Map next; + do { + current = onlinePlayers.get(); + next = operation.apply(current); + if (next == current) return; + } while (!onlinePlayers.compareAndSet(current, next)); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 48aff1f0fe..406df59581 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -1560,8 +1560,8 @@ public String getPointsPath() { * * @return the number of sites not voted on */ - public int getSitesNotVotedOn() { - int amount = 0; + public int getSitesNotVotedOn() { + int amount = 0; for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { if (!site.isHidden()) { if (site.getPermissionToView().isEmpty() || hasPermission(site.getPermissionToView(), false)) { @@ -1571,20 +1571,46 @@ public int getSitesNotVotedOn() { } } } - return amount; - } + return amount; + } + + /** + * Match the existing offline result without resolving a live Bukkit player. + * Permission-gated sites are excluded because the normal call uses + * {@code hasPermission(permission, false)}, which returns false offline. + */ + public int getSitesNotVotedOnWithoutOnlinePermissions() { + int amount = 0; + for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { + if (!site.isHidden() && site.getPermissionToView().isEmpty() && canVoteSite(site)) amount++; + } + return amount; + } - public int getTotalNumberOfSites() { - int amount = 0; - for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { + public int getTotalNumberOfSites() { + int amount = 0; + for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { if (!site.isHidden()) { if (site.getPermissionToView().isEmpty() || hasPermission(site.getPermissionToView(), false)) { amount++; } } } - return amount; - } + return amount; + } + + /** + * Match the existing offline total without resolving a live Bukkit player. + * Permission-gated sites are excluded because the normal call uses + * {@code hasPermission(permission, false)}, which returns false offline. + */ + public int getTotalNumberOfSitesWithoutOnlinePermissions() { + int amount = 0; + for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { + if (!site.isHidden() && site.getPermissionToView().isEmpty()) amount++; + } + return amount; + } public int getSitesVotedOn() { int amount = 0; diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerJoinEventPresenceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerJoinEventPresenceTest.java new file mode 100644 index 0000000000..35eb7e6e36 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerJoinEventPresenceTest.java @@ -0,0 +1,49 @@ +package com.bencodez.votingplugin.listeners; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.UUID; +import java.util.concurrent.ScheduledExecutorService; + +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerQuitEvent; +import org.junit.jupiter.api.Test; + +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.config.BungeeSettings; +import com.bencodez.votingplugin.placeholders.PlaceholderPlayerPresence; + +class PlayerJoinEventPresenceTest { + @Test + void bukkitJoinAndQuitUpdatePresenceBeforeAsyncQuitCleanup() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + PlaceholderPlayerPresence presence = new PlaceholderPlayerPresence(); + when(plugin.getPlaceholderPlayerPresence()).thenReturn(presence); + when(plugin.isEnabled()).thenReturn(true); + BungeeSettings bungee = mock(BungeeSettings.class); + when(plugin.getBungeeSettings()).thenReturn(bungee); + when(bungee.isUseBungeecoord()).thenReturn(false); + ScheduledExecutorService loginTimer = mock(ScheduledExecutorService.class); + when(plugin.getLoginTimer()).thenReturn(loginTimer); + doAnswer(call -> null).when(loginTimer).execute(any(Runnable.class)); + + UUID uuid = UUID.randomUUID(); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(uuid); + org.bukkit.event.player.PlayerJoinEvent join = mock(org.bukkit.event.player.PlayerJoinEvent.class); + when(join.getPlayer()).thenReturn(player); + PlayerQuitEvent quit = mock(PlayerQuitEvent.class); + when(quit.getPlayer()).thenReturn(player); + PlayerJoinEvent listener = new PlayerJoinEvent(plugin); + + listener.onPlayerJoin(join); + assertTrue(presence.isOnline(uuid)); + listener.onPlayerQuit(quit); + assertFalse(presence.isOnline(uuid), "quit must publish offline before asynchronous storage cleanup"); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java new file mode 100644 index 0000000000..f84db978ee --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java @@ -0,0 +1,246 @@ +package com.bencodez.votingplugin.placeholders; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.bukkit.entity.Player; +import org.bukkit.Bukkit; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import com.bencodez.advancedcore.api.placeholder.CalculatingPlaceholder; +import com.bencodez.advancedcore.api.placeholder.PlaceHolder; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.config.Config; +import com.bencodez.votingplugin.user.UserManager; +import com.bencodez.votingplugin.user.VotingPluginUser; + +class PlaceHoldersWorkerSafetyTest { + @Test + void workerSafeUpdateUsesCapturedPresenceWithoutBukkitScheduling() { + Fixture fixture = new Fixture(); + AtomicInteger requests = new AtomicInteger(); + PlaceHolder placeholder = fixture.cachedPlaceholder("Points", requests); + fixture.publish(placeholder); + + try (var bukkit = mockStatic(Bukkit.class)) { + fixture.placeholders.onUserDataChange(fixture.advancedUser, "Points"); + assertEquals(0, requests.get(), "offline online-only users must not be refreshed"); + + fixture.presence.playerOnline(fixture.player); + fixture.placeholders.onUserDataChange(fixture.advancedUser, "Points"); + assertEquals(1, requests.get()); + assertEquals("value", placeholder.getCache().get("points").get(fixture.uuid)); + verify(fixture.scheduler, never()).runTask(eq(fixture.plugin), any(Runnable.class)); + verify(fixture.advancedUser, never()).isOnline(); + + fixture.presence.playerOffline(fixture.uuid); + fixture.placeholders.onLogout(fixture.uuid); + assertFalse(placeholder.getCache().get("points").containsKey(fixture.uuid)); + fixture.placeholders.onUserDataChange(fixture.advancedUser, "Points"); + assertEquals(1, requests.get()); + bukkit.verifyNoInteractions(); + } + } + + @Test + void playerDependentUpdateSchedulesOnlyItsEntityOwnedWork() { + Fixture fixture = new Fixture(); + fixture.presence.playerOnline(fixture.player); + AtomicInteger requests = new AtomicInteger(); + PlaceHolder placeholder = fixture.cachedPlaceholder("LastVotes", requests); + fixture.placeholders.getPlaceholders().add(fixture.placeholders.platformOwned(placeholder)); + fixture.placeholders.publishUserDataChangePlaceholders(); + + fixture.placeholders.onUserDataChange(fixture.advancedUser, "LastVotes"); + + assertEquals(0, requests.get()); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), task.capture(), eq(fixture.player)); + task.getValue().run(); + assertEquals(1, requests.get()); + verify(fixture.advancedUser, never()).isOnline(); + } + + @Test + void backgroundWarmupSchedulesPlayerDependentWorkOnTheEntityOwner() { + Fixture fixture = new Fixture(); + fixture.presence.playerOnline(fixture.player); + AtomicInteger requests = new AtomicInteger(); + PlaceHolder placeholder = fixture.cachedPlaceholder("LastVotes", requests); + fixture.placeholders.getPlaceholders().add(fixture.placeholders.platformOwned(placeholder)); + fixture.placeholders.publishUserDataChangePlaceholders(); + + fixture.placeholders.onUpdate(fixture.votingUser, true); + + assertEquals(0, requests.get()); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), task.capture(), eq(fixture.player)); + task.getValue().run(); + assertEquals(1, requests.get()); + } + + @Test + void offlineAllCacheUsesTheWorkerSafePlatformFallback() { + Fixture fixture = new Fixture(PlaceholderCacheLevel.AUTOALL); + when(fixture.votingUser.getSitesNotVotedOnWithoutOnlinePermissions()).thenReturn(4); + when(fixture.votingUser.getTotalNumberOfSitesWithoutOnlinePermissions()).thenReturn(6); + AtomicInteger liveRequests = new AtomicInteger(); + PlaceHolder available = fixture.cachedPlaceholder("SitesAvailable", "LastVotes", liveRequests); + PlaceHolder total = fixture.cachedPlaceholder("SitesAvailableTotal", "LastVotes", liveRequests); + fixture.placeholders.getPlaceholders().add( + fixture.placeholders.platformOwnedWithOfflineWorkerFallback(available)); + fixture.placeholders.getPlaceholders().add( + fixture.placeholders.platformOwnedWithOfflineWorkerFallback(total)); + fixture.placeholders.publishUserDataChangePlaceholders(); + + fixture.placeholders.onUserDataChange(fixture.advancedUser, "LastVotes"); + + assertEquals("4", available.getCache().get("sitesavailable").get(fixture.uuid)); + assertEquals("6", total.getCache().get("sitesavailabletotal").get(fixture.uuid)); + assertEquals(0, liveRequests.get(), "offline fallback must not resolve live player permissions"); + verify(fixture.scheduler, never()).runTask(eq(fixture.plugin), any(Runnable.class), any(Player.class)); + } + + @Test + void playerDependentUpdateMovesToTheReplacementEntityOwner() { + Fixture fixture = new Fixture(); + fixture.presence.playerOnline(fixture.player); + AtomicInteger requests = new AtomicInteger(); + PlaceHolder placeholder = fixture.cachedPlaceholder("LastVotes", requests); + fixture.placeholders.getPlaceholders().add(fixture.placeholders.platformOwned(placeholder)); + fixture.placeholders.publishUserDataChangePlaceholders(); + + fixture.placeholders.onUserDataChange(fixture.advancedUser, "LastVotes"); + ArgumentCaptor tasks = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), tasks.capture(), eq(fixture.player)); + + Player replacement = mock(Player.class); + when(replacement.getUniqueId()).thenReturn(fixture.uuid); + fixture.presence.playerOffline(fixture.uuid); + fixture.presence.playerOnline(replacement); + tasks.getValue().run(); + assertEquals(0, requests.get(), "the stale owner task must not touch the replacement player"); + + verify(fixture.scheduler).runTask(eq(fixture.plugin), tasks.capture(), eq(replacement)); + tasks.getAllValues().get(1).run(); + assertEquals(1, requests.get()); + verify(fixture.scheduler, times(2)).runTask(eq(fixture.plugin), any(Runnable.class), any(Player.class)); + } + + @Test + void concurrentCalculatingUpdatesKeepBothCachesCoherent() throws Exception { + Fixture fixture = new Fixture(); + fixture.presence.playerOnline(fixture.player); + AtomicInteger sequence = new AtomicInteger(); + CalculatingPlaceholder placeholder = new CalculatingPlaceholder<>("Next_site") { + @Override public String placeholderRequest(VotingPluginUser user, String identifier) { + return getCacheData().get(user.getJavaUUID()); + } + @Override public String placeholderDataRequest(VotingPluginUser user, String identifier) { + return Integer.toString(sequence.incrementAndGet()); + } + }; + placeholder.updateDataKey("LastVotes").setUseCache(true, "next_site"); + fixture.publish(placeholder); + + ExecutorService workers = Executors.newFixedThreadPool(8); + try { + List> updates = new ArrayList<>(); + for (int i = 0; i < 100; i++) { + boolean bulkUpdate = i % 2 == 0; + updates.add(() -> { + if (bulkUpdate) fixture.placeholders.onUpdate(fixture.votingUser, true); + else fixture.placeholders.onUserDataChange(fixture.advancedUser, "LastVotes"); + return null; + }); + } + workers.invokeAll(updates).forEach(result -> { + try { result.get(); } + catch (Exception failure) { throw new AssertionError(failure); } + }); + } finally { + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + assertEquals(placeholder.getCacheData().get(fixture.uuid), + placeholder.getCache().get("next_site").get(fixture.uuid)); + assertEquals(1, placeholder.getCache().size()); + } + + private static final class Fixture { + final UUID uuid = UUID.randomUUID(); + final VotingPluginMain plugin = mock(VotingPluginMain.class); + final Config config = mock(Config.class); + final UserManager userManager = mock(UserManager.class); + final VotingPluginUser votingUser = mock(VotingPluginUser.class); + final AdvancedCoreUser advancedUser = mock(AdvancedCoreUser.class); + final BukkitScheduler scheduler = mock(BukkitScheduler.class); + final PlaceholderPlayerPresence presence = new PlaceholderPlayerPresence(); + final Player player = mock(Player.class); + final PlaceHolders placeholders; + + Fixture() { + this(PlaceholderCacheLevel.AUTO); + } + + Fixture(PlaceholderCacheLevel level) { + when(plugin.getConfigFile()).thenReturn(config); + when(config.getPlaceholderCacheLevel()).thenReturn(level); + when(plugin.getVotingPluginUserManager()).thenReturn(userManager); + when(userManager.getVotingPluginUser(advancedUser)).thenReturn(votingUser); + when(votingUser.isCached()).thenReturn(true); + when(votingUser.getJavaUUID()).thenReturn(uuid); + when(votingUser.getUUID()).thenReturn(uuid.toString()); + when(advancedUser.getJavaUUID()).thenReturn(uuid); + doThrow(new AssertionError("worker callback accessed live Bukkit online state")) + .when(advancedUser).isOnline(); + when(plugin.getPlaceholderPlayerPresence()).thenReturn(presence); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.isEnabled()).thenReturn(true); + when(player.getUniqueId()).thenReturn(uuid); + placeholders = new PlaceHolders(plugin); + } + + PlaceHolder cachedPlaceholder(String key, AtomicInteger requests) { + return cachedPlaceholder(key, key, requests); + } + + PlaceHolder cachedPlaceholder(String identifier, String key, AtomicInteger requests) { + PlaceHolder placeholder = new PlaceHolder<>(identifier) { + @Override public String placeholderRequest(VotingPluginUser user, String identifier) { + requests.incrementAndGet(); + return "value"; + } + }; + placeholder.updateDataKey(key).setUseCache(true, identifier.toLowerCase()); + return placeholder; + } + + void publish(PlaceHolder placeholder) { + placeholders.getPlaceholders().add(placeholder); + placeholders.publishUserDataChangePlaceholders(); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresenceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresenceTest.java new file mode 100644 index 0000000000..51449a2cc4 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresenceTest.java @@ -0,0 +1,52 @@ +package com.bencodez.votingplugin.placeholders; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +class PlaceholderPlayerPresenceTest { + @Test + void joinQuitReloadAndDisableSnapshotsStayConsistent() { + PlaceholderPlayerPresence presence = new PlaceholderPlayerPresence(); + UUID firstId = UUID.randomUUID(); + UUID secondId = UUID.randomUUID(); + Player first = player(firstId); + Player second = player(secondId); + + assertFalse(presence.isOnline(firstId)); + AtomicInteger offlinePublications = new AtomicInteger(); + assertTrue(presence.runIfOffline(firstId, offlinePublications::incrementAndGet)); + assertEquals(1, offlinePublications.get()); + presence.playerOnline(first); + assertTrue(presence.isOnline(firstId)); + assertSame(first, presence.schedulerOwner(firstId)); + assertFalse(presence.runIfOffline(firstId, offlinePublications::incrementAndGet)); + assertEquals(1, offlinePublications.get()); + + presence.playerOffline(firstId); + assertFalse(presence.isOnline(firstId)); + presence.replace(List.of(first, second)); + assertTrue(presence.isOnline(firstId)); + assertTrue(presence.isOnline(secondId)); + + presence.clear(); + assertFalse(presence.isOnline(firstId)); + assertFalse(presence.isOnline(secondId)); + } + + private Player player(UUID uuid) { + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(uuid); + return player; + } +} From 564312902ded4fb94f1033540c5c022f65cbbea2 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:19:49 -0600 Subject: [PATCH 02/13] Harden placeholder presence lifecycle handling --- .../votingplugin/VotingPluginMain.java | 7 ++- .../listeners/PlayerJoinEvent.java | 19 +++++-- .../placeholders/PlaceHolders.java | 49 ++++++++-------- .../PlaceholderPlayerPresence.java | 38 ++++++++++++- .../util/BukkitCompletionScheduler.java | 23 +++++--- .../PlayerJoinEventPresenceTest.java | 14 ++++- .../PlaceHoldersWorkerSafetyTest.java | 56 +++++++++++++++++++ .../PlaceholderPlayerPresenceTest.java | 9 +++ .../util/BukkitCompletionSchedulerTest.java | 18 ++++++ 9 files changed, 196 insertions(+), 37 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 7a40b3e740..483e78cfde 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -1937,7 +1937,12 @@ public void reloadAll() { /** Captures Bukkit presence while the lifecycle caller owns platform access. */ public void refreshPlaceholderPlayerPresence() { - placeholderPlayerPresence.replace(Bukkit.getOnlinePlayers()); + HashMap online = new HashMap<>(); + for (Player player : Bukkit.getOnlinePlayers()) { + VotingPluginUser user = getVotingPluginUserManager().getVotingPluginUser(player); + online.put(user == null || user.getJavaUUID() == null ? player.getUniqueId() : user.getJavaUUID(), player); + } + placeholderPlayerPresence.replace(online); } /** Reloads configuration applied by Control before its result is acknowledged. */ diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerJoinEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerJoinEvent.java index 7ba5eccdf5..f3ddf93e22 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerJoinEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerJoinEvent.java @@ -1,4 +1,6 @@ -package com.bencodez.votingplugin.listeners; +package com.bencodez.votingplugin.listeners; + +import java.util.UUID; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -29,7 +31,14 @@ public PlayerJoinEvent(VotingPluginMain plugin) { /** Capture the entity owner before asynchronous user notifications can need it. */ @EventHandler(priority = EventPriority.LOWEST) public void onPlayerJoin(org.bukkit.event.player.PlayerJoinEvent event) { - if (event != null) plugin.getPlaceholderPlayerPresence().playerOnline(event.getPlayer()); + if (event == null || event.getPlayer() == null) return; + Player player = event.getPlayer(); + plugin.getPlaceholderPlayerPresence().playerOnline(placeholderUuid(player), player); + } + + private UUID placeholderUuid(Player player) { + VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(player); + return user == null || user.getJavaUUID() == null ? player.getUniqueId() : user.getJavaUUID(); } private static boolean isBlank(String s) { @@ -100,8 +109,10 @@ public void onPlayerQuit(PlayerQuitEvent event) { if (player == null) { return; } - plugin.getPlaceholderPlayerPresence().playerOffline(player.getUniqueId()); - if (plugin.getPlaceholders() != null) plugin.getPlaceholders().onLogout(player.getUniqueId()); + UUID placeholderUuid = plugin.getPlaceholderPlayerPresence().storageUuid(player); + if (placeholderUuid == null) placeholderUuid = placeholderUuid(player); + plugin.getPlaceholderPlayerPresence().playerOffline(placeholderUuid); + if (plugin.getPlaceholders() != null) plugin.getPlaceholders().onLogout(placeholderUuid); if (plugin.getBungeeSettings().isUseBungeecoord()) { plugin.getBackendProxyHandler().playerOffline(player.getName()); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java index eb04b40877..884770a423 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java @@ -33,8 +33,9 @@ import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.specialrewards.votemilestones.VoteMilestone; import com.bencodez.votingplugin.topvoter.TopVoter; -import com.bencodez.votingplugin.topvoter.TopVoterPlayer; -import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.topvoter.TopVoterPlayer; +import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.util.BukkitCompletionScheduler; import com.bencodez.votingplugin.votesites.VoteSite; import lombok.Getter; @@ -56,9 +57,10 @@ public class PlaceHolders { Collections.newSetFromMap(new IdentityHashMap<>()); private final Set> offlineWorkerPlaceholders = Collections.newSetFromMap(new IdentityHashMap<>()); - private volatile List> userDataChangePlaceholders = List.of(); - private volatile Set> userDataChangePlatformPlaceholders = Set.of(); - private volatile Set> userDataChangeOfflineWorkerPlaceholders = Set.of(); + private record PlaceholderClassification(List> all, + Set> platform, Set> offlineWorker) { } + private volatile PlaceholderClassification userDataChangeClassification = + new PlaceholderClassification(List.of(), Set.of(), Set.of()); private boolean userDataChangeListenerRegistered; @Getter @@ -1212,9 +1214,8 @@ PlaceHolder platformOwnedWithOfflineWorkerFallback( } void publishUserDataChangePlaceholders() { - userDataChangePlaceholders = List.copyOf(placeholders); - userDataChangePlatformPlaceholders = Set.copyOf(platformOwnedPlaceholders); - userDataChangeOfflineWorkerPlaceholders = Set.copyOf(offlineWorkerPlaceholders); + userDataChangeClassification = new PlaceholderClassification(List.copyOf(placeholders), + Set.copyOf(platformOwnedPlaceholders), Set.copyOf(offlineWorkerPlaceholders)); } void onUserDataChange(AdvancedCoreUser user, String... keys) { @@ -1226,14 +1227,13 @@ void onUserDataChange(AdvancedCoreUser user, String... keys) { UUID uuid = user.getJavaUUID(); List> platformUpdates = new ArrayList<>(); List> offlineUpdates = new ArrayList<>(); - Set> platformSnapshot = userDataChangePlatformPlaceholders; - Set> offlineSnapshot = userDataChangeOfflineWorkerPlaceholders; + PlaceholderClassification classification = userDataChangeClassification; Player owner = plugin.getPlaceholderPlayerPresence().schedulerOwner(uuid); - for (PlaceHolder placeholder : userDataChangePlaceholders) { + for (PlaceHolder placeholder : classification.all()) { if (!shouldRefresh(placeholder, uuid, keys)) continue; - if (platformSnapshot.contains(placeholder)) { + if (classification.platform().contains(placeholder)) { if (owner != null) platformUpdates.add(placeholder); - else if (offlineSnapshot.contains(placeholder)) offlineUpdates.add(placeholder); + else if (classification.offlineWorker().contains(placeholder)) offlineUpdates.add(placeholder); } else updateCachedPlaceholder(placeholder, vpUser, uuid, keys); } @@ -1261,7 +1261,11 @@ private void schedulePlatformUpdates(UUID uuid, VotingPluginUser user, if (shouldRefresh(placeholder, uuid, keys)) updateCachedPlaceholder(placeholder, user, uuid, keys); } }; - plugin.getBukkitScheduler().runTask(plugin, update, owner); + BukkitCompletionScheduler.run(plugin, owner, update, () -> { + if (!plugin.isEnabled()) return; + plugin.getPlaceholderPlayerPresence().playerOffline(uuid, owner); + dispatchOfflinePlatformUpdates(uuid, user, platformUpdates, keys); + }, () -> { }); } private void dispatchOfflinePlatformUpdates(UUID uuid, VotingPluginUser user, @@ -1273,9 +1277,10 @@ private void dispatchOfflinePlatformUpdates(UUID uuid, VotingPluginUser user, return; } if (getCacheLevel().onlineOnly()) return; - Set> offlineSnapshot = userDataChangeOfflineWorkerPlaceholders; + PlaceholderClassification classification = userDataChangeClassification; List> offlineUpdates = platformUpdates.stream() - .filter(offlineSnapshot::contains).filter(placeholder -> shouldRefresh(placeholder, uuid, keys)) + .filter(classification.offlineWorker()::contains) + .filter(placeholder -> shouldRefresh(placeholder, uuid, keys)) .toList(); if (!offlineUpdates.isEmpty()) updateOfflinePlatformPlaceholders(uuid, user, offlineUpdates, keys); }); @@ -1369,7 +1374,8 @@ public void onLogout(VotingPluginUser user) { /** Clears online-only cache entries without resolving user storage. */ public void onLogout(UUID uuid) { if (getCacheLevel().onlineOnly()) { - for (PlaceHolder placeholder : userDataChangePlaceholders) { + PlaceholderClassification classification = userDataChangeClassification; + for (PlaceHolder placeholder : classification.all()) { if (placeholder.isUsesCache()) { if (placeholder instanceof CalculatingPlaceholder) { synchronized (placeholder) { placeholder.clearCachePlayer(uuid); } @@ -1407,16 +1413,15 @@ public void onUpdate(VotingPluginUser user, boolean login) { if (user == null) return; UUID uuid = user.getJavaUUID(); Player owner = plugin.getPlaceholderPlayerPresence().schedulerOwner(uuid); - Set> platformSnapshot = userDataChangePlatformPlaceholders; - Set> offlineSnapshot = userDataChangeOfflineWorkerPlaceholders; + PlaceholderClassification classification = userDataChangeClassification; List> platformUpdates = new ArrayList<>(); List> offlineUpdates = new ArrayList<>(); - for (PlaceHolder placeholder : List.copyOf(placeholders)) { + for (PlaceHolder placeholder : classification.all()) { if (!placeholder.isUsesCache() || placeholder.getCache() == null || (!placeholder.isCached(placeholder.getIdentifier(), uuid) && !login)) continue; - if (platformSnapshot.contains(placeholder)) { + if (classification.platform().contains(placeholder)) { if (owner != null) platformUpdates.add(placeholder); - else if (!getCacheLevel().onlineOnly() && offlineSnapshot.contains(placeholder)) { + else if (!getCacheLevel().onlineOnly() && classification.offlineWorker().contains(placeholder)) { offlineUpdates.add(placeholder); } } else updateCachedPlaceholder(placeholder, user, uuid, placeholder.getUpdateDataKey()); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresence.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresence.java index 88d23dd6b1..b7ee6ac8c5 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresence.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresence.java @@ -25,9 +25,23 @@ public Player schedulerOwner(UUID uuid) { return uuid == null ? null : onlinePlayers.get().get(uuid); } + /** Resolve the storage key already published for this exact scheduler owner. */ + public UUID storageUuid(Player player) { + if (player == null) return null; + for (Map.Entry entry : onlinePlayers.get().entrySet()) { + if (entry.getValue() == player) return entry.getKey(); + } + return null; + } + public void playerOnline(Player player) { if (player == null) return; - UUID uuid = player.getUniqueId(); + playerOnline(player.getUniqueId(), player); + } + + /** Publish the storage UUID with the captured Bukkit scheduler owner. */ + public void playerOnline(UUID uuid, Player player) { + if (uuid == null || player == null) return; synchronized (lifecycleLock) { update(current -> { Map next = new HashMap<>(current); @@ -49,6 +63,19 @@ public void playerOffline(UUID uuid) { } } + /** Remove only the retired scheduler owner, preserving a concurrently joined replacement. */ + public void playerOffline(UUID uuid, Player expectedOwner) { + if (uuid == null || expectedOwner == null) return; + synchronized (lifecycleLock) { + update(current -> { + if (current.get(uuid) != expectedOwner) return current; + Map next = new HashMap<>(current); + next.remove(uuid); + return Map.copyOf(next); + }); + } + } + public void replace(Collection players) { Map next = new HashMap<>(); if (players != null) { @@ -59,6 +86,15 @@ public void replace(Collection players) { synchronized (lifecycleLock) { onlinePlayers.set(Map.copyOf(next)); } } + /** Replace presence with storage UUIDs resolved at a platform-owned boundary. */ + public void replace(Map players) { + Map next = new HashMap<>(); + if (players != null) players.forEach((uuid, player) -> { + if (uuid != null && player != null) next.put(uuid, player); + }); + synchronized (lifecycleLock) { onlinePlayers.set(Map.copyOf(next)); } + } + public void clear() { synchronized (lifecycleLock) { onlinePlayers.set(Map.of()); } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitCompletionScheduler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitCompletionScheduler.java index f5135b76a2..3d806c6948 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitCompletionScheduler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitCompletionScheduler.java @@ -23,25 +23,34 @@ public static void run(VotingPluginMain plugin, Player player, Runnable task) { * work has a durable pre-scheduler claim use this to record a recovery state. */ public static void run(VotingPluginMain plugin, Player player, Runnable task, Runnable rejected) { + run(plugin, player, task, task, rejected); + } + + /** Schedule entity-owned work with a separate global-safe retirement fallback. */ + public static void run(VotingPluginMain plugin, Player player, Runnable entityTask, + Runnable fallbackTask, Runnable rejected) { AtomicBoolean executed = new AtomicBoolean(); - Runnable once = () -> { - if (executed.compareAndSet(false, true)) task.run(); + Runnable entityOnce = () -> { + if (executed.compareAndSet(false, true)) entityTask.run(); + }; + Runnable fallbackOnce = () -> { + if (executed.compareAndSet(false, true)) fallbackTask.run(); }; if (player == null) { - runGlobal(plugin, once, rejected); + runGlobal(plugin, fallbackOnce, rejected); return; } AtomicBoolean fallbackSubmitted = new AtomicBoolean(); Runnable fallback = () -> { - if (fallbackSubmitted.compareAndSet(false, true)) runGlobal(plugin, once, rejected); + if (fallbackSubmitted.compareAndSet(false, true)) runGlobal(plugin, fallbackOnce, rejected); }; try { if (plugin.getBukkitScheduler().getFoliaLib() == null) { - runLegacyEntity(plugin, player, once, fallback); + runLegacyEntity(plugin, player, entityOnce, fallback); return; } CompletableFuture result = plugin.getBukkitScheduler().getFoliaLib().getImpl() - .runAtEntityWithFallback(player, ignored -> once.run(), fallback); + .runAtEntityWithFallback(player, ignored -> entityOnce.run(), fallback); result.whenComplete((status, failure) -> { // ENTITY_RETIRED invokes fallback itself. A scheduler that was already // retired returns SCHEDULER_RETIRED without invoking it. @@ -50,7 +59,7 @@ public static void run(VotingPluginMain plugin, Player player, Runnable task, Ru } else if (!executed.get()) { // Compatibility with scheduler adapters that report admission but do // not run the consumer inline with future completion. - runLegacyEntity(plugin, player, once, fallback); + runLegacyEntity(plugin, player, entityOnce, fallback); } }); } catch (RuntimeException schedulingFailure) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerJoinEventPresenceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerJoinEventPresenceTest.java index 35eb7e6e36..58ccb8fcae 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerJoinEventPresenceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerJoinEventPresenceTest.java @@ -17,6 +17,8 @@ import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.config.BungeeSettings; import com.bencodez.votingplugin.placeholders.PlaceholderPlayerPresence; +import com.bencodez.votingplugin.user.UserManager; +import com.bencodez.votingplugin.user.VotingPluginUser; class PlayerJoinEventPresenceTest { @Test @@ -33,8 +35,14 @@ void bukkitJoinAndQuitUpdatePresenceBeforeAsyncQuitCleanup() { doAnswer(call -> null).when(loginTimer).execute(any(Runnable.class)); UUID uuid = UUID.randomUUID(); + UUID storageUuid = UUID.randomUUID(); Player player = mock(Player.class); when(player.getUniqueId()).thenReturn(uuid); + UserManager userManager = mock(UserManager.class); + VotingPluginUser user = mock(VotingPluginUser.class); + when(plugin.getVotingPluginUserManager()).thenReturn(userManager); + when(userManager.getVotingPluginUser(player)).thenReturn(user); + when(user.getJavaUUID()).thenReturn(storageUuid); org.bukkit.event.player.PlayerJoinEvent join = mock(org.bukkit.event.player.PlayerJoinEvent.class); when(join.getPlayer()).thenReturn(player); PlayerQuitEvent quit = mock(PlayerQuitEvent.class); @@ -42,8 +50,10 @@ void bukkitJoinAndQuitUpdatePresenceBeforeAsyncQuitCleanup() { PlayerJoinEvent listener = new PlayerJoinEvent(plugin); listener.onPlayerJoin(join); - assertTrue(presence.isOnline(uuid)); + assertTrue(presence.isOnline(storageUuid)); + assertFalse(presence.isOnline(uuid)); listener.onPlayerQuit(quit); - assertFalse(presence.isOnline(uuid), "quit must publish offline before asynchronous storage cleanup"); + assertFalse(presence.isOnline(storageUuid), + "quit must publish the storage UUID offline before asynchronous storage cleanup"); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java index f84db978ee..f9d8b50252 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java @@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; @@ -17,6 +18,7 @@ import java.util.List; import java.util.UUID; import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; @@ -30,6 +32,10 @@ import com.bencodez.advancedcore.api.placeholder.CalculatingPlaceholder; import com.bencodez.advancedcore.api.placeholder.PlaceHolder; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.simpleapi.folialib.FoliaLib; +import com.bencodez.simpleapi.folialib.enums.EntityTaskResult; +import com.bencodez.simpleapi.folialib.impl.ServerImplementation; import com.bencodez.simpleapi.scheduler.BukkitScheduler; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.config.Config; @@ -101,6 +107,20 @@ void backgroundWarmupSchedulesPlayerDependentWorkOnTheEntityOwner() { assertEquals(1, requests.get()); } + @Test + void unpublishedReloadGenerationCannotBypassPlatformClassification() { + Fixture fixture = new Fixture(); + fixture.presence.playerOnline(fixture.player); + AtomicInteger requests = new AtomicInteger(); + PlaceHolder placeholder = fixture.cachedPlaceholder("LastVotes", requests); + fixture.placeholders.getPlaceholders().add(fixture.placeholders.platformOwned(placeholder)); + + fixture.placeholders.onUpdate(fixture.votingUser, true); + + assertEquals(0, requests.get(), "readers must use the last complete published classification"); + verify(fixture.scheduler, never()).runTask(eq(fixture.plugin), any(Runnable.class), any(Player.class)); + } + @Test void offlineAllCacheUsesTheWorkerSafePlatformFallback() { Fixture fixture = new Fixture(PlaceholderCacheLevel.AUTOALL); @@ -123,6 +143,35 @@ void offlineAllCacheUsesTheWorkerSafePlatformFallback() { verify(fixture.scheduler, never()).runTask(eq(fixture.plugin), any(Runnable.class), any(Player.class)); } + @Test + void retiredEntityFallsBackToOfflineWorkerUpdate() { + Fixture fixture = new Fixture(PlaceholderCacheLevel.AUTOALL); + when(fixture.votingUser.getSitesNotVotedOnWithoutOnlinePermissions()).thenReturn(4); + AtomicInteger liveRequests = new AtomicInteger(); + PlaceHolder available = fixture.cachedPlaceholder( + "SitesAvailable", "LastVotes", liveRequests); + fixture.placeholders.getPlaceholders().add( + fixture.placeholders.platformOwnedWithOfflineWorkerFallback(available)); + fixture.placeholders.publishUserDataChangePlaceholders(); + fixture.presence.playerOnline(fixture.player); + FoliaLib folia = mock(FoliaLib.class); + ServerImplementation entityScheduler = mock(ServerImplementation.class); + when(fixture.scheduler.getFoliaLib()).thenReturn(folia); + when(folia.getImpl()).thenReturn(entityScheduler); + when(entityScheduler.runAtEntityWithFallback(eq(fixture.player), any(), any(Runnable.class))) + .thenAnswer(call -> { + call.getArgument(2, Runnable.class).run(); + return CompletableFuture.completedFuture(EntityTaskResult.ENTITY_RETIRED); + }); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(fixture.scheduler).runTask(eq(fixture.plugin), any(Runnable.class)); + + fixture.placeholders.onUserDataChange(fixture.advancedUser, "LastVotes"); + + assertEquals("4", available.getCache().get("sitesavailable").get(fixture.uuid)); + assertEquals(0, liveRequests.get(), "retirement fallback must not read live player permissions"); + } + @Test void playerDependentUpdateMovesToTheReplacementEntityOwner() { Fixture fixture = new Fixture(); @@ -194,6 +243,9 @@ private static final class Fixture { final VotingPluginMain plugin = mock(VotingPluginMain.class); final Config config = mock(Config.class); final UserManager userManager = mock(UserManager.class); + final com.bencodez.advancedcore.api.user.UserManager advancedUserManager = + mock(com.bencodez.advancedcore.api.user.UserManager.class); + final UserDataManager dataManager = mock(UserDataManager.class); final VotingPluginUser votingUser = mock(VotingPluginUser.class); final AdvancedCoreUser advancedUser = mock(AdvancedCoreUser.class); final BukkitScheduler scheduler = mock(BukkitScheduler.class); @@ -209,6 +261,10 @@ private static final class Fixture { when(plugin.getConfigFile()).thenReturn(config); when(config.getPlaceholderCacheLevel()).thenReturn(level); when(plugin.getVotingPluginUserManager()).thenReturn(userManager); + when(plugin.getUserManager()).thenReturn(advancedUserManager); + when(advancedUserManager.getDataManager()).thenReturn(dataManager); + doAnswer(call -> { call.getArgument(0, Runnable.class).run(); return null; }) + .when(dataManager).dispatchSharedUserDataNotification(any(Runnable.class)); when(userManager.getVotingPluginUser(advancedUser)).thenReturn(votingUser); when(votingUser.isCached()).thenReturn(true); when(votingUser.getJavaUUID()).thenReturn(uuid); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresenceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresenceTest.java index 51449a2cc4..3d73fe9c6b 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresenceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresenceTest.java @@ -35,6 +35,15 @@ void joinQuitReloadAndDisableSnapshotsStayConsistent() { presence.playerOffline(firstId); assertFalse(presence.isOnline(firstId)); + UUID storageId = UUID.randomUUID(); + presence.playerOnline(storageId, first); + assertTrue(presence.isOnline(storageId)); + assertSame(first, presence.schedulerOwner(storageId)); + assertEquals(storageId, presence.storageUuid(first)); + presence.playerOffline(storageId, second); + assertTrue(presence.isOnline(storageId), "a replacement owner must not retire the captured player"); + presence.playerOffline(storageId, first); + assertFalse(presence.isOnline(storageId)); presence.replace(List.of(first, second)); assertTrue(presence.isOnline(firstId)); assertTrue(presence.isOnline(secondId)); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/BukkitCompletionSchedulerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/BukkitCompletionSchedulerTest.java index 175c91407d..4eb786f528 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/BukkitCompletionSchedulerTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/BukkitCompletionSchedulerTest.java @@ -81,6 +81,24 @@ void rejectedEveryFallbackCallsRejectedOnlyWhenTaskNeverBegan() { assertEquals(1, rejected.get()); } + @Test + void retiredEntityRunsDistinctGlobalSafeFallback() { + Fixture fixture = fixture(); + when(fixture.entityScheduler.runAtEntityWithFallback(eq(fixture.player), any(), any(Runnable.class))) + .thenAnswer(invocation -> { + invocation.getArgument(2, Runnable.class).run(); + return CompletableFuture.completedFuture(EntityTaskResult.ENTITY_RETIRED); + }); + AtomicInteger entity = new AtomicInteger(); + AtomicInteger fallback = new AtomicInteger(); + + BukkitCompletionScheduler.run(fixture.plugin, fixture.player, + entity::incrementAndGet, fallback::incrementAndGet, () -> { }); + + assertEquals(0, entity.get()); + assertEquals(1, fallback.get()); + } + private static Fixture fixture() { VotingPluginMain plugin = mock(VotingPluginMain.class); BukkitScheduler scheduler = mock(BukkitScheduler.class); From 1980befd8041c98d8a753ba98781d44e898af0c8 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:41:25 -0600 Subject: [PATCH 03/13] Preserve placeholder ownership across login --- .../listeners/PlayerJoinEvent.java | 9 ++-- .../placeholders/PlaceHolders.java | 47 +++++++++++-------- .../PlaceholderPlayerPresence.java | 1 + .../PlayerJoinEventPresenceTest.java | 40 ++++++++++++++++ .../PlaceHoldersWorkerSafetyTest.java | 6 ++- 5 files changed, 79 insertions(+), 24 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerJoinEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerJoinEvent.java index f3ddf93e22..d38b4f5a66 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerJoinEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerJoinEvent.java @@ -72,9 +72,12 @@ public void onPlayerLogin(AdvancedCoreLoginEvent event) { return; } - Player player = event.getPlayer(); - - if (player != null && player.isOp() && plugin.isYmlError()) { + Player player = event.getPlayer(); + if (player != null) { + plugin.getPlaceholderPlayerPresence().playerOnline(user.getJavaUUID(), player); + } + + if (player != null && player.isOp() && plugin.isYmlError()) { user.sendMessage("&cVotingPlugin: Detected yml error, please check console for details"); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java index 884770a423..5b09f6a961 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java @@ -14,9 +14,10 @@ import java.util.Locale; import java.util.Map.Entry; import java.util.Set; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.RejectedExecutionException; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; @@ -1247,7 +1248,7 @@ private void schedulePlatformUpdates(UUID uuid, VotingPluginUser user, List> platformUpdates, String[] keys) { Player owner = plugin.getPlaceholderPlayerPresence().schedulerOwner(uuid); if (owner == null) { - dispatchOfflinePlatformUpdates(uuid, user, platformUpdates, keys); + dispatchOfflinePlatformUpdates(uuid, platformUpdates, keys); return; } Runnable update = () -> { @@ -1264,26 +1265,32 @@ private void schedulePlatformUpdates(UUID uuid, VotingPluginUser user, BukkitCompletionScheduler.run(plugin, owner, update, () -> { if (!plugin.isEnabled()) return; plugin.getPlaceholderPlayerPresence().playerOffline(uuid, owner); - dispatchOfflinePlatformUpdates(uuid, user, platformUpdates, keys); + dispatchOfflinePlatformUpdates(uuid, platformUpdates, keys); }, () -> { }); } - private void dispatchOfflinePlatformUpdates(UUID uuid, VotingPluginUser user, + private void dispatchOfflinePlatformUpdates(UUID uuid, List> platformUpdates, String[] keys) { - plugin.getUserManager().getDataManager().dispatchSharedUserDataNotification(() -> { - if (!plugin.isEnabled()) return; - if (plugin.getPlaceholderPlayerPresence().schedulerOwner(uuid) != null) { - schedulePlatformUpdates(uuid, user, platformUpdates, keys); - return; - } - if (getCacheLevel().onlineOnly()) return; - PlaceholderClassification classification = userDataChangeClassification; - List> offlineUpdates = platformUpdates.stream() - .filter(classification.offlineWorker()::contains) - .filter(placeholder -> shouldRefresh(placeholder, uuid, keys)) - .toList(); - if (!offlineUpdates.isEmpty()) updateOfflinePlatformPlaceholders(uuid, user, offlineUpdates, keys); - }); + try { + plugin.getUserManager().getDataManager().getTimer().execute(() -> { + if (!plugin.isEnabled()) return; + VotingPluginUser currentUser = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, false); + if (currentUser == null) return; + if (plugin.getPlaceholderPlayerPresence().schedulerOwner(uuid) != null) { + schedulePlatformUpdates(uuid, currentUser, platformUpdates, keys); + return; + } + if (getCacheLevel().onlineOnly()) return; + PlaceholderClassification classification = userDataChangeClassification; + List> offlineUpdates = platformUpdates.stream() + .filter(classification.offlineWorker()::contains) + .filter(placeholder -> shouldRefresh(placeholder, uuid, keys)) + .toList(); + if (!offlineUpdates.isEmpty()) updateOfflinePlatformPlaceholders(uuid, currentUser, offlineUpdates, keys); + }); + } catch (RejectedExecutionException rejected) { + plugin.debug(rejected); + } } private void updateOfflinePlatformPlaceholders(UUID uuid, VotingPluginUser user, diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresence.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresence.java index b7ee6ac8c5..9fe260c070 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresence.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresence.java @@ -45,6 +45,7 @@ public void playerOnline(UUID uuid, Player player) { synchronized (lifecycleLock) { update(current -> { Map next = new HashMap<>(current); + next.entrySet().removeIf(entry -> entry.getValue() == player && !entry.getKey().equals(uuid)); next.put(uuid, player); return Map.copyOf(next); }); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerJoinEventPresenceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerJoinEventPresenceTest.java index 58ccb8fcae..3ed8f74ed8 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerJoinEventPresenceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerJoinEventPresenceTest.java @@ -16,11 +16,51 @@ import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.config.BungeeSettings; +import com.bencodez.votingplugin.placeholders.PlaceHolders; import com.bencodez.votingplugin.placeholders.PlaceholderPlayerPresence; import com.bencodez.votingplugin.user.UserManager; import com.bencodez.votingplugin.user.VotingPluginUser; class PlayerJoinEventPresenceTest { + @Test + void advancedCoreLoginRekeysPresenceToTheAuthoritativeStorageUuid() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + PlaceholderPlayerPresence presence = new PlaceholderPlayerPresence(); + when(plugin.getPlaceholderPlayerPresence()).thenReturn(presence); + when(plugin.isMySQLOkay()).thenReturn(true); + BungeeSettings bungee = mock(BungeeSettings.class); + when(plugin.getBungeeSettings()).thenReturn(bungee); + Player player = mock(Player.class); + UUID playerUuid = UUID.randomUUID(); + UUID storageUuid = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(playerUuid); + presence.playerOnline(playerUuid, player); + + UserManager userManager = mock(UserManager.class); + VotingPluginUser user = mock(VotingPluginUser.class); + when(user.getJavaUUID()).thenReturn(storageUuid); + when(plugin.getVotingPluginUserManager()).thenReturn(userManager); + when(userManager.getVotingPluginUser(storageUuid.toString())).thenReturn(user); + PlaceHolders placeholders = mock(PlaceHolders.class); + when(plugin.getPlaceholders()).thenReturn(placeholders); + doAnswer(call -> { + assertTrue(presence.isOnline(storageUuid)); + assertFalse(presence.isOnline(playerUuid)); + return null; + }).when(placeholders).onUpdate(user, true); + + com.bencodez.advancedcore.listeners.AdvancedCoreLoginEvent login = + mock(com.bencodez.advancedcore.listeners.AdvancedCoreLoginEvent.class); + when(login.getUuid()).thenReturn(storageUuid.toString()); + when(login.getPlayer()).thenReturn(player); + when(login.getUser()).thenReturn(mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class)); + + new PlayerJoinEvent(plugin).onPlayerLogin(login); + + assertTrue(presence.isOnline(storageUuid)); + assertFalse(presence.isOnline(playerUuid)); + } + @Test void bukkitJoinAndQuitUpdatePresenceBeforeAsyncQuitCleanup() { VotingPluginMain plugin = mock(VotingPluginMain.class); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java index f9d8b50252..9ba954478b 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java @@ -246,6 +246,8 @@ private static final class Fixture { final com.bencodez.advancedcore.api.user.UserManager advancedUserManager = mock(com.bencodez.advancedcore.api.user.UserManager.class); final UserDataManager dataManager = mock(UserDataManager.class); + final java.util.concurrent.ScheduledExecutorService storageWorker = + mock(java.util.concurrent.ScheduledExecutorService.class); final VotingPluginUser votingUser = mock(VotingPluginUser.class); final AdvancedCoreUser advancedUser = mock(AdvancedCoreUser.class); final BukkitScheduler scheduler = mock(BukkitScheduler.class); @@ -263,9 +265,11 @@ private static final class Fixture { when(plugin.getVotingPluginUserManager()).thenReturn(userManager); when(plugin.getUserManager()).thenReturn(advancedUserManager); when(advancedUserManager.getDataManager()).thenReturn(dataManager); + when(dataManager.getTimer()).thenReturn(storageWorker); doAnswer(call -> { call.getArgument(0, Runnable.class).run(); return null; }) - .when(dataManager).dispatchSharedUserDataNotification(any(Runnable.class)); + .when(storageWorker).execute(any(Runnable.class)); when(userManager.getVotingPluginUser(advancedUser)).thenReturn(votingUser); + when(userManager.getVotingPluginUser(uuid, false)).thenReturn(votingUser); when(votingUser.isCached()).thenReturn(true); when(votingUser.getJavaUUID()).thenReturn(uuid); when(votingUser.getUUID()).thenReturn(uuid.toString()); From 8b83238f58871be404f161411218bdaf74833d28 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:05:07 -0600 Subject: [PATCH 04/13] Snapshot vote eligibility before placeholder scheduling --- .../placeholders/PlaceHolders.java | 58 ++++++++++++++++++- .../PlaceHoldersWorkerSafetyTest.java | 32 ++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java index 5b09f6a961..c6cf43523f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java @@ -1246,6 +1246,13 @@ void onUserDataChange(AdvancedCoreUser user, String... keys) { private void schedulePlatformUpdates(UUID uuid, VotingPluginUser user, List> platformUpdates, String[] keys) { + schedulePlatformUpdates(uuid, user, platformUpdates, keys, + capturePlatformPlaceholderSnapshot(user, platformUpdates)); + } + + private void schedulePlatformUpdates(UUID uuid, VotingPluginUser user, + List> platformUpdates, String[] keys, + PlatformPlaceholderSnapshot snapshot) { Player owner = plugin.getPlaceholderPlayerPresence().schedulerOwner(uuid); if (owner == null) { dispatchOfflinePlatformUpdates(uuid, platformUpdates, keys); @@ -1254,12 +1261,17 @@ private void schedulePlatformUpdates(UUID uuid, VotingPluginUser user, Runnable update = () -> { if (!plugin.isEnabled()) return; if (plugin.getPlaceholderPlayerPresence().schedulerOwner(uuid) != owner) { - schedulePlatformUpdates(uuid, user, platformUpdates, keys); + schedulePlatformUpdates(uuid, user, platformUpdates, keys, snapshot); return; } if (getCacheLevel().onlineOnly() && !plugin.getPlaceholderPlayerPresence().isOnline(uuid)) return; for (PlaceHolder placeholder : platformUpdates) { - if (shouldRefresh(placeholder, uuid, keys)) updateCachedPlaceholder(placeholder, user, uuid, keys); + if (!shouldRefresh(placeholder, uuid, keys)) continue; + String identifier = placeholder.getIdentifier(); + if (identifier.equalsIgnoreCase("CanVoteSites") || identifier.equalsIgnoreCase("SitesAvailable")) { + publishCachedValue(placeholder, user, uuid, + Integer.toString(snapshot.sitesAvailable(user)), keys); + } else updateCachedPlaceholder(placeholder, user, uuid, keys); } }; BukkitCompletionScheduler.run(plugin, owner, update, () -> { @@ -1269,6 +1281,33 @@ private void schedulePlatformUpdates(UUID uuid, VotingPluginUser user, }, () -> { }); } + private PlatformPlaceholderSnapshot capturePlatformPlaceholderSnapshot(VotingPluginUser user, + List> platformUpdates) { + boolean needsVoteEligibility = platformUpdates.stream().map(PlaceHolder::getIdentifier) + .anyMatch(identifier -> identifier.equalsIgnoreCase("CanVoteSites") + || identifier.equalsIgnoreCase("SitesAvailable")); + if (!needsVoteEligibility) return PlatformPlaceholderSnapshot.EMPTY; + List votableSitePermissions = new ArrayList<>(); + for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { + if (!site.isHidden() && user.canVoteSite(site)) { + votableSitePermissions.add(site.getPermissionToView()); + } + } + return new PlatformPlaceholderSnapshot(List.copyOf(votableSitePermissions)); + } + + private record PlatformPlaceholderSnapshot(List votableSitePermissions) { + private static final PlatformPlaceholderSnapshot EMPTY = new PlatformPlaceholderSnapshot(List.of()); + + private int sitesAvailable(VotingPluginUser user) { + int amount = 0; + for (String permission : votableSitePermissions) { + if (permission.isEmpty() || user.hasPermission(permission, false)) amount++; + } + return amount; + } + } + private void dispatchOfflinePlatformUpdates(UUID uuid, List> platformUpdates, String[] keys) { try { @@ -1363,6 +1402,21 @@ private void updateCachedPlaceholder(PlaceHolder placeholder, } } + private void publishCachedValue(PlaceHolder placeholder, VotingPluginUser user, UUID uuid, + String value, String... keys) { + for (String ident : Set.copyOf(placeholder.getCache().keySet())) { + ConcurrentHashMap values = placeholder.getCache().get(ident); + if (values == null) continue; + values.put(uuid, value); + if (getCacheLevel().onlineOnly() && !plugin.getPlaceholderPlayerPresence().isOnline(uuid)) { + values.remove(uuid); + continue; + } + plugin.devDebug("Updated placeholder cache for " + user.getUUID() + " on " + + matchingKey(placeholder, keys) + " with " + value); + } + } + private String matchingKey(PlaceHolder placeholder, String... keys) { for (String key : keys) { if (key != null && placeholder.getUpdateDataKey().equalsIgnoreCase(key)) return key; diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java index 9ba954478b..d521edaf5c 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java @@ -41,6 +41,8 @@ import com.bencodez.votingplugin.config.Config; import com.bencodez.votingplugin.user.UserManager; import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.votesites.VoteSite; +import com.bencodez.votingplugin.votesites.VoteSiteManager; class PlaceHoldersWorkerSafetyTest { @Test @@ -107,6 +109,34 @@ void backgroundWarmupSchedulesPlayerDependentWorkOnTheEntityOwner() { assertEquals(1, requests.get()); } + @Test + void backgroundWarmupCapturesVoteEligibilityBeforeTemporaryDataIsCleared() { + Fixture fixture = new Fixture(); + fixture.presence.playerOnline(fixture.player); + VoteSite site = mock(VoteSite.class); + when(site.isHidden()).thenReturn(false); + when(site.getPermissionToView()).thenReturn(""); + when(fixture.voteSiteManager.getVoteSitesEnabled()).thenReturn(new ArrayList<>(List.of(site))); + when(fixture.votingUser.canVoteSite(site)).thenReturn(true) + .thenThrow(new AssertionError("entity task reread cleared TEMP_ONLY vote data")); + AtomicInteger requests = new AtomicInteger(); + PlaceHolder placeholder = fixture.cachedPlaceholder( + "CanVoteSites", "LastVotes", requests); + placeholder.setUseCache(true, "CanVoteSites"); + placeholder.getCache().get("CanVoteSites").put(fixture.uuid, "old"); + fixture.placeholders.getPlaceholders().add(fixture.placeholders.platformOwned(placeholder)); + fixture.placeholders.publishUserDataChangePlaceholders(); + + fixture.placeholders.onUpdate(fixture.votingUser, false); + + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), task.capture(), eq(fixture.player)); + task.getValue().run(); + assertEquals("1", placeholder.getCache().get("CanVoteSites").get(fixture.uuid)); + assertEquals(0, requests.get(), "the entity task must publish the captured result directly"); + verify(fixture.votingUser).canVoteSite(site); + } + @Test void unpublishedReloadGenerationCannotBypassPlatformClassification() { Fixture fixture = new Fixture(); @@ -249,6 +279,7 @@ private static final class Fixture { final java.util.concurrent.ScheduledExecutorService storageWorker = mock(java.util.concurrent.ScheduledExecutorService.class); final VotingPluginUser votingUser = mock(VotingPluginUser.class); + final VoteSiteManager voteSiteManager = mock(VoteSiteManager.class); final AdvancedCoreUser advancedUser = mock(AdvancedCoreUser.class); final BukkitScheduler scheduler = mock(BukkitScheduler.class); final PlaceholderPlayerPresence presence = new PlaceholderPlayerPresence(); @@ -263,6 +294,7 @@ private static final class Fixture { when(plugin.getConfigFile()).thenReturn(config); when(config.getPlaceholderCacheLevel()).thenReturn(level); when(plugin.getVotingPluginUserManager()).thenReturn(userManager); + when(plugin.getVoteSiteManager()).thenReturn(voteSiteManager); when(plugin.getUserManager()).thenReturn(advancedUserManager); when(advancedUserManager.getDataManager()).thenReturn(dataManager); when(dataManager.getTimer()).thenReturn(storageWorker); From 35886d94bc1558a4201306e13e098ebacba8655b Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:18:35 -0600 Subject: [PATCH 05/13] Preserve placeholder presence ownership across reload --- .../votingplugin/VotingPluginMain.java | 7 +--- .../listeners/PlayerJoinEvent.java | 27 ++++++++----- .../PlaceholderPlayerPresence.java | 40 +++++++++++++------ .../PlayerJoinEventPresenceTest.java | 35 ++++++++++++++++ .../PlaceholderPlayerPresenceTest.java | 8 +++- 5 files changed, 86 insertions(+), 31 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 483e78cfde..7a40b3e740 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -1937,12 +1937,7 @@ public void reloadAll() { /** Captures Bukkit presence while the lifecycle caller owns platform access. */ public void refreshPlaceholderPlayerPresence() { - HashMap online = new HashMap<>(); - for (Player player : Bukkit.getOnlinePlayers()) { - VotingPluginUser user = getVotingPluginUserManager().getVotingPluginUser(player); - online.put(user == null || user.getJavaUUID() == null ? player.getUniqueId() : user.getJavaUUID(), player); - } - placeholderPlayerPresence.replace(online); + placeholderPlayerPresence.replace(Bukkit.getOnlinePlayers()); } /** Reloads configuration applied by Control before its result is acknowledged. */ diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerJoinEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerJoinEvent.java index d38b4f5a66..28a4d56eb6 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerJoinEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerJoinEvent.java @@ -10,9 +10,10 @@ import com.bencodez.advancedcore.api.player.UuidLookup; import com.bencodez.advancedcore.api.user.UserDataFetchMode; -import com.bencodez.advancedcore.listeners.AdvancedCoreLoginEvent; -import com.bencodez.votingplugin.VotingPluginMain; -import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.advancedcore.listeners.AdvancedCoreLoginEvent; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.placeholders.PlaceHolders; +import com.bencodez.votingplugin.user.VotingPluginUser; public class PlayerJoinEvent implements Listener { @@ -111,11 +112,11 @@ public void onPlayerQuit(PlayerQuitEvent event) { final Player player = event.getPlayer(); if (player == null) { return; - } + } UUID placeholderUuid = plugin.getPlaceholderPlayerPresence().storageUuid(player); if (placeholderUuid == null) placeholderUuid = placeholderUuid(player); - plugin.getPlaceholderPlayerPresence().playerOffline(placeholderUuid); - if (plugin.getPlaceholders() != null) plugin.getPlaceholders().onLogout(placeholderUuid); + boolean retiredPresence = plugin.getPlaceholderPlayerPresence().playerOffline(placeholderUuid, player); + if (retiredPresence && plugin.getPlaceholders() != null) plugin.getPlaceholders().onLogout(placeholderUuid); if (plugin.getBungeeSettings().isUseBungeecoord()) { plugin.getBackendProxyHandler().playerOffline(player.getName()); @@ -139,10 +140,14 @@ public void run() { return; } - user.userDataFetechMode(UserDataFetchMode.NO_CACHE); - user.logoutRewards(); - plugin.getPlaceholders().onLogout(user); - } - }); + user.userDataFetechMode(UserDataFetchMode.NO_CACHE); + user.logoutRewards(); + PlaceHolders placeholders = plugin.getPlaceholders(); + if (placeholders != null + && !plugin.getPlaceholderPlayerPresence().isOnline(user.getJavaUUID())) { + placeholders.onLogout(user); + } + } + }); } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresence.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresence.java index 9fe260c070..1b387a7fb9 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresence.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresence.java @@ -2,6 +2,7 @@ import java.util.Collection; import java.util.HashMap; +import java.util.IdentityHashMap; import java.util.Map; import java.util.UUID; import java.util.concurrent.atomic.AtomicReference; @@ -65,26 +66,39 @@ public void playerOffline(UUID uuid) { } /** Remove only the retired scheduler owner, preserving a concurrently joined replacement. */ - public void playerOffline(UUID uuid, Player expectedOwner) { - if (uuid == null || expectedOwner == null) return; + public boolean playerOffline(UUID uuid, Player expectedOwner) { + if (uuid == null || expectedOwner == null) return false; synchronized (lifecycleLock) { - update(current -> { - if (current.get(uuid) != expectedOwner) return current; - Map next = new HashMap<>(current); - next.remove(uuid); - return Map.copyOf(next); - }); + Map current = onlinePlayers.get(); + if (current.get(uuid) != expectedOwner) return false; + Map next = new HashMap<>(current); + next.remove(uuid); + onlinePlayers.set(Map.copyOf(next)); + return true; } } + /** Rebuild online owners while retaining storage UUIDs already established by login. */ public void replace(Collection players) { - Map next = new HashMap<>(); - if (players != null) { - for (Player player : players) { - if (player != null) next.put(player.getUniqueId(), player); + synchronized (lifecycleLock) { + Map knownStorageUuids = new IdentityHashMap<>(); + Map storageUuidByPlayerUuid = new HashMap<>(); + onlinePlayers.get().forEach((uuid, player) -> { + knownStorageUuids.put(player, uuid); + storageUuidByPlayerUuid.put(player.getUniqueId(), uuid); + }); + Map next = new HashMap<>(); + if (players != null) { + for (Player player : players) { + if (player == null) continue; + UUID uuid = knownStorageUuids.get(player); + UUID playerUuid = player.getUniqueId(); + if (uuid == null) uuid = storageUuidByPlayerUuid.get(playerUuid); + next.put(uuid == null ? playerUuid : uuid, player); + } } + onlinePlayers.set(Map.copyOf(next)); } - synchronized (lifecycleLock) { onlinePlayers.set(Map.copyOf(next)); } } /** Replace presence with storage UUIDs resolved at a platform-owned boundary. */ diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerJoinEventPresenceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerJoinEventPresenceTest.java index 3ed8f74ed8..1ddc8297c3 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerJoinEventPresenceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerJoinEventPresenceTest.java @@ -5,6 +5,8 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.UUID; @@ -96,4 +98,37 @@ void bukkitJoinAndQuitUpdatePresenceBeforeAsyncQuitCleanup() { assertFalse(presence.isOnline(storageUuid), "quit must publish the storage UUID offline before asynchronous storage cleanup"); } + + @Test + void staleQuitCannotRetireOrClearAReplacementOwner() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + PlaceholderPlayerPresence presence = new PlaceholderPlayerPresence(); + when(plugin.getPlaceholderPlayerPresence()).thenReturn(presence); + when(plugin.isEnabled()).thenReturn(true); + BungeeSettings bungee = mock(BungeeSettings.class); + when(plugin.getBungeeSettings()).thenReturn(bungee); + ScheduledExecutorService loginTimer = mock(ScheduledExecutorService.class); + when(plugin.getLoginTimer()).thenReturn(loginTimer); + doAnswer(call -> null).when(loginTimer).execute(any(Runnable.class)); + PlaceHolders placeholders = mock(PlaceHolders.class); + when(plugin.getPlaceholders()).thenReturn(placeholders); + UserManager userManager = mock(UserManager.class); + when(plugin.getVotingPluginUserManager()).thenReturn(userManager); + UUID storageUuid = UUID.randomUUID(); + Player retired = mock(Player.class); + Player replacement = mock(Player.class); + when(retired.getUniqueId()).thenReturn(UUID.randomUUID()); + VotingPluginUser retiredUser = mock(VotingPluginUser.class); + when(retiredUser.getJavaUUID()).thenReturn(storageUuid); + when(userManager.getVotingPluginUser(retired)).thenReturn(retiredUser); + presence.playerOnline(storageUuid, retired); + presence.playerOnline(storageUuid, replacement); + PlayerQuitEvent quit = mock(PlayerQuitEvent.class); + when(quit.getPlayer()).thenReturn(retired); + + new PlayerJoinEvent(plugin).onPlayerQuit(quit); + + assertTrue(presence.isOnline(storageUuid)); + verify(placeholders, never()).onLogout(storageUuid); + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresenceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresenceTest.java index 3d73fe9c6b..b2a3fc7a50 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresenceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresenceTest.java @@ -42,7 +42,13 @@ void joinQuitReloadAndDisableSnapshotsStayConsistent() { assertEquals(storageId, presence.storageUuid(first)); presence.playerOffline(storageId, second); assertTrue(presence.isOnline(storageId), "a replacement owner must not retire the captured player"); - presence.playerOffline(storageId, first); + Player refreshedFirst = player(firstId); + presence.replace(List.of(refreshedFirst, second)); + assertTrue(presence.isOnline(storageId), "reload must retain the authoritative storage UUID"); + assertFalse(presence.isOnline(firstId)); + assertSame(refreshedFirst, presence.schedulerOwner(storageId)); + assertTrue(presence.isOnline(secondId)); + presence.playerOffline(storageId, refreshedFirst); assertFalse(presence.isOnline(storageId)); presence.replace(List.of(first, second)); assertTrue(presence.isOnline(firstId)); From 5040bc65715bcf036bf46cda21672b2240f7bffa Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:29:31 -0600 Subject: [PATCH 06/13] Resolve placeholder reloads with storage identity --- .../placeholders/PlaceHolders.java | 30 ++++++++++++------- .../PlaceHoldersWorkerSafetyTest.java | 22 ++++++++++++++ 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java index c6cf43523f..e3cce55c3e 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java @@ -1449,11 +1449,11 @@ public void onLogout(UUID uuid) { /** * Update all placeholders. */ - public void onUpdate() { - checkNonCachedPlaceholders(); - for (Player p : Bukkit.getOnlinePlayers()) { - onUpdate(plugin.getVotingPluginUserManager().getVotingPluginUser(p), true); - } + public void onUpdate() { + checkNonCachedPlaceholders(); + for (Player p : Bukkit.getOnlinePlayers()) { + onUpdate(getPresenceUser(p), true); + } /* * for (NonPlayerPlaceHolder placeholder : * nonPlayerPlaceholders) { if (placeholder.isUsesCache()) { for (String ident : @@ -1517,12 +1517,20 @@ public void reload() { plugin.refreshPlaceholderPlayerPresence(); cacheLevel = plugin.getConfigFile().getPlaceholderCacheLevel(); onUpdate(); - if (!cacheLevel.equals(PlaceholderCacheLevel.NONE)) { - for (Player player : Bukkit.getOnlinePlayers()) { - onUpdate(plugin.getVotingPluginUserManager().getVotingPluginUser(player), player.isOnline()); - } - } - } + if (!cacheLevel.equals(PlaceholderCacheLevel.NONE)) { + for (Player player : Bukkit.getOnlinePlayers()) { + onUpdate(getPresenceUser(player), player.isOnline()); + } + } + } + + private VotingPluginUser getPresenceUser(Player player) { + UUID storageUuid = plugin.getPlaceholderPlayerPresence().storageUuid(player); + if (storageUuid != null) { + return plugin.getVotingPluginUserManager().getVotingPluginUser(storageUuid); + } + return plugin.getVotingPluginUserManager().getVotingPluginUser(player); + } /** * Schedule placeholder check for user. diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java index d521edaf5c..5a0d973d71 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java @@ -109,6 +109,28 @@ void backgroundWarmupSchedulesPlayerDependentWorkOnTheEntityOwner() { assertEquals(1, requests.get()); } + @Test + void reloadUsesThePresenceStorageUuidForReplacementPlayerWrappers() { + Fixture fixture = new Fixture(); + Player replacement = mock(Player.class); + UUID bukkitUuid = UUID.randomUUID(); + when(replacement.getUniqueId()).thenReturn(bukkitUuid); + when(replacement.isOnline()).thenReturn(true); + when(fixture.userManager.getVotingPluginUser(fixture.uuid)).thenReturn(fixture.votingUser); + fixture.presence.playerOnline(fixture.uuid, replacement); + AtomicInteger requests = new AtomicInteger(); + fixture.publish(fixture.cachedPlaceholder("Points", requests)); + + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(replacement)); + fixture.placeholders.reload(); + } + + assertEquals(2, requests.get(), "reload updates both existing warmup passes"); + verify(fixture.userManager, times(2)).getVotingPluginUser(fixture.uuid); + verify(fixture.userManager, never()).getVotingPluginUser(replacement); + } + @Test void backgroundWarmupCapturesVoteEligibilityBeforeTemporaryDataIsCleared() { Fixture fixture = new Fixture(); From 5e3c303c0a2140650c9f2e56b249e369548fcc2c Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:33:51 -0600 Subject: [PATCH 07/13] Restore storage identities during presence refresh --- .../votingplugin/VotingPluginMain.java | 16 ++++++- ...tingPluginMainPlaceholderPresenceTest.java | 46 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainPlaceholderPresenceTest.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 7a40b3e740..49040a91bc 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -39,6 +39,7 @@ import com.bencodez.advancedcore.api.item.ItemBuilder; import com.bencodez.advancedcore.api.javascript.JavascriptPlaceholderRequest; import com.bencodez.advancedcore.api.messages.PlaceholderUtils; +import com.bencodez.advancedcore.api.player.UuidLookup; import com.bencodez.advancedcore.api.rewards.DirectlyDefinedReward; import com.bencodez.advancedcore.api.rewards.Reward; import com.bencodez.advancedcore.api.rewards.RewardEditData; @@ -1937,7 +1938,20 @@ public void reloadAll() { /** Captures Bukkit presence while the lifecycle caller owns platform access. */ public void refreshPlaceholderPlayerPresence() { - placeholderPlayerPresence.replace(Bukkit.getOnlinePlayers()); + HashMap onlinePlayers = new HashMap<>(); + for (Player player : Bukkit.getOnlinePlayers()) { + onlinePlayers.put(placeholderStorageUuid(player), player); + } + placeholderPlayerPresence.replace(onlinePlayers); + } + + static UUID placeholderStorageUuid(Player player) { + String cachedUuid = UuidLookup.getInstance().getCachedUUID(player.getName()); + try { + return UUID.fromString(cachedUuid); + } catch (IllegalArgumentException | NullPointerException ignored) { + return player.getUniqueId(); + } } /** Reloads configuration applied by Control before its result is acknowledged. */ diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainPlaceholderPresenceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainPlaceholderPresenceTest.java new file mode 100644 index 0000000000..431889cef5 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainPlaceholderPresenceTest.java @@ -0,0 +1,46 @@ +package com.bencodez.votingplugin; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.util.UUID; + +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.player.UuidLookup; + +class VotingPluginMainPlaceholderPresenceTest { + @Test + void cachedStorageIdentityWinsDuringColdPresenceRefresh() { + Player player = mock(Player.class); + UuidLookup lookup = mock(UuidLookup.class); + UUID bukkitUuid = UUID.randomUUID(); + UUID storageUuid = UUID.randomUUID(); + when(player.getName()).thenReturn("Player"); + when(player.getUniqueId()).thenReturn(bukkitUuid); + when(lookup.getCachedUUID("Player")).thenReturn(storageUuid.toString()); + + try (var uuidLookup = mockStatic(UuidLookup.class)) { + uuidLookup.when(UuidLookup::getInstance).thenReturn(lookup); + assertEquals(storageUuid, VotingPluginMain.placeholderStorageUuid(player)); + } + } + + @Test + void bukkitIdentityIsUsedWhenNoStorageIdentityWasCaptured() { + Player player = mock(Player.class); + UuidLookup lookup = mock(UuidLookup.class); + UUID bukkitUuid = UUID.randomUUID(); + when(player.getName()).thenReturn("Player"); + when(player.getUniqueId()).thenReturn(bukkitUuid); + when(lookup.getCachedUUID("Player")).thenReturn(""); + + try (var uuidLookup = mockStatic(UuidLookup.class)) { + uuidLookup.when(UuidLookup::getInstance).thenReturn(lookup); + assertEquals(bukkitUuid, VotingPluginMain.placeholderStorageUuid(player)); + } + } +} From ef0aba21ba69aa8b74aad2f988d804dcdf6b9723 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:48:36 -0600 Subject: [PATCH 08/13] Order placeholder presence lifecycle updates --- .../votingplugin/VotingPluginMain.java | 6 +-- .../placeholders/PlaceHolders.java | 25 +++++++--- .../PlaceholderPlayerPresence.java | 15 ++++++ .../PlaceHoldersWorkerSafetyTest.java | 32 ++++++++++++ .../PlaceholderPlayerPresenceTest.java | 49 +++++++++++++++++++ 5 files changed, 116 insertions(+), 11 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 49040a91bc..7dddcd0a78 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -1938,11 +1938,7 @@ public void reloadAll() { /** Captures Bukkit presence while the lifecycle caller owns platform access. */ public void refreshPlaceholderPlayerPresence() { - HashMap onlinePlayers = new HashMap<>(); - for (Player player : Bukkit.getOnlinePlayers()) { - onlinePlayers.put(placeholderStorageUuid(player), player); - } - placeholderPlayerPresence.replace(onlinePlayers); + placeholderPlayerPresence.replace(Bukkit::getOnlinePlayers, VotingPluginMain::placeholderStorageUuid); } static UUID placeholderStorageUuid(Player player) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java index e3cce55c3e..d0198318bc 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java @@ -18,6 +18,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicLong; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; @@ -62,6 +63,8 @@ private record PlaceholderClassification(List> all Set> platform, Set> offlineWorker) { } private volatile PlaceholderClassification userDataChangeClassification = new PlaceholderClassification(List.of(), Set.of(), Set.of()); + private final AtomicLong platformUpdateSequence = new AtomicLong(); + private final ConcurrentHashMap platformUpdateGenerations = new ConcurrentHashMap<>(); private boolean userDataChangeListenerRegistered; @Getter @@ -1246,26 +1249,30 @@ void onUserDataChange(AdvancedCoreUser user, String... keys) { private void schedulePlatformUpdates(UUID uuid, VotingPluginUser user, List> platformUpdates, String[] keys) { + long generation = platformUpdateGenerations.compute(uuid, + (key, current) -> platformUpdateSequence.incrementAndGet()); schedulePlatformUpdates(uuid, user, platformUpdates, keys, - capturePlatformPlaceholderSnapshot(user, platformUpdates)); + capturePlatformPlaceholderSnapshot(user, platformUpdates), generation); } private void schedulePlatformUpdates(UUID uuid, VotingPluginUser user, List> platformUpdates, String[] keys, - PlatformPlaceholderSnapshot snapshot) { + PlatformPlaceholderSnapshot snapshot, long generation) { + if (!isCurrentPlatformUpdate(uuid, generation)) return; Player owner = plugin.getPlaceholderPlayerPresence().schedulerOwner(uuid); if (owner == null) { dispatchOfflinePlatformUpdates(uuid, platformUpdates, keys); return; } Runnable update = () -> { - if (!plugin.isEnabled()) return; + if (!plugin.isEnabled() || !isCurrentPlatformUpdate(uuid, generation)) return; if (plugin.getPlaceholderPlayerPresence().schedulerOwner(uuid) != owner) { - schedulePlatformUpdates(uuid, user, platformUpdates, keys, snapshot); + schedulePlatformUpdates(uuid, user, platformUpdates, keys, snapshot, generation); return; } if (getCacheLevel().onlineOnly() && !plugin.getPlaceholderPlayerPresence().isOnline(uuid)) return; for (PlaceHolder placeholder : platformUpdates) { + if (!isCurrentPlatformUpdate(uuid, generation)) return; if (!shouldRefresh(placeholder, uuid, keys)) continue; String identifier = placeholder.getIdentifier(); if (identifier.equalsIgnoreCase("CanVoteSites") || identifier.equalsIgnoreCase("SitesAvailable")) { @@ -1275,12 +1282,16 @@ private void schedulePlatformUpdates(UUID uuid, VotingPluginUser user, } }; BukkitCompletionScheduler.run(plugin, owner, update, () -> { - if (!plugin.isEnabled()) return; + if (!plugin.isEnabled() || !isCurrentPlatformUpdate(uuid, generation)) return; plugin.getPlaceholderPlayerPresence().playerOffline(uuid, owner); dispatchOfflinePlatformUpdates(uuid, platformUpdates, keys); }, () -> { }); } + private boolean isCurrentPlatformUpdate(UUID uuid, long generation) { + return Long.valueOf(generation).equals(platformUpdateGenerations.get(uuid)); + } + private PlatformPlaceholderSnapshot capturePlatformPlaceholderSnapshot(VotingPluginUser user, List> platformUpdates) { boolean needsVoteEligibility = platformUpdates.stream().map(PlaceHolder::getIdentifier) @@ -1434,6 +1445,7 @@ public void onLogout(VotingPluginUser user) { /** Clears online-only cache entries without resolving user storage. */ public void onLogout(UUID uuid) { + platformUpdateGenerations.remove(uuid); if (getCacheLevel().onlineOnly()) { PlaceholderClassification classification = userDataChangeClassification; for (PlaceHolder placeholder : classification.all()) { @@ -1513,7 +1525,8 @@ public void onVotePartyUpdate() { /** * Reload placeholders. */ - public void reload() { + public void reload() { + platformUpdateGenerations.clear(); plugin.refreshPlaceholderPlayerPresence(); cacheLevel = plugin.getConfigFile().getPlaceholderCacheLevel(); onUpdate(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresence.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresence.java index 1b387a7fb9..703d85acce 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresence.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresence.java @@ -6,6 +6,8 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import java.util.function.Supplier; import org.bukkit.entity.Player; @@ -80,7 +82,19 @@ public boolean playerOffline(UUID uuid, Player expectedOwner) { /** Rebuild online owners while retaining storage UUIDs already established by login. */ public void replace(Collection players) { + replace(players, Player::getUniqueId); + } + + /** Resolve and replace one presence snapshot under the same lifecycle boundary. */ + public void replace(Collection players, Function storageUuidResolver) { + replace(() -> players, storageUuidResolver); + } + + /** Capture, resolve, and replace one presence snapshot under the same lifecycle boundary. */ + public void replace(Supplier> playersSupplier, + Function storageUuidResolver) { synchronized (lifecycleLock) { + Collection players = playersSupplier == null ? null : playersSupplier.get(); Map knownStorageUuids = new IdentityHashMap<>(); Map storageUuidByPlayerUuid = new HashMap<>(); onlinePlayers.get().forEach((uuid, player) -> { @@ -94,6 +108,7 @@ public void replace(Collection players) { UUID uuid = knownStorageUuids.get(player); UUID playerUuid = player.getUniqueId(); if (uuid == null) uuid = storageUuidByPlayerUuid.get(playerUuid); + if (uuid == null && storageUuidResolver != null) uuid = storageUuidResolver.apply(player); next.put(uuid == null ? playerUuid : uuid, player); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java index 5a0d973d71..8723478e27 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java @@ -250,6 +250,38 @@ void playerDependentUpdateMovesToTheReplacementEntityOwner() { verify(fixture.scheduler, times(2)).runTask(eq(fixture.plugin), any(Runnable.class), any(Player.class)); } + @Test + void staleEligibilityUpdateCannotOverwriteANewerReplacementOwnerUpdate() { + Fixture fixture = new Fixture(); + fixture.presence.playerOnline(fixture.player); + Player replacement = mock(Player.class); + when(replacement.getUniqueId()).thenReturn(fixture.uuid); + VoteSite site = mock(VoteSite.class); + when(site.isHidden()).thenReturn(false); + when(site.getPermissionToView()).thenReturn(""); + when(fixture.voteSiteManager.getVoteSitesEnabled()).thenReturn(new ArrayList<>(List.of(site))); + when(fixture.votingUser.canVoteSite(site)).thenReturn(true, false); + AtomicInteger requests = new AtomicInteger(); + PlaceHolder placeholder = fixture.cachedPlaceholder( + "CanVoteSites", "LastVotes", requests); + fixture.placeholders.getPlaceholders().add(fixture.placeholders.platformOwned(placeholder)); + fixture.placeholders.publishUserDataChangePlaceholders(); + + fixture.placeholders.onUserDataChange(fixture.advancedUser, "LastVotes"); + fixture.presence.playerOffline(fixture.uuid); + fixture.presence.playerOnline(replacement); + fixture.placeholders.onUserDataChange(fixture.advancedUser, "LastVotes"); + + ArgumentCaptor tasks = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler, times(2)).runTask(eq(fixture.plugin), tasks.capture(), any(Player.class)); + tasks.getAllValues().get(1).run(); + assertEquals("0", placeholder.getCache().get("canvotesites").get(fixture.uuid)); + tasks.getAllValues().get(0).run(); + assertEquals("0", placeholder.getCache().get("canvotesites").get(fixture.uuid)); + verify(fixture.scheduler, times(2)).runTask(eq(fixture.plugin), any(Runnable.class), any(Player.class)); + assertEquals(0, requests.get()); + } + @Test void concurrentCalculatingUpdatesKeepBothCachesCoherent() throws Exception { Fixture fixture = new Fixture(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresenceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresenceTest.java index b2a3fc7a50..01615896d9 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresenceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresenceTest.java @@ -9,6 +9,11 @@ import java.util.List; import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import org.bukkit.entity.Player; @@ -59,6 +64,50 @@ void joinQuitReloadAndDisableSnapshotsStayConsistent() { assertFalse(presence.isOnline(secondId)); } + @Test + void snapshotReplacementDoesNotLoseConcurrentLifecycleChanges() throws Exception { + PlaceholderPlayerPresence presence = new PlaceholderPlayerPresence(); + UUID firstId = UUID.randomUUID(); + UUID joinedId = UUID.randomUUID(); + Player first = player(firstId); + Player joined = player(joinedId); + CountDownLatch resolving = new CountDownLatch(1); + CountDownLatch releaseResolution = new CountDownLatch(1); + CountDownLatch joinStarted = new CountDownLatch(1); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + Future refresh = workers.submit(() -> presence.replace(() -> { + resolving.countDown(); + await(releaseResolution); + return List.of(first); + }, player -> firstId)); + assertTrue(resolving.await(2, TimeUnit.SECONDS)); + Future join = workers.submit(() -> { + joinStarted.countDown(); + presence.playerOnline(joinedId, joined); + }); + assertTrue(joinStarted.await(2, TimeUnit.SECONDS)); + assertFalse(join.isDone(), "join must wait for the atomic snapshot replacement"); + releaseResolution.countDown(); + refresh.get(2, TimeUnit.SECONDS); + join.get(2, TimeUnit.SECONDS); + assertSame(first, presence.schedulerOwner(firstId)); + assertSame(joined, presence.schedulerOwner(joinedId)); + } finally { + workers.shutdownNow(); + assertTrue(workers.awaitTermination(2, TimeUnit.SECONDS)); + } + } + + private void await(CountDownLatch latch) { + try { + if (!latch.await(2, TimeUnit.SECONDS)) throw new AssertionError("timed out waiting for test release"); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError(interrupted); + } + } + private Player player(UUID uuid) { Player player = mock(Player.class); when(player.getUniqueId()).thenReturn(uuid); From 986a3c13c34cac405c5e91a9815c22060a4278b6 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:57:50 -0600 Subject: [PATCH 09/13] Guard quit cache cleanup against reconnects --- .../listeners/PlayerJoinEvent.java | 21 ++++++++------ .../PlayerJoinEventPresenceTest.java | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerJoinEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerJoinEvent.java index 28a4d56eb6..36a0d0f3f0 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerJoinEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerJoinEvent.java @@ -42,9 +42,16 @@ private UUID placeholderUuid(Player player) { return user == null || user.getJavaUUID() == null ? player.getUniqueId() : user.getJavaUUID(); } - private static boolean isBlank(String s) { - return s == null || s.trim().isEmpty() || "null".equalsIgnoreCase(s.trim()); - } + private static boolean isBlank(String s) { + return s == null || s.trim().isEmpty() || "null".equalsIgnoreCase(s.trim()); + } + + private void clearPlaceholderCachesIfOffline(UUID uuid) { + PlaceHolders placeholders = plugin.getPlaceholders(); + if (placeholders != null) { + plugin.getPlaceholderPlayerPresence().runIfOffline(uuid, () -> placeholders.onLogout(uuid)); + } + } /** * On AdvancedCore login event (post-auth / delayed login). @@ -116,7 +123,7 @@ public void onPlayerQuit(PlayerQuitEvent event) { UUID placeholderUuid = plugin.getPlaceholderPlayerPresence().storageUuid(player); if (placeholderUuid == null) placeholderUuid = placeholderUuid(player); boolean retiredPresence = plugin.getPlaceholderPlayerPresence().playerOffline(placeholderUuid, player); - if (retiredPresence && plugin.getPlaceholders() != null) plugin.getPlaceholders().onLogout(placeholderUuid); + if (retiredPresence) clearPlaceholderCachesIfOffline(placeholderUuid); if (plugin.getBungeeSettings().isUseBungeecoord()) { plugin.getBackendProxyHandler().playerOffline(player.getName()); @@ -142,11 +149,7 @@ public void run() { user.userDataFetechMode(UserDataFetchMode.NO_CACHE); user.logoutRewards(); - PlaceHolders placeholders = plugin.getPlaceholders(); - if (placeholders != null - && !plugin.getPlaceholderPlayerPresence().isOnline(user.getJavaUUID())) { - placeholders.onLogout(user); - } + clearPlaceholderCachesIfOffline(user.getJavaUUID()); } }); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerJoinEventPresenceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerJoinEventPresenceTest.java index 1ddc8297c3..dac97435e0 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerJoinEventPresenceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerJoinEventPresenceTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -131,4 +132,31 @@ void staleQuitCannotRetireOrClearAReplacementOwner() { assertTrue(presence.isOnline(storageUuid)); verify(placeholders, never()).onLogout(storageUuid); } + + @Test + void reconnectBetweenRetirementAndCleanupPreservesReplacementCaches() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + PlaceholderPlayerPresence presence = mock(PlaceholderPlayerPresence.class); + when(plugin.getPlaceholderPlayerPresence()).thenReturn(presence); + when(plugin.isEnabled()).thenReturn(true); + BungeeSettings bungee = mock(BungeeSettings.class); + when(plugin.getBungeeSettings()).thenReturn(bungee); + ScheduledExecutorService loginTimer = mock(ScheduledExecutorService.class); + when(plugin.getLoginTimer()).thenReturn(loginTimer); + doAnswer(call -> null).when(loginTimer).execute(any(Runnable.class)); + PlaceHolders placeholders = mock(PlaceHolders.class); + when(plugin.getPlaceholders()).thenReturn(placeholders); + UUID storageUuid = UUID.randomUUID(); + Player retired = mock(Player.class); + when(presence.storageUuid(retired)).thenReturn(storageUuid); + when(presence.playerOffline(storageUuid, retired)).thenReturn(true); + when(presence.runIfOffline(eq(storageUuid), any(Runnable.class))).thenReturn(false); + PlayerQuitEvent quit = mock(PlayerQuitEvent.class); + when(quit.getPlayer()).thenReturn(retired); + + new PlayerJoinEvent(plugin).onPlayerQuit(quit); + + verify(presence).runIfOffline(eq(storageUuid), any(Runnable.class)); + verify(placeholders, never()).onLogout(storageUuid); + } } From 2e79d81e0fd7d0fa8d972034fa1bedc0b558f762 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:08:58 -0600 Subject: [PATCH 10/13] Scope placeholder update ordering --- .../placeholders/PlaceHolders.java | 71 ++++++++++++++----- .../PlaceHoldersWorkerSafetyTest.java | 39 ++++++++++ 2 files changed, 92 insertions(+), 18 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java index d0198318bc..bb26dd88a3 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java @@ -64,7 +64,8 @@ private record PlaceholderClassification(List> all private volatile PlaceholderClassification userDataChangeClassification = new PlaceholderClassification(List.of(), Set.of(), Set.of()); private final AtomicLong platformUpdateSequence = new AtomicLong(); - private final ConcurrentHashMap platformUpdateGenerations = new ConcurrentHashMap<>(); + private record PlatformUpdateKey(UUID uuid, PlaceHolder placeholder) { } + private final ConcurrentHashMap platformUpdateGenerations = new ConcurrentHashMap<>(); private boolean userDataChangeListenerRegistered; @Getter @@ -204,7 +205,7 @@ public String getPlaceholderValue(OfflinePlayer p, String identifier, boolean ja return "no player"; } - VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(p); + VotingPluginUser user = resolvePlaceholderUser(p); if (useCache) { if (!cachedPlaceholders.contains(identifier)) { // not cached placeholder @@ -1249,30 +1250,35 @@ void onUserDataChange(AdvancedCoreUser user, String... keys) { private void schedulePlatformUpdates(UUID uuid, VotingPluginUser user, List> platformUpdates, String[] keys) { - long generation = platformUpdateGenerations.compute(uuid, - (key, current) -> platformUpdateSequence.incrementAndGet()); + IdentityHashMap, Long> generations = new IdentityHashMap<>(); + for (PlaceHolder placeholder : platformUpdates) { + long generation = platformUpdateGenerations.compute(new PlatformUpdateKey(uuid, placeholder), + (key, current) -> platformUpdateSequence.incrementAndGet()); + generations.put(placeholder, generation); + } schedulePlatformUpdates(uuid, user, platformUpdates, keys, - capturePlatformPlaceholderSnapshot(user, platformUpdates), generation); + capturePlatformPlaceholderSnapshot(user, platformUpdates), generations); } private void schedulePlatformUpdates(UUID uuid, VotingPluginUser user, List> platformUpdates, String[] keys, - PlatformPlaceholderSnapshot snapshot, long generation) { - if (!isCurrentPlatformUpdate(uuid, generation)) return; + PlatformPlaceholderSnapshot snapshot, + IdentityHashMap, Long> generations) { + if (!hasCurrentPlatformUpdate(uuid, platformUpdates, generations)) return; Player owner = plugin.getPlaceholderPlayerPresence().schedulerOwner(uuid); if (owner == null) { - dispatchOfflinePlatformUpdates(uuid, platformUpdates, keys); + dispatchOfflinePlatformUpdates(uuid, platformUpdates, keys, generations); return; } Runnable update = () -> { - if (!plugin.isEnabled() || !isCurrentPlatformUpdate(uuid, generation)) return; + if (!plugin.isEnabled() || !hasCurrentPlatformUpdate(uuid, platformUpdates, generations)) return; if (plugin.getPlaceholderPlayerPresence().schedulerOwner(uuid) != owner) { - schedulePlatformUpdates(uuid, user, platformUpdates, keys, snapshot, generation); + schedulePlatformUpdates(uuid, user, platformUpdates, keys, snapshot, generations); return; } if (getCacheLevel().onlineOnly() && !plugin.getPlaceholderPlayerPresence().isOnline(uuid)) return; for (PlaceHolder placeholder : platformUpdates) { - if (!isCurrentPlatformUpdate(uuid, generation)) return; + if (!isCurrentPlatformUpdate(uuid, placeholder, generations)) continue; if (!shouldRefresh(placeholder, uuid, keys)) continue; String identifier = placeholder.getIdentifier(); if (identifier.equalsIgnoreCase("CanVoteSites") || identifier.equalsIgnoreCase("SitesAvailable")) { @@ -1282,14 +1288,22 @@ private void schedulePlatformUpdates(UUID uuid, VotingPluginUser user, } }; BukkitCompletionScheduler.run(plugin, owner, update, () -> { - if (!plugin.isEnabled() || !isCurrentPlatformUpdate(uuid, generation)) return; + if (!plugin.isEnabled() || !hasCurrentPlatformUpdate(uuid, platformUpdates, generations)) return; plugin.getPlaceholderPlayerPresence().playerOffline(uuid, owner); - dispatchOfflinePlatformUpdates(uuid, platformUpdates, keys); + dispatchOfflinePlatformUpdates(uuid, platformUpdates, keys, generations); }, () -> { }); } - private boolean isCurrentPlatformUpdate(UUID uuid, long generation) { - return Long.valueOf(generation).equals(platformUpdateGenerations.get(uuid)); + private boolean hasCurrentPlatformUpdate(UUID uuid, List> placeholdersToUpdate, + IdentityHashMap, Long> generations) { + return placeholdersToUpdate.stream().anyMatch(placeholder -> + isCurrentPlatformUpdate(uuid, placeholder, generations)); + } + + private boolean isCurrentPlatformUpdate(UUID uuid, PlaceHolder placeholder, + IdentityHashMap, Long> generations) { + return generations.get(placeholder) != null && generations.get(placeholder).equals( + platformUpdateGenerations.get(new PlatformUpdateKey(uuid, placeholder))); } private PlatformPlaceholderSnapshot capturePlatformPlaceholderSnapshot(VotingPluginUser user, @@ -1321,18 +1335,29 @@ private int sitesAvailable(VotingPluginUser user) { private void dispatchOfflinePlatformUpdates(UUID uuid, List> platformUpdates, String[] keys) { + dispatchOfflinePlatformUpdates(uuid, platformUpdates, keys, null); + } + + private void dispatchOfflinePlatformUpdates(UUID uuid, + List> platformUpdates, String[] keys, + IdentityHashMap, Long> generations) { try { plugin.getUserManager().getDataManager().getTimer().execute(() -> { if (!plugin.isEnabled()) return; + List> currentUpdates = generations == null ? platformUpdates + : platformUpdates.stream() + .filter(placeholder -> isCurrentPlatformUpdate(uuid, placeholder, generations)) + .toList(); + if (currentUpdates.isEmpty()) return; VotingPluginUser currentUser = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, false); if (currentUser == null) return; if (plugin.getPlaceholderPlayerPresence().schedulerOwner(uuid) != null) { - schedulePlatformUpdates(uuid, currentUser, platformUpdates, keys); + schedulePlatformUpdates(uuid, currentUser, currentUpdates, keys); return; } if (getCacheLevel().onlineOnly()) return; PlaceholderClassification classification = userDataChangeClassification; - List> offlineUpdates = platformUpdates.stream() + List> offlineUpdates = currentUpdates.stream() .filter(classification.offlineWorker()::contains) .filter(placeholder -> shouldRefresh(placeholder, uuid, keys)) .toList(); @@ -1445,7 +1470,7 @@ public void onLogout(VotingPluginUser user) { /** Clears online-only cache entries without resolving user storage. */ public void onLogout(UUID uuid) { - platformUpdateGenerations.remove(uuid); + platformUpdateGenerations.keySet().removeIf(key -> key.uuid().equals(uuid)); if (getCacheLevel().onlineOnly()) { PlaceholderClassification classification = userDataChangeClassification; for (PlaceHolder placeholder : classification.all()) { @@ -1544,6 +1569,16 @@ private VotingPluginUser getPresenceUser(Player player) { } return plugin.getVotingPluginUserManager().getVotingPluginUser(player); } + + VotingPluginUser resolvePlaceholderUser(OfflinePlayer player) { + if (player instanceof Player onlinePlayer) { + UUID storageUuid = plugin.getPlaceholderPlayerPresence().storageUuid(onlinePlayer); + if (storageUuid != null) { + return plugin.getVotingPluginUserManager().getVotingPluginUser(storageUuid); + } + } + return plugin.getVotingPluginUserManager().getVotingPluginUser(player); + } /** * Schedule placeholder check for user. diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java index 8723478e27..8513fc5351 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java @@ -131,6 +131,20 @@ void reloadUsesThePresenceStorageUuidForReplacementPlayerWrappers() { verify(fixture.userManager, never()).getVotingPluginUser(replacement); } + @Test + void cacheMissUsesThePresenceStorageUuidForAnOnlinePlayer() { + Fixture fixture = new Fixture(); + Player onlinePlayer = mock(Player.class); + when(onlinePlayer.getUniqueId()).thenReturn(UUID.randomUUID()); + when(fixture.userManager.getVotingPluginUser(fixture.uuid)).thenReturn(fixture.votingUser); + fixture.presence.playerOnline(fixture.uuid, onlinePlayer); + + assertEquals(fixture.votingUser, fixture.placeholders.resolvePlaceholderUser(onlinePlayer)); + + verify(fixture.userManager).getVotingPluginUser(fixture.uuid); + verify(fixture.userManager, never()).getVotingPluginUser(onlinePlayer); + } + @Test void backgroundWarmupCapturesVoteEligibilityBeforeTemporaryDataIsCleared() { Fixture fixture = new Fixture(); @@ -282,6 +296,31 @@ void staleEligibilityUpdateCannotOverwriteANewerReplacementOwnerUpdate() { assertEquals(0, requests.get()); } + @Test + void narrowUpdateDoesNotCancelUnrelatedWarmupPlaceholders() { + Fixture fixture = new Fixture(); + fixture.presence.playerOnline(fixture.player); + AtomicInteger changedRequests = new AtomicInteger(); + AtomicInteger warmupRequests = new AtomicInteger(); + PlaceHolder changed = fixture.cachedPlaceholder( + "Changed", "LastVotes", changedRequests); + PlaceHolder warmupOnly = fixture.cachedPlaceholder( + "WarmupOnly", "Unrelated", warmupRequests); + fixture.placeholders.getPlaceholders().add(fixture.placeholders.platformOwned(changed)); + fixture.placeholders.getPlaceholders().add(fixture.placeholders.platformOwned(warmupOnly)); + fixture.placeholders.publishUserDataChangePlaceholders(); + + fixture.placeholders.onUpdate(fixture.votingUser, true); + fixture.placeholders.onUserDataChange(fixture.advancedUser, "LastVotes"); + + ArgumentCaptor tasks = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler, times(2)).runTask(eq(fixture.plugin), tasks.capture(), eq(fixture.player)); + tasks.getAllValues().get(1).run(); + tasks.getAllValues().get(0).run(); + assertEquals(1, changedRequests.get()); + assertEquals(1, warmupRequests.get(), "narrow changes must not cancel unrelated warmup work"); + } + @Test void concurrentCalculatingUpdatesKeepBothCachesCoherent() throws Exception { Fixture fixture = new Fixture(); From 49cbb6b0c1d9652aa57d2c63b4b48b9df41f6a8f Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:11:40 -0600 Subject: [PATCH 11/13] Keep online presence on Bukkit identity --- .../bencodez/votingplugin/VotingPluginMain.java | 7 +++++-- ...VotingPluginMainPlaceholderPresenceTest.java | 17 +++++++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 7dddcd0a78..d7fec9ffa7 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -1938,10 +1938,13 @@ public void reloadAll() { /** Captures Bukkit presence while the lifecycle caller owns platform access. */ public void refreshPlaceholderPlayerPresence() { - placeholderPlayerPresence.replace(Bukkit::getOnlinePlayers, VotingPluginMain::placeholderStorageUuid); + boolean onlineMode = getOptions().isOnlineMode(); + placeholderPlayerPresence.replace(Bukkit::getOnlinePlayers, + player -> placeholderStorageUuid(player, onlineMode)); } - static UUID placeholderStorageUuid(Player player) { + static UUID placeholderStorageUuid(Player player, boolean onlineMode) { + if (onlineMode) return player.getUniqueId(); String cachedUuid = UuidLookup.getInstance().getCachedUUID(player.getName()); try { return UUID.fromString(cachedUuid); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainPlaceholderPresenceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainPlaceholderPresenceTest.java index 431889cef5..c30acd3e5b 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainPlaceholderPresenceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainPlaceholderPresenceTest.java @@ -25,7 +25,7 @@ void cachedStorageIdentityWinsDuringColdPresenceRefresh() { try (var uuidLookup = mockStatic(UuidLookup.class)) { uuidLookup.when(UuidLookup::getInstance).thenReturn(lookup); - assertEquals(storageUuid, VotingPluginMain.placeholderStorageUuid(player)); + assertEquals(storageUuid, VotingPluginMain.placeholderStorageUuid(player, false)); } } @@ -40,7 +40,20 @@ void bukkitIdentityIsUsedWhenNoStorageIdentityWasCaptured() { try (var uuidLookup = mockStatic(UuidLookup.class)) { uuidLookup.when(UuidLookup::getInstance).thenReturn(lookup); - assertEquals(bukkitUuid, VotingPluginMain.placeholderStorageUuid(player)); + assertEquals(bukkitUuid, VotingPluginMain.placeholderStorageUuid(player, false)); + } + } + + @Test + void onlineModeIgnoresAStaleCachedNameMapping() { + Player player = mock(Player.class); + UuidLookup lookup = mock(UuidLookup.class); + UUID bukkitUuid = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(bukkitUuid); + + try (var uuidLookup = mockStatic(UuidLookup.class)) { + uuidLookup.when(UuidLookup::getInstance).thenReturn(lookup); + assertEquals(bukkitUuid, VotingPluginMain.placeholderStorageUuid(player, true)); } } } From 62c12392af563e5e9573e95454d34f493d3df42c Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:20:30 -0600 Subject: [PATCH 12/13] Register presence listeners before snapshot --- .../main/java/com/bencodez/votingplugin/VotingPluginMain.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index d7fec9ffa7..c21efc54ce 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -654,8 +654,8 @@ public void onPostLoad() { registerCommands(); checkVotifier(); - refreshPlaceholderPlayerPresence(); registerEvents(); + refreshPlaceholderPlayerPresence(); loadVoteBroadcast(); From 290f058a63c205aa2dbafbe543f02e652dc8daf7 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:28:12 -0600 Subject: [PATCH 13/13] Complete all-player cache updates after quit --- .../placeholders/PlaceHolders.java | 2 +- .../PlaceHoldersWorkerSafetyTest.java | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java index bb26dd88a3..3fadea0c42 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java @@ -1470,8 +1470,8 @@ public void onLogout(VotingPluginUser user) { /** Clears online-only cache entries without resolving user storage. */ public void onLogout(UUID uuid) { - platformUpdateGenerations.keySet().removeIf(key -> key.uuid().equals(uuid)); if (getCacheLevel().onlineOnly()) { + platformUpdateGenerations.keySet().removeIf(key -> key.uuid().equals(uuid)); PlaceholderClassification classification = userDataChangeClassification; for (PlaceHolder placeholder : classification.all()) { if (placeholder.isUsesCache()) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java index 8513fc5351..a2e6f2df09 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java @@ -238,6 +238,29 @@ void retiredEntityFallsBackToOfflineWorkerUpdate() { assertEquals(0, liveRequests.get(), "retirement fallback must not read live player permissions"); } + @Test + void allPlayerCacheCompletesOfflineFallbackWhenQuitPrecedesEntityTask() { + Fixture fixture = new Fixture(PlaceholderCacheLevel.AUTOALL); + when(fixture.votingUser.getSitesNotVotedOnWithoutOnlinePermissions()).thenReturn(3); + AtomicInteger liveRequests = new AtomicInteger(); + PlaceHolder available = fixture.cachedPlaceholder( + "SitesAvailable", "LastVotes", liveRequests); + fixture.placeholders.getPlaceholders().add( + fixture.placeholders.platformOwnedWithOfflineWorkerFallback(available)); + fixture.placeholders.publishUserDataChangePlaceholders(); + fixture.presence.playerOnline(fixture.player); + + fixture.placeholders.onUserDataChange(fixture.advancedUser, "LastVotes"); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), task.capture(), eq(fixture.player)); + fixture.presence.playerOffline(fixture.uuid); + fixture.placeholders.onLogout(fixture.uuid); + task.getValue().run(); + + assertEquals("3", available.getCache().get("sitesavailable").get(fixture.uuid)); + assertEquals(0, liveRequests.get(), "quit fallback must not access live player permissions"); + } + @Test void playerDependentUpdateMovesToTheReplacementEntityOwner() { Fixture fixture = new Fixture();