diff --git a/AGENTS.md b/AGENTS.md index efdf6bf991..5641289101 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,8 +76,8 @@ automation because it copies a JAR into a developer-specific server directory. Keep these paths separate: - discovery/presence advertises current node identity and topology; -- configuration capabilities (`config.*.v1`) poll `/operations`, may read/preview/apply typed configuration, and journal - results; +- configuration capabilities (`config.*.v1` plus explicitly negotiated successors) poll `/operations`, may + read/preview/apply typed configuration, and journal results; - inspection capability `data.inspect.v1` polls `/inspections`, executes only `ControlInspectionService`, and does not journal because a lost acknowledgement can safely repeat a read. Repeated failures back this lane off exponentially from one second to five minutes without changing voting or configuration availability. @@ -160,6 +160,14 @@ limit: Prefer one cohesive PR per repository for a paired feature, keeping its implementation, tests, and docs together. Split further only when a part is independently deployable or has materially different review/rollback risk. +`config.proxy-method.v1` covers plugin messaging and Redis; `config.proxy-method.v2` adds HTTP. Dispatch and validate the +exact capability for the requested method. `config.quick-setup.v2` adds `VoteParty.Enabled`; keep legacy Vote Party +payloads on v1, preserve the installed Enabled value when they omit it, and reject the `enabled` field unless v2 was +accepted. The VotingPlugin connector may deploy first and +advertise these successors without using them until Control accepts them. A newer Control deployed first must leave its +v2-only actions unavailable on older nodes. Merge the VotingPlugin capability implementation before relying on the new +Control behavior in production. + Before pushing, run the focused tests, the full Maven build, and `git diff --check`. Do not commit server runtime data, credentials, generated JARs, dependency caches, IDE output, or unrelated formatting. diff --git a/VotingPlugin/pom.xml b/VotingPlugin/pom.xml index 6474f83eb3..5a74489bb9 100644 --- a/VotingPlugin/pom.xml +++ b/VotingPlugin/pom.xml @@ -427,6 +427,12 @@ + + com.google.code.gson + gson + 2.14.0 + provided + org.junit.jupiter junit-jupiter-engine diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 35fb886a8e..4f34d41c97 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -609,6 +609,7 @@ public void onPostLoad() { voteTester = new VoteTester(plugin); loadVoteTimer(); + getVotingPluginUserManager().startSharedPointTransferRecovery(); if (bungeeSettings.isUseBungeecoord()) { loadBungeeHandler(); @@ -1539,6 +1540,9 @@ private void reloadPlugin(boolean userStorage, boolean reconcileHostedControl) { updateAdvancedCoreHook(); reloadAdvancedCore(userStorage); + // PerServerPoints can switch shared point storage on during a live reload. + // Re-evaluate after storage has reloaded; UserManager keeps this lifecycle task unique. + getVotingPluginUserManager().startSharedPointTransferRecovery(); if (bungeeSettings.isUseBungeecoord()) { BackendProxyHandler handler = getBackendProxyHandler(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java index 710d5bd351..4674f5d50d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java @@ -91,7 +91,12 @@ import com.bencodez.votingplugin.specialrewards.votestreak.VoteStreakType; import com.bencodez.votingplugin.topvoter.TopVoter; import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.user.PointTransferResult; import com.bencodez.votingplugin.util.VoteTaskAdmission; +import com.bencodez.votingplugin.util.BukkitCompletionScheduler; +import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseResult; +import com.bencodez.votingplugin.voteshop.shop.VoteShopEntry; +import com.bencodez.votingplugin.voteshop.shop.VoteShopItem; import com.bencodez.votingplugin.votesites.VoteSite; public class CommandLoader { @@ -116,6 +121,24 @@ public CommandLoader(VotingPluginMain plugin) { this.plugin = plugin; } + void runForCommandSender(CommandSender sender, Runnable task) { + BukkitCompletionScheduler.run(plugin, sender instanceof Player player ? player : null, task); + } + + void runForVotingUser(VotingPluginUser user, Runnable task) { + BukkitCompletionScheduler.run(plugin, user.getPlayer(), task); + } + + String transferFailureMessage(PointTransferResult result) { + if (result == PointTransferResult.INSUFFICIENT_POINTS) { + return plugin.getConfigFile().getFormatCommandsVoteGivePointsNotEnoughPoints(); + } + if (result == PointTransferResult.PENDING_CONFIRMATION) { + return plugin.getConfigFile().getFormatCommandsVoteGivePointsPendingConfirmation(); + } + return plugin.getConfigFile().getFormatCommandsVoteGivePointsUnavailable(); + } + /** * @return the adminPerm */ @@ -311,22 +334,43 @@ public void executeAll(CommandSender sender, String[] args) { int num = Integer.parseInt(args[3]); sender.sendMessage(MessageAPI.colorize("&cSetting all players points to " + args[3])); + java.util.List users = new java.util.ArrayList<>(); for (String uuidStr : plugin.getUserManager().getAllUUIDs()) { UUID uuid = UUID.fromString(uuidStr); - VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid); - user.userDataFetechMode(UserDataFetchMode.NO_CACHE); - user.setPoints(num); + users.add(plugin.getVotingPluginUserManager().getVotingPluginUser(uuid)); + } + if (users.isEmpty()) { + sender.sendMessage(MessageAPI.colorize("&cNo players were available to update")); + return; + } + java.util.concurrent.atomic.AtomicInteger remaining = + new java.util.concurrent.atomic.AtomicInteger(users.size()); + java.util.concurrent.atomic.AtomicInteger updated = new java.util.concurrent.atomic.AtomicInteger(); + VotingPluginUser.setPointsStorageAware(plugin, users, num, (user, success) -> { + if (success) updated.incrementAndGet(); + if (remaining.decrementAndGet() == 0) { + runForCommandSender(sender, () -> { + sender.sendMessage(MessageAPI.colorize("&cSet all players points to " + args[3] + + " for " + updated.get() + "/" + users.size() + " players")); + plugin.getPlaceholders().onUpdate(); + }); + } + }); } - sender.sendMessage(MessageAPI.colorize("&cDone setting all players points to " + args[3])); - plugin.getPlaceholders().onUpdate(); - } @Override public void executeSinglePlayer(CommandSender sender, String[] args) { VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(args[1]); - user.setPoints(Integer.parseInt(args[3])); - sender.sendMessage(MessageAPI.colorize("&cSet " + args[1] + " points to " + args[3])); - plugin.getPlaceholders().onUpdate(user, false); + user.setPointsStorageAware(Integer.parseInt(args[3]), success -> { + if (!success) { + runForCommandSender(sender, () -> sender.sendMessage( + MessageAPI.colorize("&cUnable to set " + args[1] + " points to " + args[3]))); + return; + } + runForCommandSender(sender, () -> sender.sendMessage( + MessageAPI.colorize("&cSet " + args[1] + " points to " + args[3]))); + plugin.getPlaceholders().onUpdate(user, false); + }); } }, adminPerm)); @@ -422,37 +466,75 @@ public void executeAll(CommandSender sender, String[] args) { return; } + java.util.List userIds = new java.util.ArrayList<>(plugin.getUserManager().getAllUUIDs()); + if (userIds.isEmpty()) { + sender.sendMessage(MessageAPI.colorize("&cNo players were available to update")); + return; + } sender.sendMessage( MessageAPI.colorize("&cGiving " + "all players" + " " + args[3] + " points")); - for (String uuidStr : plugin.getUserManager().getAllUUIDs()) { + java.util.List users = new java.util.ArrayList<>(); + for (String uuidStr : userIds) { UUID uuid = UUID.fromString(uuidStr); - VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid); - user.userDataFetechMode(UserDataFetchMode.NO_CACHE); - user.addPoints(num); - if (user.isOnline()) { - user.sendMessage(plugin.getConfigFile().getFormatCommandsAdminVotePointsPlayerGiven(), - "amount", args[3]); + users.add(plugin.getVotingPluginUserManager().getVotingPluginUser(uuid)); + } + java.util.concurrent.atomic.AtomicInteger remaining = + new java.util.concurrent.atomic.AtomicInteger(users.size()); + java.util.concurrent.atomic.AtomicInteger updated = + new java.util.concurrent.atomic.AtomicInteger(); + String batchOperationId = "admin-bulk-points/" + UUID.randomUUID(); + VotingPluginUser.addPointsStorageAware(plugin, users, num, batchOperationId, (user, success) -> { + try { + if (success) { + updated.incrementAndGet(); + if (user.isOnline()) { + user.sendMessage(plugin.getConfigFile().getFormatCommandsAdminVotePointsPlayerGiven(), + "amount", args[3]); + } + } + } finally { + if (remaining.decrementAndGet() == 0) { + runForCommandSender(sender, () -> { + sender.sendMessage(MessageAPI.colorize("&cGave all players " + args[3] + + " points to " + updated.get() + "/" + users.size() + + " players. Any failure may be indeterminate; do not rerun without reconciliation.")); + plugin.getPlaceholders().onUpdate(); + }); + } } - } - sender.sendMessage(MessageAPI.colorize("&cGave " + "all players" + " " + args[3] + " points")); - - plugin.getPlaceholders().onUpdate(); + }); } @Override public void executeSinglePlayer(CommandSender sender, String[] args) { VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(args[1]); user.cache(); - int newTotal = 0; - newTotal = user.addPoints(Integer.parseInt(args[3])); - if (user.isOnline()) { - user.sendMessage(plugin.getConfigFile().getFormatCommandsAdminVotePointsPlayerGiven(), - "amount", args[3]); - } - sender.sendMessage(MessageAPI.colorize("&cGave " + args[1] + " " + args[3] + " points" + ", " - + args[1] + " now has " + newTotal + " points")); - - plugin.getPlaceholders().onUpdate(user, false); + int amount = Integer.parseInt(args[3]); + String operationId = "admin-points/" + UUID.randomUUID(); + user.addPointsStorageAwareAsync(amount, operationId).whenComplete((newTotal, failure) -> { + if (failure != null) { + runForVotingUser(user, () -> + runForCommandSender(sender, () -> sender.sendMessage(MessageAPI.colorize( + "&cUnable to confirm the point addition; do not retry this command")))); + return; + } + // Retire the durable admin operation independently of UI scheduling. If + // acknowledgement remains unavailable, its prefix makes the completed + // row eligible for bounded retention cleanup. + user.acknowledgeStorageAwarePointOperation(operationId) + .whenComplete((ignored, acknowledgementFailure) -> { + if (acknowledgementFailure != null) plugin.debug(acknowledgementFailure); + }); + runForVotingUser(user, () -> { + if (user.isOnline()) { + user.sendMessage(plugin.getConfigFile().getFormatCommandsAdminVotePointsPlayerGiven(), + "amount", args[3]); + } + runForCommandSender(sender, () -> sender.sendMessage(MessageAPI.colorize("&cGave " + args[1] + + " " + args[3] + " points" + ", " + args[1] + " now has " + newTotal + " points"))); + plugin.getPlaceholders().onUpdate(user, false); + }); + }); } }, adminPerm)); @@ -471,34 +553,58 @@ public void executeAll(CommandSender sender, String[] args) { sender.sendMessage( MessageAPI.colorize("&cGiving " + "all players" + " " + args[3] + " points")); - for (String uuidStr : plugin.getUserManager().getAllUUIDs()) { + java.util.List userIds = new java.util.ArrayList<>(plugin.getUserManager().getAllUUIDs()); + if (userIds.isEmpty()) { + sender.sendMessage(MessageAPI.colorize("&cNo players were available to update")); + return; + } + java.util.List users = new java.util.ArrayList<>(); + for (String uuidStr : userIds) { UUID uuid = UUID.fromString(uuidStr); - VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid); - user.userDataFetechMode(UserDataFetchMode.NO_CACHE); - user.removePoints(num); - if (user.isOnline()) { - user.sendMessage(plugin.getConfigFile().getFormatCommandsAdminVotePointsPlayerRemoved(), - "amount", args[3]); - } + users.add(plugin.getVotingPluginUserManager().getVotingPluginUser(uuid)); } - sender.sendMessage( - MessageAPI.colorize("&cRemoved " + "all players" + " " + args[3] + " points")); - - plugin.getPlaceholders().onUpdate(); + java.util.concurrent.atomic.AtomicInteger remaining = + new java.util.concurrent.atomic.AtomicInteger(users.size()); + java.util.concurrent.atomic.AtomicInteger removed = new java.util.concurrent.atomic.AtomicInteger(); + String batchOperationId = "admin-bulk-remove/" + UUID.randomUUID(); + VotingPluginUser.removePointsStorageAware(plugin, users, num, batchOperationId, (user, success) -> { + try { + if (success) { + removed.incrementAndGet(); + if (user.isOnline()) user.sendMessage( + plugin.getConfigFile().getFormatCommandsAdminVotePointsPlayerRemoved(), + "amount", args[3]); + } + } finally { + if (remaining.decrementAndGet() == 0) { + runForCommandSender(sender, () -> { + sender.sendMessage(MessageAPI.colorize("&cRemoved " + args[3] + " points from " + + removed.get() + "/" + userIds.size() + + " players. Any failure may be indeterminate; do not rerun without reconciliation.")); + plugin.getPlaceholders().onUpdate(); + }); + } + } + }); } @Override public void executeSinglePlayer(CommandSender sender, String[] args) { VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(args[1]); user.cache(); - user.removePoints(Integer.parseInt(args[3])); - if (user.isOnline()) { - user.sendMessage(plugin.getConfigFile().getFormatCommandsAdminVotePointsPlayerRemoved(), - "amount", args[3]); - } - sender.sendMessage(MessageAPI.colorize("&cRemoved " + args[3] + " points from " + args[1] + ", " - + args[1] + " now has " + user.getPoints() + " points")); - plugin.getPlaceholders().onUpdate(user, false); + user.removePoints(Integer.parseInt(args[3]), removed -> { + if (!removed) { + runForCommandSender(sender, () -> sender.sendMessage(MessageAPI.colorize( + "&cUnable to confirm removing " + args[3] + " points from " + args[1] + + "; do not retry without reconciliation"))); + return; + } + if (user.isOnline()) user.sendMessage( + plugin.getConfigFile().getFormatCommandsAdminVotePointsPlayerRemoved(), "amount", args[3]); + runForCommandSender(sender, () -> sender.sendMessage( + MessageAPI.colorize("&cRemoved " + args[3] + " points from " + args[1]))); + plugin.getPlaceholders().onUpdate(user, false); + }); } }, adminPerm)); @@ -2443,7 +2549,10 @@ public void execute(CommandSender sender, String[] args) { canonicalUser.getData().setInt("MonthTotal", month); canonicalUser.getData().setInt("WeeklyTotal", week); canonicalUser.getData().setInt("DailyTotal", day); - canonicalUser.getData().setInt("Points", points); + // Point writes must use VotingPlugin's shared-storage mutator so this + // repair cannot enqueue an absolute cached write that later overwrites + // an atomic update from another server. + new VotingPluginUser(plugin, canonicalUser).setPoints(points); // Rebuild LastVotes string if (!lastVotes.isEmpty()) { @@ -3288,11 +3397,6 @@ public void execute(CommandSender sender, String[] args) { @Override public void execute(CommandSender sender, String[] args) { - if (!plugin.getShopFile().isVoteShopEnabled()) { - sender.sendMessage(MessageAPI.colorize("&cVote shop disabled")); - return; - } - String identifier = args[1]; Set identifiers = plugin.getShopFile().getShopIdentifiers(); if (ArrayUtils.containsIgnoreCase(identifiers, identifier)) { @@ -3302,61 +3406,19 @@ public void execute(CommandSender sender, String[] args) { } } - String perm = plugin.getShopFile().getVoteShopPermission(identifier); - boolean hasPerm = false; - if (perm.isEmpty()) { - hasPerm = true; - } else { - hasPerm = sender.hasPermission(perm); - } - - int limit = plugin.getShopFile().getShopIdentifierLimit(identifier); - VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(sender.getName()); - boolean limitPass = true; - if (limit > 0) { - - if (user.getVoteShopIdentifierLimit(identifier) >= limit) { - limitPass = false; - } + VoteShopEntry entry = plugin.getVoteShopManager().getMainEntry(identifier); + if (!(entry instanceof VoteShopItem)) { + sendMessage(sender, "&cWrong voteshop item"); + return; } - - if (!plugin.getShopFile().getVoteShopNotBuyable(identifier)) { - if (hasPerm) { - if (plugin.getConfigFile().isExtraVoteShopCheck()) { - user.cache(); - } - int points = plugin.getShopFile().getShopIdentifierCost(identifier); - if (identifier != null) { - - if (limitPass) { - HashMap placeholders = new HashMap<>(); - placeholders.put("identifier", identifier); - placeholders.put("points", "" + points); - placeholders.put("limit", "" + limit); - if (user.removePoints(points, true)) { - - plugin.getRewardHandler().giveReward(user, plugin.getShopFile().getData(), - plugin.getShopFile().getShopIdentifierRewardsPath(identifier), - new RewardOptions().setPlaceholders(placeholders)); - - user.sendMessage(PlaceholderUtils.replacePlaceHolder( - plugin.getConfigFile().getFormatShopPurchaseMsg(), placeholders)); - if (limit > 0) { - user.setVoteShopIdentifierLimit(identifier, - user.getVoteShopIdentifierLimit(identifier) + 1); - } - } else { - user.sendMessage(PlaceholderUtils.replacePlaceHolder( - plugin.getConfigFile().getFormatShopFailedMsg(), placeholders)); - } - } else { - user.sendMessage(plugin.getShopFile().getVoteShopLimitReached()); - } - } - + VoteShopItem item = (VoteShopItem) entry; + plugin.getVoteShopManager().purchase((Player) sender, user, item, result -> { + if (result != VoteShopPurchaseResult.SUCCESS) { + plugin.getVoteShopManager().getPurchaseService().sendFailureMessage((Player) sender, user, + item, result); } - } + }); } else { sendMessage(sender, "&cWrong voteshop item"); } @@ -3791,9 +3853,8 @@ public void execute(CommandSender sender, String[] args) { } int pointsToGive = Integer.parseInt(args[2]); if (pointsToGive > 0) { - if (cPlayer.getPoints() >= pointsToGive) { - user.addPoints(pointsToGive); - cPlayer.removePoints(pointsToGive); + cPlayer.transferPointsWithResult(user, pointsToGive, result -> { + if (result == PointTransferResult.SUCCESS) { HashMap placeholders = new HashMap<>(); placeholders.put("transfer", "" + pointsToGive); placeholders.put("touser", "" + user.getPlayerName()); @@ -3803,13 +3864,14 @@ public void execute(CommandSender sender, String[] args) { plugin.getConfigFile() .getFormatCommandsVoteGivePointsTransferFrom(), placeholders)); - user.sendMessage(PlaceholderUtils.replacePlaceHolder( - plugin.getConfigFile().getFormatCommandsVoteGivePointsTransferTo(), - placeholders)); - } else { - sendMessage(sender, plugin.getConfigFile() - .getFormatCommandsVoteGivePointsNotEnoughPoints()); - } + runForVotingUser(user, + () -> user.sendMessage(PlaceholderUtils.replacePlaceHolder(plugin + .getConfigFile().getFormatCommandsVoteGivePointsTransferTo(), + placeholders))); + } else { + sendMessage(sender, transferFailureMessage(result)); + } + }); } else { sendMessage(sender, plugin.getConfigFile() .getFormatCommandsVoteGivePointsNumberLowerThanZero()); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShop.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShop.java index 0b3bb2400e..1b14101fe3 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShop.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShop.java @@ -125,9 +125,8 @@ protected void addItemButton(BInventory inv, final Player player, final VotingPl @Override public void onClick(ClickEvent event) { VotingPluginUser clickedUser = getUser(event.getPlayer()); - if (plugin.getConfigFile().isExtraVoteShopCheck()) { - clickedUser.cache(); - } + plugin.getVoteShopManager().getPurchaseService().refreshUserForPurchaseValidation(clickedUser, + item, plugin.getConfigFile().isExtraVoteShopCheck()); if (item.isNotBuyable()) { clickedUser.sendMessage(plugin.getConfigFile().getFormatShopNotPurchasable()); @@ -224,20 +223,21 @@ public void onClick(ClickEvent clickEvent) { */ protected void handlePurchase(Player player, VotingPluginUser currentUser, VoteShopItem item, VoteShopCategory category) { - VoteShopPurchaseResult result = plugin.getVoteShopManager().purchase(player, currentUser, item); - if (result != VoteShopPurchaseResult.SUCCESS) { - plugin.getVoteShopManager().getPurchaseService().sendFailureMessage(player, currentUser, item, result); - return; - } + plugin.getVoteShopManager().purchase(player, currentUser, item, result -> { + if (result != VoteShopPurchaseResult.SUCCESS) { + plugin.getVoteShopManager().getPurchaseService().sendFailureMessage(player, currentUser, item, result); + return; + } - plugin.getCommandLoader().processSlotClick(player, currentUser, item.getIdentifier()); - if (plugin.getVoteShopManager().getDefinition().isReopenGuiOnPurchase()) { - if (category != null) { - new VoteShopCategoryMenu(plugin, player, currentUser, category).open(GUIMethod.CHEST); - } else { - plugin.getCommandLoader().processSlotClick(player, currentUser, "shop"); + plugin.getCommandLoader().processSlotClick(player, currentUser, item.getIdentifier()); + if (plugin.getVoteShopManager().getDefinition().isReopenGuiOnPurchase()) { + if (category != null) { + new VoteShopCategoryMenu(plugin, player, currentUser, category).open(GUIMethod.CHEST); + } else { + plugin.getCommandLoader().processSlotClick(player, currentUser, "shop"); + } } - } + }); } /** @@ -257,4 +257,4 @@ protected VotingPluginUser getUser(Player player) { public void open() { open(GUIMethod.CHEST); } -} \ No newline at end of file +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShopConfirm.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShopConfirm.java index 56dd5d0ac6..d03c451266 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShopConfirm.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShopConfirm.java @@ -1,6 +1,7 @@ package com.bencodez.votingplugin.commands.gui.player; import java.util.ArrayList; +import java.util.concurrent.atomic.AtomicBoolean; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; @@ -31,6 +32,8 @@ public class VoteShopConfirm extends GUIHandler { private VotingPluginUser user; + private final AtomicBoolean purchaseSubmitted = new AtomicBoolean(); + /** * Creates the GUI. * @@ -71,20 +74,23 @@ public void onChest(final Player player) { @Override public void onClick(ClickEvent event) { - user.cache(); - VoteShopPurchaseResult result = plugin.getVoteShopManager().purchase(player, user, item); - if (result != VoteShopPurchaseResult.SUCCESS) { - plugin.getVoteShopManager().getPurchaseService().sendFailureMessage(player, user, item, result); - returnToPrevious(event.getPlayer()); - return; - } + if (!beginPurchase()) return; + event.closeInventory(); + plugin.getVoteShopManager().getPurchaseService().refreshUserForPurchaseValidation(user, item, true); + plugin.getVoteShopManager().purchase(player, user, item, result -> { + if (result != VoteShopPurchaseResult.SUCCESS) { + plugin.getVoteShopManager().getPurchaseService().sendFailureMessage(player, user, item, result); + returnToPrevious(event.getPlayer()); + return; + } - plugin.getCommandLoader().processSlotClick(player, user, item.getIdentifier()); - if (item.isCloseGUI()) { - event.closeInventory(); - } else { - returnToPrevious(event.getPlayer()); - } + plugin.getCommandLoader().processSlotClick(player, user, item.getIdentifier()); + if (item.isCloseGUI()) { + event.closeInventory(); + } else { + returnToPrevious(event.getPlayer()); + } + }); } }); inv.addButton(new BInventoryButton(new ItemBuilder(plugin.getShopFile().getShopConfirmPurchaseNoItem())) { @@ -101,6 +107,10 @@ public void onClick(ClickEvent event) { inv.openInventory(player); } + boolean beginPurchase() { + return purchaseSubmitted.compareAndSet(false, true); + } + @Override public void onDialog(Player player) { PlayerUtils.setPlayerMeta(plugin, player, "ident", item.getIdentifier()); @@ -113,23 +123,24 @@ public void onDialog(Player player) { .noText(new ItemBuilder(plugin.getShopFile().getShopConfirmPurchaseNoItem()).getName()) .onYes(payload -> { Player clicked = player.getServer().getPlayer(payload.owner()); - if (clicked == null) { - return; - } - - user.cache(); - VoteShopPurchaseResult result = plugin.getVoteShopManager().purchase(clicked, user, item); - if (result != VoteShopPurchaseResult.SUCCESS) { - plugin.getVoteShopManager().getPurchaseService().sendFailureMessage(clicked, user, item, - result); - returnToPrevious(clicked); + if (clicked == null || !beginPurchase()) { return; } - plugin.getCommandLoader().processSlotClick(clicked, user, item.getIdentifier()); - if (!item.isCloseGUI()) { - returnToPrevious(clicked); - } + plugin.getVoteShopManager().getPurchaseService().refreshUserForPurchaseValidation(user, item, true); + plugin.getVoteShopManager().purchase(clicked, user, item, result -> { + if (result != VoteShopPurchaseResult.SUCCESS) { + plugin.getVoteShopManager().getPurchaseService().sendFailureMessage(clicked, user, item, + result); + returnToPrevious(clicked); + return; + } + + plugin.getCommandLoader().processSlotClick(clicked, user, item.getIdentifier()); + if (!item.isCloseGUI()) { + returnToPrevious(clicked); + } + }); }).onNo(payload -> { Player clicked = player.getServer().getPlayer(payload.owner()); if (clicked != null) { @@ -155,4 +166,4 @@ protected void returnToPrevious(Player player) { public void open() { open(GUIMethod.CHEST); } -} \ No newline at end of file +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/Config.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/Config.java index 4e52787b2b..5e14c17c0c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/Config.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/Config.java @@ -385,7 +385,16 @@ public String getDiscordSRVTopVoterRankDisplay(TopVoter topVoter) { @ConfigDataString(path = "Format.Commands.Vote.GivePoints.NotEnoughPoints") @Getter - private String formatCommandsVoteGivePointsNotEnoughPoints = "&cNot enough points"; + private String formatCommandsVoteGivePointsNotEnoughPoints = "&cNot enough points"; + + @ConfigDataString(path = "Format.Commands.Vote.GivePoints.Unavailable") + @Getter + private String formatCommandsVoteGivePointsUnavailable = "&cUnable to transfer points right now, please try again"; + + @ConfigDataString(path = "Format.Commands.Vote.GivePoints.PendingConfirmation") + @Getter + private String formatCommandsVoteGivePointsPendingConfirmation = + "&eTransfer pending confirmation; do not retry it"; @ConfigDataString(path = "Format.Commands.Vote.GivePoints.NotJoinedServer") @Getter diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java index 6d7d82e702..f4722b2610 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java @@ -678,8 +678,12 @@ public QuickState readQuickSetup(String preset, Map options) thr if (!READABLE_QUICK_SETUPS.contains(preset)) { throw new IllegalArgumentException("quick setup preset cannot be read"); } - rejectUnknownOptions(options, "vote-site".equals(preset) ? Set.of("name") : Set.of()); + rejectUnknownOptions(options, "vote-site".equals(preset) ? Set.of("name") + : "vote-party".equals(preset) ? Set.of("enabled") : Set.of()); if ("vote-site".equals(preset)) option(options, "name", "[A-Za-z0-9_-]{1,64}"); + if ("vote-party".equals(preset) && options != null && options.containsKey("enabled")) { + booleanOption(options, "enabled"); + } return retryRead(() -> readQuickSetupOnce(preset, options)); } @@ -844,7 +848,7 @@ private QuickProposal quickProposal(String preset, Map options, case "vote-logging" -> Set.of("enabled", "purgeDays", "useMainMySQL"); case "common-settings" -> Set.of("processRewards", "autoCreateVoteSites", "extraAllSitesCheck", "countFakeVotes", "disableNoServiceSiteMessage", "disableUpdateChecking"); - case "vote-party" -> Set.of("votesRequired", "broadcast", "giveAllPlayers", "onlineOnly", "command"); + case "vote-party" -> Set.of("enabled", "votesRequired", "broadcast", "giveAllPlayers", "onlineOnly", "command"); case "sync-vote-sites" -> Set.of("sourceContent"); default -> throw new IllegalArgumentException("quick setup preset is unsupported"); }); @@ -948,7 +952,11 @@ private QuickProposal quickProposal(String preset, Map options, return new QuickProposal(fileName, yaml.saveToString()); } if ("vote-party".equals(preset)) { - yaml.set("VoteParty.Enabled", true); + // v1 does not carry Enabled, so it must leave the installed value untouched. + // v2 supplies the field so its actual state can round-trip explicitly. + if (options != null && options.containsKey("enabled")) { + yaml.set("VoteParty.Enabled", booleanOption(options, "enabled")); + } yaml.set("VoteParty.VotesRequired", boundedInteger(option(options, "votesRequired", "[0-9]{1,6}"), 1, 100000)); yaml.set("VoteParty.GiveAllPlayers", booleanOption(options, "giveAllPlayers")); yaml.set("VoteParty.GiveOnlinePlayersOnly", booleanOption(options, "onlineOnly")); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java index 7bf1a11818..91a8a5915f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -51,8 +51,8 @@ public final class BackendControlConnector implements AutoCloseable { private static final long INSPECTION_SHUTDOWN_TIMEOUT_SECONDS = 5; private static final Pattern NODE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); private static final Set CAPABILITIES = Set.of("config.files.v1", "config.file-comments.v1", - "config.quick-setup.v1", "config.vote-sites-sync.v1", "config.proxy-method.v1", "config.reward-files.v1", - "data.inspect.v1"); + "config.quick-setup.v1", "config.quick-setup.v2", "config.vote-sites-sync.v1", + "config.proxy-method.v1", "config.reward-files.v1", "data.inspect.v1"); private final VotingPluginMain plugin; private final Path dataDirectory; @@ -76,6 +76,7 @@ public final class BackendControlConnector implements AutoCloseable { private volatile boolean registered; private volatile boolean operationsAccepted; private volatile boolean quickSetupsAccepted; + private volatile boolean votePartySetupsAccepted; private volatile boolean voteSitesSyncAccepted; private volatile boolean rewardFilesAccepted; private volatile boolean inspectionsAccepted; @@ -386,6 +387,7 @@ private void cycle() { } operationsAccepted = negotiatedCapability(node, "config.files.v1", operationsAccepted); quickSetupsAccepted = negotiatedCapability(node, "config.quick-setup.v1", quickSetupsAccepted); + votePartySetupsAccepted = negotiatedCapability(node, "config.quick-setup.v2", votePartySetupsAccepted); voteSitesSyncAccepted = negotiatedCapability(node, "config.vote-sites-sync.v1", voteSitesSyncAccepted); rewardFilesAccepted = configurations.supportsNamedRewardFiles() && negotiatedCapability(node, "config.reward-files.v1", rewardFilesAccepted); @@ -439,6 +441,7 @@ private JsonObject register() throws Exception { // A registration must explicitly establish required capabilities. Heartbeats may omit the unchanged set. operationsAccepted = false; quickSetupsAccepted = false; + votePartySetupsAccepted = false; voteSitesSyncAccepted = false; rewardFilesAccepted = false; inspectionsAccepted = false; @@ -702,7 +705,6 @@ private TaskResult execute(UUID operationId, JsonObject task) { String domain = string(configuration, "domain"); if ("file".equals(domain)) return executeFile(operationId, type, configuration, task); if ("quick-setup".equals(domain)) { - if (!quickSetupsAccepted) return TaskResult.failure("UNSUPPORTED_TASK", "Quick setups were not negotiated"); return executeQuick(operationId, type, configuration, task); } return TaskResult.failure("UNSUPPORTED_TASK", "Configuration domain is unsupported"); @@ -785,13 +787,15 @@ private TaskResult executeFile(UUID operationId, String type, JsonObject configu private TaskResult executeQuick(UUID operationId, String type, JsonObject configuration, JsonObject task) throws IOException { String preset = string(configuration, "preset"); - if (!quickSetupCapabilityAccepted(preset, quickSetupsAccepted, voteSitesSyncAccepted)) { - return TaskResult.failure("UNSUPPORTED_TASK", "VoteSites sync was not negotiated"); - } Map options = options(configuration.getAsJsonObject("options")); + if (!quickSetupCapabilityAccepted(preset, quickSetupsAccepted, votePartySetupsAccepted, + voteSitesSyncAccepted, options)) { + return TaskResult.failure("UNSUPPORTED_TASK", "The required quick setup capability was not negotiated"); + } if ("READ".equals(type)) { BackendConfigurationService.QuickState state = configurations.readQuickSetup(preset, options); - return TaskResult.quick(preset, state.options(), state.revision(), List.of(), false); + return TaskResult.quick(preset, resultQuickReadOptions(preset, state.options(), options), + state.revision(), List.of(), false); } if ("PREVIEW".equals(type)) { BackendConfigurationService.QuickPreview preview = configurations.previewQuickSetup(preset, options); @@ -834,10 +838,21 @@ static List boundedResultChanges(List changes) { } static boolean quickSetupCapabilityAccepted(String preset, boolean quickSetupsAccepted, - boolean voteSitesSyncAccepted) { + boolean votePartySetupsAccepted, boolean voteSitesSyncAccepted) { + if ("vote-party".equals(preset)) return quickSetupsAccepted && votePartySetupsAccepted; return quickSetupsAccepted && (!"sync-vote-sites".equals(preset) || voteSitesSyncAccepted); } + static boolean quickSetupCapabilityAccepted(String preset, boolean quickSetupsAccepted, + boolean votePartySetupsAccepted, boolean voteSitesSyncAccepted, Map options) { + if ("vote-party".equals(preset)) { + return options != null && options.containsKey("enabled") + ? quickSetupsAccepted && votePartySetupsAccepted : quickSetupsAccepted; + } + return quickSetupCapabilityAccepted(preset, quickSetupsAccepted, votePartySetupsAccepted, + voteSitesSyncAccepted); + } + private Response send(String method, String path, JsonObject body) throws Exception { HttpRequest request = HttpRequest.newBuilder(settings.endpoint().resolve(path)) .timeout(Duration.ofMillis(settings.requestTimeoutMillis())).header("Content-Type", "application/json") @@ -916,6 +931,16 @@ static Map resultQuickOptions(String preset, Map return options == null ? Map.of() : Map.copyOf(options); } + static Map resultQuickReadOptions(String preset, Map state, + Map requestOptions) { + if (!"vote-party".equals(preset) || requestOptions != null && requestOptions.containsKey("enabled")) { + return resultQuickOptions(preset, state); + } + Map legacy = new LinkedHashMap<>(state); + legacy.remove("enabled"); + return Map.copyOf(legacy); + } + private static int bounded(int value, int min, int max, String name) { if (value < min || value > max) throw new IllegalArgumentException("Control.Backend." + name + " is invalid"); return value; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerReceivePointsEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerReceivePointsEvent.java index 65d6bde5fb..f33486556a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerReceivePointsEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerReceivePointsEvent.java @@ -44,11 +44,22 @@ public static HandlerList getHandlerList() { * @param user the voting plugin user * @param points the points received */ - public PlayerReceivePointsEvent(VotingPluginUser user, int points) { - super(true); - this.player = user; - this.points = points; - } + public PlayerReceivePointsEvent(VotingPluginUser user, int points) { + this(user, points, true); + } + + /** + * Constructs an event with an explicit Bukkit dispatch context. + * + * @param user the voting plugin user + * @param points the points received + * @param async whether the event is being dispatched off the Bukkit/entity lane + */ + public PlayerReceivePointsEvent(VotingPluginUser user, int points, boolean async) { + super(async); + this.player = user; + this.points = points; + } @Override public HandlerList getHandlers() { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/VoteShopPurchaseEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/VoteShopPurchaseEvent.java index d1bca01266..9b164576a6 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/VoteShopPurchaseEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/VoteShopPurchaseEvent.java @@ -32,7 +32,17 @@ public class VoteShopPurchaseEvent extends Event { public VoteShopPurchaseEvent(UUID playerUUID, String playerName, VotingPluginUser user, String identifier, int cost) { - super(true); + this(playerUUID, playerName, user, identifier, cost, true); + } + + /** + * Creates a purchase event with the dispatch lane made explicit. Internal + * entity-lane purchases are synchronous; the legacy constructor remains + * asynchronous for source and behavioral compatibility with external callers. + */ + public VoteShopPurchaseEvent(UUID playerUUID, String playerName, VotingPluginUser user, String identifier, + int cost, boolean async) { + super(async); this.playerName = playerName; this.user = user; this.playerUuid = playerUUID; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java index 820e8e204f..528958c5f4 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java @@ -1,6 +1,11 @@ package com.bencodez.votingplugin.rewards.builtin; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.HashMap; +import java.util.concurrent.CompletionStage; import org.bukkit.Material; import org.bukkit.configuration.ConfigurationSection; @@ -46,8 +51,79 @@ public void onValidate(Reward reward, RewardInject inject, ConfigurationSection public String onRewardRequest(Reward reward, com.bencodez.advancedcore.api.user.AdvancedCoreUser user, int num, HashMap placeholders) { VotingPluginUser vpUser = plugin.getVotingPluginUserManager().getVotingPluginUser(user); - String result = "" + vpUser.addPoints(num); + // Reward injection is a synchronous chain, so publish the storage-aware + // predicted total immediately while shared-MySQL persistence stays off the + // Bukkit/Folia entity lane. Ordinary storage retains its synchronous path. + String result = "" + vpUser.addPointsStorageAware(num); plugin.debug("Setting points to " + result); return result; } + + @Override + public boolean supportsAsyncRequest() { + return true; + } + + @Override + public CompletionStage onRewardRequestAsync(Reward reward, + com.bencodez.advancedcore.api.user.AdvancedCoreUser user, int num, + HashMap placeholders) { + VotingPluginUser vpUser = plugin.getVotingPluginUserManager().getVotingPluginUser(user); + String operationId = replayOperationId(vpUser); + CompletionStage addition = operationId == null ? vpUser.addPointsStorageAwareAsync(num) + : vpUser.addPointsStorageAwareAsync(num, operationId); + return addition.thenApply(total -> { + String result = String.valueOf(total); + plugin.debug("Setting points to " + result); + return result; + }); + } + + @Override + public CompletionStage onReplayCheckpointPersisted(Reward reward, + com.bencodez.advancedcore.api.user.AdvancedCoreUser user, String occurrenceId, String injectionKey) { + VotingPluginUser vpUser = plugin.getVotingPluginUserManager().getVotingPluginUser(user); + String operationId = replayOperationId(vpUser, occurrenceId, injectionKey); + return operationId == null ? java.util.concurrent.CompletableFuture.completedFuture(null) + : vpUser.acknowledgeStorageAwarePointOperation(operationId); + } + + /** + * AdvancedCore #317 exposes a durable occurrence identity for a queued replay + * plus its active stage path. Use both when present, but retain compatibility + * with releases that cannot distinguish a retry from a new reward occurrence. + */ + private static String replayOperationId(VotingPluginUser user) { + try { + Method currentReplayOccurrenceId = Reward.class.getMethod("currentReplayOccurrenceId"); + Method currentReplayKey = Reward.class.getMethod("currentReplayKey"); + Object occurrence = currentReplayOccurrenceId.invoke(null); + Object value = currentReplayKey.invoke(null); + if (!(occurrence instanceof String) || ((String) occurrence).isEmpty() + || !(value instanceof String) || ((String) value).isEmpty()) return null; + return replayOperationId(user, (String) occurrence, (String) value); + } catch (ReflectiveOperationException | SecurityException ignored) { + return null; + } + } + + private static String replayOperationId(VotingPluginUser user, String occurrenceId, String injectionKey) { + if (occurrenceId == null || occurrenceId.isEmpty() || injectionKey == null || injectionKey.isEmpty()) return null; + return sha256("VotingPlugin:shared-points-reward:v1\0" + user.getUUID() + '\0' + occurrenceId + '\0' + + injectionKey); + } + + private static String sha256(String value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder result = new StringBuilder(digest.length * 2); + for (byte element : digest) { + result.append(Character.forDigit((element >>> 4) & 0xf, 16)); + result.append(Character.forDigit(element & 0xf, 16)); + } + return result.toString(); + } catch (NoSuchAlgorithmException failure) { + throw new IllegalStateException("SHA-256 is unavailable", failure); + } + } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java index 7a5eee0eac..36dfdc77d7 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -38,6 +38,7 @@ import com.bencodez.simpleapi.sql.DataType; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService; /** * Handles top voter rankings and statistics. @@ -235,7 +236,8 @@ public void onDayChange(DayChangeEvent event) { for (String shopIdent : plugin.getShopFile().getShopIdentifiers()) { if (plugin.getShopFile().getVoteShopResetDaily(shopIdent)) { - resetVoteShopLimit(shopIdent); + resetVoteShopLimit(shopIdent, + VoteShopPurchaseService.currentLimitGenerationId(plugin, shopIdent)); } } @@ -357,7 +359,8 @@ public void onMonthChange(MonthChangeEvent event) { for (String shopIdent : plugin.getShopFile().getShopIdentifiers()) { if (plugin.getShopFile().getVoteShopResetMonthly(shopIdent)) { - resetVoteShopLimit(shopIdent); + resetVoteShopLimit(shopIdent, + VoteShopPurchaseService.currentLimitGenerationId(plugin, shopIdent)); } } @@ -480,7 +483,8 @@ public void onWeekChange(WeekChangeEvent event) { for (String shopIdent : plugin.getShopFile().getShopIdentifiers()) { if (plugin.getShopFile().getVoteShopResetWeekly(shopIdent)) { - resetVoteShopLimit(shopIdent); + resetVoteShopLimit(shopIdent, + VoteShopPurchaseService.currentLimitGenerationId(plugin, shopIdent)); } } @@ -526,7 +530,26 @@ public void resetTotals(TopVoter topVoter) { * @param shopIdent the shop identifier */ public void resetVoteShopLimit(String shopIdent) { - plugin.getUserManager().removeAllKeyValues("VoteShopLimit" + shopIdent, DataType.INTEGER); + resetVoteShopLimit(shopIdent, null); + } + + private void resetVoteShopLimit(String shopIdent, String resetGeneration) { + String limitColumn = "VoteShopLimit" + shopIdent; + if (UserStorage.MYSQL.equals(plugin.getStorageType()) && !plugin.getBungeeSettings().isPerServerPoints()) { + if (resetGeneration == null) VoteShopPurchaseService.resetSharedMysqlLimit(plugin, limitColumn); + else VoteShopPurchaseService.resetSharedMysqlLimit(plugin, limitColumn, resetGeneration); + return; + } + if (UserStorage.MYSQL.equals(plugin.getStorageType())) { + // The limit column is still shared with backends that have not switched to + // per-server points. Its wipe and epoch advance must therefore use the + // journal's one transaction; doing the UserManager wipe after advancing the + // epoch can erase a new-epoch reservation. + VoteShopPurchaseService.resetMysqlLimitWithPurchaseFence(plugin, limitColumn, + resetGeneration == null ? UUID.randomUUID().toString() : resetGeneration); + return; + } + plugin.getUserManager().removeAllKeyValues(limitColumn, DataType.INTEGER); } /** diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/PointTransferResult.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/PointTransferResult.java new file mode 100644 index 0000000000..ba380ba140 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/PointTransferResult.java @@ -0,0 +1,10 @@ +package com.bencodez.votingplugin.user; + +/** The externally observable outcome of a point transfer. */ +public enum PointTransferResult { + SUCCESS, + INSUFFICIENT_POINTS, + CANCELLED, + PENDING_CONFIRMATION, + UNAVAILABLE +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java new file mode 100644 index 0000000000..cd92f0235d --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java @@ -0,0 +1,158 @@ +package com.bencodez.votingplugin.user; + +import java.util.Map; +import java.util.UUID; +import java.util.WeakHashMap; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.simpleapi.sql.data.DataValue; +import com.bencodez.votingplugin.VotingPluginMain; + +/** Invalidates only fields changed directly by a shared-MySQL mutation. */ +public final class SharedMysqlCacheReconciler { + /* + * This fence is intentionally JVM-local. Cross-backend reset correctness does + * not depend on it: shared-MySQL VoteShopLimit setters enqueue=false, and + * UserDataCache.dump() persists only queued UserDataChange entries, not the + * read-through value map. Purchases and resets mutate limit columns through the + * epoch-serialized JDBC journal. The fence only orders this JVM's cache drains + * around its own reset transaction. + */ + private static final ReentrantReadWriteLock RESET_FENCE = new ReentrantReadWriteLock(true); + private static final Map> OPTIMISTIC_POINT_VALUES = + java.util.Collections.synchronizedMap(new WeakHashMap<>()); + + private SharedMysqlCacheReconciler() { + } + + /** Excludes every shared-MySQL cache dump while a limit reset is committing. */ + public static void withResetFence(Runnable action) { + var lock = RESET_FENCE.writeLock(); + lock.lock(); + try { + action.run(); + } finally { + lock.unlock(); + } + } + + /** Allows concurrent cache drains while excluding a shared-MySQL limit reset. */ + public static void withCacheDumpFence(Runnable action) { + var lock = RESET_FENCE.readLock(); + lock.lock(); + try { + action.run(); + } finally { + lock.unlock(); + } + } + + static void recordOptimisticPoint(UserDataCache cache, String path, DataValue prediction) { + synchronized (OPTIMISTIC_POINT_VALUES) { + OPTIMISTIC_POINT_VALUES.computeIfAbsent(cache, ignored -> new java.util.HashMap<>()) + .put(path, prediction); + } + } + + /** Removes only the still-current predicted point value before any cache dump. */ + public static void discardOptimisticPoint(UserDataCache cache, String path) { + if (cache == null || path == null) return; + synchronized (cache) { + DataValue prediction; + synchronized (OPTIMISTIC_POINT_VALUES) { + Map predictions = OPTIMISTIC_POINT_VALUES.get(cache); + prediction = predictions == null ? null : predictions.remove(path); + if (predictions != null && predictions.isEmpty()) OPTIMISTIC_POINT_VALUES.remove(cache); + } + var values = cache.getCache(); + if (prediction != null && values != null && values.get(path) == prediction) values.remove(path); + } + } + + /** + * Invalidates an existing cache without creating one or doing JDBC work. The + * mutation has already committed before this method runs. + */ + public static void invalidate(VotingPluginMain plugin, String uuid, String... columns) { + if (plugin == null || uuid == null || columns == null || columns.length == 0) return; + final UUID playerUuid; + try { + playerUuid = UUID.fromString(uuid); + } catch (IllegalArgumentException invalidUuid) { + plugin.debug(invalidUuid); + return; + } + UserDataCache cache = plugin.getUserManager().getDataManager().getUserDataCache().get(playerUuid); + if (cache == null) return; + synchronized (cache) { + var values = cache.getCache(); + if (values == null) return; + for (String column : columns) { + if (column != null) values.remove(column); + } + } + } + + /** Invalidates changed fields and schedules a nonblocking authoritative refill. */ + public static void invalidateAndRefresh(VotingPluginMain plugin, String uuid, String... columns) { + invalidate(plugin, uuid, columns); + if (plugin == null || uuid == null) return; + try { + plugin.getVotingPluginUserManager().getVotingPluginUser(UUID.fromString(uuid), false).cacheAsync(); + } catch (RuntimeException refreshFailure) { + plugin.debug(refreshFailure); + } + } + + /** Removes a reset column from every currently live cache without flushing it. */ + public static void invalidateAll(VotingPluginMain plugin, String column) { + if (plugin == null || column == null) return; + ConcurrentHashMap caches = plugin.getUserManager().getDataManager().getUserDataCache(); + if (caches == null) return; + for (Map.Entry entry : snapshot(caches)) { + invalidate(entry.getValue(), column); + } + } + + /** Invalidates a shared column and repopulates live user caches asynchronously. */ + public static void invalidateAllAndRefresh(VotingPluginMain plugin, String column) { + if (plugin == null || column == null) return; + ConcurrentHashMap caches = plugin.getUserManager().getDataManager().getUserDataCache(); + if (caches == null) return; + Map.Entry[] entries = snapshot(caches); + UUID[] users = new UUID[entries.length]; + for (int i = 0; i < entries.length; i++) { + users[i] = entries[i].getKey(); + invalidate(entries[i].getValue(), column); + } + try { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, () -> { + for (UUID uuid : users) { + try { + plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, false).cache(); + } catch (RuntimeException refreshFailure) { + plugin.debug(refreshFailure); + } + } + }); + } catch (RuntimeException schedulingFailure) { + plugin.debug(schedulingFailure); + } + } + + /** Copies the registry before any cache is touched; the registry is live. */ + @SuppressWarnings("unchecked") + private static Map.Entry[] snapshot(ConcurrentHashMap caches) { + return caches.entrySet().toArray(new Map.Entry[0]); + } + + private static void invalidate(UserDataCache cache, String column) { + if (cache == null) return; + synchronized (cache) { + var values = cache.getCache(); + if (values != null) values.remove(column); + } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java new file mode 100644 index 0000000000..db0e26a924 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -0,0 +1,1057 @@ +package com.bencodez.votingplugin.user; + +import java.io.IOException; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.IntFunction; + +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; +import com.bencodez.simpleapi.sql.mysql.DbType; +import com.bencodez.simpleapi.sql.data.DataValue; +import com.bencodez.simpleapi.sql.data.DataValueInt; +import com.bencodez.simpleapi.folialib.enums.EntityTaskResult; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.util.BukkitCompletionScheduler; + +/** Performs point writes that must remain atomic across shared MySQL servers. */ +final class SharedMysqlPointMutator { + /* + * An async addition may return a predicted total to its caller before its + * atomic database operation begins. UserDataCache is flushable, however, so + * that prediction must never be mistaken for an ordinary pending write when + * the persistence task drains unrelated cached fields. Keep the identity of + * those transient values separately rather than making the persisted cache + * format carry an optimistic-write flag. + */ + private final VotingPluginMain plugin; + + SharedMysqlPointMutator(VotingPluginMain plugin) { + this.plugin = plugin; + } + + boolean applies() { + return usesSharedMysqlPoints(plugin); + } + + static boolean usesSharedMysqlPoints(VotingPluginMain plugin) { + return plugin != null && UserStorage.MYSQL.equals(plugin.getStorageType()) + && !plugin.getBungeeSettings().isPerServerPoints(); + } + + /** Existing durable rows must be recovered even after shared points are disabled. */ + static boolean canRecoverSharedMysqlPointJournals(VotingPluginMain plugin) { + return plugin != null && UserStorage.MYSQL.equals(plugin.getStorageType()); + } + + /** + * Recovers a bounded batch immediately and periodically. The executor belongs + * to the plugin lifecycle, so no independent task survives shutdown. + */ + static void scheduleTransferRecovery(VotingPluginMain plugin) { + plugin.getTimer().execute(() -> recoverSharedPointJournalsSafely(plugin)); + plugin.getTimer().scheduleWithFixedDelay(() -> recoverSharedPointJournalsSafely(plugin), 1L, 1L, + TimeUnit.MINUTES); + } + + private static void recoverSharedPointJournalsSafely(VotingPluginMain plugin) { + try { + recoverSharedPointJournals(plugin); + } catch (RuntimeException failure) { + plugin.getLogger().severe("Unable to recover shared MySQL point journals: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + } + } + + private static void recoverSharedPointJournals(VotingPluginMain plugin) { + recoverTransfers(plugin); + if (!canRecoverSharedMysqlPointJournals(plugin)) return; + try { + SharedPointAdditionJournal.forTable(plugin.getMysql()).cleanupAcknowledged(System.currentTimeMillis()); + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to clean up shared MySQL point additions: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + } + } + + private static void recoverTransfers(VotingPluginMain plugin) { + if (!canRecoverSharedMysqlPointJournals(plugin)) return; + try { + recoverTransfers(plugin, SharedPointTransferJournal.forTable(plugin.getMysql())); + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to recover shared MySQL point transfers: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + } + } + + static void recoverTransfers(VotingPluginMain plugin, SharedPointTransferJournal journal) + throws SQLException { + retryPendingCompensationMarkers(plugin, journal); + for (SharedPointTransferJournal.RefundedTransfer refund : journal.recoverAndCleanup(System.currentTimeMillis())) { + SharedMysqlCacheReconciler.invalidate(plugin, refund.uuid(), refund.pointsColumn()); + } + } + + int add(VotingPluginUser user, int amount, boolean async) { + if (async) { + int previousTotal = cachedPoints(user); + int predictedTotal = previousTotal + amount; + cachePredictedPoints(user, predictedTotal); + if (!run(() -> update(user, amount, false), true)) { + discardOptimisticPoints(user); + return previousTotal; + } + // The mutation has not happened yet, so the historical asynchronous API + // returns its predicted post-event total without blocking for storage. + return predictedTotal; + } + return addAndReadCommitted(user, amount); + } + + private void cachePredictedPoints(VotingPluginUser user, int predictedTotal) { + UserDataCache cache = user.getCache(); + if (cache == null) return; + synchronized (cache) { + var values = cache.getCache(); + if (values == null) return; + DataValue prediction = new DataValueInt(predictedTotal); + values.put(user.getPointsPath(), prediction); + SharedMysqlCacheReconciler.recordOptimisticPoint(cache, user.getPointsPath(), prediction); + } + } + + /** + * Drops an optimistic value, if it is still the current cache value, before + * {@link UserDataCache#dump()} flushes other pending fields. The identity + * comparison deliberately preserves a later real point write that replaced + * the prediction while the async operation waited in the executor. + */ + private void discardOptimisticPoints(VotingPluginUser user) { + UserDataCache cache = user.getCache(); + if (cache == null) return; + synchronized (cache) { + SharedMysqlCacheReconciler.discardOptimisticPoint(cache, user.getPointsPath()); + } + } + + AddResult addCommitted(VotingPluginUser user, int amount) { + return addAndReadCommittedResult(user, amount); + } + + /** + * Adds points under a caller-supplied durable operation id. Retryable reward + * stages use this overload so an ambiguous commit can be confirmed on their + * next invocation without applying the credit again. + */ + AddResult addCommitted(VotingPluginUser user, int amount, String operationId) { + if (operationId == null || operationId.isEmpty()) return addCommitted(user, amount); + drainCache(user); + try { + SharedPointAdditionJournal.AdditionResult result = SharedPointAdditionJournal.forTable(plugin.getMysql()) + .add(operationId, user.getUUID(), user.getPointsPath(), amount, System.currentTimeMillis()); + return new AddResult(true, result.total()); + } catch (SQLException failure) { + logFailure(failure); + return new AddResult(MutationOutcome.INDETERMINATE, 0); + } finally { + discardPointsCache(user); + } + } + + /** Durable, confirmable conditional debit for administrative and purchase retries. */ + AddResult removeCommitted(VotingPluginUser user, int amount, String operationId) { + if (operationId == null || operationId.isEmpty()) return new AddResult(false, 0); + drainCache(user); + try { + SharedPointAdditionJournal.AdditionResult result = SharedPointAdditionJournal.forTable(plugin.getMysql()) + .subtract(operationId, user.getUUID(), user.getPointsPath(), amount, System.currentTimeMillis()); + return new AddResult(true, result.total()); + } catch (SQLException failure) { + logFailure(failure); + return new AddResult(failure instanceof SharedPointAdditionJournal.DebitRejectedException + ? MutationOutcome.REJECTED : MutationOutcome.INDETERMINATE, 0); + } finally { + discardPointsCache(user); + } + } + + /** Looks up a completed retry before its Bukkit receive event is dispatched. */ + Integer completedPointAdditionTotal(String operationId, String uuid, String pointsColumn) { + try { + SharedPointAdditionJournal.AdditionResult result = SharedPointAdditionJournal.forTable(plugin.getMysql()) + .findCompleted(operationId, uuid, pointsColumn); + return result == null ? null : Integer.valueOf(result.total()); + } catch (SQLException failure) { + logFailure(failure); + throw new IllegalStateException("Unable to look up shared MySQL point addition", failure); + } + } + + SharedPointAdditionJournal.HookClaim claimPointAdditionHook(String operationId, String uuid, String pointsColumn, + int requestedAmount, String owner) { + try { + return SharedPointAdditionJournal.forTable(plugin.getMysql()).claimHook(operationId, uuid, pointsColumn, + requestedAmount, owner, System.currentTimeMillis()); + } catch (SQLException failure) { + logFailure(failure); + throw new IllegalStateException("Unable to claim shared MySQL point addition", failure); + } + } + + void markPointAdditionIndeterminate(String operationId, String uuid, String pointsColumn, int requestedAmount, + String owner) throws SQLException { + SharedPointAdditionJournal.forTable(plugin.getMysql()).markIndeterminate(operationId, uuid, pointsColumn, + requestedAmount, owner); + } + + void releaseUnstartedPointAdditionHook(String operationId, String uuid, String pointsColumn, int requestedAmount, + String owner) throws SQLException { + SharedPointAdditionJournal.forTable(plugin.getMysql()).releaseUnstartedHook(operationId, uuid, pointsColumn, + requestedAmount, owner); + } + + AddResult settleClaimedPointAddition(VotingPluginUser user, String operationId, String uuid, String pointsColumn, + int requestedAmount, String owner, Integer adjustedAmount) { + drainCache(user); + try { + SharedPointAdditionJournal.AdditionResult result = SharedPointAdditionJournal.forTable(plugin.getMysql()) + .settleClaim(operationId, uuid, pointsColumn, pointsColumn, requestedAmount, owner, adjustedAmount); + return new AddResult(true, result.total()); + } catch (SQLException failure) { + logFailure(failure); + return new AddResult(false, 0); + } finally { + discardPointsCache(user, pointsColumn); + } + } + + /** Atomically records a PerServerPoints hook outcome and credits its local column. */ + AddResult settlePerServerClaimedPointAddition(VotingPluginUser user, String operationId, String uuid, + String journalPointsColumn, String creditPointsColumn, int requestedAmount, String owner, + Integer adjustedAmount) { + drainCache(user, creditPointsColumn); + try { + SharedPointAdditionJournal.AdditionResult result = SharedPointAdditionJournal.forTable(plugin.getMysql()) + .settleClaim(operationId, uuid, journalPointsColumn, creditPointsColumn, requestedAmount, owner, + adjustedAmount); + return new AddResult(true, result.total()); + } catch (SQLException failure) { + logFailure(failure); + return new AddResult(false, 0); + } finally { + discardPointsCache(user, creditPointsColumn); + } + } + + CompletionStage acknowledgePointAddition(String operationId) { + if (!canRecoverSharedMysqlPointJournals(plugin) || operationId == null || operationId.isEmpty()) { + return CompletableFuture.completedFuture(null); + } + CompletableFuture completion = new CompletableFuture<>(); + try { + plugin.getTimer().execute(() -> { + try { + SharedPointAdditionJournal.forTable(plugin.getMysql()).acknowledge(operationId, + System.currentTimeMillis()); + completion.complete(null); + } catch (Throwable failure) { + completion.completeExceptionally(failure); + } + }); + } catch (RuntimeException rejected) { + completion.completeExceptionally(rejected); + } + return completion; + } + + void acknowledgePointAdditionNow(String operationId) { + if (!canRecoverSharedMysqlPointJournals(plugin) || operationId == null || operationId.isEmpty()) return; + try { + SharedPointAdditionJournal.forTable(plugin.getMysql()).acknowledge(operationId, + System.currentTimeMillis()); + } catch (SQLException failure) { + // The mutation is already confirmed. Keep the COMPLETED row as a safe + // replay record when its retirement checkpoint cannot be persisted. + logFailure(failure); + } + } + + void set(VotingPluginUser user, int value, boolean async) { + run(() -> setAbsolute(user, value), async); + } + + boolean setCommitted(VotingPluginUser user, int value) { + return setAbsolute(user, value); + } + + void cap(VotingPluginUser user, int maximum, boolean async) { + run(() -> capAt(user, maximum), async); + } + + /** + * Adds points and applies the configured upper bound in one accepted + * persistence operation. This prevents executor saturation from accepting + * the addition while dropping a separately submitted cap. + */ + void addAndCap(VotingPluginUser user, int amount, int maximum, boolean async) { + if (!async) { + addAndCapAt(user, amount, maximum); + return; + } + int previousTotal = cachedPoints(user); + int predictedTotal = (int) Math.max(Integer.MIN_VALUE, + Math.min((long) previousTotal + amount, maximum)); + cachePredictedPoints(user, predictedTotal); + if (!run(() -> addAndCapAt(user, amount, maximum), true)) { + discardOptimisticPoints(user); + } + } + + boolean remove(VotingPluginUser user, int amount) { + return update(user, -amount, true); + } + + boolean remove(VotingPluginUser user, int amount, boolean async) { + if (!async) return remove(user, amount); + boolean predictedSuccess = cachedPoints(user) >= amount; + boolean submitted = run(() -> update(user, -amount, true), true); + // Preserve the historical asynchronous API contract: the caller receives + // the cached prediction while the conditional database debit runs later. + return submitted && predictedSuccess; + } + + private int cachedPoints(VotingPluginUser user) { + String path = user.getPointsPath(); + UserDataCache cache = user.getCache(); + if (cache != null) { + synchronized (cache) { + DataValue value = cache.getCache() == null ? null : cache.getCache().get(path); + if (value != null) { + if (value.isInt()) return value.getInt(); + if (value.isString()) { + try { + return Integer.parseInt(value.getString()); + } catch (NumberFormatException ignored) { + // Fall through to the temporary cache/default below. + } + } + } + } + } + return user.getUserData().getInt(path, UserDataFetchMode.TEMP_ONLY); + } + + boolean transfer(VotingPluginUser source, VotingPluginUser target, int amount) { + return transfer(source, target, amount, amount); + } + + boolean transfer(VotingPluginUser source, VotingPluginUser target, int debitAmount, int creditAmount) { + return transferAtomically(source, target, debitAmount, creditAmount); + } + + /** + * Transfers points while allowing the recipient hook to approve or adjust the + * credit after the conditional debit has succeeded. The reservation transaction + * is committed and its connection is closed before the callback is invoked, so + * arbitrary listeners may safely read from the database. A durable journal then + * makes the refund/credit settlement idempotent. + */ + boolean transfer(VotingPluginUser source, VotingPluginUser target, int debitAmount, + IntFunction creditAmountProvider) { + drainCache(source); + drainCache(target); + MySQL table = plugin.getMysql(); + String sourcePoints = source.getPointsPath(); + String targetPoints = target.getPointsPath(); + String transferId = UUID.randomUUID().toString(); + String owner = UUID.randomUUID().toString(); + SharedPointTransferJournal journal = null; + try { + journal = SharedPointTransferJournal.forTable(table); + recoverTransfers(plugin, journal); + try { + if (!journal.reserve(transferId, source.getUUID(), sourcePoints, debitAmount, target.getUUID(), debitAmount, + System.currentTimeMillis())) return false; + } catch (SQLException failure) { + // If reservation commit acknowledgement was lost, release the source + // only when the journal still proves the hook never started. + journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount); + throw failure; + } + SharedPointTransferJournal.ClaimOutcome claim = journal.claimHookWithConfirmation(transferId, owner, + System.currentTimeMillis()); + if (claim == SharedPointTransferJournal.ClaimOutcome.NOT_CLAIMED) { + journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount); + return false; + } + if (claim == SharedPointTransferJournal.ClaimOutcome.INDETERMINATE) { + logIndeterminateClaim(transferId); + return true; + } + Integer creditAmount; + try { + creditAmount = creditAmountProvider.apply(debitAmount); + } catch (RuntimeException failure) { + SharedPointTransferJournal.SettlementOutcome outcome; + try { + outcome = journal.settleWithConfirmation(transferId, owner, source.getUUID(), sourcePoints, + target.getUUID(), targetPoints, debitAmount, null); + } finally { + // A listener may have recreated the recipient cache while the + // settlement transaction was running. Invalidate only its stale + // points value without dumping it back to storage. + discardPointsCache(target, targetPoints); + } + logApprovalFailure(failure); + return isAcceptedSettlement(outcome); + } + // The approval hook may inspect or mutate the recipient and recreate its + // cache after the initial drain. Persist and remove that cache before the + // settlement credit so no queued pre-settlement value can overwrite it. + drainCache(target); + SharedPointTransferJournal.SettlementOutcome outcome; + try { + outcome = journal.settleWithConfirmation(transferId, owner, source.getUUID(), sourcePoints, + target.getUUID(), targetPoints, debitAmount, creditAmount); + } finally { + // A concurrent lookup can recreate the cache after the final + // pre-settlement drain. Never dump its stale points snapshot after + // the credit commits; preserve unrelated cached fields. + discardPointsCache(target, targetPoints); + } + return isAcceptedSettlement(outcome); + } catch (SQLException failure) { + logFailure(failure); + return false; + } + } + + /** + * Runs the durable transfer phases around a Bukkit-thread approval hook. The + * reservation and claim happen on the persistence executor, the arbitrary + * listener runs on Bukkit's thread, and settlement returns to persistence + * before the completion callback is posted back to the source entity lane. + */ + void transferWithBukkitApproval(VotingPluginUser source, VotingPluginUser target, int debitAmount, + IntFunction creditAmountProvider, Consumer completion) { + org.bukkit.entity.Player sourcePlayer = source.getPlayer(); + org.bukkit.entity.Player targetPlayer = target.getPlayer(); + org.bukkit.entity.Player approvalPlayer = targetPlayer != null ? targetPlayer : sourcePlayer; + try { + plugin.getTimer().execute(() -> { + try { + drainCache(source); + drainCache(target); + } catch (RuntimeException cacheFailure) { + plugin.debug(cacheFailure); + completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE); + return; + } + MySQL table = plugin.getMysql(); + String sourcePoints = source.getPointsPath(); + String targetPoints = target.getPointsPath(); + String transferId = UUID.randomUUID().toString(); + String owner = UUID.randomUUID().toString(); + SharedPointTransferJournal journal; + try { + journal = SharedPointTransferJournal.forTable(table); + recoverTransfers(plugin, journal); + if (!journal.reserve(transferId, source.getUUID(), sourcePoints, debitAmount, target.getUUID(), + debitAmount, System.currentTimeMillis())) { + completeOnBukkit(sourcePlayer, completion, PointTransferResult.INSUFFICIENT_POINTS); + return; + } + // The source cache may have been recreated while the reservation was being + // committed. Invalidate its points after the durable debit, before any later + // dump can restore the pre-debit balance. + discardPointsCache(source, sourcePoints); + } catch (SQLException failure) { + // An acknowledgement/confirmation failure can follow a committed + // reservation debit. Never allow a cache recreated during the unknown + // outcome to flush the pre-debit source balance over it. + discardPointsCache(source, sourcePoints); + logFailure(failure); + completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE); + return; + } + + /* + * Do not claim the reservation until the Bukkit approval task has actually + * started. If scheduling is rejected, the row remains RESERVED and startup + * recovery can safely return the debit. JDBC claim work remains on the + * persistence executor, never on the Bukkit lane. + */ + try { + plugin.getBukkitScheduler().runTask(plugin, () -> { + try { + plugin.getTimer().execute(() -> claimTransferForApproval(source, target, sourcePlayer, + approvalPlayer, debitAmount, creditAmountProvider, completion, journal, transferId, + owner, sourcePoints, targetPoints)); + } catch (RuntimeException schedulingFailure) { + // This callback is on Bukkit's lane. The durable RESERVED row is + // intentionally left for the bounded periodic/startup recovery instead + // of running its JDBC refund inline after executor rejection. + completeRejectedPersistenceSubmission(sourcePlayer, completion, schedulingFailure); + } + }); + } catch (RuntimeException schedulingFailure) { + refundReservedAfterSchedulingFailure(source, sourcePlayer, completion, journal, transferId, + sourcePoints, debitAmount, schedulingFailure); + } + }); + } catch (RuntimeException schedulingFailure) { + // No reservation exists when the initial persistence task is rejected. + // Still complete the command contract on the source entity lane. + plugin.debug(schedulingFailure); + completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE); + } + } + + private void claimTransferForApproval(VotingPluginUser source, VotingPluginUser target, + org.bukkit.entity.Player sourcePlayer, org.bukkit.entity.Player approvalPlayer, int debitAmount, + IntFunction creditAmountProvider, Consumer completion, + SharedPointTransferJournal journal, String transferId, String owner, String sourcePoints, String targetPoints) { + SharedPointTransferJournal.ClaimOutcome claim = journal.claimHookWithConfirmation(transferId, owner, + System.currentTimeMillis()); + if (claim == SharedPointTransferJournal.ClaimOutcome.NOT_CLAIMED) { + try { + journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount); + discardPointsCache(source, sourcePoints); + } catch (SQLException failure) { + logFailure(failure); + } + completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE); + return; + } + if (claim == SharedPointTransferJournal.ClaimOutcome.INDETERMINATE) { + // The approval task has not been submitted yet, so an ambiguous claim + // cannot have invoked the recipient hook. Compensate the durable claim + // instead of reporting success and leaving a HOOK_STARTED debit behind. + refundIndeterminateClaimBeforeApproval(source, sourcePlayer, completion, journal, transferId, sourcePoints, + debitAmount); + return; + } + discardPointsCache(source, sourcePoints); + AtomicInteger approvalState = new AtomicInteger(0); + Runnable rejectBeforeStart = () -> { + if (!approvalState.compareAndSet(0, 2)) return; + scheduleRejectedTransferCompensation(source, sourcePlayer, completion, journal, transferId, sourcePoints, + debitAmount); + }; + try { + runTransferApprovalEntityTask(approvalPlayer, () -> { + if (!approvalState.compareAndSet(0, 1)) return; + Integer approvedAmount; + try { + approvedAmount = creditAmountProvider.apply(debitAmount); + } catch (RuntimeException failure) { + approvedAmount = null; + logApprovalFailure(failure); + } + Integer finalApprovedAmount = approvedAmount; + try { + plugin.getTimer().execute(() -> settleTransfer(source, target, sourcePlayer, debitAmount, completion, + journal, transferId, owner, sourcePoints, targetPoints, finalApprovedAmount)); + } catch (RuntimeException schedulingFailure) { + plugin.debug(schedulingFailure); + try { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, + () -> settleTransfer(source, target, sourcePlayer, debitAmount, completion, journal, + transferId, owner, sourcePoints, targetPoints, finalApprovedAmount)); + } catch (RuntimeException asyncSchedulingFailure) { + plugin.debug(asyncSchedulingFailure); + logIndeterminateClaim(transferId); + completeOnBukkit(sourcePlayer, completion, PointTransferResult.PENDING_CONFIRMATION); + } + } finally { + approvalState.set(2); + } + }, rejectBeforeStart); + } catch (RuntimeException schedulingFailure) { + plugin.debug(schedulingFailure); + rejectBeforeStart.run(); + } + } + + /** Keeps Folia's entity-retirement result while safely supporting legacy Bukkit scheduling. */ + private void runTransferApprovalEntityTask(org.bukkit.entity.Player player, Runnable task, Runnable rejected) { + if (plugin.getBukkitScheduler().getFoliaLib() == null) { + BukkitCompletionScheduler.run(plugin, player, task, rejected); + return; + } + CompletableFuture result = plugin.getBukkitScheduler().getFoliaLib().getImpl() + .runAtEntityWithFallback(player, ignored -> task.run(), rejected); + result.whenComplete((status, failure) -> { + if (failure != null || status != EntityTaskResult.SUCCESS) rejected.run(); + }); + } + + private void scheduleRejectedTransferCompensation(VotingPluginUser source, org.bukkit.entity.Player sourcePlayer, + Consumer completion, + SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount) { + Runnable compensation = () -> compensateRejectedTransfer(source, sourcePlayer, completion, journal, transferId, + sourcePoints, debitAmount); + try { + plugin.getTimer().execute(compensation); + } catch (RuntimeException persistenceRejected) { + plugin.debug(persistenceRejected); + try { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, compensation); + } catch (RuntimeException asyncRejected) { + plugin.debug(asyncRejected); + rememberPendingCompensationMarker(plugin, transferId); + completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE); + } + } + } + + private void rememberPendingCompensationMarker(VotingPluginMain plugin, String transferId) { + try { + compensationStore(plugin).record(transferId); + } catch (IOException persistenceFailure) { + plugin.getLogger().severe("Unable to persist a shared point transfer compensation marker: " + + persistenceFailure.getClass().getSimpleName()); + plugin.debug(persistenceFailure); + } + } + + private static void retryPendingCompensationMarkers(VotingPluginMain plugin, + SharedPointTransferJournal journal) { + SharedPointTransferCompensationStore store = compensationStore(plugin); + final java.util.List pending; + try { + pending = store.loadBatch(); + } catch (IOException loadFailure) { + plugin.debug(loadFailure); + return; + } + for (String transferId : pending) { + try { + journal.markCompensating(transferId); + store.remove(transferId); + } catch (SQLException retryFailure) { + plugin.debug(retryFailure); + } catch (IOException removalFailure) { + plugin.debug(removalFailure); + } + } + } + + private static SharedPointTransferCompensationStore compensationStore(VotingPluginMain plugin) { + return new SharedPointTransferCompensationStore(plugin.getDataFolder().toPath()); + } + + private void compensateRejectedTransfer(VotingPluginUser source, org.bukkit.entity.Player sourcePlayer, + Consumer completion, + SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount) { + try { + // The CAS fence proves the approval callback cannot run. Write the + // recoverable state before relying on completion delivery. + if (!journal.markCompensating(transferId)) { + completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE); + return; + } + } catch (SQLException markerFailure) { + boolean refunded = false; + try { + refunded = journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount); + if (refunded) { + discardPointsCache(source, sourcePoints); + } + } catch (SQLException refundFailure) { + logFailure(refundFailure); + } + // The scheduler fence proves the approval hook cannot run. If neither + // MySQL compensation operation completed, retain the local durable proof + // so periodic recovery can move the HOOK_STARTED row to COMPENSATING. + if (!refunded) rememberPendingCompensationMarker(plugin, transferId); + logFailure(markerFailure); + completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE); + return; + } + refundClaimedAfterSchedulingFailure(source, sourcePlayer, completion, journal, transferId, sourcePoints, + debitAmount, new IllegalStateException("Transfer approval task did not start")); + } + + private void settleTransfer(VotingPluginUser source, VotingPluginUser target, org.bukkit.entity.Player sourcePlayer, + int debitAmount, + Consumer completion, SharedPointTransferJournal journal, String transferId, String owner, + String sourcePoints, String targetPoints, Integer approvedAmount) { + PointTransferResult result; + try { + // The hook may have recreated either cache while it ran on Bukkit. + drainCache(target); + SharedPointTransferJournal.SettlementOutcome outcome; + try { + outcome = journal.settleWithConfirmation(transferId, owner, source.getUUID(), sourcePoints, + target.getUUID(), targetPoints, debitAmount, approvedAmount); + } finally { + discardPointsCache(source, sourcePoints); + discardPointsCache(target, targetPoints); + } + result = outcome == SharedPointTransferJournal.SettlementOutcome.COMPLETED ? PointTransferResult.SUCCESS + : outcome == SharedPointTransferJournal.SettlementOutcome.REFUNDED ? PointTransferResult.CANCELLED + : PointTransferResult.PENDING_CONFIRMATION; + } catch (RuntimeException failure) { + plugin.getLogger().severe("Unable to settle shared MySQL point transfer: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + result = PointTransferResult.PENDING_CONFIRMATION; + } + completeOnBukkit(sourcePlayer, completion, result); + } + + private void refundReservedAfterSchedulingFailure(VotingPluginUser source, org.bukkit.entity.Player sourcePlayer, + Consumer completion, + SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount, + RuntimeException failure) { + try { + if (journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount)) { + discardPointsCache(source, sourcePoints); + } + } catch (SQLException refundFailure) { + logFailure(refundFailure); + } + plugin.debug(failure); + completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE); + } + + private void refundIndeterminateClaimBeforeApproval(VotingPluginUser source, org.bukkit.entity.Player sourcePlayer, + Consumer completion, + SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount) { + boolean refunded = false; + try { + // HOOK_STARTED is safe to compensate because the approval task has not + // been submitted yet. A RESERVED row is handled by its normal refund. + if (journal.markCompensating(transferId)) { + refunded = journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount); + } else { + refunded = journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount); + } + } catch (SQLException markerFailure) { + // A lost marker acknowledgement may still have committed. Both refund + // operations are idempotent and cover either durable pre-hook state. + try { + refunded = journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount); + if (!refunded) { + refunded = journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount); + } + } catch (SQLException refundFailure) { + logFailure(refundFailure); + } + logFailure(markerFailure); + } + if (refunded) discardPointsCache(source, sourcePoints); + if (!refunded) { + rememberPendingCompensationMarker(plugin, transferId); + logIndeterminateClaim(transferId); + } + completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE); + } + + void completeRejectedPersistenceSubmission(org.bukkit.entity.Player sourcePlayer, + Consumer completion, + RuntimeException failure) { + plugin.debug(failure); + completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE); + } + + void refundClaimedAfterSchedulingFailure(VotingPluginUser source, org.bukkit.entity.Player sourcePlayer, + Consumer completion, + SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount, + RuntimeException failure) { + try { + journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount); + } catch (SQLException refundFailure) { + logFailure(refundFailure); + } finally { + // The refund may have committed even when its acknowledgement and + // confirmation read both failed. Invalidate a cache recreated after the + // original debit so it cannot later overwrite either durable outcome. + discardPointsCache(source, sourcePoints); + } + plugin.debug(failure); + completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE); + } + + private boolean isAcceptedSettlement(SharedPointTransferJournal.SettlementOutcome outcome) { + if (outcome == SharedPointTransferJournal.SettlementOutcome.INDETERMINATE) { + // The callback has already run. Reporting a retryable failure could create + // a second transfer after an unconfirmed credit, so retain the journal row + // for explicit reconciliation and suppress a new debit attempt. + plugin.getLogger().severe("Shared MySQL point transfer outcome is indeterminate; retaining journal entry for reconciliation"); + return true; + } + return outcome == SharedPointTransferJournal.SettlementOutcome.COMPLETED; + } + + private void logIndeterminateClaim(String transferId) { + plugin.getLogger().severe("Shared MySQL point transfer " + transferId + + " has indeterminate claim state (RESERVED or HOOK_STARTED); retaining it for explicit reconciliation"); + } + + private boolean transferAtomically(VotingPluginUser source, VotingPluginUser target, + int debitAmount, int creditAmount) { + drainCache(source); + drainCache(target); + MySQL table = plugin.getMysql(); + String sourcePoints = source.getPointsPath(); + String targetPoints = target.getPointsPath(); + String uuidMatch = table.qi("uuid") + (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); + String debit = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(sourcePoints) + " = " + + table.qi(sourcePoints) + " - ? WHERE " + uuidMatch + " AND " + table.qi(sourcePoints) + " >= ?"; + String credit = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(targetPoints) + " = " + + table.qi(targetPoints) + " + ? WHERE " + uuidMatch; + try (Connection connection = requireConnection(table)) { + connection.setAutoCommit(false); + try (PreparedStatement debitStatement = connection.prepareStatement(debit); + PreparedStatement creditStatement = connection.prepareStatement(credit)) { + debitStatement.setInt(1, debitAmount); + debitStatement.setString(2, source.getUUID()); + debitStatement.setInt(3, debitAmount); + if (debitStatement.executeUpdate() != 1) { + connection.rollback(); + return false; + } + creditStatement.setInt(1, creditAmount); + creditStatement.setString(2, target.getUUID()); + if (creditStatement.executeUpdate() != 1) { + connection.rollback(); + return false; + } + connection.commit(); + return true; + } catch (SQLException failure) { + connection.rollback(); + throw failure; + } + } catch (SQLException failure) { + logFailure(failure); + return false; + } + } + + private boolean run(Runnable operation, boolean async) { + if (async) { + try { + plugin.getTimer().execute(operation); + return true; + } catch (RuntimeException rejected) { + plugin.debug(rejected); + return false; + } + } else { + operation.run(); + return true; + } + } + + private boolean update(VotingPluginUser user, int delta, boolean requireNonnegative) { + drainCache(user); + MySQL table = plugin.getMysql(); + String points = user.getPointsPath(); + StringBuilder sql = new StringBuilder("UPDATE ").append(table.qi(table.getTableName())).append(" SET ") + .append(table.qi(points)).append(" = ").append(table.qi(points)).append(" + ? WHERE ") + .append(table.qi("uuid")).append(table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); + if (requireNonnegative) { + sql.append(" AND ").append(table.qi(points)).append(" >= ?"); + } + try (Connection connection = requireConnection(table); + PreparedStatement statement = connection.prepareStatement(sql.toString())) { + statement.setInt(1, delta); + statement.setString(2, user.getUUID()); + if (requireNonnegative) statement.setInt(3, -delta); + return statement.executeUpdate() == 1; + } catch (SQLException failure) { + logFailure(failure); + return false; + } finally { + discardPointsCache(user); + } + } + + /** + * Adds points and reads the resulting value through the same JDBC connection. + * This bypasses the wrapper's temporary user-data cache, which can remain stale + * even when the caller requests {@code NO_CACHE}. + */ + private int addAndReadCommitted(VotingPluginUser user, int amount) { + return addAndReadCommittedResult(user, amount).total(); + } + + private AddResult addAndReadCommittedResult(VotingPluginUser user, int amount) { + drainCache(user); + MySQL table = plugin.getMysql(); + String points = user.getPointsPath(); + String uuidMatch = table.qi("uuid") + (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); + String update = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points) + " = " + + table.qi(points) + " + ? WHERE " + uuidMatch; + String read = "SELECT " + table.qi(points) + " FROM " + table.qi(table.getTableName()) + " WHERE " + uuidMatch; + boolean updateCommitted = false; + Integer committedTotal = null; + try (Connection connection = requireConnection(table); + PreparedStatement updateStatement = connection.prepareStatement(update); + PreparedStatement readStatement = connection.prepareStatement(read)) { + updateStatement.setInt(1, amount); + updateStatement.setString(2, user.getUUID()); + if (updateStatement.executeUpdate() == 1) { + // With JDBC auto-commit, executeUpdate returning one means the mutation + // completed. A later read may still fail after the points have been + // committed, so never turn that outcome into a retryable failure. + updateCommitted = true; + readStatement.setString(1, user.getUUID()); + try (java.sql.ResultSet result = readStatement.executeQuery()) { + if (result.next()) committedTotal = result.getInt(1); + } + } + } catch (SQLException failure) { + logFailure(failure); + } finally { + discardPointsCache(user); + } + // Do not evaluate the fallback while the JDBC handle is still held. With a + // one-connection pool, getPoints() may need that same handle after a missing + // row or a failed follow-up read. + if (committedTotal != null) return new AddResult(true, committedTotal); + // A failed acquisition cannot support the fallback read either. Report the + // mutation failure without checking out a second connection and let callers + // complete their callback deterministically. + if (!updateCommitted) return new AddResult(false, 0); + return new AddResult(true, user.getPoints()); + } + + enum MutationOutcome { CONFIRMED, REJECTED, INDETERMINATE } + + record AddResult(MutationOutcome outcome, int total) { + AddResult(boolean success, int total) { + this(success ? MutationOutcome.CONFIRMED : MutationOutcome.INDETERMINATE, total); + } + boolean success() { return outcome == MutationOutcome.CONFIRMED; } + } + + private boolean setAbsolute(VotingPluginUser user, int value) { + drainCache(user); + MySQL table = plugin.getMysql(); + String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(user.getPointsPath()) + + " = ? WHERE " + table.qi("uuid") + + (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); + try (Connection connection = requireConnection(table); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setInt(1, value); + statement.setString(2, user.getUUID()); + return statement.executeUpdate() == 1; + } catch (SQLException failure) { + logFailure(failure); + return false; + } finally { + discardPointsCache(user); + } + } + + private void capAt(VotingPluginUser user, int maximum) { + drainCache(user); + MySQL table = plugin.getMysql(); + String points = user.getPointsPath(); + String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points) + " = LEAST(" + + table.qi(points) + ", ?) WHERE " + table.qi("uuid") + + (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); + try (Connection connection = requireConnection(table); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setInt(1, maximum); + statement.setString(2, user.getUUID()); + statement.executeUpdate(); + } catch (SQLException failure) { + logFailure(failure); + } finally { + discardPointsCache(user); + } + } + + private void addAndCapAt(VotingPluginUser user, int amount, int maximum) { + drainCache(user); + MySQL table = plugin.getMysql(); + String points = user.getPointsPath(); + String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points) + " = LEAST(" + + table.qi(points) + " + ?, ?) WHERE " + table.qi("uuid") + + (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); + try (Connection connection = requireConnection(table); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setInt(1, amount); + statement.setInt(2, maximum); + statement.setString(3, user.getUUID()); + statement.executeUpdate(); + } catch (SQLException failure) { + logFailure(failure); + } finally { + discardPointsCache(user); + } + } + + private void drainCache(VotingPluginUser user) { + drainCache(user, user.getPointsPath()); + } + + private void drainCache(VotingPluginUser user, String pointsColumn) { + SharedMysqlCacheReconciler.withCacheDumpFence(() -> { + if (!user.isCached()) return; + UserDataCache cache = user.getCache(); + if (cache == null) return; + synchronized (cache) { + SharedMysqlCacheReconciler.discardOptimisticPoint(cache, pointsColumn); + cache.dump(); + plugin.getUserManager().getDataManager().removeCache(UUID.fromString(user.getUUID()), null); + } + }); + } + + private static Connection requireConnection(MySQL table) throws SQLException { + Connection connection = table.getMysql().getConnectionManager().getConnection(); + if (connection == null) throw new SQLException("Unable to acquire shared MySQL connection"); + return connection; + } + + /** + * Removes only the value made stale by a direct shared-MySQL point mutation. + * The cache can be recreated while JDBC is in progress by vote processing on + * the Bukkit lane; dropping that whole cache would also lose unrelated queued + * streak, milestone, or cooldown updates. VotingPlugin routes every supported + * Points writer through this mutator; a later generic UserData Points change is + * deliberately not discarded here because it is a distinct, later write and + * the generic absolute-value API cannot provide cross-server atomic semantics. + */ + private void discardPointsCache(VotingPluginUser user) { + discardPointsCache(user, user.getPointsPath()); + } + + private void discardPointsCache(VotingPluginUser user, String pointsColumn) { + SharedMysqlCacheReconciler.invalidate(plugin, user.getUUID(), pointsColumn); + } + + private void completeOnBukkit(org.bukkit.entity.Player sourcePlayer, Consumer completion, + PointTransferResult result) { + BukkitCompletionScheduler.run(plugin, sourcePlayer, () -> completion.accept(result)); + } + + private void logFailure(SQLException failure) { + plugin.getLogger().severe("Unable to update shared MySQL vote points: " + failure.getClass().getSimpleName()); + plugin.debug(failure); + } + + private void logApprovalFailure(RuntimeException failure) { + plugin.getLogger().severe("Unable to approve shared MySQL point transfer on the persistence worker: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java new file mode 100644 index 0000000000..532f01a6be --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java @@ -0,0 +1,744 @@ +package com.bencodez.votingplugin.user; + +import java.lang.ref.ReferenceQueue; +import java.lang.ref.WeakReference; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; +import com.bencodez.simpleapi.sql.DataType; +import com.bencodez.simpleapi.sql.mysql.DbType; + +/** + * Durable, idempotent shared-MySQL point additions for retryable reward stages. + * + *

The journal update and the point credit commit in the same transaction. A + * retry with the same operation id can consequently prove the earlier credit + * instead of applying the delta a second time after a lost commit acknowledgement.

+ */ +final class SharedPointAdditionJournal { + private static final String COMPLETED = "COMPLETED"; + private static final String ACKNOWLEDGED = "ACKNOWLEDGED"; + /* A claimed hook is never replayed automatically: arbitrary listeners may + * already have produced side effects before a backend stops. */ + private static final String HOOK_STARTED = "HOOK_STARTED"; + /* The receive hook may have run, but no durable credit/cancellation outcome + * was confirmed. This state is deliberately never replayed automatically. */ + private static final String INDETERMINATE = "INDETERMINATE"; + /* Matches the bounded durable reconciliation window used by shared transfers. */ + static final long COMPLETED_RETENTION_MILLIS = TimeUnit.DAYS.toMillis(7); + /* A live receive hook normally settles immediately. Do not let a replacement + * backend preempt it; only a bounded, expired foreign claim is recoverable. */ + static final long HOOK_RECOVERY_LEASE_MILLIS = TimeUnit.MINUTES.toMillis(5); + private static final int CLEANUP_BATCH_SIZE = 100; + private static final int MAX_IDENTIFIER_BYTES = 63; + private static final String JOURNAL_SUFFIX = "_PointAdditions"; + private static final String HASHED_TABLE_PREFIX = "vp_pa_"; + private static final int HASHED_TABLE_HEX_LENGTH = 32; + + private static final ReferenceQueue INITIALIZED_QUEUE = new ReferenceQueue<>(); + private static final Set INITIALIZED = new HashSet<>(); + + private final MySQL table; + private final String journalTable; + + SharedPointAdditionJournal(MySQL table, boolean initializeSchema) throws SQLException { + this.table = table; + journalTable = journalTableName(table.getTableName()); + if (initializeSchema) ensureSchema(); + } + + static String journalTableName(String sourceTable) { + String legacyName = sourceTable + JOURNAL_SUFFIX; + if (legacyName.getBytes(StandardCharsets.UTF_8).length <= MAX_IDENTIFIER_BYTES) return legacyName; + return HASHED_TABLE_PREFIX + hash(sourceTable + '\0' + JOURNAL_SUFFIX).substring(0, HASHED_TABLE_HEX_LENGTH); + } + + static SharedPointAdditionJournal forTable(MySQL table) throws SQLException { + synchronized (INITIALIZED) { + expungeInitialized(); + for (IdentityWeakReference marker : INITIALIZED) { + if (marker.get() == table) return new SharedPointAdditionJournal(table, false); + } + new SharedPointAdditionJournal(table, true); + INITIALIZED.add(new IdentityWeakReference(table, INITIALIZED_QUEUE)); + return new SharedPointAdditionJournal(table, false); + } + } + + /** Applies the operation exactly once and returns the resulting durable total. */ + AdditionResult add(String operationId, String uuid, String pointsColumn, int amount, long now) throws SQLException { + return mutate(operationId, uuid, pointsColumn, amount, now, false); + } + + /** Applies a durable conditional debit. A retry confirms the journal row and + * never debits the player twice; a row with insufficient points is a definite + * rejection and is not recorded as a successful mutation. */ + AdditionResult subtract(String operationId, String uuid, String pointsColumn, int amount, long now) throws SQLException { + if (amount < 0) throw new SQLException("Invalid shared point debit"); + return mutate(operationId, uuid, pointsColumn, -amount, now, true); + } + + private AdditionResult mutate(String operationId, String uuid, String pointsColumn, int amount, long now, + boolean requireNonnegative) throws SQLException { + if (!isSafeColumn(pointsColumn)) throw new SQLException("Unsafe shared point column"); + AdditionRow existing = find(operationId); + if (existing != null) return existingResult(operationId, existing, uuid, pointsColumn, amount); + + String insert = "INSERT INTO " + qiJournal() + " (" + qi("operation_id") + ", " + qi("player_uuid") + + ", " + qi("points_column") + ", " + qi("amount") + ", " + qi("requested_amount") + ", " + + qi("state") + ", " + qi("total_points") + ", " + qi("created_at") + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; + String points = qi(pointsColumn); + String update = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points + + " + ? WHERE " + qi("uuid") + uuidCast() + + (requireNonnegative ? " AND " + points + " >= ?" : ""); + String read = "SELECT " + points + " FROM " + qi(table.getTableName()) + " WHERE " + qi("uuid") + uuidCast(); + String complete = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ?, " + qi("total_points") + + " = ? WHERE " + qi("operation_id") + " = ?"; + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try (PreparedStatement insertStatement = connection.prepareStatement(insert); + PreparedStatement updateStatement = connection.prepareStatement(update); + PreparedStatement readStatement = connection.prepareStatement(read); + PreparedStatement completeStatement = connection.prepareStatement(complete)) { + insertStatement.setString(1, operationId); + insertStatement.setString(2, uuid); + insertStatement.setString(3, pointsColumn); + insertStatement.setInt(4, amount); + insertStatement.setInt(5, amount); + insertStatement.setString(6, COMPLETED); + insertStatement.setNull(7, java.sql.Types.INTEGER); + insertStatement.setLong(8, now); + insertStatement.executeUpdate(); + + updateStatement.setInt(1, amount); + updateStatement.setString(2, uuid); + if (requireNonnegative) updateStatement.setInt(3, -amount); + if (updateStatement.executeUpdate() != 1) { + rollback(connection); + if (requireNonnegative) throw new DebitRejectedException(); + throw new SQLException("Shared point user row missing"); + } + readStatement.setString(1, uuid); + int total; + try (ResultSet result = readStatement.executeQuery()) { + if (!result.next()) { + rollback(connection); + throw new SQLException("Shared point user row disappeared"); + } + total = result.getInt(1); + } + completeStatement.setString(1, COMPLETED); + completeStatement.setInt(2, total); + completeStatement.setString(3, operationId); + if (completeStatement.executeUpdate() != 1) { + rollback(connection); + throw new SQLException("Shared point addition journal row missing"); + } + commitAndConfirm(connection, operationId); + return new AdditionResult(total); + } catch (SQLException failure) { + rollback(connection); + if (isDuplicate(failure)) { + closeQuietly(connection); + AdditionRow duplicate = find(operationId); + if (duplicate != null) return existingResult(operationId, duplicate, uuid, pointsColumn, amount); + } + throw failure; + } + } + } + + static final class DebitRejectedException extends SQLException { + private static final long serialVersionUID = 1L; + + DebitRejectedException() { + super("Shared point debit rejected: insufficient points or missing user"); + } + } + + /** + * Claims an idempotent reward operation before its arbitrary Bukkit receive + * hook runs. The claim is shared by all backend JVMs, unlike the caller's + * process-local coalescing map. + */ + HookClaim claimHook(String operationId, String uuid, String pointsColumn, int requestedAmount, String owner, + long now) throws SQLException { + if (!isSafeColumn(pointsColumn)) throw new SQLException("Unsafe shared point column"); + AdditionRow existing = find(operationId); + if (existing != null) { + if (isExpiredForeignHook(existing, owner, now) + && markExpiredHookIndeterminate(operationId, existing, uuid, pointsColumn, requestedAmount, now)) { + return HookClaim.reconciliationRequired(); + } + return claimForExisting(existing, uuid, pointsColumn, requestedAmount, owner); + } + + String insert = "INSERT INTO " + qiJournal() + " (" + qi("operation_id") + ", " + qi("player_uuid") + + ", " + qi("points_column") + ", " + qi("amount") + ", " + qi("requested_amount") + ", " + + qi("state") + ", " + qi("total_points") + ", " + qi("hook_owner") + ", " + + qi("created_at") + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"; + try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(insert)) { + connection.setAutoCommit(false); + statement.setString(1, operationId); + statement.setString(2, uuid); + statement.setString(3, pointsColumn); + statement.setInt(4, requestedAmount); + statement.setInt(5, requestedAmount); + statement.setString(6, HOOK_STARTED); + statement.setNull(7, java.sql.Types.INTEGER); + statement.setString(8, owner); + statement.setLong(9, now); + statement.executeUpdate(); + try { + connection.commit(); + return HookClaim.claimedByCaller(); + } catch (SQLException ambiguousCommit) { + closeQuietly(connection); + try { + AdditionRow confirmed = find(operationId); + if (confirmed != null) { + return claimForExisting(confirmed, uuid, pointsColumn, requestedAmount, owner); + } + } catch (SQLException confirmationFailure) { + ambiguousCommit.addSuppressed(confirmationFailure); + } + // The receive hook has not been scheduled yet. Delete only this owner's + // exact HOOK_STARTED claim so an ambiguous insert cannot turn a safe + // retry into manual reconciliation. + try { + releaseUnstartedHook(operationId, uuid, pointsColumn, requestedAmount, owner); + } catch (SQLException releaseFailure) { + ambiguousCommit.addSuppressed(releaseFailure); + } + throw ambiguousCommit; + } + } catch (SQLException failure) { + if (!isDuplicate(failure)) throw failure; + AdditionRow duplicate = find(operationId); + if (duplicate != null) return claimForExisting(duplicate, uuid, pointsColumn, requestedAmount, owner); + throw failure; + } + } + + /** + * Completes a claimed hook exactly once, including a durable cancellation + * outcome. The journal identity is kept separate from the physical credit + * column so a PerServerPoints hook can atomically credit its local column + * while retaining the shared {@code Points} replay identity. + */ + AdditionResult settleClaim(String operationId, String uuid, String journalPointsColumn, String creditPointsColumn, + int requestedAmount, String owner, Integer adjustedAmount) throws SQLException { + if (!isSafeColumn(journalPointsColumn) || !isSafeColumn(creditPointsColumn)) { + throw new SQLException("Unsafe shared point column"); + } + // The legacy UserData write created a PerServerPoints column on first use. + // Direct settlement bypasses that path, so create the local integer column + // before opening the atomic credit-and-journal transaction. + if (!journalPointsColumn.equals(creditPointsColumn)) table.checkColumn(creditPointsColumn, DataType.INTEGER); + String select = "SELECT " + qi("player_uuid") + ", " + qi("points_column") + ", " + qi("amount") + + ", " + qi("state") + ", " + qi("total_points") + ", " + qi("requested_amount") + ", " + + qi("hook_owner") + ", " + qi("created_at") + " FROM " + qiJournal() + " WHERE " + + qi("operation_id") + " = ? FOR UPDATE"; + String points = qi(creditPointsColumn); + // A first-use PerServerPoints column is nullable for existing player rows. + // Coalesce inside the same transaction so completion cannot record a credit + // while NULL arithmetic left the physical balance unchanged. + String updatePoints = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = COALESCE(" + points + + ", 0) + ? WHERE " + qi("uuid") + uuidCast(); + String readPoints = "SELECT " + points + " FROM " + qi(table.getTableName()) + " WHERE " + qi("uuid") + + uuidCast(); + String complete = "UPDATE " + qiJournal() + " SET " + qi("amount") + " = ?, " + qi("state") + + " = ?, " + qi("total_points") + " = ? WHERE " + qi("operation_id") + " = ?"; + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try { + try (PreparedStatement selectStatement = connection.prepareStatement(select)) { + selectStatement.setString(1, operationId); + AdditionRow row; + try (ResultSet result = selectStatement.executeQuery()) { + if (!result.next()) { + rollback(connection); + throw new SQLException("Shared point addition claim disappeared"); + } + Integer total = result.getObject(5) == null ? null : Integer.valueOf(result.getInt(5)); + Integer requested = result.getObject(6) == null ? null : Integer.valueOf(result.getInt(6)); + row = new AdditionRow(result.getString(1), result.getString(2), result.getInt(3), result.getString(4), + total, requested, result.getString(7), result.getLong(8)); + } + HookClaim resolved = claimForExisting(row, uuid, journalPointsColumn, requestedAmount, owner); + if (resolved.completed()) { + rollback(connection); + return new AdditionResult(resolved.total()); + } + if (!resolved.claimed()) { + rollback(connection); + throw new SQLException("Shared point addition claim is not owned by this operation"); + } + } + if (adjustedAmount != null) { + try (PreparedStatement statement = connection.prepareStatement(updatePoints)) { + statement.setInt(1, adjustedAmount.intValue()); + statement.setString(2, uuid); + if (statement.executeUpdate() != 1) { + rollback(connection); + throw new SQLException("Shared point user row missing"); + } + } + } + int total; + try (PreparedStatement statement = connection.prepareStatement(readPoints)) { + statement.setString(1, uuid); + try (ResultSet result = statement.executeQuery()) { + if (!result.next()) { + rollback(connection); + throw new SQLException("Shared point user row disappeared"); + } + total = result.getInt(1); + } + } + try (PreparedStatement statement = connection.prepareStatement(complete)) { + // amount is a durable, non-null actual credit. A cancelled hook has + // no credit, rather than a nullable/ambiguous amount. + statement.setInt(1, adjustedAmount == null ? 0 : adjustedAmount.intValue()); + statement.setString(2, COMPLETED); + statement.setInt(3, total); + statement.setString(4, operationId); + if (statement.executeUpdate() != 1) { + rollback(connection); + throw new SQLException("Shared point addition journal row missing"); + } + } + try { + connection.commit(); + return new AdditionResult(total); + } catch (SQLException ambiguousCommit) { + closeQuietly(connection); + AdditionRow confirmed = find(operationId); + HookClaim resolved = confirmed == null ? null + : claimForExisting(confirmed, uuid, journalPointsColumn, requestedAmount, owner); + if (resolved != null && resolved.completed()) return new AdditionResult(resolved.total()); + throw ambiguousCommit; + } + } catch (SQLException failure) { + rollback(connection); + throw failure; + } + } + } + private HookClaim claimForExisting(AdditionRow row, String uuid, String pointsColumn, int requestedAmount, + String owner) throws SQLException { + if (!row.matchesTarget(uuid, pointsColumn) + || row.requestedAmount != null && row.requestedAmount.intValue() != requestedAmount) { + throw new SQLException("Mismatched shared point addition operation"); + } + if ((COMPLETED.equals(row.state) || ACKNOWLEDGED.equals(row.state)) && row.total != null) { + return HookClaim.completed(row.total.intValue()); + } + if (HOOK_STARTED.equals(row.state) && owner.equals(row.hookOwner)) return HookClaim.claimedByCaller(); + if (HOOK_STARTED.equals(row.state) || INDETERMINATE.equals(row.state)) { + return HookClaim.reconciliationRequired(); + } + return HookClaim.inProgress(); + } + + private boolean isExpiredForeignHook(AdditionRow row, String owner, long now) { + return HOOK_STARTED.equals(row.state) && !owner.equals(row.hookOwner) + && row.createdAt <= now - HOOK_RECOVERY_LEASE_MILLIS; + } + + /** + * Atomically changes only an expired foreign hook claim. A current owner can + * settle while its lease is live; after expiry we retain the operation for + * manual reconciliation instead of replaying an arbitrary hook. + */ + private boolean markExpiredHookIndeterminate(String operationId, AdditionRow row, String uuid, String pointsColumn, + int requestedAmount, long now) throws SQLException { + if (!row.matchesTarget(uuid, pointsColumn) + || row.requestedAmount != null && row.requestedAmount.intValue() != requestedAmount) { + throw new SQLException("Mismatched shared point addition operation"); + } + String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("operation_id") + + " = ? AND " + qi("state") + " = ? AND " + qi("created_at") + " <= ?"; + try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(update)) { + connection.setAutoCommit(false); + statement.setString(1, INDETERMINATE); + statement.setString(2, operationId); + statement.setString(3, HOOK_STARTED); + statement.setLong(4, now - HOOK_RECOVERY_LEASE_MILLIS); + if (statement.executeUpdate() != 1) { + rollback(connection); + return false; + } + connection.commit(); + return true; + } + } + + /** + * Records that a claimed receive hook cannot be safely retried. This is a + * durable operator-facing distinction from a live claim: a later retry must + * not wait for an owner that can no longer settle it, or invoke listeners + * again. + */ + void markIndeterminate(String operationId, String uuid, String pointsColumn, int requestedAmount, String owner) + throws SQLException { + if (!isSafeColumn(pointsColumn)) throw new SQLException("Unsafe shared point column"); + String select = "SELECT " + qi("player_uuid") + ", " + qi("points_column") + ", " + qi("amount") + + ", " + qi("state") + ", " + qi("total_points") + ", " + qi("requested_amount") + ", " + + qi("hook_owner") + ", " + qi("created_at") + " FROM " + qiJournal() + " WHERE " + + qi("operation_id") + " = ? FOR UPDATE"; + String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("operation_id") + + " = ? AND " + qi("state") + " = ? AND " + qi("hook_owner") + " = ?"; + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try (PreparedStatement selectStatement = connection.prepareStatement(select)) { + selectStatement.setString(1, operationId); + try (ResultSet result = selectStatement.executeQuery()) { + if (!result.next()) { + rollback(connection); + throw new SQLException("Shared point addition claim disappeared"); + } + Integer total = result.getObject(5) == null ? null : Integer.valueOf(result.getInt(5)); + Integer requested = result.getObject(6) == null ? null : Integer.valueOf(result.getInt(6)); + AdditionRow row = new AdditionRow(result.getString(1), result.getString(2), result.getInt(3), + result.getString(4), total, requested, result.getString(7), result.getLong(8)); + if (!row.matchesTarget(uuid, pointsColumn) + || row.requestedAmount != null && row.requestedAmount.intValue() != requestedAmount) { + rollback(connection); + throw new SQLException("Mismatched shared point addition operation"); + } + if (!HOOK_STARTED.equals(row.state)) { + rollback(connection); + return; + } + } + } + try (PreparedStatement updateStatement = connection.prepareStatement(update)) { + updateStatement.setString(1, INDETERMINATE); + updateStatement.setString(2, operationId); + updateStatement.setString(3, HOOK_STARTED); + updateStatement.setString(4, owner); + if (updateStatement.executeUpdate() != 1) { + rollback(connection); + return; + } + } + connection.commit(); + } + } + + /** + * Releases a hook claim only when the scheduler proved that its callback never + * began. Unlike {@link #markIndeterminate(String, String, String, int, String)}, + * this makes an idempotent retry safe because no listener was invoked. + */ + void releaseUnstartedHook(String operationId, String uuid, String pointsColumn, int requestedAmount, String owner) + throws SQLException { + if (!isSafeColumn(pointsColumn)) throw new SQLException("Unsafe shared point column"); + String select = "SELECT " + qi("player_uuid") + ", " + qi("points_column") + ", " + qi("amount") + + ", " + qi("state") + ", " + qi("total_points") + ", " + qi("requested_amount") + ", " + + qi("hook_owner") + ", " + qi("created_at") + " FROM " + qiJournal() + " WHERE " + + qi("operation_id") + " = ? FOR UPDATE"; + String delete = "DELETE FROM " + qiJournal() + " WHERE " + qi("operation_id") + " = ? AND " + + qi("state") + " = ? AND " + qi("hook_owner") + " = ?"; + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try (PreparedStatement selectStatement = connection.prepareStatement(select)) { + selectStatement.setString(1, operationId); + try (ResultSet result = selectStatement.executeQuery()) { + if (!result.next()) { + rollback(connection); + return; + } + Integer total = result.getObject(5) == null ? null : Integer.valueOf(result.getInt(5)); + Integer requested = result.getObject(6) == null ? null : Integer.valueOf(result.getInt(6)); + AdditionRow row = new AdditionRow(result.getString(1), result.getString(2), result.getInt(3), + result.getString(4), total, requested, result.getString(7), result.getLong(8)); + if (!row.matchesTarget(uuid, pointsColumn) + || row.requestedAmount != null && row.requestedAmount.intValue() != requestedAmount) { + rollback(connection); + throw new SQLException("Mismatched shared point addition operation"); + } + if (!HOOK_STARTED.equals(row.state) || !owner.equals(row.hookOwner)) { + rollback(connection); + return; + } + } + } + try (PreparedStatement deleteStatement = connection.prepareStatement(delete)) { + deleteStatement.setString(1, operationId); + deleteStatement.setString(2, HOOK_STARTED); + deleteStatement.setString(3, owner); + if (deleteStatement.executeUpdate() != 1) { + rollback(connection); + return; + } + } + try { + connection.commit(); + } catch (SQLException ambiguousCommit) { + // A shutdown can interrupt the acknowledgement after the delete reached + // MySQL. Confirm through a fresh connection before classifying a + // scheduler-proven unstarted hook as unresolved. + closeQuietly(connection); + if (find(operationId) == null) return; + throw ambiguousCommit; + } + } + } + + /** + * Returns a previously committed addition before a retry invokes its Bukkit + * receive hook. The amount deliberately remains part of {@link #add}: a + * listener may have adjusted it during the original invocation, so comparing + * it to a retry's pre-listener amount would make a completed operation look + * new. The immutable operation id is still bound to its player and points + * column before its total can be replayed. + */ + AdditionResult findCompleted(String operationId, String uuid, String pointsColumn) throws SQLException { + if (!isSafeColumn(pointsColumn)) throw new SQLException("Unsafe shared point column"); + AdditionRow existing = find(operationId); + if (existing == null) return null; + if (!existing.matchesTarget(uuid, pointsColumn)) { + throw new SQLException("Mismatched shared point addition operation"); + } + if (!(COMPLETED.equals(existing.state) || ACKNOWLEDGED.equals(existing.state)) || existing.total == null) { + throw new SQLException("Shared point addition operation is not confirmable: " + operationId); + } + return new AdditionResult(existing.total.intValue()); + } + + private AdditionResult existingResult(String operationId, AdditionRow row, String uuid, String pointsColumn, + int amount) throws SQLException { + if (!row.matches(uuid, pointsColumn, amount)) throw new SQLException("Mismatched shared point addition operation"); + if (!(COMPLETED.equals(row.state) || ACKNOWLEDGED.equals(row.state)) || row.total == null) { + throw new SQLException("Shared point addition operation is not confirmable: " + operationId); + } + return new AdditionResult(row.total.intValue()); + } + + /** Marks an applied operation safe to retire after its replay checkpoint is durable. */ + void acknowledge(String operationId, long now) throws SQLException { + String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ?, " + qi("created_at") + + " = ? WHERE " + qi("operation_id") + " = ? AND " + qi("state") + " = ?"; + try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(update)) { + statement.setString(1, ACKNOWLEDGED); + statement.setLong(2, now); + statement.setString(3, operationId); + statement.setString(4, COMPLETED); + statement.executeUpdate(); + } + } + + /** + * Removes a bounded batch after the retention window. Replay operations still + * require an explicit acknowledgement; ephemeral administrator operations may + * also retire directly from COMPLETED because their generated IDs are never + * reused after the command has reported its confirmed result. + */ + void cleanupAcknowledged(long now) throws SQLException { + long cutoff = now - COMPLETED_RETENTION_MILLIS; + String select = "SELECT " + qi("operation_id") + " FROM " + qiJournal() + " WHERE (" + qi("state") + + " = ? OR (" + qi("state") + " = ? AND (" + qi("operation_id") + " LIKE ? OR " + + qi("operation_id") + " LIKE ? OR " + qi("operation_id") + " LIKE ? OR " + + qi("operation_id") + " LIKE ?))) AND " + + qi("created_at") + " <= ? ORDER BY " + qi("created_at") + " ASC LIMIT ?"; + String delete = "DELETE FROM " + qiJournal() + " WHERE " + qi("operation_id") + " = ? AND " + + qi("created_at") + " <= ? AND (" + qi("state") + " = ? OR " + qi("state") + " = ?)"; + try (Connection connection = connection(); PreparedStatement selectStatement = connection.prepareStatement(select); + PreparedStatement deleteStatement = connection.prepareStatement(delete)) { + selectStatement.setString(1, ACKNOWLEDGED); + selectStatement.setString(2, COMPLETED); + selectStatement.setString(3, "admin-points/%"); + selectStatement.setString(4, "admin-bulk-points/%"); + selectStatement.setString(5, "admin-bulk-remove/%"); + selectStatement.setString(6, "remove-points/%"); + selectStatement.setLong(7, cutoff); + selectStatement.setInt(8, CLEANUP_BATCH_SIZE); + Set operationIds = new java.util.LinkedHashSet<>(); + try (ResultSet result = selectStatement.executeQuery()) { + while (result.next()) operationIds.add(result.getString(1)); + } + for (String operationId : operationIds) { + deleteStatement.setString(1, operationId); + deleteStatement.setLong(2, cutoff); + deleteStatement.setString(3, ACKNOWLEDGED); + deleteStatement.setString(4, COMPLETED); + deleteStatement.executeUpdate(); + } + } + } + + private void commitAndConfirm(Connection connection, String operationId) throws SQLException { + try { + connection.commit(); + } catch (SQLException ambiguousCommit) { + closeQuietly(connection); + AdditionRow row = find(operationId); + if (row != null && COMPLETED.equals(row.state) && row.total != null) return; + throw ambiguousCommit; + } + } + + private AdditionRow find(String operationId) throws SQLException { + String select = "SELECT " + qi("player_uuid") + ", " + qi("points_column") + ", " + qi("amount") + + ", " + qi("state") + ", " + qi("total_points") + ", " + qi("requested_amount") + ", " + + qi("hook_owner") + ", " + qi("created_at") + " FROM " + qiJournal() + " WHERE " + + qi("operation_id") + " = ?"; + try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(select)) { + statement.setString(1, operationId); + try (ResultSet result = statement.executeQuery()) { + if (!result.next()) return null; + Integer total = result.getObject(5) == null ? null : Integer.valueOf(result.getInt(5)); + Integer requested = result.getObject(6) == null ? null : Integer.valueOf(result.getInt(6)); + return new AdditionRow(result.getString(1), result.getString(2), result.getInt(3), result.getString(4), total, + requested, result.getString(7), result.getLong(8)); + } + } + } + + private void ensureSchema() throws SQLException { + String create = "CREATE TABLE IF NOT EXISTS " + qiJournal() + " (" + qi("operation_id") + + " VARCHAR(64) NOT NULL, " + qi("player_uuid") + " VARCHAR(37) NOT NULL, " + + qi("points_column") + " VARCHAR(128) NOT NULL, " + qi("amount") + " INT NOT NULL, " + + qi("requested_amount") + " INT NULL, " + qi("state") + " VARCHAR(16) NOT NULL, " + + qi("total_points") + " INT NULL, " + qi("hook_owner") + " VARCHAR(64) NULL, " + + qi("created_at") + " BIGINT NOT NULL, PRIMARY KEY (" + qi("operation_id") + "));"; + try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(create)) { + statement.executeUpdate(); + addColumnIfMissing(connection, "requested_amount", "INT NULL"); + addColumnIfMissing(connection, "hook_owner", "VARCHAR(64) NULL"); + createIndex(connection); + } + } + + private void addColumnIfMissing(Connection connection, String column, String definition) throws SQLException { + String alter = "ALTER TABLE " + qiJournal() + " ADD COLUMN " + qi(column) + " " + definition; + try (PreparedStatement statement = connection.prepareStatement(alter)) { + statement.executeUpdate(); + } catch (SQLException failure) { + if (!isDuplicateColumn(failure)) throw failure; + } + } + + private void createIndex(Connection connection) throws SQLException { + String indexName = "vp_pa_" + Integer.toUnsignedString(journalTable.hashCode(), 36) + "_state_created"; + String create = "CREATE INDEX " + (table.getDbType() == DbType.POSTGRESQL ? "IF NOT EXISTS " : "") + + qi(indexName) + " ON " + qiJournal() + " (" + qi("state") + ", " + qi("created_at") + ");"; + try (PreparedStatement statement = connection.prepareStatement(create)) { + statement.executeUpdate(); + } catch (SQLException failure) { + if (!isDuplicateIndex(failure)) throw failure; + } + } + + private static void expungeInitialized() { + IdentityWeakReference cleared; + while ((cleared = (IdentityWeakReference) INITIALIZED_QUEUE.poll()) != null) INITIALIZED.remove(cleared); + for (Iterator iterator = INITIALIZED.iterator(); iterator.hasNext();) { + if (iterator.next().get() == null) iterator.remove(); + } + } + + private static String hash(String value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(digest.length * 2); + for (byte valueByte : digest) { + hex.append(Character.forDigit((valueByte >>> 4) & 0x0f, 16)); + hex.append(Character.forDigit(valueByte & 0x0f, 16)); + } + return hex.toString(); + } catch (NoSuchAlgorithmException failure) { + throw new IllegalStateException("SHA-256 is unavailable", failure); + } + } + + private static boolean isSafeColumn(String column) { + // AbstractSqlTable.qi escapes the dialect's identifier delimiter. Existing + // PerServerPoints names may begin with a digit or contain spaces; keep + // those legacy columns usable while bounding the journal value. + return column != null && !column.isEmpty() && column.length() <= 128 && column.indexOf('\0') < 0; + } + + private Connection connection() throws SQLException { + Connection connection = table.getMysql().getConnectionManager().getConnection(); + if (connection == null) throw new SQLException("Unable to acquire shared MySQL connection"); + return connection; + } + + private String qiJournal() { return table.qi(journalTable); } + private String qi(String identifier) { return table.qi(identifier); } + private String uuidCast() { return table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"; } + + private static void rollback(Connection connection) { + try { + connection.rollback(); + } catch (SQLException ignored) { + // The follow-up lookup determines whether an ambiguous commit landed. + } + } + + private static void closeQuietly(Connection connection) { + try { + connection.close(); + } catch (SQLException ignored) { + // Confirmation remains safe even if this broken handle cannot close cleanly. + } + } + + private static boolean isDuplicate(SQLException failure) { + return "23505".equals(failure.getSQLState()) || failure.getErrorCode() == 1062; + } + + private static boolean isDuplicateIndex(SQLException failure) { + return failure.getErrorCode() == 1061 || "42P07".equals(failure.getSQLState()); + } + + private static boolean isDuplicateColumn(SQLException failure) { + return failure.getErrorCode() == 1060 || "42701".equals(failure.getSQLState()); + } + + private static final class IdentityWeakReference extends WeakReference { + private final int identityHash; + + IdentityWeakReference(MySQL referent, ReferenceQueue queue) { + super(referent, queue); + identityHash = System.identityHashCode(referent); + } + + @Override public int hashCode() { return identityHash; } + @Override public boolean equals(Object other) { + return this == other || other instanceof IdentityWeakReference reference && get() != null + && get() == reference.get(); + } + } + + record AdditionResult(int total) {} + record HookClaim(boolean claimed, boolean completed, boolean requiresReconciliation, int total) { + static HookClaim claimedByCaller() { return new HookClaim(true, false, false, 0); } + static HookClaim completed(int total) { return new HookClaim(false, true, false, total); } + static HookClaim reconciliationRequired() { return new HookClaim(false, false, true, 0); } + static HookClaim inProgress() { return new HookClaim(false, false, false, 0); } + } + private record AdditionRow(String uuid, String pointsColumn, int amount, String state, Integer total, + Integer requestedAmount, String hookOwner, long createdAt) { + boolean matches(String expectedUuid, String expectedPointsColumn, int expectedAmount) { + return uuid.equals(expectedUuid) && pointsColumn.equals(expectedPointsColumn) && amount == expectedAmount; + } + + boolean matchesTarget(String expectedUuid, String expectedPointsColumn) { + return uuid.equals(expectedUuid) && pointsColumn.equals(expectedPointsColumn); + } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferCompensationStore.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferCompensationStore.java new file mode 100644 index 0000000000..b5af9e0eab --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferCompensationStore.java @@ -0,0 +1,107 @@ +package com.bencodez.votingplugin.user; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; + +import com.bencodez.votingplugin.util.DurableFiles; + +/** Restart-safe local proof that a rejected transfer callback is safe to refund. */ +final class SharedPointTransferCompensationStore { + private static final String DIRECTORY = ".point-transfer-compensations"; + private static final String SUFFIX = ".pending"; + private static final int MAX_TRANSFER_ID_BYTES = 256; + private static final int RECOVERY_BATCH_SIZE = 128; + + private final Path directory; + + SharedPointTransferCompensationStore(Path dataDirectory) { + this.directory = dataDirectory.toAbsolutePath().normalize().resolve(DIRECTORY); + } + + void record(String transferId) throws IOException { + byte[] contents = contents(transferId); + ensureSafeDirectory(); + Path target = marker(transferId); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) throw unsafe(); + return; + } + Path temporary = Files.createTempFile(directory, ".compensation-", ".tmp"); + try { + Files.write(temporary, contents, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + DurableFiles.forceFile(temporary); + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException unsupported) { + Files.move(temporary, target); + } + DurableFiles.forceDirectory(directory); + } finally { + Files.deleteIfExists(temporary); + } + } + + List loadBatch() throws IOException { + if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) return List.of(); + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(directory)) throw unsafe(); + List transfers = new ArrayList<>(); + try (DirectoryStream entries = Files.newDirectoryStream(directory, "*" + SUFFIX)) { + for (Path entry : entries) { + if (transfers.size() == RECOVERY_BATCH_SIZE) break; + if (!Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(entry) + || Files.size(entry) > MAX_TRANSFER_ID_BYTES) continue; + String transferId = Files.readString(entry, StandardCharsets.UTF_8); + if (marker(transferId).equals(entry.toAbsolutePath().normalize())) transfers.add(transferId); + } + } + return transfers; + } + + void remove(String transferId) throws IOException { + DurableFiles.deleteIfExists(marker(transferId)); + } + + private void ensureSafeDirectory() throws IOException { + if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(directory)) throw unsafe(); + return; + } + Files.createDirectories(directory); + DurableFiles.forceDirectory(directory.getParent()); + } + + private Path marker(String transferId) throws IOException { + return directory.resolve(hash(contents(transferId)) + SUFFIX).toAbsolutePath().normalize(); + } + + private static byte[] contents(String transferId) throws IOException { + if (transferId == null || transferId.isBlank()) throw unsafe(); + byte[] contents = transferId.getBytes(StandardCharsets.UTF_8); + if (contents.length > MAX_TRANSFER_ID_BYTES) throw unsafe(); + return contents; + } + + private static String hash(byte[] value) throws IOException { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); + } catch (NoSuchAlgorithmException impossible) { + throw new IOException("SHA-256 is unavailable", impossible); + } + } + + private static IOException unsafe() { + return new IOException("Unsafe point transfer compensation marker"); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java new file mode 100644 index 0000000000..f428bcf700 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java @@ -0,0 +1,726 @@ +package com.bencodez.votingplugin.user; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.lang.ref.ReferenceQueue; +import java.lang.ref.WeakReference; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; +import com.bencodez.simpleapi.sql.mysql.DbType; + +/** + * Durable state for a shared-MySQL point transfer. + * + *

The journal is deliberately stored in the same database as the user row. + * A reservation and its source debit therefore commit together. The event hook + * is invoked only after that short transaction has released its connection; + * settlement is a second short, idempotent transaction.

+ */ +final class SharedPointTransferJournal { + private static final String RESERVED = "RESERVED"; + private static final String HOOK_STARTED = "HOOK_STARTED"; + /* + * The approval callback has proved it did not run while this state is + * present. It is written only after the scheduler fence rejects the callback, + * so startup/periodic recovery can compensate it immediately. + */ + private static final String COMPENSATING = "COMPENSATING"; + private static final String COMPLETED = "COMPLETED"; + private static final String REFUNDED = "REFUNDED"; + static final long RESERVED_RECOVERY_AGE_MILLIS = TimeUnit.MINUTES.toMillis(5); + static final long TERMINAL_RETENTION_MILLIS = TimeUnit.DAYS.toMillis(7); + private static final int RECOVERY_BATCH_SIZE = 32; + private static final int CLEANUP_BATCH_SIZE = 100; + /* PostgreSQL permits 63 bytes and is the tighter supported database limit. */ + private static final int MAX_IDENTIFIER_BYTES = 63; + private static final String JOURNAL_SUFFIX = "_PointTransfers"; + private static final String HASHED_TABLE_PREFIX = "vp_pt_"; + private static final int HASHED_TABLE_HEX_LENGTH = 32; + + private final MySQL table; + private final String journalTable; + /* + * MySQL does not promise identity-based equals/hashCode. A regular + * WeakHashMap can therefore conflate two live handles that compare equal, + * and a value containing the handle would keep its weak key alive. Keep only + * identity weak references as initialization markers instead. + */ + private static final ReferenceQueue INITIALIZED_QUEUE = new ReferenceQueue<>(); + private static final Set INITIALIZED = new HashSet<>(); + + SharedPointTransferJournal(MySQL table) throws SQLException { + this(table, true); + } + + private SharedPointTransferJournal(MySQL table, boolean initializeSchema) throws SQLException { + this.table = table; + this.journalTable = journalTableName(table.getTableName()); + if (initializeSchema) ensureSchema(); + } + + /** + * Keeps the historic auxiliary-table name where it is portable, while using + * a fixed, collision-resistant name for source tables which would exceed the + * PostgreSQL identifier limit. + */ + static String journalTableName(String sourceTable) { + String legacyName = sourceTable + JOURNAL_SUFFIX; + if (legacyName.getBytes(StandardCharsets.UTF_8).length <= MAX_IDENTIFIER_BYTES) return legacyName; + return HASHED_TABLE_PREFIX + hash(sourceTable + '\0' + JOURNAL_SUFFIX).substring(0, HASHED_TABLE_HEX_LENGTH); + } + + private static String hash(String value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(digest.length * 2); + for (byte valueByte : digest) { + hex.append(Character.forDigit((valueByte >>> 4) & 0x0f, 16)); + hex.append(Character.forDigit(valueByte & 0x0f, 16)); + } + return hex.toString(); + } catch (NoSuchAlgorithmException failure) { + throw new IllegalStateException("SHA-256 is unavailable", failure); + } + } + + /** Returns a journal handle after ensuring the schema once per live MySQL table handle. */ + static SharedPointTransferJournal forTable(MySQL table) throws SQLException { + synchronized (INITIALIZED) { + expungeInitialized(); + for (IdentityWeakReference marker : INITIALIZED) { + if (marker.get() == table) return new SharedPointTransferJournal(table, false); + } + new SharedPointTransferJournal(table, true); + INITIALIZED.add(new IdentityWeakReference(table, INITIALIZED_QUEUE)); + return new SharedPointTransferJournal(table, false); + } + } + + private static void expungeInitialized() { + IdentityWeakReference cleared; + while ((cleared = (IdentityWeakReference) INITIALIZED_QUEUE.poll()) != null) { + INITIALIZED.remove(cleared); + } + for (Iterator iterator = INITIALIZED.iterator(); iterator.hasNext();) { + if (iterator.next().get() == null) iterator.remove(); + } + } + + private static final class IdentityWeakReference extends WeakReference { + private final int identityHash; + + IdentityWeakReference(MySQL referent, ReferenceQueue queue) { + super(referent, queue); + identityHash = System.identityHashCode(referent); + } + + @Override + public int hashCode() { + return identityHash; + } + + @Override + public boolean equals(Object other) { + return this == other || other instanceof IdentityWeakReference reference && get() != null + && get() == reference.get(); + } + } + + /** + * Inserts a reservation and conditionally debits the source in one transaction. + * A duplicate transfer id is treated as an idempotent retry of the same + * reservation, which is needed after a commit acknowledgement is lost. + */ + boolean reserve(String transferId, String sourceUuid, String sourcePointsColumn, int debitPoints, String targetUuid, + int requestedCreditPoints, long now) throws SQLException { + TransferRow existing = find(transferId); + if (existing != null) { + return existing.matches(sourceUuid, targetUuid, debitPoints, requestedCreditPoints) + && !REFUNDED.equals(existing.state); + } + + String insert = "INSERT INTO " + qiJournal() + " (" + qi("transfer_id") + ", " + qi("source_uuid") + + ", " + qi("source_points_column") + ", " + qi("target_uuid") + ", " + qi("debit_points") + + ", " + qi("requested_credit_points") + ", " + qi("state") + ", " + qi("created_at") + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; + String points = qi(sourcePointsColumn); + String debit = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points + + " - ? WHERE " + qi("uuid") + uuidCast() + " AND " + points + " >= ?"; + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try (PreparedStatement insertStatement = connection.prepareStatement(insert); + PreparedStatement debitStatement = connection.prepareStatement(debit)) { + insertStatement.setString(1, transferId); + insertStatement.setString(2, sourceUuid); + insertStatement.setString(3, sourcePointsColumn); + insertStatement.setString(4, targetUuid); + insertStatement.setInt(5, debitPoints); + insertStatement.setInt(6, requestedCreditPoints); + insertStatement.setString(7, RESERVED); + insertStatement.setLong(8, now); + insertStatement.executeUpdate(); + + debitStatement.setInt(1, debitPoints); + debitStatement.setString(2, sourceUuid); + debitStatement.setInt(3, debitPoints); + if (debitStatement.executeUpdate() != 1) { + connection.rollback(); + return false; + } + return commitAndConfirm(connection, transferId, RESERVED); + } catch (SQLException failure) { + rollback(connection); + if (isDuplicate(failure)) { + closeQuietly(connection); + TransferRow duplicate = find(transferId); + return duplicate != null && duplicate.matches(sourceUuid, targetUuid, debitPoints, + requestedCreditPoints) && !REFUNDED.equals(duplicate.state); + } + throw failure; + } + } + } + + /** + * Claims a reservation before invoking the arbitrary external event hook. + * A lost commit acknowledgement is retried by transfer id: a row already + * owned by this attempt proves that the hook may now run exactly once. + */ + ClaimOutcome claimHookWithConfirmation(String transferId, String owner, long startedAt) { + for (int attempt = 0; attempt < 3; attempt++) { + try { + ClaimOutcome outcome = claimHookOnce(transferId, owner, startedAt); + if (outcome != ClaimOutcome.INDETERMINATE) return outcome; + } catch (SQLException ignored) { + // Re-read the same journal row; do not create a second transfer id. + } + } + return ClaimOutcome.INDETERMINATE; + } + + private ClaimOutcome claimHookOnce(String transferId, String owner, long startedAt) throws SQLException { + String select = "SELECT " + qi("state") + ", " + qi("hook_owner") + " FROM " + qiJournal() + + " WHERE " + qi("transfer_id") + " = ? FOR UPDATE"; + String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ?, " + qi("hook_owner") + + " = ?, " + qi("hook_started_at") + " = ? WHERE " + qi("transfer_id") + " = ?"; + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try (PreparedStatement selectStatement = connection.prepareStatement(select)) { + selectStatement.setString(1, transferId); + try (ResultSet result = selectStatement.executeQuery()) { + if (!result.next()) { + connection.rollback(); + return ClaimOutcome.NOT_CLAIMED; + } + String state = result.getString(1); + String currentOwner = result.getString(2); + if (HOOK_STARTED.equals(state)) { + connection.rollback(); + return owner.equals(currentOwner) ? ClaimOutcome.CLAIMED : ClaimOutcome.INDETERMINATE; + } + if (!RESERVED.equals(state)) { + connection.rollback(); + return ClaimOutcome.NOT_CLAIMED; + } + } + } + try (PreparedStatement updateStatement = connection.prepareStatement(update)) { + updateStatement.setString(1, HOOK_STARTED); + updateStatement.setString(2, owner); + updateStatement.setLong(3, startedAt); + updateStatement.setString(4, transferId); + if (updateStatement.executeUpdate() != 1) { + connection.rollback(); + return ClaimOutcome.INDETERMINATE; + } + commitAndConfirm(connection, transferId, HOOK_STARTED); + return ClaimOutcome.CLAIMED; + } + } + } + + enum ClaimOutcome { + CLAIMED, + NOT_CLAIMED, + INDETERMINATE + } + + /** + * Persists a recoverable marker after the scheduler has proved that the + * Bukkit approval callback did not begin. + */ + boolean markCompensating(String transferId) throws SQLException { + String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + + qi("transfer_id") + " = ? AND " + qi("state") + " IN (?, ?)"; + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try (PreparedStatement updateStatement = connection.prepareStatement(update)) { + updateStatement.setString(1, COMPENSATING); + updateStatement.setString(2, transferId); + updateStatement.setString(3, HOOK_STARTED); + updateStatement.setString(4, COMPENSATING); + if (updateStatement.executeUpdate() != 1) { + connection.rollback(); + return false; + } + return commitAndConfirm(connection, transferId, COMPENSATING); + } + } + } + + /** + * Safely releases a reservation when the hook was never claimed. A + * {@code HOOK_STARTED} row is deliberately left alone because a listener may + * already be executing and replaying/refunding it automatically is unsafe. + */ + boolean refundReserved(String transferId, String sourceUuid, String sourcePointsColumn, int debitPoints) + throws SQLException { + if (!isSafeColumn(sourcePointsColumn)) return false; + String select = "SELECT " + qi("state") + " FROM " + qiJournal() + " WHERE " + qi("transfer_id") + + " = ? FOR UPDATE"; + String points = qi(sourcePointsColumn); + String refund = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points + + " + ? WHERE " + qi("uuid") + uuidCast(); + String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("transfer_id") + + " = ?"; + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try (PreparedStatement selectStatement = connection.prepareStatement(select)) { + selectStatement.setString(1, transferId); + try (ResultSet result = selectStatement.executeQuery()) { + if (!result.next() || !RESERVED.equals(result.getString(1))) { + connection.rollback(); + return false; + } + } + } + try (PreparedStatement refundStatement = connection.prepareStatement(refund)) { + refundStatement.setInt(1, debitPoints); + refundStatement.setString(2, sourceUuid); + if (refundStatement.executeUpdate() != 1) { + connection.rollback(); + return false; + } + } + try (PreparedStatement updateStatement = connection.prepareStatement(update)) { + updateStatement.setString(1, REFUNDED); + updateStatement.setString(2, transferId); + if (updateStatement.executeUpdate() != 1) { + connection.rollback(); + return false; + } + } + return commitAndConfirm(connection, transferId, REFUNDED); + } + } + + /** + * Refunds a claimed transfer when the second Bukkit approval task was rejected + * before its callback could start. The caller has the scheduler's proof that + * no listener ran, so it is safe to reverse the source debit. + */ + boolean refundHookStarted(String transferId, String sourceUuid, String sourcePointsColumn, int debitPoints) + throws SQLException { + if (!isSafeColumn(sourcePointsColumn)) return false; + String select = "SELECT " + qi("state") + " FROM " + qiJournal() + " WHERE " + qi("transfer_id") + + " = ? FOR UPDATE"; + String points = qi(sourcePointsColumn); + String refund = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points + + " + ? WHERE " + qi("uuid") + uuidCast(); + String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("transfer_id") + + " = ?"; + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try (PreparedStatement selectStatement = connection.prepareStatement(select)) { + selectStatement.setString(1, transferId); + try (ResultSet result = selectStatement.executeQuery()) { + if (!result.next()) { + connection.rollback(); + return false; + } + String state = result.getString(1); + if (REFUNDED.equals(state)) { + connection.rollback(); + return true; + } + if (!HOOK_STARTED.equals(state) && !COMPENSATING.equals(state)) { + connection.rollback(); + return false; + } + } + } + try (PreparedStatement refundStatement = connection.prepareStatement(refund)) { + refundStatement.setInt(1, debitPoints); + refundStatement.setString(2, sourceUuid); + if (refundStatement.executeUpdate() != 1) { + connection.rollback(); + return false; + } + } + try (PreparedStatement updateStatement = connection.prepareStatement(update)) { + updateStatement.setString(1, REFUNDED); + updateStatement.setString(2, transferId); + if (updateStatement.executeUpdate() != 1) { + connection.rollback(); + return false; + } + } + return commitAndConfirm(connection, transferId, REFUNDED); + } catch (SQLException failure) { + throw failure; + } + } + + /** + * Retries settlement with the same transfer id when a commit acknowledgement + * or its first confirmation read is lost. A terminal state proves the prior + * attempt's outcome; a still-HOOK_STARTED row safely retries the same point + * update. Exhausted attempts remain explicitly indeterminate rather than + * being exposed as a retryable transfer failure. + */ + SettlementOutcome settleWithConfirmation(String transferId, String owner, String sourceUuid, + String sourcePointsColumn, String targetUuid, String targetPointsColumn, int debitPoints, + Integer adjustedCreditPoints) { + for (int attempt = 0; attempt < 3; attempt++) { + try { + SettlementOutcome outcome = settleOnce(transferId, owner, sourceUuid, sourcePointsColumn, targetUuid, + targetPointsColumn, debitPoints, adjustedCreditPoints); + if (outcome != SettlementOutcome.INDETERMINATE) return outcome; + } catch (SQLException ignored) { + // The next attempt re-reads the durable state using the same id. + } + } + return SettlementOutcome.INDETERMINATE; + } + + /** Retained for direct callers that only need the historic completed/not-completed boolean. */ + boolean settle(String transferId, String owner, String sourceUuid, String sourcePointsColumn, String targetUuid, + String targetPointsColumn, int debitPoints, Integer adjustedCreditPoints) { + return settleWithConfirmation(transferId, owner, sourceUuid, sourcePointsColumn, targetUuid, + targetPointsColumn, debitPoints, adjustedCreditPoints) == SettlementOutcome.COMPLETED; + } + + private SettlementOutcome settleOnce(String transferId, String owner, String sourceUuid, String sourcePointsColumn, + String targetUuid, String targetPointsColumn, int debitPoints, Integer adjustedCreditPoints) throws SQLException { + String select = "SELECT " + qi("state") + ", " + qi("hook_owner") + " FROM " + qiJournal() + + " WHERE " + qi("transfer_id") + " = ? FOR UPDATE"; + String points = qi(adjustedCreditPoints == null ? sourcePointsColumn : targetPointsColumn); + String credit = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points + + " + ? WHERE " + qi("uuid") + uuidCast(); + String updateJournal = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ?, " + + qi("adjusted_credit_points") + " = ? WHERE " + qi("transfer_id") + " = ?"; + try (Connection connection = connection()) { + connection.setAutoCommit(false); + String state; + try (PreparedStatement selectStatement = connection.prepareStatement(select)) { + selectStatement.setString(1, transferId); + try (ResultSet result = selectStatement.executeQuery()) { + if (!result.next()) { + connection.rollback(); + return SettlementOutcome.INDETERMINATE; + } + state = result.getString(1); + String currentOwner = result.getString(2); + if (COMPLETED.equals(state)) { + connection.rollback(); + return SettlementOutcome.COMPLETED; + } + if (REFUNDED.equals(state)) { + connection.rollback(); + return SettlementOutcome.REFUNDED; + } + if (!HOOK_STARTED.equals(state) || !owner.equals(currentOwner)) { + connection.rollback(); + return SettlementOutcome.INDETERMINATE; + } + } + } + + boolean refund = adjustedCreditPoints == null; + try (PreparedStatement pointStatement = connection.prepareStatement(credit)) { + pointStatement.setInt(1, refund ? debitPoints : adjustedCreditPoints); + pointStatement.setString(2, refund ? sourceUuid : targetUuid); + if (pointStatement.executeUpdate() != 1) { + connection.rollback(); + return SettlementOutcome.INDETERMINATE; + } + } + try (PreparedStatement journalStatement = connection.prepareStatement(updateJournal)) { + journalStatement.setString(1, refund ? REFUNDED : COMPLETED); + if (refund) journalStatement.setNull(2, java.sql.Types.INTEGER); + else journalStatement.setInt(2, adjustedCreditPoints); + journalStatement.setString(3, transferId); + if (journalStatement.executeUpdate() != 1) { + connection.rollback(); + return SettlementOutcome.INDETERMINATE; + } + } + commitAndConfirm(connection, transferId, refund ? REFUNDED : COMPLETED); + return refund ? SettlementOutcome.REFUNDED : SettlementOutcome.COMPLETED; + } + } + + enum SettlementOutcome { + COMPLETED, + REFUNDED, + INDETERMINATE + } + + record RefundedTransfer(String uuid, String pointsColumn) { + } + + /** + * Reclaims old reservations and compensation markers that have never entered + * an external hook, + * then removes a small batch of old terminal rows. Each candidate is locked + * and checked again before a refund, so another server cannot compensate a + * transfer that it has just claimed. HOOK_STARTED rows require explicit + * reconciliation because an arbitrary listener may still have side effects. + */ + List recoverAndCleanup(long now) throws SQLException { + long reservationCutoff = now - RESERVED_RECOVERY_AGE_MILLIS; + List refunded = new ArrayList<>(); + for (String transferId : findExpiredRecoverableTransferIds(reservationCutoff, RECOVERY_BATCH_SIZE)) { + RefundedTransfer result = recoverRecoverableTransfer(transferId, reservationCutoff); + if (result != null) refunded.add(result); + } + cleanupTerminalRows(now - TERMINAL_RETENTION_MILLIS, CLEANUP_BATCH_SIZE); + return List.copyOf(refunded); + } + + private List findExpiredRecoverableTransferIds(long cutoff, int limit) + throws SQLException { + String sql = "SELECT " + qi("transfer_id") + " FROM " + qiJournal() + " WHERE (" + qi("state") + + " = ? AND " + qi("created_at") + " <= ?) OR " + qi("state") + " = ? ORDER BY " + + "CASE WHEN " + qi("state") + " = ? THEN 0 ELSE 1 END ASC, " + qi("created_at") + + " ASC LIMIT ?"; + List transferIds = new ArrayList<>(); + try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, RESERVED); + statement.setLong(2, cutoff); + statement.setString(3, COMPENSATING); + statement.setString(4, COMPENSATING); + statement.setInt(5, limit); + try (ResultSet result = statement.executeQuery()) { + while (result.next()) { + transferIds.add(result.getString(1)); + } + } + } + return transferIds; + } + + private RefundedTransfer recoverRecoverableTransfer(String transferId, long reservationCutoff) throws SQLException { + String select = "SELECT " + qi("state") + ", " + qi("created_at") + ", " + qi("source_uuid") + + ", " + qi("source_points_column") + ", " + qi("debit_points") + " FROM " + qiJournal() + + " WHERE " + qi("transfer_id") + " = ? FOR UPDATE"; + String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("transfer_id") + + " = ?"; + try (Connection connection = connection()) { + connection.setAutoCommit(false); + String sourceUuid; + String sourcePointsColumn; + int debitPoints; + try (PreparedStatement selectStatement = connection.prepareStatement(select)) { + selectStatement.setString(1, transferId); + try (ResultSet result = selectStatement.executeQuery()) { + if (!result.next() || (!RESERVED.equals(result.getString(1)) + && !COMPENSATING.equals(result.getString(1))) + || (RESERVED.equals(result.getString(1)) && result.getLong(2) > reservationCutoff)) { + connection.rollback(); + return null; + } + sourceUuid = result.getString(3); + sourcePointsColumn = result.getString(4); + debitPoints = result.getInt(5); + } + } + if (!isSafeColumn(sourcePointsColumn)) { + connection.rollback(); + return null; + } + String points = qi(sourcePointsColumn); + String refund = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points + + " + ? WHERE " + qi("uuid") + uuidCast(); + try (PreparedStatement refundStatement = connection.prepareStatement(refund)) { + refundStatement.setInt(1, debitPoints); + refundStatement.setString(2, sourceUuid); + if (refundStatement.executeUpdate() != 1) { + connection.rollback(); + return null; + } + } + try (PreparedStatement updateStatement = connection.prepareStatement(update)) { + updateStatement.setString(1, REFUNDED); + updateStatement.setString(2, transferId); + if (updateStatement.executeUpdate() != 1) { + connection.rollback(); + return null; + } + } + if (!commitAndConfirm(connection, transferId, REFUNDED)) return null; + return new RefundedTransfer(sourceUuid, sourcePointsColumn); + } + } + + private void cleanupTerminalRows(long cutoff, int limit) throws SQLException { + String select = "SELECT " + qi("transfer_id") + " FROM " + qiJournal() + " WHERE " + qi("state") + + " IN (?, ?) AND " + qi("created_at") + " <= ? ORDER BY " + qi("created_at") + " ASC LIMIT ?"; + String delete = "DELETE FROM " + qiJournal() + " WHERE " + qi("transfer_id") + " = ? AND " + + qi("state") + " IN (?, ?) AND " + qi("created_at") + " <= ?"; + try (Connection connection = connection(); PreparedStatement selectStatement = connection.prepareStatement(select); + PreparedStatement deleteStatement = connection.prepareStatement(delete)) { + selectStatement.setString(1, COMPLETED); + selectStatement.setString(2, REFUNDED); + selectStatement.setLong(3, cutoff); + selectStatement.setInt(4, limit); + List transferIds = new ArrayList<>(); + try (ResultSet result = selectStatement.executeQuery()) { + while (result.next()) { + transferIds.add(result.getString(1)); + } + } + for (String transferId : transferIds) { + deleteStatement.setString(1, transferId); + deleteStatement.setString(2, COMPLETED); + deleteStatement.setString(3, REFUNDED); + deleteStatement.setLong(4, cutoff); + deleteStatement.executeUpdate(); + } + } + } + + private static boolean isSafeColumn(String column) { + return column != null && column.matches("[A-Za-z][A-Za-z0-9_]{0,127}"); + } + + private TransferRow find(String transferId) throws SQLException { + String sql = "SELECT " + qi("source_uuid") + ", " + qi("target_uuid") + ", " + qi("debit_points") + + ", " + qi("requested_credit_points") + ", " + qi("state") + " FROM " + qiJournal() + + " WHERE " + qi("transfer_id") + " = ?"; + try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, transferId); + try (ResultSet result = statement.executeQuery()) { + if (!result.next()) return null; + return new TransferRow(result.getString(1), result.getString(2), result.getInt(3), result.getInt(4), + result.getString(5)); + } + } + } + + private boolean commitAndConfirm(Connection connection, String transferId, String expectedState) throws SQLException { + try { + connection.commit(); + return true; + } catch (SQLException ambiguousCommit) { + // A single-connection pool cannot service the confirmation lookup while + // this possibly-broken connection is still checked out. + closeQuietly(connection); + TransferRow row = find(transferId); + if (row != null && expectedState.equals(row.state)) return true; + throw ambiguousCommit; + } + } + + private void ensureSchema() throws SQLException { + String create = "CREATE TABLE IF NOT EXISTS " + qiJournal() + " (" + qi("transfer_id") + + " VARCHAR(36) NOT NULL, " + qi("source_uuid") + " VARCHAR(37) NOT NULL, " + + qi("source_points_column") + " VARCHAR(128) NOT NULL, " + qi("target_uuid") + + " VARCHAR(37) NOT NULL, " + qi("debit_points") + " INT NOT NULL, " + + qi("requested_credit_points") + " INT NOT NULL, " + qi("adjusted_credit_points") + + " INT NULL, " + qi("state") + " VARCHAR(16) NOT NULL, " + qi("created_at") + + " BIGINT NOT NULL, " + qi("hook_started_at") + " BIGINT NULL, " + qi("hook_owner") + + " VARCHAR(64) NULL, PRIMARY KEY (" + qi("transfer_id") + + "));"; + try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(create)) { + statement.executeUpdate(); + createIndex(connection, indexName("state_created"), new String[] { "state", "created_at" }); + } + } + + private void createIndex(Connection connection, String indexName, String[] columns) throws SQLException { + StringBuilder columnSql = new StringBuilder("("); + for (int index = 0; index < columns.length; index++) { + if (index > 0) columnSql.append(", "); + columnSql.append(qi(columns[index])); + } + columnSql.append(")"); + String sql = "CREATE INDEX " + (dbType() == DbType.POSTGRESQL ? "IF NOT EXISTS " : "") + qi(indexName) + + " ON " + qiJournal() + " " + columnSql + ";"; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.executeUpdate(); + } catch (SQLException failure) { + if (!isDuplicateIndex(failure)) throw failure; + } + } + + private String indexName(String suffix) { + return "vp_pt_" + Integer.toUnsignedString(journalTable.hashCode(), 36) + "_" + suffix; + } + + private Connection connection() throws SQLException { + Connection connection = table.getMysql().getConnectionManager().getConnection(); + if (connection == null) throw new SQLException("Unable to acquire shared MySQL connection"); + return connection; + } + + private String qiJournal() { + return table.qi(journalTable); + } + + private String qi(String identifier) { + return table.qi(identifier); + } + + private String uuidCast() { + return dbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"; + } + + private DbType dbType() { + return table.getDbType(); + } + + private static void rollback(Connection connection) { + try { + connection.rollback(); + } catch (SQLException ignored) { + // Preserve the original failure. The journal row remains recoverable. + } + } + + private static void closeQuietly(Connection connection) { + try { + connection.close(); + } catch (SQLException ignored) { + // The confirmation lookup below will determine whether the commit landed. + } + } + + private static boolean isDuplicate(SQLException failure) { + String state = failure.getSQLState(); + return "23505".equals(state) || failure.getErrorCode() == 1062; + } + + private static boolean isDuplicateIndex(SQLException failure) { + return failure.getErrorCode() == 1061 || "42P07".equals(failure.getSQLState()); + } + + private record TransferRow(String sourceUuid, String targetUuid, int debitPoints, int requestedCreditPoints, + String state) { + boolean matches(String source, String target, int debit, int requestedCredit) { + return sourceUuid.equals(source) && targetUuid.equals(target) && debitPoints == debit + && requestedCreditPoints == requestedCredit; + } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/UserManager.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/UserManager.java index 6b1a166f41..8c6b2c1564 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/UserManager.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/UserManager.java @@ -23,6 +23,7 @@ */ public class UserManager { private VotingPluginMain plugin; + private boolean sharedPointTransferRecoveryScheduled; /** * Constructs a new user manager. @@ -32,6 +33,19 @@ public UserManager(VotingPluginMain plugin) { this.plugin = plugin; } + /** Starts the durable shared-point transfer recovery exactly once per plugin lifecycle. */ + public synchronized void startSharedPointTransferRecovery() { + if (sharedPointTransferRecoveryScheduled + || !SharedMysqlPointMutator.canRecoverSharedMysqlPointJournals(plugin)) return; + try { + SharedMysqlPointMutator.scheduleTransferRecovery(plugin); + sharedPointTransferRecoveryScheduled = true; + } catch (RuntimeException rejected) { + // Shutdown can reject either the immediate recovery or its periodic task. + // Leave the lifecycle guard open so a later reload can retry scheduling. + plugin.debug(rejected); + } + } /** * Adds caching keys to the user data manager. */ 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 c7907b0f44..c096f497a2 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -1,6 +1,7 @@ package com.bencodez.votingplugin.user; -import java.text.SimpleDateFormat; +import java.text.SimpleDateFormat; +import java.sql.SQLException; import java.time.Duration; import java.time.Instant; import java.time.LocalDate; @@ -13,8 +14,14 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Map.Entry; -import java.util.UUID; +import java.util.Map.Entry; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.function.BiConsumer; +import java.util.function.Consumer; import java.util.stream.Collectors; import org.bukkit.Bukkit; @@ -24,7 +31,10 @@ import com.bencodez.advancedcore.api.misc.MiscUtils; import com.bencodez.advancedcore.api.rewards.RewardBuilder; import com.bencodez.advancedcore.api.rewards.RewardOptions; -import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; import com.bencodez.simpleapi.messages.MessageAPI; import com.bencodez.simpleapi.sql.data.DataValue; import com.bencodez.simpleapi.sql.data.DataValueInt; @@ -36,7 +46,8 @@ import com.bencodez.votingplugin.events.SpecialRewardType; import com.bencodez.votingplugin.proxy.VoteTotalsSnapshot; import com.bencodez.votingplugin.topvoter.TopVoter; -import com.bencodez.votingplugin.topvoter.TopVoterPlayer; +import com.bencodez.votingplugin.topvoter.TopVoterPlayer; +import com.bencodez.votingplugin.util.BukkitCompletionScheduler; import com.bencodez.votingplugin.votesites.NextSite; import com.bencodez.votingplugin.votesites.VoteSite; @@ -45,7 +56,10 @@ * system. It extends the AdvancedCoreUser class and provides additional * functionality specific to the VotingPlugin. */ -public class VotingPluginUser extends com.bencodez.advancedcore.api.user.AdvancedCoreUser { +public class VotingPluginUser extends com.bencodez.advancedcore.api.user.AdvancedCoreUser { + private static final int BULK_POINT_BATCH_SIZE = 64; + private static final ConcurrentMap> IN_FLIGHT_POINT_REPLAYS = + new ConcurrentHashMap<>(); /** The plugin instance. */ private VotingPluginMain plugin; @@ -177,15 +191,35 @@ public void addOfflineVote(String voteSiteName) { /** * Adds points to the user based on the configuration. */ - public void addPoints() { - int points = plugin.getConfigFile().getPointsOnVote(); - if (points != 0) { - addPoints(points); - } - if (plugin.getConfigFile().getLimitVotePoints() > 0) { - if (getPoints() > plugin.getConfigFile().getLimitVotePoints()) { - setPoints(plugin.getConfigFile().getLimitVotePoints()); - } + public void addPoints() { + int points = plugin.getConfigFile().getPointsOnVote(); + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + boolean sharedMysql = sharedPoints.applies(); + int limit = plugin.getConfigFile().getLimitVotePoints(); + if (sharedMysql && points != 0 && limit > 0) { + // Keep the receive hook semantics of addPoints(int, boolean), while + // accepting the addition and upper bound as one persistence task. + PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, points); + Bukkit.getPluginManager().callEvent(event); + if (!event.isCancelled()) { + sharedPoints.addAndCap(this, event.getPoints(), limit, true); + } else { + sharedPoints.cap(this, limit, true); + } + return; + } + if (points != 0) { + // Vote processing runs on the server lane. Shared MySQL arithmetic must + // use the lifecycle persistence executor instead of blocking a tick on + // connection acquisition and the committed-balance read. + addPoints(points, sharedMysql); + } + if (limit > 0) { + if (sharedMysql) { + sharedPoints.cap(this, limit, true); + } else if (getPoints() > limit) { + setPoints(limit); + } } } @@ -206,17 +240,611 @@ public int addPoints(int value) { * @param async whether to add the points asynchronously * @return the current total points */ - public synchronized int addPoints(int value, boolean async) { + public synchronized int addPoints(int value, boolean async) { PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value); Bukkit.getPluginManager().callEvent(event); if (event.isCancelled()) { return getPoints(); } - int newTotal = getPoints() + event.getPoints(); - setPoints(newTotal, async); - return newTotal; - } + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + if (sharedPoints.applies()) { + return sharedPoints.add(this, event.getPoints(), async); + } + int newTotal = getPoints() + event.getPoints(); + setPoints(newTotal, async); + return newTotal; + } + + /** + * Keeps ordinary storage writes synchronous while moving shared-MySQL atomic + * arithmetic onto the persistence executor. + */ + public int addPointsStorageAware(int value) { + return addPoints(value, new SharedMysqlPointMutator(plugin).applies()); + } + + /** + * Adds points and completes with the committed total. Shared-MySQL work runs on + * the persistence executor; ordinary storage preserves its synchronous write. + * The returned stage never reports a predicted shared-MySQL value. + * + * @param value point delta + * @return committed point total, or an exceptional stage when persistence fails + */ + public synchronized CompletionStage addPointsStorageAwareAsync(int value) { + return addPointsStorageAwareAsync(value, null); + } + + /** + * Adds points and completes with the committed total, optionally binding the + * addition to a durable operation id supplied by a retryable reward stage. + * Callers without a stable id retain the historical behavior. + * + * @param value point delta + * @param operationId stable id for a retry of the same logical addition, or null + * @return committed point total, or an exceptional stage when persistence fails + */ + public synchronized CompletionStage addPointsStorageAwareAsync(int value, String operationId) { + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + if (!sharedPoints.applies()) { + if (operationId != null && !operationId.isEmpty() + && SharedMysqlPointMutator.canRecoverSharedMysqlPointJournals(plugin)) { + return addPerServerPointsWithReplayClaim(sharedPoints, value, operationId); + } + PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value); + Bukkit.getPluginManager().callEvent(event); + if (event.isCancelled()) return CompletableFuture.completedFuture(getPoints()); + int newTotal = getPoints() + event.getPoints(); + setPoints(newTotal, false); + return CompletableFuture.completedFuture(newTotal); + } + if (operationId != null && !operationId.isEmpty()) { + return addSharedPointsWithReplayLookup(sharedPoints, value, operationId); + } + + PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value); + Bukkit.getPluginManager().callEvent(event); + if (event.isCancelled()) return CompletableFuture.completedFuture(getPoints()); + + CompletableFuture completion = new CompletableFuture<>(); + try { + plugin.getTimer().execute(() -> { + try { + SharedMysqlPointMutator.AddResult result = sharedPoints.addCommitted(this, event.getPoints(), operationId); + if (result.success()) completion.complete(result.total()); + else completion.completeExceptionally( + new IllegalStateException("Unable to persist shared MySQL points")); + } catch (Throwable failure) { + completion.completeExceptionally(failure); + } + }); + } catch (RuntimeException rejected) { + plugin.debug(rejected); + completion.completeExceptionally(rejected); + } + return completion; + } + + /** + * Claims the shared reward-operation journal before invoking a PerServerPoints + * receive hook. The journal prevents another backend from running that hook + * and creating a second server-local credit for the same operation. + */ + private CompletionStage addPerServerPointsWithReplayClaim(SharedMysqlPointMutator sharedPoints, + int value, String operationId) { + CompletableFuture completion = new CompletableFuture<>(); + String uuid = getUUID(); + String journalPointsPath = "Points"; + ReplayPointKey replayKey = new ReplayPointKey(plugin, operationId, uuid, journalPointsPath, value); + CompletableFuture existing = IN_FLIGHT_POINT_REPLAYS.putIfAbsent(replayKey, completion); + if (existing != null) return existing; + completion.whenComplete((ignored, failure) -> IN_FLIGHT_POINT_REPLAYS.remove(replayKey, completion)); + Player player = getPlayer(); + String claimOwner = UUID.randomUUID().toString(); + try { + plugin.getTimer().execute(() -> { + try { + SharedPointAdditionJournal.HookClaim claim = sharedPoints.claimPointAdditionHook(operationId, uuid, + journalPointsPath, value, claimOwner); + if (claim.completed()) { + completion.complete(claim.total()); + return; + } + if (!claim.claimed()) { + if (claim.requiresReconciliation()) { + reportIndeterminateSharedPointAddition(sharedPoints, operationId, uuid, journalPointsPath, value, + claimOwner, completion, null); + } else { + completion.completeExceptionally(new IllegalStateException( + "Shared MySQL point addition is already being confirmed")); + } + return; + } + BukkitCompletionScheduler.run(plugin, player, + () -> completePerServerPointAddition(sharedPoints, value, operationId, uuid, journalPointsPath, + claimOwner, completion), + () -> releaseUnstartedSharedPointAddition(sharedPoints, operationId, uuid, journalPointsPath, value, + claimOwner, completion)); + } catch (Throwable failure) { + completion.completeExceptionally(failure); + } + }); + } catch (RuntimeException rejected) { + plugin.debug(rejected); + completion.completeExceptionally(rejected); + } + return completion; + } + + private void completePerServerPointAddition(SharedMysqlPointMutator sharedPoints, int value, String operationId, + String uuid, String journalPointsPath, String claimOwner, CompletableFuture completion) { + boolean hookStarted = false; + try { + String localPointsPath; + Integer adjusted; + synchronized (this) { + // A pre-hook mode flip proves no listener has run, so release the exact + // claim rather than allowing a shared-points retry to see a live owner. + if (sharedPoints.applies()) { + releaseUnstartedSharedPointAddition(sharedPoints, operationId, uuid, journalPointsPath, value, + claimOwner, completion); + return; + } + PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value, false); + hookStarted = true; + Bukkit.getPluginManager().callEvent(event); + // Capture the local key and listener-adjusted delta while still on the + // entity lane. The persistence transaction reads and credits that key. + localPointsPath = getPointsPath(); + adjusted = event.isCancelled() ? null : Integer.valueOf(event.getPoints()); + } + String capturedLocalPointsPath = localPointsPath; + Integer capturedAdjusted = adjusted; + try { + plugin.getTimer().execute(() -> { + try { + // The receive hook already ran. Do not write to a potentially shared + // column if a reload changed the mode or local key after that hook. + if (sharedPoints.applies() || !capturedLocalPointsPath.equals(getPointsPath())) { + reportIndeterminateSharedPointAddition(sharedPoints, operationId, uuid, journalPointsPath, + value, claimOwner, completion, new IllegalStateException( + "Point storage mode changed after the PerServerPoints receive hook")); + return; + } + // This transaction credits the local column and completes the shared + // journal together. It bypasses legacy UserData writes, which cannot + // report a durable MySQL failure to this caller. + SharedMysqlPointMutator.AddResult result = sharedPoints.settlePerServerClaimedPointAddition( + this, operationId, uuid, journalPointsPath, capturedLocalPointsPath, value, claimOwner, + capturedAdjusted); + if (result.success()) completion.complete(result.total()); + else reportIndeterminateSharedPointAddition(sharedPoints, operationId, uuid, journalPointsPath, + value, claimOwner, completion, new IllegalStateException( + "Unable to persist PerServerPoints reward completion")); + } catch (Throwable failure) { + reportIndeterminateSharedPointAddition(sharedPoints, operationId, uuid, journalPointsPath, value, + claimOwner, completion, failure); + } + }); + } catch (RuntimeException rejected) { + reportIndeterminateSharedPointAddition(sharedPoints, operationId, uuid, journalPointsPath, value, + claimOwner, completion, rejected); + } + } catch (Throwable failure) { + if (hookStarted) { + reportIndeterminateSharedPointAddition(sharedPoints, operationId, uuid, journalPointsPath, value, + claimOwner, completion, failure); + } else { + releaseUnstartedSharedPointAddition(sharedPoints, operationId, uuid, journalPointsPath, value, + claimOwner, completion); + } + } + } + + /** + * Claims the durable reward-operation journal before returning to the Bukkit + * lane for the receive event. JDBC therefore never blocks that lane, and the + * claim prevents another shared-MySQL backend from invoking listeners again. + */ + private CompletionStage addSharedPointsWithReplayLookup(SharedMysqlPointMutator sharedPoints, int value, + String operationId) { + CompletableFuture completion = new CompletableFuture<>(); + String uuid = getUUID(); + String pointsPath = getPointsPath(); + ReplayPointKey replayKey = new ReplayPointKey(plugin, operationId, uuid, pointsPath, value); + CompletableFuture existing = IN_FLIGHT_POINT_REPLAYS.putIfAbsent(replayKey, completion); + if (existing != null) return existing; + completion.whenComplete((ignored, failure) -> IN_FLIGHT_POINT_REPLAYS.remove(replayKey, completion)); + // Entity lookup is Bukkit-owned, so retain the player before persistence + // submission just as callback-based point mutations do. + Player player = getPlayer(); + String claimOwner = java.util.UUID.randomUUID().toString(); + try { + plugin.getTimer().execute(() -> { + try { + SharedPointAdditionJournal.HookClaim claim = sharedPoints.claimPointAdditionHook(operationId, uuid, + pointsPath, value, claimOwner); + if (claim.completed()) { + completion.complete(claim.total()); + return; + } + if (!claim.claimed()) { + if (claim.requiresReconciliation()) { + reportIndeterminateSharedPointAddition(sharedPoints, operationId, uuid, pointsPath, value, + claimOwner, completion, null); + } else { + completion.completeExceptionally(new IllegalStateException( + "Shared MySQL point addition is already being confirmed")); + } + return; + } + BukkitCompletionScheduler.run(plugin, player, + () -> { + try { + submitSharedPointAdditionAfterReplayLookup(sharedPoints, value, operationId, uuid, + pointsPath, claimOwner, completion); + } catch (Throwable failure) { + reportIndeterminateSharedPointAddition(sharedPoints, operationId, uuid, pointsPath, value, + claimOwner, completion, failure); + } + }, () -> releaseUnstartedSharedPointAddition(sharedPoints, operationId, uuid, pointsPath, value, + claimOwner, completion)); + } catch (Throwable failure) { + completion.completeExceptionally(failure); + } + }); + } catch (RuntimeException rejected) { + plugin.debug(rejected); + completion.completeExceptionally(rejected); + } + return completion; + } + + /** + * A receive hook can have arbitrary effects outside the points table. Once it + * was claimed, a scheduler or lifecycle failure must therefore remain a + * durable reconciliation task instead of being retried on another backend. + * This method never performs JDBC on the Bukkit/entity lane. + */ + private void reportIndeterminateSharedPointAddition(SharedMysqlPointMutator sharedPoints, String operationId, + String uuid, String pointsPath, int requestedAmount, String claimOwner, CompletableFuture completion, + Throwable cause) { + String message = "Shared MySQL point addition " + operationId + + " requires manual reconciliation; its receive hook will not be replayed"; + plugin.getLogger().severe(message); + if (cause != null) plugin.debug(cause); + completion.completeExceptionally(new IllegalStateException(message, cause)); + Runnable persist = () -> { + try { + sharedPoints.markPointAdditionIndeterminate(operationId, uuid, pointsPath, requestedAmount, claimOwner); + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to persist indeterminate shared MySQL point addition " + operationId + + "; the existing claim remains for manual reconciliation"); + plugin.debug(failure); + } + }; + try { + plugin.getTimer().execute(persist); + } catch (RuntimeException rejected) { + plugin.debug(rejected); + try { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, persist); + } catch (RuntimeException asyncRejected) { + plugin.debug(asyncRejected); + // HOOK_STARTED is itself recognized as reconciliation-required after a + // restart, so rejected lifecycle executors cannot make a retry unsafe. + } + } + } + + /** + * A rejected scheduler never invoked the receive hook. Queue release of that + * exact durable claim on a database-safe worker. Scheduler completions can run + * on a Folia entity lane, so this callback must never open a JDBC connection + * directly. If shutdown rejects every database-safe worker, HOOK_STARTED is + * deliberately retained for reconciliation instead of making a retry unsafe. + * Started hooks still use the indeterminate path above. + */ + private void releaseUnstartedSharedPointAddition(SharedMysqlPointMutator sharedPoints, String operationId, + String uuid, String pointsPath, int requestedAmount, String claimOwner, CompletableFuture completion) { + Runnable release = () -> { + try { + sharedPoints.releaseUnstartedPointAdditionHook(operationId, uuid, pointsPath, requestedAmount, claimOwner); + completion.completeExceptionally( + new IllegalStateException("Shared MySQL point addition was not started; retry is safe")); + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to release unstarted shared MySQL point addition " + operationId + + "; retaining its claim for manual reconciliation"); + plugin.debug(failure); + completion.completeExceptionally( + new IllegalStateException("Unable to release unstarted shared MySQL point addition", failure)); + } + }; + try { + plugin.getTimer().execute(release); + } catch (RuntimeException persistenceRejected) { + plugin.debug(persistenceRejected); + try { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, release); + } catch (RuntimeException asyncRejected) { + plugin.debug(asyncRejected); + plugin.getLogger().severe("Unable to schedule release of unstarted shared MySQL point addition " + + operationId + "; retaining its claim for manual reconciliation"); + completion.completeExceptionally(new IllegalStateException( + "Unable to schedule release of unstarted shared MySQL point addition; retry requires reconciliation", + asyncRejected)); + } + } + } + + private record ReplayPointKey(VotingPluginMain plugin, String operationId, String uuid, String pointsPath, + int requestedAmount) { } + + private void submitSharedPointAdditionAfterReplayLookup(SharedMysqlPointMutator sharedPoints, int value, + String operationId, String uuid, String pointsPath, String claimOwner, CompletableFuture completion) { + PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value, false); + Bukkit.getPluginManager().callEvent(event); + try { + plugin.getTimer().execute(() -> { + try { + SharedMysqlPointMutator.AddResult result = sharedPoints.settleClaimedPointAddition(this, operationId, + uuid, pointsPath, value, claimOwner, + event.isCancelled() ? null : Integer.valueOf(event.getPoints())); + if (result.success()) completion.complete(result.total()); + else reportIndeterminateSharedPointAddition(sharedPoints, operationId, uuid, pointsPath, value, + claimOwner, completion, new IllegalStateException("Unable to persist shared MySQL points")); + } catch (Throwable failure) { + reportIndeterminateSharedPointAddition(sharedPoints, operationId, uuid, pointsPath, value, + claimOwner, completion, failure); + } + }); + } catch (RuntimeException rejected) { + reportIndeterminateSharedPointAddition(sharedPoints, operationId, uuid, pointsPath, value, claimOwner, + completion, rejected); + } + } + + /** Retires an idempotent point-addition record after its replay checkpoint is durable. */ + public CompletionStage acknowledgeStorageAwarePointOperation(String operationId) { + return new SharedMysqlPointMutator(plugin).acknowledgePointAddition(operationId); + } + + /** + * Adds points and reports the committed total after shared-MySQL persistence + * completes. The callback runs on the user's Bukkit/entity lane. + */ + public void addPointsStorageAware(int value, Consumer completion) { + addPointsStorageAware(value, (success, total) -> completion.accept(total)); + } + + /** + * Applies one shared-MySQL add operation for a collection of users on a + * single persistence task. This keeps administrative bulk commands from + * flooding the bounded persistence executor with one task per user. + * + * @param plugin plugin owning the persistence executor + * @param users users to update + * @param value points delta + * @param completion callback invoked on the Bukkit lane for every user + */ + public static void addPointsStorageAware(VotingPluginMain plugin, List users, int value, + BiConsumer completion) { + addPointsStorageAware(plugin, users, value, "admin-bulk-points/" + UUID.randomUUID(), completion); + } + + public static void addPointsStorageAware(VotingPluginMain plugin, List users, int value, + String batchOperationId, BiConsumer completion) { + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + if (!sharedPoints.applies()) { + for (VotingPluginUser user : users) { + user.userDataFetechMode(UserDataFetchMode.NO_CACHE); + user.addPointsStorageAware(value, (success, ignored) -> completion.accept(user, success)); + } + return; + } + java.util.IdentityHashMap eventAmounts = new java.util.IdentityHashMap<>(); + for (VotingPluginUser user : users) { + PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(user, value); + Bukkit.getPluginManager().callEvent(event); + if (!event.isCancelled()) eventAmounts.put(user, event.getPoints()); + } + bulkSharedMysqlMutation(plugin, users, completion, + (mutator, user) -> { + Integer amount = eventAmounts.get(user); + if (amount == null) return false; + // Every member of a bulk operation gets its own durable id. A + // connection loss after commit must be confirmable per player; + // sharing one id would make a retry unable to distinguish which + // rows were already credited. + String operationId = bulkPointOperationId("admin-bulk-points/", batchOperationId, user.getUUID()); + boolean success = mutator.addCommitted(user, amount, operationId).success(); + if (success) mutator.acknowledgePointAdditionNow(operationId); + return success; + }, + (user, done) -> done.accept(false), false); + } + + /** + * Applies one shared-MySQL absolute point update for a collection of users + * on a single persistence task. + * + * @param plugin plugin owning the persistence executor + * @param users users to update + * @param value new point total + * @param completion callback invoked on the Bukkit lane for every user + */ + public static void setPointsStorageAware(VotingPluginMain plugin, List users, int value, + BiConsumer completion) { + bulkSharedMysqlMutation(plugin, users, completion, + (mutator, user) -> mutator.setCommitted(user, value), + (user, done) -> { + user.setPoints(value); + done.accept(true); + }, false); + } + + /** + * Sets points without performing shared-MySQL I/O on the caller thread and + * reports whether the durable update affected the user row. + * + * @param value new point total + * @param completion completion callback on the user's Bukkit/entity lane + */ + public void setPointsStorageAware(int value, Consumer completion) { + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + if (!sharedPoints.applies()) { + setPoints(value); + completion.accept(true); + return; + } + Player player = getPlayer(); + try { + plugin.getTimer().execute(() -> { + boolean updated = sharedPoints.setCommitted(this, value); + BukkitCompletionScheduler.run(plugin, player, () -> completion.accept(updated)); + }); + } catch (RuntimeException rejected) { + plugin.debug(rejected); + BukkitCompletionScheduler.run(plugin, player, () -> completion.accept(false)); + } + } + + /** + * Applies one shared-MySQL conditional removal for a collection of users on + * a single persistence task. + * + * @param plugin plugin owning the persistence executor + * @param users users to update + * @param value points to remove + * @param completion callback invoked on the Bukkit lane for every user + */ + public static void removePointsStorageAware(VotingPluginMain plugin, List users, int value, + BiConsumer completion) { + removePointsStorageAware(plugin, users, value, "admin-bulk-remove/" + UUID.randomUUID(), completion); + } + + public static void removePointsStorageAware(VotingPluginMain plugin, List users, int value, + String batchOperationId, BiConsumer completion) { + bulkSharedMysqlMutation(plugin, users, completion, + (mutator, user) -> { + String operationId = bulkPointOperationId("admin-bulk-remove/", batchOperationId, user.getUUID()); + boolean success = mutator.removeCommitted(user, value, operationId).success(); + if (success) mutator.acknowledgePointAdditionNow(operationId); + return success; + }, + (user, done) -> user.removePoints(value, done), true); + } + + static String bulkPointOperationId(String prefix, String batchOperationId, String userId) { + UUID digest = UUID.nameUUIDFromBytes((batchOperationId + "/" + userId) + .getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return prefix + digest; + } + + @FunctionalInterface + private interface SharedPointMutation { + boolean apply(SharedMysqlPointMutator mutator, VotingPluginUser user); + } + + @FunctionalInterface + private interface OrdinaryPointMutation { + void apply(VotingPluginUser user, Consumer completion); + } + + private static void bulkSharedMysqlMutation(VotingPluginMain plugin, List users, + BiConsumer completion, SharedPointMutation sharedMutation, + OrdinaryPointMutation ordinaryMutation, boolean authoritativeReadRequired) { + if (users.isEmpty()) return; + if (!new SharedMysqlPointMutator(plugin).applies()) { + for (VotingPluginUser user : users) { + if (authoritativeReadRequired) user.userDataFetechMode(UserDataFetchMode.NO_CACHE); + ordinaryMutation.apply(user, success -> completion.accept(user, success)); + } + return; + } + List players = new ArrayList<>(users.size()); + for (VotingPluginUser user : users) { + players.add(user.getPlayer()); + } + submitSharedMysqlChunk(plugin, users, players, 0, completion, sharedMutation); + } + + private static void submitSharedMysqlChunk(VotingPluginMain plugin, List users, List players, + int start, + BiConsumer completion, SharedPointMutation sharedMutation) { + int end = Math.min(start + BULK_POINT_BATCH_SIZE, users.size()); + Runnable persistenceWork = () -> { + boolean[] results = new boolean[end - start]; + SharedMysqlPointMutator mutator = new SharedMysqlPointMutator(plugin); + for (int index = start; index < end; index++) { + try { + results[index - start] = sharedMutation.apply(mutator, users.get(index)); + } catch (RuntimeException failure) { + plugin.debug(failure); + } + } + scheduleBulkCompletions(plugin, users, players, start, end, results, completion); + if (end < users.size()) { + submitSharedMysqlChunk(plugin, users, players, end, completion, sharedMutation); + } + }; + try { + plugin.getTimer().execute(persistenceWork); + } catch (RuntimeException rejected) { + plugin.debug(rejected); + scheduleBulkCompletions(plugin, users, players, start, users.size(), null, completion); + } + } + + private static void scheduleBulkCompletions(VotingPluginMain plugin, List users, List players, + int start, + int end, boolean[] results, BiConsumer completion) { + for (int index = start; index < end; index++) { + VotingPluginUser user = users.get(index); + Player player = players.get(index); + boolean success = results != null && results[index - start]; + try { + BukkitCompletionScheduler.run(plugin, player, () -> { + try { + completion.accept(user, success); + } catch (RuntimeException failure) { + plugin.debug(failure); + } + }); + } catch (RuntimeException schedulingFailure) { + plugin.debug(schedulingFailure); + } + } + } + + /** Adds points and reports both persistence success and the committed total. */ + public synchronized void addPointsStorageAware(int value, BiConsumer completion) { + PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value); + Bukkit.getPluginManager().callEvent(event); + if (event.isCancelled()) { + completion.accept(false, getPoints()); + return; + } + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + if (!sharedPoints.applies()) { + int newTotal = getPoints() + event.getPoints(); + setPoints(newTotal, false); + completion.accept(true, newTotal); + return; + } + Player player = getPlayer(); + try { + plugin.getTimer().execute(() -> { + SharedMysqlPointMutator.AddResult result = sharedPoints.addCommitted(this, event.getPoints()); + BukkitCompletionScheduler.run(plugin, player, + () -> completion.accept(result.success(), result.total())); + }); + } catch (RuntimeException rejected) { + plugin.debug(rejected); + BukkitCompletionScheduler.run(plugin, player, () -> completion.accept(false, 0)); + } + } /** * Adds one to the total votes. @@ -1038,9 +1666,17 @@ public int getVotePartyVotes() { * @param identifier the identifier for the vote shop * @return the vote shop identifier limit */ - public int getVoteShopIdentifierLimit(String identifier) { - return getData().getInt("VoteShopLimit" + identifier); - } + public int getVoteShopIdentifierLimit(String identifier) { + String path = "VoteShopLimit" + identifier; + if (usesSharedMysqlPoints()) { + // Placeholder and GUI rendering may run on the Bukkit main thread or a + // Folia-owned tick thread. This accessor therefore never performs JDBC for + // shared MySQL; purchases remain protected by their atomic reservation. + return getData().getInt(path, + isCached() ? UserDataFetchMode.CACHE_ONLY : UserDataFetchMode.TEMP_ONLY); + } + return getData().getInt(path); + } /** * Gets the weekly total votes. @@ -1300,28 +1936,102 @@ public void playerVote(VoteSite voteSite, boolean online, boolean bungee) { * @param points the number of points to remove * @return true if the points were removed, false otherwise */ - public boolean removePoints(int points) { - if (getPoints() >= points) { - setPoints(getPoints() - points); - return true; - } - return false; - } - - /** - * Removes points from the user asynchronously. - * - * @param points the number of points to remove - * @param async whether to remove the points asynchronously - * @return true if the points were removed, false otherwise - */ - public boolean removePoints(int points, boolean async) { - if (getPoints() >= points) { - setPoints(getPoints() - points, async); - return true; - } - return false; - } + public boolean removePoints(int points) { + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + if (sharedPoints.applies()) return sharedPoints.remove(this, points); + if (getPoints() >= points) { + setPoints(getPoints() - points); + return true; + } + return false; + } + + /** + * Removes points from the user asynchronously. + * + * @param points the number of points to remove + * @param async whether to remove the points asynchronously + * @return true if the points were removed, false otherwise + */ + public boolean removePoints(int points, boolean async) { + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + if (sharedPoints.applies()) return sharedPoints.remove(this, points, async); + if (getPoints() >= points) { + setPoints(getPoints() - points, async); + return true; + } + return false; + } + + /** Removes points without performing shared-database I/O on the caller thread. */ + public void removePoints(int points, Consumer completion) { + removePointsOutcome(points, outcome -> completion.accept(outcome == SharedMysqlPointMutator.MutationOutcome.CONFIRMED)); + } + + void removePointsOutcome(int points, Consumer completion) { + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + if (!sharedPoints.applies()) { + completion.accept(removePoints(points) ? SharedMysqlPointMutator.MutationOutcome.CONFIRMED + : SharedMysqlPointMutator.MutationOutcome.REJECTED); + return; + } + Player player = getPlayer(); + try { + String operationId = "remove-points/" + UUID.randomUUID(); + plugin.getTimer().execute(() -> { + SharedMysqlPointMutator.AddResult result = sharedPoints.removeCommitted(this, points, operationId); + if (result.outcome() == SharedMysqlPointMutator.MutationOutcome.CONFIRMED) { + sharedPoints.acknowledgePointAdditionNow(operationId); + } + BukkitCompletionScheduler.run(plugin, player, () -> completion.accept(result.outcome())); + }); + } catch (RuntimeException rejected) { + plugin.debug(rejected); + BukkitCompletionScheduler.run(plugin, player, + () -> completion.accept(SharedMysqlPointMutator.MutationOutcome.INDETERMINATE)); + } + } + + /** + * Atomically transfers points to another user when points are shared through + * MySQL, reporting completion on the Bukkit thread. + * + * @param target recipient + * @param points positive number of points + * @param completion whether the transfer completed + */ + public void transferPoints(VotingPluginUser target, int points, Consumer completion) { + transferPointsWithResult(target, points, result -> completion.accept(completesLegacyTransfer(result))); + } + + /** + * Legacy callers must not retry an indeterminate transfer: its approval hook + * has run and its durable journal row is retained for reconciliation. + */ + static boolean completesLegacyTransfer(PointTransferResult result) { + return result == PointTransferResult.SUCCESS || result == PointTransferResult.PENDING_CONFIRMATION; + } + + /** + * Transfers points and reports whether a failed transfer was caused by a + * conditional debit or by cancellation/availability. + */ + public void transferPointsWithResult(VotingPluginUser target, int points, Consumer completion) { + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + if (sharedPoints.applies()) { + sharedPoints.transferWithBukkitApproval(this, target, points, ignored -> { + PlayerReceivePointsEvent receiveEvent = new PlayerReceivePointsEvent(target, points, false); + Bukkit.getPluginManager().callEvent(receiveEvent); + return receiveEvent.isCancelled() ? null : receiveEvent.getPoints(); + }, completion); + return; + } + boolean transferred = removePoints(points); + if (transferred) { + target.addPoints(points); + } + completion.accept(transferred ? PointTransferResult.SUCCESS : PointTransferResult.INSUFFICIENT_POINTS); + } /** * Resets the last voted time for all vote sites. @@ -1589,8 +2299,13 @@ public void setOfflineVotes(ArrayList offlineVotes) { * * @param value the number of points */ - public void setPoints(int value) { - getUserData().setInt(getPointsPath(), value, false); + public void setPoints(int value) { + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + if (sharedPoints.applies()) { + sharedPoints.set(this, value, false); + } else { + getUserData().setInt(getPointsPath(), value, false); + } } /** @@ -1599,8 +2314,13 @@ public void setPoints(int value) { * @param value the number of points * @param async whether to set the points asynchronously */ - public void setPoints(int value, boolean async) { - getUserData().setInt(getPointsPath(), value, false, async); + public void setPoints(int value, boolean async) { + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + if (sharedPoints.applies()) { + sharedPoints.set(this, value, async); + } else { + getUserData().setInt(getPointsPath(), value, false, async); + } } /** @@ -1689,9 +2409,21 @@ public void setVotePartyVotes(int value) { * @param identifier the identifier for the vote shop * @param value the limit to set */ - public void setVoteShopIdentifierLimit(String identifier, int value) { - getData().setInt("VoteShopLimit" + identifier, value); - } + public void setVoteShopIdentifierLimit(String identifier, int value) { + String path = "VoteShopLimit" + identifier; + // VoteShopLimit columns are not server-suffixed, even when points are. On + // MySQL, always make their writes direct: an asynchronous absolute cache + // write can otherwise land after a journalled reset (or a PerServerPoints + // mode change) and restore a stale period's limit. Non-MySQL storage keeps + // its established queued-write behavior. + boolean queue = plugin == null || !UserStorage.MYSQL.equals(plugin.getStorageType()); + getData().setInt(path, value, queue); + } + + private boolean usesSharedMysqlPoints() { + return plugin != null && UserStorage.MYSQL.equals(plugin.getStorageType()) + && !plugin.getBungeeSettings().isPerServerPoints(); + } /** * Sets the weekly total votes. @@ -1986,8 +2718,22 @@ public String getVoteStreakState(String columnName) { return getData().getString(columnName); } - public void setVoteStreakState(String columnName, String value) { - getData().setString(columnName, value); - } - -} + public void setVoteStreakState(String columnName, String value) { + getData().setString(columnName, value); + } + + /** + * An asynchronous shared-MySQL point addition exposes a predicted value until + * its persistence task commits. A generic cache clear may dump the cache + * first, so remove only that still-current prediction before delegating to the + * normal flush-and-clear lifecycle. + */ + @Override + public void clearCache() { + if (!isCached()) return; + UserDataCache cache = getCache(); + SharedMysqlCacheReconciler.discardOptimisticPoint(cache, getPointsPath()); + cache.clearCache(); + } + +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitCompletionScheduler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitCompletionScheduler.java new file mode 100644 index 0000000000..f5135b76a2 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitCompletionScheduler.java @@ -0,0 +1,79 @@ +package com.bencodez.votingplugin.util; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.bukkit.entity.Player; + +import com.bencodez.simpleapi.folialib.enums.EntityTaskResult; +import com.bencodez.votingplugin.VotingPluginMain; + +/** Schedules exactly-once completion work with an entity-retirement fallback. */ +public final class BukkitCompletionScheduler { + private BukkitCompletionScheduler() { + } + + public static void run(VotingPluginMain plugin, Player player, Runnable task) { + run(plugin, player, task, () -> { }); + } + + /** + * Schedules completion work and invokes {@code rejected} only after every + * entity/global fallback was rejected before the task began. Callers whose + * 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) { + AtomicBoolean executed = new AtomicBoolean(); + Runnable once = () -> { + if (executed.compareAndSet(false, true)) task.run(); + }; + if (player == null) { + runGlobal(plugin, once, rejected); + return; + } + AtomicBoolean fallbackSubmitted = new AtomicBoolean(); + Runnable fallback = () -> { + if (fallbackSubmitted.compareAndSet(false, true)) runGlobal(plugin, once, rejected); + }; + try { + if (plugin.getBukkitScheduler().getFoliaLib() == null) { + runLegacyEntity(plugin, player, once, fallback); + return; + } + CompletableFuture result = plugin.getBukkitScheduler().getFoliaLib().getImpl() + .runAtEntityWithFallback(player, ignored -> once.run(), fallback); + result.whenComplete((status, failure) -> { + // ENTITY_RETIRED invokes fallback itself. A scheduler that was already + // retired returns SCHEDULER_RETIRED without invoking it. + if (failure != null || status != EntityTaskResult.SUCCESS) { + fallback.run(); + } 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); + } + }); + } catch (RuntimeException schedulingFailure) { + plugin.debug(schedulingFailure); + fallback.run(); + } + } + + private static void runLegacyEntity(VotingPluginMain plugin, Player player, Runnable task, Runnable fallback) { + try { + plugin.getBukkitScheduler().runTask(plugin, task, player); + } catch (RuntimeException legacyFailure) { + plugin.debug(legacyFailure); + fallback.run(); + } + } + + private static void runGlobal(VotingPluginMain plugin, Runnable task, Runnable rejected) { + try { + plugin.getBukkitScheduler().runTask(plugin, task); + } catch (RuntimeException schedulingFailure) { + plugin.debug(schedulingFailure); + rejected.run(); + } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/VoteShopManager.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/VoteShopManager.java index ba591ed3b7..f854f823af 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/VoteShopManager.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/VoteShopManager.java @@ -1,5 +1,8 @@ package com.bencodez.votingplugin.voteshop; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + import org.bukkit.entity.Player; import com.bencodez.votingplugin.VotingPluginMain; @@ -36,6 +39,32 @@ public class VoteShopManager { public VoteShopManager(VotingPluginMain plugin) { this.plugin = plugin; reload(); + startSharedPurchaseRecovery(); + } + + /** + * Recover stale durable shared-MySQL purchase reservations even when no player + * opens the vote shop after a restart. The inherited persistence executor is + * shut down with the plugin, so this task has no independent lifecycle. + */ + private void startSharedPurchaseRecovery() { + scheduleSharedPurchaseRecovery(plugin); + } + + static void scheduleSharedPurchaseRecovery(VotingPluginMain plugin) { + plugin.getTimer().execute(() -> recoverSharedMysqlPurchasesSafely(plugin)); + plugin.getTimer().scheduleWithFixedDelay( + () -> recoverSharedMysqlPurchasesSafely(plugin), 1L, 1L, TimeUnit.MINUTES); + } + + private static void recoverSharedMysqlPurchasesSafely(VotingPluginMain plugin) { + try { + VoteShopPurchaseService.recoverSharedMysqlPurchases(plugin); + } catch (RuntimeException failure) { + plugin.getLogger().severe("Unable to recover shared MySQL vote shop purchases: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + } } /** @@ -91,8 +120,15 @@ public VoteShopCategory getCategory(String categoryId) { * @param player the player * @param user the user * @param item the item - * @return the result + * @param completion completion callback on the Bukkit thread */ + public void purchase(Player player, VotingPluginUser user, VoteShopItem item, + Consumer completion) { + purchaseService.purchase(player, user, item, completion); + } + + /** @deprecated use the callback overload for the final shared-storage result. */ + @Deprecated public VoteShopPurchaseResult purchase(Player player, VotingPluginUser user, VoteShopItem item) { return purchaseService.purchase(player, user, item); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlCompensationStore.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlCompensationStore.java new file mode 100644 index 0000000000..4e8de55a8a --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlCompensationStore.java @@ -0,0 +1,107 @@ +package com.bencodez.votingplugin.voteshop.service; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; + +import com.bencodez.votingplugin.util.DurableFiles; + +/** Restart-safe local proof that a rejected reward callback is safe to refund. */ +final class SharedMysqlCompensationStore { + private static final String DIRECTORY = ".voteshop-compensations"; + private static final String SUFFIX = ".pending"; + private static final int MAX_PURCHASE_ID_BYTES = 256; + private static final int RECOVERY_BATCH_SIZE = 128; + + private final Path directory; + + SharedMysqlCompensationStore(Path dataDirectory) { + this.directory = dataDirectory.toAbsolutePath().normalize().resolve(DIRECTORY); + } + + void record(String purchaseId) throws IOException { + byte[] contents = contents(purchaseId); + ensureSafeDirectory(); + Path target = marker(purchaseId); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) throw unsafe(); + return; + } + Path temporary = Files.createTempFile(directory, ".compensation-", ".tmp"); + try { + Files.write(temporary, contents, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + DurableFiles.forceFile(temporary); + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException unsupported) { + Files.move(temporary, target); + } + DurableFiles.forceDirectory(directory); + } finally { + Files.deleteIfExists(temporary); + } + } + + List loadBatch() throws IOException { + if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) return List.of(); + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(directory)) throw unsafe(); + List purchases = new ArrayList<>(); + try (DirectoryStream entries = Files.newDirectoryStream(directory, "*" + SUFFIX)) { + for (Path entry : entries) { + if (purchases.size() == RECOVERY_BATCH_SIZE) break; + if (!Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(entry) + || Files.size(entry) > MAX_PURCHASE_ID_BYTES) continue; + String purchaseId = Files.readString(entry, StandardCharsets.UTF_8); + if (marker(purchaseId).equals(entry.toAbsolutePath().normalize())) purchases.add(purchaseId); + } + } + return purchases; + } + + void remove(String purchaseId) throws IOException { + DurableFiles.deleteIfExists(marker(purchaseId)); + } + + private void ensureSafeDirectory() throws IOException { + if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(directory)) throw unsafe(); + return; + } + Files.createDirectories(directory); + DurableFiles.forceDirectory(directory.getParent()); + } + + private Path marker(String purchaseId) throws IOException { + return directory.resolve(hash(contents(purchaseId)) + SUFFIX).toAbsolutePath().normalize(); + } + + private static byte[] contents(String purchaseId) throws IOException { + if (purchaseId == null || purchaseId.isBlank()) throw unsafe(); + byte[] contents = purchaseId.getBytes(StandardCharsets.UTF_8); + if (contents.length > MAX_PURCHASE_ID_BYTES) throw unsafe(); + return contents; + } + + private static String hash(byte[] value) throws IOException { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); + } catch (NoSuchAlgorithmException impossible) { + throw new IOException("SHA-256 is unavailable", impossible); + } + } + + private static IOException unsafe() { + return new IOException("Unsafe vote shop compensation marker"); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java new file mode 100644 index 0000000000..28eef10dbc --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -0,0 +1,715 @@ +package com.bencodez.votingplugin.voteshop.service; + +import java.lang.ref.ReferenceQueue; +import java.lang.ref.WeakReference; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; +import com.bencodez.simpleapi.sql.mysql.DbType; + +/** + * Durable shared-MySQL vote-shop debit state. + * + *

The reservation and conditional debit commit in one transaction. A reward + * must claim the reservation immediately before invoking the reward hook. This + * lets recovery refund only work which never reached that hook; a claimed row is + * deliberately retained for reconciliation because a reward executor can have + * arbitrary, non-idempotent side effects.

+ */ +final class SharedMysqlPurchaseJournal { + private static final String PENDING = "PENDING"; + private static final String HOOK_STARTED = "HOOK_STARTED"; + private static final String COMPENSATING = "COMPENSATING"; + private static final String COMPLETED = "COMPLETED"; + private static final String REFUNDED = "REFUNDED"; + static final String NO_LIMIT_RESET_GENERATION = "NONE"; + static final long PENDING_RECOVERY_AGE_MILLIS = TimeUnit.MINUTES.toMillis(5); + static final long TERMINAL_RETENTION_MILLIS = TimeUnit.DAYS.toMillis(7); + private static final int RECOVERY_BATCH_SIZE = 32; + private static final int CLEANUP_BATCH_SIZE = 100; + /* PostgreSQL permits 63 bytes and is the tighter supported database limit. */ + private static final int MAX_IDENTIFIER_BYTES = 63; + private static final String JOURNAL_SUFFIX = "_VoteShopPurchases"; + private static final String EPOCH_SUFFIX = "_VoteShopLimitEpochs"; + private static final String HASHED_TABLE_PREFIX = "vp_vsp_"; + private static final String HASHED_EPOCH_TABLE_PREFIX = "vp_vse_"; + private static final int HASHED_TABLE_HEX_LENGTH = 32; + + private static final ReferenceQueue INITIALIZED_QUEUE = new ReferenceQueue<>(); + private static final Set INITIALIZED = new HashSet<>(); + + private final MySQL table; + private final String journalTable; + private final String epochTable; + + SharedMysqlPurchaseJournal(MySQL table, boolean initializeSchema) throws SQLException { + this.table = table; + journalTable = journalTableName(table.getTableName()); + epochTable = epochTableName(table.getTableName()); + if (initializeSchema) ensureSchema(); + } + + /** + * Keeps the historic auxiliary-table name where it is portable, while using + * a fixed, collision-resistant name for source tables which would exceed the + * PostgreSQL identifier limit. + */ + static String journalTableName(String sourceTable) { + return auxiliaryTableName(sourceTable, JOURNAL_SUFFIX, HASHED_TABLE_PREFIX); + } + + static String epochTableName(String sourceTable) { + return auxiliaryTableName(sourceTable, EPOCH_SUFFIX, HASHED_EPOCH_TABLE_PREFIX); + } + + private static String auxiliaryTableName(String sourceTable, String suffix, String hashedPrefix) { + String legacyName = sourceTable + suffix; + if (legacyName.getBytes(StandardCharsets.UTF_8).length <= MAX_IDENTIFIER_BYTES) return legacyName; + return hashedPrefix + hash(sourceTable + '\0' + suffix).substring(0, HASHED_TABLE_HEX_LENGTH); + } + + private static String hash(String value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(digest.length * 2); + for (byte valueByte : digest) { + hex.append(Character.forDigit((valueByte >>> 4) & 0x0f, 16)); + hex.append(Character.forDigit(valueByte & 0x0f, 16)); + } + return hex.toString(); + } catch (NoSuchAlgorithmException failure) { + throw new IllegalStateException("SHA-256 is unavailable", failure); + } + } + + static SharedMysqlPurchaseJournal forTable(MySQL table) throws SQLException { + synchronized (INITIALIZED) { + expungeInitialized(); + for (IdentityWeakReference marker : INITIALIZED) { + if (marker.get() == table) return new SharedMysqlPurchaseJournal(table, false); + } + new SharedMysqlPurchaseJournal(table, true); + INITIALIZED.add(new IdentityWeakReference(table, INITIALIZED_QUEUE)); + return new SharedMysqlPurchaseJournal(table, false); + } + } + + private static void expungeInitialized() { + IdentityWeakReference cleared; + while ((cleared = (IdentityWeakReference) INITIALIZED_QUEUE.poll()) != null) { + INITIALIZED.remove(cleared); + } + for (Iterator iterator = INITIALIZED.iterator(); iterator.hasNext();) { + if (iterator.next().get() == null) iterator.remove(); + } + } + + /** Atomically records a pending purchase and conditionally charges it. */ + boolean reserve(String purchaseId, String uuid, String pointsColumn, String limitColumn, int cost, int limit, + String limitGeneration, long limitGenerationExpiresAt, long now) throws SQLException { + String insert = "INSERT INTO " + qiJournal() + " (" + qi("purchase_id") + ", " + qi("player_uuid") + + ", " + qi("points_column") + ", " + qi("limit_column") + ", " + qi("cost") + ", " + + qi("limit_value") + ", " + qi("limit_generation") + ", " + + qi("limit_generation_expires_at") + ", " + qi("limit_epoch") + ", " + qi("state") + + ", " + qi("created_at") + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + String points = qi(pointsColumn); + StringBuilder debit = new StringBuilder("UPDATE ").append(qi(table.getTableName())).append(" SET ") + .append(points).append(" = ").append(points).append(" - ?"); + if (limitColumn != null) { + debit.append(", ").append(qi(limitColumn)).append(" = COALESCE(").append(qi(limitColumn)) + .append(", 0) + 1"); + } + debit.append(" WHERE ").append(qi("uuid")).append(uuidCast()).append(" AND ").append(points) + .append(" >= ?"); + if (limitColumn != null) { + debit.append(" AND COALESCE(").append(qi(limitColumn)).append(", 0) < ?"); + } + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try { + Long limitEpoch = limitColumn == null ? null : lockLimitEpoch(connection, limitColumn); + try (PreparedStatement insertStatement = connection.prepareStatement(insert); + PreparedStatement debitStatement = connection.prepareStatement(debit.toString())) { + insertStatement.setString(1, purchaseId); + insertStatement.setString(2, uuid); + insertStatement.setString(3, pointsColumn); + insertStatement.setString(4, limitColumn); + insertStatement.setInt(5, cost); + if (limitColumn == null) insertStatement.setNull(6, java.sql.Types.INTEGER); + else insertStatement.setInt(6, limit); + if (limitGeneration == null) insertStatement.setNull(7, java.sql.Types.VARCHAR); + else insertStatement.setString(7, limitGeneration); + if (limitGenerationExpiresAt <= 0L) insertStatement.setNull(8, java.sql.Types.BIGINT); + else insertStatement.setLong(8, limitGenerationExpiresAt); + if (limitEpoch == null) insertStatement.setNull(9, java.sql.Types.BIGINT); + else insertStatement.setLong(9, limitEpoch.longValue()); + insertStatement.setString(10, PENDING); + insertStatement.setLong(11, now); + insertStatement.executeUpdate(); + + debitStatement.setInt(1, cost); + debitStatement.setString(2, uuid); + debitStatement.setInt(3, cost); + if (limitColumn != null) debitStatement.setInt(4, limit); + if (debitStatement.executeUpdate() != 1) { + rollback(connection); + return false; + } + return commitAndConfirm(connection, purchaseId, PENDING); + } + } catch (SQLException failure) { + rollback(connection); + throw failure; + } + } + } + + /** + * Wipes a resettable limit and advances its epoch while holding the same row + * that reservations lock before they debit. A reservation can therefore land + * wholly before or wholly after the reset, never in the wiped interval. + */ + void resetLimit(String limitColumn, String resetGeneration) throws SQLException { + if (!isSafeColumn(limitColumn)) throw new SQLException("Unsafe vote shop limit column"); + if (resetGeneration == null || resetGeneration.isEmpty() || resetGeneration.length() > 128) { + throw new SQLException("Invalid vote shop reset generation"); + } + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try { + EpochRow marker = lockLimitEpochRow(connection, limitColumn); + if (resetGeneration.equals(marker.lastResetGeneration())) { + rollback(connection); + return; + } + long oldEpoch = marker.epoch(); + if (oldEpoch == Long.MAX_VALUE) throw new SQLException("Vote shop limit epoch overflow"); + long expectedEpoch = oldEpoch + 1L; + try (PreparedStatement wipe = connection.prepareStatement("UPDATE " + qi(table.getTableName()) + + " SET " + qi(limitColumn) + " = 0"); + PreparedStatement advance = connection.prepareStatement("UPDATE " + qiEpoch() + " SET " + + qi("epoch") + " = ?, " + qi("last_reset_generation") + " = ? WHERE " + + qi("limit_column") + " = ?")) { + wipe.executeUpdate(); + advance.setLong(1, expectedEpoch); + advance.setString(2, resetGeneration); + advance.setString(3, limitColumn); + if (advance.executeUpdate() != 1) throw new SQLException("Vote shop limit epoch marker missing"); + } + try { + connection.commit(); + } catch (SQLException ambiguousCommit) { + closeQuietly(connection); + EpochRow confirmed = findLimitEpoch(limitColumn); + if (confirmed != null && resetGeneration.equals(confirmed.lastResetGeneration())) return; + throw ambiguousCommit; + } + } catch (SQLException failure) { + rollback(connection); + throw failure; + } + } + } + + /** + * A JDBC commit error does not prove that the database discarded the + * transaction. Close the possibly-broken handle before looking up the same + * id, which also keeps a one-connection pool from deadlocking itself. + */ + private boolean commitAndConfirm(Connection connection, String purchaseId, String expectedState) + throws SQLException { + try { + connection.commit(); + return true; + } catch (SQLException ambiguousCommit) { + closeQuietly(connection); + PurchaseRow row = find(purchaseId); + if (row != null && expectedState.equals(row.state())) return true; + throw ambiguousCommit; + } + } + + private PurchaseRow find(String purchaseId) throws SQLException { + String select = "SELECT " + qi("state") + " FROM " + qiJournal() + " WHERE " + qi("purchase_id") + + " = ?"; + try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(select)) { + statement.setString(1, purchaseId); + try (ResultSet result = statement.executeQuery()) { + return result.next() ? new PurchaseRow(result.getString(1)) : null; + } + } + } + + /** Claims a still-pending debit immediately before the external reward hook. */ + ClaimOutcome claimReward(String purchaseId, long startedAt) throws SQLException { + String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ?, " + qi("hook_started_at") + + " = ? WHERE " + qi("purchase_id") + " = ? AND " + qi("state") + " = ?"; + try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(update)) { + statement.setString(1, HOOK_STARTED); + statement.setLong(2, startedAt); + statement.setString(3, purchaseId); + statement.setString(4, PENDING); + try { + return statement.executeUpdate() == 1 ? ClaimOutcome.CLAIMED : ClaimOutcome.NOT_CLAIMED; + } catch (SQLException ambiguousUpdate) { + // An autocommit update can reach the database even when its acknowledgement + // does not reach this process. Release the suspect handle before confirming + // through a fresh connection, including with a one-connection pool. + closeQuietly(connection); + try { + PurchaseRow row = find(purchaseId); + if (row != null && HOOK_STARTED.equals(row.state())) return ClaimOutcome.CLAIMED; + if (row != null && PENDING.equals(row.state())) return ClaimOutcome.NOT_CLAIMED; + } catch (SQLException confirmationFailure) { + ambiguousUpdate.addSuppressed(confirmationFailure); + } + return ClaimOutcome.INDETERMINATE; + } + } + } + + void complete(String purchaseId) throws SQLException { + setTerminal(purchaseId, COMPLETED, 0L); + } + + /** Refunds only a debit whose reward hook has not started. */ + boolean refundPending(String purchaseId) throws SQLException { + return refundPending(purchaseId, System.currentTimeMillis()); + } + + boolean refundPending(String purchaseId, long now) throws SQLException { + return setTerminal(purchaseId, REFUNDED, now, PENDING); + } + + RefundedPurchase refundPendingDetails(String purchaseId, long now) throws SQLException { + return setTerminalDetails(purchaseId, REFUNDED, now, PENDING); + } + + /** + * Compensates a pending or claimed purchase only when the local scheduler + * guard proves that its reward callback cannot run. The intermediate durable + * state makes a failed refund retryable after a database outage or restart. + */ + boolean refundUnstartedReward(String purchaseId) throws SQLException { + SQLException lastFailure = null; + for (int attempt = 0; attempt < 3; attempt++) { + try { + if (!requestUnstartedRewardRefund(purchaseId)) { + PurchaseRow row = find(purchaseId); + return row != null && REFUNDED.equals(row.state()); + } + return refundCompensatingReward(purchaseId); + } catch (SQLException failure) { + lastFailure = failure; + } + } + throw lastFailure; + } + + /** + * Durably fences a rejected reward callback before another scheduler is used. + * + *

The caller has already won the local scheduler state race, so recovery may + * safely refund this row even if the persistence or Bukkit fallback scheduler + * is rejected or the process stops before its refund task starts.

+ */ + boolean markCompensating(String purchaseId) throws SQLException { + SQLException lastFailure = null; + for (int attempt = 0; attempt < 3; attempt++) { + try { + return requestUnstartedRewardRefund(purchaseId); + } catch (SQLException failure) { + lastFailure = failure; + } + } + throw lastFailure; + } + + /** Durable marker used before attempting compensation, so recovery can retry it. */ + private boolean requestUnstartedRewardRefund(String purchaseId) throws SQLException { + String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("purchase_id") + + " = ? AND " + qi("state") + " IN (?, ?, ?)"; + try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(update)) { + connection.setAutoCommit(false); + statement.setString(1, COMPENSATING); + statement.setString(2, purchaseId); + statement.setString(3, PENDING); + statement.setString(4, HOOK_STARTED); + statement.setString(5, COMPENSATING); + if (statement.executeUpdate() != 1) return false; + return commitAndConfirm(connection, purchaseId, COMPENSATING); + } + } + + /** Retries the already-marked compensation without reopening the hook. */ + boolean refundCompensatingReward(String purchaseId) throws SQLException { + return setTerminal(purchaseId, REFUNDED, System.currentTimeMillis(), COMPENSATING); + } + + private RefundedPurchase refundCompensatingRewardDetails(String purchaseId) throws SQLException { + return setTerminalDetails(purchaseId, REFUNDED, System.currentTimeMillis(), COMPENSATING); + } + + private boolean setTerminal(String purchaseId, String terminalState, long now, String... refundableStates) + throws SQLException { + return setTerminalDetails(purchaseId, terminalState, now, refundableStates) != null; + } + + private RefundedPurchase setTerminalDetails(String purchaseId, String terminalState, long now, + String... refundableStates) + throws SQLException { + boolean refund = REFUNDED.equals(terminalState); + String refundedUuid = null; + String refundedPointsColumn = null; + String refundedLimitColumn = null; + String select = "SELECT " + qi("state") + ", " + qi("player_uuid") + ", " + qi("points_column") + + ", " + qi("limit_column") + ", " + qi("cost") + ", " + qi("limit_generation") + ", " + + qi("limit_generation_expires_at") + ", " + qi("limit_epoch") + " FROM " + qiJournal() + " WHERE " + + qi("purchase_id") + " = ? FOR UPDATE"; + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try (PreparedStatement selectStatement = connection.prepareStatement(select)) { + selectStatement.setString(1, purchaseId); + try (ResultSet result = selectStatement.executeQuery()) { + if (!result.next()) { + rollback(connection); + return null; + } + String state = result.getString(1); + if (COMPLETED.equals(state) || REFUNDED.equals(state)) { + rollback(connection); + return terminalState.equals(state) ? new RefundedPurchase(null, null, null) : null; + } + if (refund && !isRefundableState(state, refundableStates)) { + rollback(connection); + return null; + } + if (!refund && !HOOK_STARTED.equals(state)) { + rollback(connection); + return null; + } + String uuid = result.getString(2); + String pointsColumn = result.getString(3); + String limitColumn = result.getString(4); + refundedUuid = uuid; + refundedPointsColumn = pointsColumn; + refundedLimitColumn = limitColumn; + int cost = result.getInt(5); + String limitGeneration = result.getString(6); + Long limitEpoch = nullableLong(result, 8); + if (refund) { + refund(connection, uuid, pointsColumn, limitColumn, cost, + shouldRefundLimit(connection, limitColumn, limitGeneration, limitEpoch)); + } + } + } + String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("purchase_id") + + " = ?"; + try (PreparedStatement updateStatement = connection.prepareStatement(update)) { + updateStatement.setString(1, terminalState); + updateStatement.setString(2, purchaseId); + if (updateStatement.executeUpdate() != 1) { + rollback(connection); + return null; + } + } + if (!commitAndConfirm(connection, purchaseId, terminalState)) return null; + return refund ? new RefundedPurchase(refundedUuid, refundedPointsColumn, refundedLimitColumn) + : new RefundedPurchase(null, null, null); + } catch (SQLException failure) { + throw failure; + } + } + + private static boolean isRefundableState(String state, String... refundableStates) { + for (String refundableState : refundableStates) { + if (refundableState.equals(state)) return true; + } + return false; + } + + private void refund(Connection connection, String uuid, String pointsColumn, String limitColumn, int cost, + boolean refundLimit) throws SQLException { + if (!isSafeColumn(pointsColumn) || (limitColumn != null && !isSafeColumn(limitColumn))) { + throw new SQLException("Unsafe durable purchase column"); + } + StringBuilder refund = new StringBuilder("UPDATE ").append(qi(table.getTableName())).append(" SET ") + .append(qi(pointsColumn)).append(" = ").append(qi(pointsColumn)).append(" + ?"); + if (refundLimit) { + refund.append(", ").append(qi(limitColumn)).append(" = GREATEST(COALESCE(").append(qi(limitColumn)) + .append(", 0) - 1, 0)"); + } + refund.append(" WHERE ").append(qi("uuid")).append(uuidCast()); + try (PreparedStatement statement = connection.prepareStatement(refund.toString())) { + statement.setInt(1, cost); + statement.setString(2, uuid); + if (statement.executeUpdate() != 1) throw new SQLException("Purchase refund player missing"); + } + } + + private boolean shouldRefundLimit(Connection connection, String limitColumn, String generation, Long storedEpoch) + throws SQLException { + if (limitColumn == null) return false; + if (storedEpoch != null) { + EpochRow currentEpoch = findAndLockLimitEpoch(connection, limitColumn); + return currentEpoch != null && storedEpoch.longValue() == currentEpoch.epoch(); + } + // Legacy rows did not capture a durable epoch, so a resettable limit cannot + // be identified safely. NONE has never reset and keeps its historic refund. + return NO_LIMIT_RESET_GENERATION.equals(generation); + } + + List recoverAndCleanup(long now) throws SQLException { + long cutoff = now - PENDING_RECOVERY_AGE_MILLIS; + String select = "SELECT " + qi("purchase_id") + " FROM " + qiJournal() + " WHERE " + qi("state") + + " = ? AND " + qi("created_at") + " <= ? ORDER BY " + qi("created_at") + " ASC LIMIT ?"; + List pending = new ArrayList<>(); + try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(select)) { + statement.setString(1, PENDING); + statement.setLong(2, cutoff); + statement.setInt(3, RECOVERY_BATCH_SIZE); + try (ResultSet result = statement.executeQuery()) { + while (result.next()) pending.add(result.getString(1)); + } + } + List refunded = new ArrayList<>(); + for (String purchaseId : pending) { + RefundedPurchase result = refundPendingDetails(purchaseId, now); + if (result != null) refunded.add(result); + } + // COMPENSATING is safe to refund: the local scheduler fence was persisted + // before the first attempt, so the reward callback cannot run. Retry these + // rows promptly after an outage rather than leaving them charged forever. + for (String purchaseId : findTransferIds(COMPENSATING, RECOVERY_BATCH_SIZE)) { + RefundedPurchase result = refundCompensatingRewardDetails(purchaseId); + if (result != null) refunded.add(result); + } + cleanupTerminalRows(now - TERMINAL_RETENTION_MILLIS); + return List.copyOf(refunded); + } + + private List findTransferIds(String state, int limit) throws SQLException { + String select = "SELECT " + qi("purchase_id") + " FROM " + qiJournal() + " WHERE " + qi("state") + + " = ? ORDER BY " + qi("created_at") + " ASC LIMIT ?"; + List purchaseIds = new ArrayList<>(); + try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(select)) { + statement.setString(1, state); + statement.setInt(2, limit); + try (ResultSet result = statement.executeQuery()) { + while (result.next()) purchaseIds.add(result.getString(1)); + } + } + return purchaseIds; + } + + private void cleanupTerminalRows(long cutoff) throws SQLException { + String select = "SELECT " + qi("purchase_id") + " FROM " + qiJournal() + " WHERE " + qi("state") + + " IN (?, ?) AND " + qi("created_at") + " <= ? ORDER BY " + qi("created_at") + " ASC LIMIT ?"; + String delete = "DELETE FROM " + qiJournal() + " WHERE " + qi("purchase_id") + " = ? AND " + + qi("state") + " IN (?, ?) AND " + qi("created_at") + " <= ?"; + try (Connection connection = connection(); PreparedStatement selectStatement = connection.prepareStatement(select); + PreparedStatement deleteStatement = connection.prepareStatement(delete)) { + selectStatement.setString(1, COMPLETED); + selectStatement.setString(2, REFUNDED); + selectStatement.setLong(3, cutoff); + selectStatement.setInt(4, CLEANUP_BATCH_SIZE); + List terminal = new ArrayList<>(); + try (ResultSet result = selectStatement.executeQuery()) { + while (result.next()) terminal.add(result.getString(1)); + } + for (String purchaseId : terminal) { + deleteStatement.setString(1, purchaseId); + deleteStatement.setString(2, COMPLETED); + deleteStatement.setString(3, REFUNDED); + deleteStatement.setLong(4, cutoff); + deleteStatement.executeUpdate(); + } + } + } + + private void ensureSchema() throws SQLException { + String create = "CREATE TABLE IF NOT EXISTS " + qiJournal() + " (" + qi("purchase_id") + + " VARCHAR(36) NOT NULL, " + qi("player_uuid") + " VARCHAR(37) NOT NULL, " + + qi("points_column") + " VARCHAR(128) NOT NULL, " + qi("limit_column") + " VARCHAR(128) NULL, " + + qi("cost") + " INT NOT NULL, " + qi("limit_value") + " INT NULL, " + qi("limit_generation") + + " VARCHAR(96) NULL, " + qi("limit_generation_expires_at") + " BIGINT NULL, " + qi("limit_epoch") + + " BIGINT NULL, " + qi("state") + + " VARCHAR(16) NOT NULL, " + qi("created_at") + " BIGINT NOT NULL, " + qi("hook_started_at") + + " BIGINT NULL, PRIMARY KEY (" + qi("purchase_id") + "));"; + try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(create)) { + statement.executeUpdate(); + ensureColumn(connection, "limit_generation", "VARCHAR(96) NULL"); + ensureColumn(connection, "limit_generation_expires_at", "BIGINT NULL"); + ensureColumn(connection, "limit_epoch", "BIGINT NULL"); + ensureEpochSchema(connection); + String index = "vp_vsp_" + Integer.toUnsignedString(journalTable.hashCode(), 36) + "_state_created"; + String createIndex = "CREATE INDEX " + (table.getDbType() == DbType.POSTGRESQL ? "IF NOT EXISTS " : "") + + qi(index) + " ON " + qiJournal() + " (" + qi("state") + ", " + qi("created_at") + ");"; + try (PreparedStatement indexStatement = connection.prepareStatement(createIndex)) { + indexStatement.executeUpdate(); + } catch (SQLException failure) { + if (failure.getErrorCode() != 1061 && !"42P07".equals(failure.getSQLState())) throw failure; + } + } + } + + private void ensureEpochSchema(Connection connection) throws SQLException { + String create = "CREATE TABLE IF NOT EXISTS " + qiEpoch() + " (" + qi("limit_column") + + " VARCHAR(128) NOT NULL, " + qi("epoch") + " BIGINT NOT NULL, " + + qi("last_reset_generation") + " VARCHAR(128) NULL, PRIMARY KEY (" + + qi("limit_column") + "));"; + try (PreparedStatement statement = connection.prepareStatement(create)) { + statement.executeUpdate(); + } + ensureEpochColumn(connection, "last_reset_generation", "VARCHAR(128) NULL"); + } + + private void ensureEpochColumn(Connection connection, String column, String definition) throws SQLException { + String alter = "ALTER TABLE " + qiEpoch() + " ADD COLUMN " + qi(column) + " " + definition; + try (PreparedStatement statement = connection.prepareStatement(alter)) { + statement.executeUpdate(); + } catch (SQLException failure) { + if (failure.getErrorCode() != 1060 && !"42701".equals(failure.getSQLState())) throw failure; + } + } + + private void ensureColumn(Connection connection, String column, String definition) throws SQLException { + String alter = "ALTER TABLE " + qiJournal() + " ADD COLUMN " + qi(column) + " " + definition; + try (PreparedStatement statement = connection.prepareStatement(alter)) { + statement.executeUpdate(); + } catch (SQLException failure) { + if (failure.getErrorCode() != 1060 && !"42701".equals(failure.getSQLState())) throw failure; + } + } + + private Connection connection() throws SQLException { + Connection connection = table.getMysql().getConnectionManager().getConnection(); + if (connection == null) throw new SQLException("Unable to acquire shared MySQL connection"); + return connection; + } + + private String qiJournal() { return table.qi(journalTable); } + private String qiEpoch() { return table.qi(epochTable); } + private String qi(String identifier) { return table.qi(identifier); } + private String uuidCast() { return table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"; } + + private long lockLimitEpoch(Connection connection, String limitColumn) throws SQLException { + return lockLimitEpochRow(connection, limitColumn).epoch(); + } + + private EpochRow lockLimitEpochRow(Connection connection, String limitColumn) throws SQLException { + ensureLimitEpochRow(connection, limitColumn); + EpochRow epoch = findAndLockLimitEpoch(connection, limitColumn); + if (epoch == null) throw new SQLException("Vote shop limit epoch marker missing"); + return epoch; + } + + private void ensureLimitEpochRow(Connection connection, String limitColumn) throws SQLException { + String insert = table.getDbType() == DbType.POSTGRESQL + ? "INSERT INTO " + qiEpoch() + " (" + qi("limit_column") + ", " + qi("epoch") + + ") VALUES (?, 0) ON CONFLICT DO NOTHING" + : "INSERT IGNORE INTO " + qiEpoch() + " (" + qi("limit_column") + ", " + qi("epoch") + + ") VALUES (?, 0)"; + try (PreparedStatement statement = connection.prepareStatement(insert)) { + statement.setString(1, limitColumn); + statement.executeUpdate(); + } + } + + private EpochRow findAndLockLimitEpoch(Connection connection, String limitColumn) throws SQLException { + String select = "SELECT " + qi("epoch") + ", " + qi("last_reset_generation") + " FROM " + qiEpoch() + + " WHERE " + qi("limit_column") + + " = ? FOR UPDATE"; + try (PreparedStatement statement = connection.prepareStatement(select)) { + statement.setString(1, limitColumn); + try (ResultSet result = statement.executeQuery()) { + return result.next() ? new EpochRow(result.getLong(1), result.getString(2)) : null; + } + } + } + + private EpochRow findLimitEpoch(String limitColumn) throws SQLException { + String select = "SELECT " + qi("epoch") + ", " + qi("last_reset_generation") + " FROM " + qiEpoch() + + " WHERE " + qi("limit_column") + + " = ?"; + try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(select)) { + statement.setString(1, limitColumn); + try (ResultSet result = statement.executeQuery()) { + return result.next() ? new EpochRow(result.getLong(1), result.getString(2)) : null; + } + } + } + + private static Long nullableLong(ResultSet result, int index) throws SQLException { + Object value = result.getObject(index); + return value instanceof Number number ? number.longValue() : null; + } + + private record EpochRow(long epoch, String lastResetGeneration) { + } + + private static boolean isSafeColumn(String column) { + // Columns are passed through AbstractSqlTable.qi(), which escapes the + // database-specific identifier delimiter. Preserve configured shop keys + // such as "Daily Reward" in the durable journal so their debit can always + // be recovered; reject only values that cannot be represented by its + // bounded VARCHAR journal column or a SQL identifier. + return column != null && !column.isEmpty() && column.length() <= 128 && column.indexOf('\0') < 0; + } + + private static void rollback(Connection connection) { + try { + connection.rollback(); + } catch (SQLException ignored) { + // Preserve the original failure; a PENDING record remains recoverable. + } + } + + private static void closeQuietly(Connection connection) { + try { + connection.close(); + } catch (SQLException ignored) { + // The confirmation query above decides whether the durable commit landed. + } + } + + private record PurchaseRow(String state) { + } + + record RefundedPurchase(String uuid, String pointsColumn, String limitColumn) { + } + + enum ClaimOutcome { + CLAIMED, + NOT_CLAIMED, + INDETERMINATE + } + + private static final class IdentityWeakReference extends WeakReference { + private final int identityHash; + + IdentityWeakReference(MySQL referent, ReferenceQueue queue) { + super(referent, queue); + identityHash = System.identityHashCode(referent); + } + + @Override public int hashCode() { return identityHash; } + + @Override public boolean equals(Object other) { + return this == other || other instanceof IdentityWeakReference reference && get() != null + && get() == reference.get(); + } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseResult.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseResult.java index fd581cab3f..b03baf9c61 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseResult.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseResult.java @@ -9,6 +9,9 @@ public enum VoteShopPurchaseResult { SUCCESS, + PENDING, + RECONCILIATION_REQUIRED, + FAILED, SHOP_DISABLED, ITEM_NOT_FOUND, NO_PERMISSION, diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseService.java index e4d91b026d..f1a3fb179b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseService.java @@ -1,15 +1,40 @@ package com.bencodez.votingplugin.voteshop.service; +import java.io.IOException; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.temporal.WeekFields; import java.util.HashMap; +import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; import org.bukkit.Bukkit; +import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.entity.Player; import com.bencodez.advancedcore.api.messages.PlaceholderUtils; import com.bencodez.advancedcore.api.rewards.RewardOptions; +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; +import com.bencodez.simpleapi.folialib.enums.EntityTaskResult; +import com.bencodez.simpleapi.sql.DataType; +import com.bencodez.simpleapi.sql.mysql.DbType; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.events.VoteShopPurchaseEvent; import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.user.SharedMysqlCacheReconciler; +import com.bencodez.votingplugin.util.BukkitCompletionScheduler; import com.bencodez.votingplugin.voteshop.shop.VoteShopDefinition; import com.bencodez.votingplugin.voteshop.shop.VoteShopItem; @@ -22,6 +47,20 @@ @Getter @Setter public class VoteShopPurchaseService { + private static final int PURCHASE_LOCK_STRIPES = 256; + private static final Object[] PURCHASE_LOCKS = createPurchaseLocks(); + private static final int COMPLETION_PENDING = 0; + private static final int COMPLETION_RUNNING = 1; + private static final int COMPLETION_COMPENSATING = 2; + private static final int COMPLETION_FINISHED = 3; + /* + * Reset generations are persisted and compared by every backend sharing a + * MySQL table. Do not derive them from a host locale: that makes Sunday- and + * Monday-first JVMs publish different generations for one network period. + * Locale.ROOT is the historic convention here, but retain the WeekFields as a + * constant so the network contract is explicit and cannot track JVM defaults. + */ + private static final WeekFields NETWORK_WEEK_FIELDS = WeekFields.of(Locale.ROOT); private VoteShopDefinition definition; @@ -47,6 +86,35 @@ public VoteShopPurchaseService(VotingPluginMain plugin, VoteShopDefinition defin * @return the result */ public VoteShopPurchaseResult validatePurchase(Player player, VotingPluginUser user, VoteShopItem item) { + VoteShopPurchaseResult staticValidation = validateStaticPurchase(player, item); + if (staticValidation != VoteShopPurchaseResult.SUCCESS) { + return staticValidation; + } + // Shared points and limits are decided atomically by the queued reservation. + // GUI rendering/click validation runs on Bukkit/Folia lanes and must not turn + // an advisory precheck into a synchronous database read. + if (usesMysqlPurchaseReservation(item)) return VoteShopPurchaseResult.SUCCESS; + if (item.getLimit() > 0 && user.getVoteShopIdentifierLimit(item.getIdentifier()) >= item.getLimit()) { + return VoteShopPurchaseResult.LIMIT_REACHED; + } + if (user.getPoints() < item.getCost()) { + return VoteShopPurchaseResult.NOT_ENOUGH_POINTS; + } + return VoteShopPurchaseResult.SUCCESS; + } + + /** Refreshes dynamic GUI validation state only when that refresh cannot block on shared MySQL. */ + public void refreshUserForPurchaseValidation(VotingPluginUser user, VoteShopItem item, boolean requested) { + if (requested && !usesMysqlPurchaseReservation(item)) user.cache(); + } + + /** @deprecated Pass the item so limited MySQL purchases can avoid a blocking refresh. */ + @Deprecated + public void refreshUserForPurchaseValidation(VotingPluginUser user, boolean requested) { + refreshUserForPurchaseValidation(user, null, requested); + } + + private VoteShopPurchaseResult validateStaticPurchase(Player player, VoteShopItem item) { if (!definition.isEnabled()) { return VoteShopPurchaseResult.SHOP_DISABLED; } @@ -59,12 +127,6 @@ public VoteShopPurchaseResult validatePurchase(Player player, VotingPluginUser u if (!hasPermission(player, item.getPermission())) { return VoteShopPurchaseResult.NO_PERMISSION; } - if (item.getLimit() > 0 && user.getVoteShopIdentifierLimit(item.getIdentifier()) >= item.getLimit()) { - return VoteShopPurchaseResult.LIMIT_REACHED; - } - if (user.getPoints() < item.getCost()) { - return VoteShopPurchaseResult.NOT_ENOUGH_POINTS; - } return VoteShopPurchaseResult.SUCCESS; } @@ -76,26 +138,341 @@ public VoteShopPurchaseResult validatePurchase(Player player, VotingPluginUser u * @param item the item * @return the result */ - public VoteShopPurchaseResult purchase(Player player, VotingPluginUser user, VoteShopItem item) { + private VoteShopPurchaseResult purchaseLocal(Player player, VotingPluginUser user, VoteShopItem item) { + if (plugin.getConfigFile().isExtraVoteShopCheck()) user.cache(); VoteShopPurchaseResult validation = validatePurchase(player, user, item); if (validation != VoteShopPurchaseResult.SUCCESS) { return validation; } + FileConfiguration shopData = plugin.getShopFile().getData(); HashMap placeholders = new HashMap(); placeholders.put("identifier", item.getIdentifierName()); placeholders.put("points", String.valueOf(item.getCost())); placeholders.put("limit", String.valueOf(item.getLimit())); placeholders.put("shop", definition.getTitle()); - if (!user.removePoints(item.getCost(), true)) { - return VoteShopPurchaseResult.NOT_ENOUGH_POINTS; + VoteShopPurchaseResult debit = debitForPurchase(user, item); + if (debit != VoteShopPurchaseResult.SUCCESS) { + return debit; + } + completePurchase(player, user, item, placeholders, shopData); + return VoteShopPurchaseResult.SUCCESS; + } + + /** + * Executes a purchase and reports its result on the Bukkit thread. Shared + * MySQL debits run on AdvancedCore's ordered persistence executor so earlier + * asynchronous user writes complete before the conditional debit. + * + * @param player the player + * @param user the user + * @param item the item + * @param completion completion callback + */ + public void purchase(Player player, VotingPluginUser user, VoteShopItem item, + Consumer completion) { + if (!usesMysqlPurchaseReservation(item)) { + completion.accept(purchaseLocal(player, user, item)); + return; } + VoteShopPurchaseResult validation = validateStaticPurchase(player, item); + if (validation != VoteShopPurchaseResult.SUCCESS) { + completion.accept(validation); + return; + } + // Keep the loaded configuration object with the queued purchase. reloadData() + // replaces ShopFile's FileConfiguration, so looking it up after the worker + // or entity task runs could pair an old debit with a newly loaded reward. + FileConfiguration shopData = plugin.getShopFile().getData(); + HashMap placeholders = purchasePlaceholders(item); + try { + plugin.getTimer().execute(() -> { + try { + SharedPurchaseDebit debit; + synchronized (purchaseLock(user.getUUID())) { + // Sample the reset window beside the conditional debit. A queued + // persistence task may otherwise cross into a new limit period. + debit = reserveSharedMysqlPurchase(user, item, + limitGeneration(item, System.currentTimeMillis())); + } + if (debit.result() != VoteShopPurchaseResult.SUCCESS) { + BukkitCompletionScheduler.run(plugin, player, () -> completion.accept(debit.result())); + return; + } + completeSharedMysqlPurchase(player, user, item, placeholders, shopData, completion, debit); + } catch (RuntimeException workerFailure) { + plugin.debug(workerFailure); + completeFailedPurchase(player, completion); + } + }); + } catch (RuntimeException persistenceRejected) { + plugin.debug(persistenceRejected); + completeFailedPurchase(player, completion); + } + } + + /** + * Compatibility entry point for integrations compiled against the synchronous + * API. Shared-MySQL purchases return {@link VoteShopPurchaseResult#PENDING} + * after static validation because their final debit result is asynchronous; + * use the callback overload when the final result is required. + * + * @deprecated use {@link #purchase(Player, VotingPluginUser, VoteShopItem, Consumer)} + */ + @Deprecated + public VoteShopPurchaseResult purchase(Player player, VotingPluginUser user, VoteShopItem item) { + if (!usesMysqlPurchaseReservation(item)) return purchaseLocal(player, user, item); + VoteShopPurchaseResult validation = validateStaticPurchase(player, item); + if (validation != VoteShopPurchaseResult.SUCCESS) return validation; + purchase(player, user, item, ignored -> { }); + return VoteShopPurchaseResult.PENDING; + } + + private void completeSharedMysqlPurchase(Player player, VotingPluginUser user, VoteShopItem item, + HashMap placeholders, FileConfiguration shopData, + Consumer completion, SharedPurchaseDebit debit) { + AtomicInteger state = new AtomicInteger(COMPLETION_PENDING); + Runnable compensateBeforeClaim = () -> { + if (!state.compareAndSet(COMPLETION_PENDING, COMPLETION_COMPENSATING)) return; + scheduleSharedMysqlCompensation(player, user, completion, debit); + }; + try { + /* + * The first entity callback is only a nonblocking scheduling gate. Keeping + * the durable row PENDING until it starts lets recovery refund a debit when + * the entity scheduler never accepts work. The JDBC claim then runs off the + * entity lane, and only a successful durable claim schedules the actual + * reward callback. BukkitCompletionScheduler retains that entity/global + * fallback behavior on Folia and safely uses Bukkit scheduling when Folia + * support is absent. + */ + runPurchaseEntityTask(player, () -> { + if (!state.compareAndSet(COMPLETION_PENDING, COMPLETION_RUNNING)) return; + try { + claimSharedMysqlPurchaseAsync(debit).whenComplete((claim, failure) -> { + if (failure != null || requiresCompensation(claim)) { + if (state.compareAndSet(COMPLETION_RUNNING, COMPLETION_COMPENSATING)) { + // runTaskAsynchronously may reject before returning its future. + // CompletableFuture then invokes this callback inline on the + // entity lane, so compensation must be admitted through its own + // off-thread scheduling path instead of doing JDBC here. + scheduleSharedMysqlCompensation(player, user, completion, debit); + } + return; + } + state.compareAndSet(COMPLETION_RUNNING, COMPLETION_FINISHED); + scheduleClaimedReward(player, user, item, placeholders, shopData, completion, debit); + }); + } catch (RuntimeException claimSchedulingFailure) { + if (state.compareAndSet(COMPLETION_RUNNING, COMPLETION_COMPENSATING)) { + scheduleSharedMysqlCompensation(player, user, completion, debit); + } + plugin.debug(claimSchedulingFailure); + } + }, compensateBeforeClaim); + } catch (RuntimeException schedulingFailure) { + compensateBeforeClaim.run(); + plugin.debug(schedulingFailure); + } + } + + static boolean requiresCompensation(SharedMysqlPurchaseJournal.ClaimOutcome claim) { + // The local reward callback has not started yet, so both a rejected claim + // and an unconfirmed claim are safe to fence and refund. + return claim != SharedMysqlPurchaseJournal.ClaimOutcome.CLAIMED; + } + + void scheduleClaimedReward(Player player, VotingPluginUser user, VoteShopItem item, + HashMap placeholders, FileConfiguration shopData, + Consumer completion, SharedPurchaseDebit debit) { + AtomicInteger state = new AtomicInteger(COMPLETION_PENDING); + Runnable rejectBeforeStart = () -> { + if (state.compareAndSet(COMPLETION_PENDING, COMPLETION_COMPENSATING)) { + scheduleSharedMysqlCompensation(player, user, completion, debit); + } + }; + try { + runPurchaseEntityTask(player, () -> { + if (!state.compareAndSet(COMPLETION_PENDING, COMPLETION_RUNNING)) return; + try { + completePurchase(player, user, item, placeholders, shopData); + } catch (RuntimeException | Error rewardFailure) { + state.set(COMPLETION_FINISHED); + logClaimedRewardSchedulingFailure(debit); + // This callback is already running on the player's entity lane. The + // claimed journal row must remain for reconciliation because the reward + // may have partially executed, but callers must not wait forever. + completeClaimedRewardFailure(completion); + throw rewardFailure; + } + try { + plugin.getTimer().execute(() -> settleSharedMysqlPurchase(player, completion, debit)); + } catch (RuntimeException schedulingFailure) { + plugin.debug(schedulingFailure); + // The reward has already run, so settlement must retain the same + // idempotent journal operation even when the persistence executor is + // saturated or stopping. Bukkit's async scheduler keeps JDBC off the + // entity lane and is independent from that executor. + try { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, + () -> settleSharedMysqlPurchase(player, completion, debit)); + } catch (RuntimeException asyncSchedulingFailure) { + // A shutdown can reject both schedulers. The reward cannot be run + // again, so retain HOOK_STARTED for explicit reconciliation while + // still completing the already-successful purchase exactly once. + plugin.debug(asyncSchedulingFailure); + completeSuccessfulPurchase(player, completion); + } + } finally { + state.set(COMPLETION_FINISHED); + } + }, rejectBeforeStart); + } catch (RuntimeException schedulingFailure) { + rejectBeforeStart.run(); + plugin.debug(schedulingFailure); + } + } + + private void logClaimedRewardSchedulingFailure(SharedPurchaseDebit debit) { + plugin.getLogger().severe("Shared MySQL vote shop purchase " + debit.purchaseId() + + " was claimed but its reward callback did not complete; retaining it for reconciliation"); + } + + /** + * Folia provides the entity-retirement signal that decides whether a durable + * reservation must be compensated. Preserve that lifecycle when it is + * available, but route legacy Bukkit/Paper through the safe entity/global + * completion scheduler instead of dereferencing a missing Folia adapter. + */ + private void runPurchaseEntityTask(Player player, Runnable task, Runnable rejected) { + if (plugin.getBukkitScheduler().getFoliaLib() == null) { + BukkitCompletionScheduler.run(plugin, player, task, rejected); + return; + } + CompletableFuture result = plugin.getBukkitScheduler().getFoliaLib().getImpl() + .runAtEntityWithFallback(player, ignored -> task.run(), rejected); + result.whenComplete((status, failure) -> { + if (failure != null || status != EntityTaskResult.SUCCESS) rejected.run(); + }); + } + + private void compensateSharedMysqlPurchase(Player player, VotingPluginUser user, + Consumer completion, SharedPurchaseDebit debit) { + try { + // The local state CAS proves that neither reward callback can start. Persist + // that fence before relying on either remaining scheduler; otherwise a task + // accepted by the persistence executor could be lost with HOOK_STARTED + // still charged and outside automatic recovery. + if (!debit.journal().markCompensating(debit.purchaseId())) { + // A terminal row may have been handled by recovery already. Do not + // enqueue another scheduler task when this invocation did not obtain + // the durable compensation fence. + completeFailedPurchase(player, completion); + return; + } + } catch (SQLException markerFailure) { + rememberPendingCompensationMarker(plugin, debit.purchaseId()); + plugin.getLogger().severe("Unable to mark an incomplete vote shop purchase for compensation: " + + markerFailure.getClass().getSimpleName()); + plugin.debug(markerFailure); + completeFailedPurchase(player, completion); + return; + } + // This method only runs on a persistence worker or Bukkit's independent + // async fallback, so completing the fenced refund here cannot block an + // entity lane and needs no second executor admission. + refundCompensatingMysqlDebit(user, debit); + BukkitCompletionScheduler.run(plugin, player, + () -> completion.accept(VoteShopPurchaseResult.FAILED)); + } + + private void scheduleSharedMysqlCompensation(Player player, VotingPluginUser user, + Consumer completion, SharedPurchaseDebit debit) { + Runnable compensation = () -> compensateSharedMysqlPurchase(player, user, completion, debit); + try { + plugin.getTimer().execute(compensation); + } catch (RuntimeException persistenceRejected) { + plugin.debug(persistenceRejected); + try { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, compensation); + } catch (RuntimeException asyncRejected) { + plugin.debug(asyncRejected); + // Both lifecycle executors are unavailable. Preserve local durable + // proof that the reward callback never started so startup recovery can + // safely move the otherwise ambiguous HOOK_STARTED row to compensation. + rememberPendingCompensationMarker(plugin, debit.purchaseId()); + completeFailedPurchase(player, completion); + } + } + } + + private void completeFailedPurchase(Player player, Consumer completion) { + try { + BukkitCompletionScheduler.run(plugin, player, + () -> completion.accept(VoteShopPurchaseResult.FAILED)); + } catch (RuntimeException completionFailure) { + plugin.debug(completionFailure); + } + } + + private void completeClaimedRewardFailure(Consumer completion) { + try { + completion.accept(VoteShopPurchaseResult.RECONCILIATION_REQUIRED); + } catch (RuntimeException completionFailure) { + plugin.debug(completionFailure); + } + } + + private void refundCompensatingMysqlDebit(VotingPluginUser user, SharedPurchaseDebit debit) { + try { + if (debit.journal().refundCompensatingReward(debit.purchaseId())) { + refreshPurchaseCache(user, debit.pointsColumn(), debit.limitColumn()); + } + } catch (SQLException failure) { + // A commit/confirmation failure is indeterminate: the refund transaction + // may have committed even though this worker could not observe its terminal + // journal state. Drop the affected snapshots before any later cache dump so + // a stale debit cannot overwrite a durable refund. Recovery will reconcile + // the journal state if the transaction did not commit. + refreshPurchaseCache(user, debit.pointsColumn(), debit.limitColumn()); + plugin.getLogger().severe("Unable to refund an incomplete vote shop purchase: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + } + } + + private void settleSharedMysqlPurchase(Player player, Consumer completion, + SharedPurchaseDebit debit) { + completeSharedMysqlPurchase(debit); + completeSuccessfulPurchase(player, completion); + } + + private void completeSuccessfulPurchase(Player player, Consumer completion) { + try { + BukkitCompletionScheduler.run(plugin, player, + () -> completion.accept(VoteShopPurchaseResult.SUCCESS)); + } catch (RuntimeException completionFailure) { + plugin.debug(completionFailure); + } + } + + private HashMap purchasePlaceholders(VoteShopItem item) { + HashMap placeholders = new HashMap(); + placeholders.put("identifier", item.getIdentifierName()); + placeholders.put("points", String.valueOf(item.getCost())); + placeholders.put("limit", String.valueOf(item.getLimit())); + placeholders.put("shop", definition.getTitle()); + return placeholders; + } + + private void completePurchase(Player player, VotingPluginUser user, VoteShopItem item, + HashMap placeholders, FileConfiguration shopData) { plugin.getLogger().info("VoteShop: " + user.getPlayerName() + "/" + user.getUUID() + " bought " + item.getIdentifier() + " for " + item.getCost()); - plugin.getRewardHandler().giveReward(user, plugin.getShopFile().getData(), item.getRewardsPath(), + plugin.getRewardHandler().giveReward(user, shopData, item.getRewardsPath(), new RewardOptions().setPlaceholders(placeholders)); String purchaseMessage = item.getPurchaseMessage(); @@ -105,15 +482,433 @@ public VoteShopPurchaseResult purchase(Player player, VotingPluginUser user, Vot user.sendMessage(PlaceholderUtils.replacePlaceHolder(purchaseMessage, placeholders)); VoteShopPurchaseEvent purchaseEvent = new VoteShopPurchaseEvent(player.getUniqueId(), player.getName(), user, - item.getIdentifier(), item.getCost()); + item.getIdentifier(), item.getCost(), false); Bukkit.getPluginManager().callEvent(purchaseEvent); + } - if (item.getLimit() > 0) { - user.setVoteShopIdentifierLimit(item.getIdentifier(), - user.getVoteShopIdentifierLimit(item.getIdentifier()) + 1); + VoteShopPurchaseResult debitForPurchase(VotingPluginUser user, VoteShopItem item) { + synchronized (purchaseLock(user.getUUID())) { + if (usesSharedMysqlPoints()) { + return debitSharedMysql(user, item); + } + if (item.getLimit() > 0 && user.getVoteShopIdentifierLimit(item.getIdentifier()) >= item.getLimit()) { + return VoteShopPurchaseResult.LIMIT_REACHED; + } + if (!user.removePoints(item.getCost(), true)) { + return VoteShopPurchaseResult.NOT_ENOUGH_POINTS; + } + if (item.getLimit() > 0) { + user.setVoteShopIdentifierLimit(item.getIdentifier(), + user.getVoteShopIdentifierLimit(item.getIdentifier()) + 1); + } + return VoteShopPurchaseResult.SUCCESS; } + } - return VoteShopPurchaseResult.SUCCESS; + private boolean usesSharedMysqlPoints() { + return usesSharedMysqlPoints(plugin); + } + + /** + * A vote-shop limit belongs to the shared MySQL user row even when point + * balances are server-suffixed. Limited MySQL purchases must therefore reserve + * both the selected points column and the shared limit under the journal epoch + * lock; otherwise two servers can independently pass a stale local limit read. + */ + private boolean usesMysqlPurchaseReservation(VoteShopItem item) { + return usesSharedMysqlPoints() || plugin != null && item != null && item.getLimit() > 0 + && UserStorage.MYSQL.equals(plugin.getStorageType()); + } + + private static boolean usesSharedMysqlPoints(VotingPluginMain plugin) { + return plugin != null && UserStorage.MYSQL.equals(plugin.getStorageType()) + && !plugin.getBungeeSettings().isPerServerPoints(); + } + + /** Existing durable purchases remain recoverable after shared points are disabled. */ + static boolean canRecoverSharedMysqlPurchases(VotingPluginMain plugin) { + return plugin != null && UserStorage.MYSQL.equals(plugin.getStorageType()); + } + + /** + * Resets a shared-MySQL vote-shop limit with the durable epoch marker used by + * reservations. Other storage modes retain the established UserManager reset. + */ + public static void resetSharedMysqlLimit(VotingPluginMain plugin, String limitColumn) { + resetSharedMysqlLimit(plugin, limitColumn, UUID.randomUUID().toString()); + } + + /** Applies a named reset at most once across all backends sharing the table. */ + public static void resetSharedMysqlLimit(VotingPluginMain plugin, String limitColumn, String resetGeneration) { + if (!usesSharedMysqlPoints(plugin)) return; + withSharedMysqlCacheResetFence(() -> { + // Shared limit writes are deliberately nonqueued. Drop read snapshots + // without dumping them, so a backend arriving after another server's reset + // can never replay a pre-reset absolute value. + SharedMysqlCacheReconciler.invalidateAll(plugin, limitColumn); + try { + MySQL table = plugin.getMysql(); + table.checkColumn(limitColumn, DataType.INTEGER); + SharedMysqlPurchaseJournal.forTable(table).resetLimit(limitColumn, resetGeneration); + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to atomically reset shared MySQL vote shop limit: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + } finally { + SharedMysqlCacheReconciler.invalidateAllAndRefresh(plugin, limitColumn); + } + }); + } + + /** + * Resets a MySQL limit during a per-server-points transition. Some backends + * can still be using shared points, so use the purchase journal rather than + * UserManager's independent wipe: {@code resetLimit} locks the epoch row, + * wipes precisely this limit column, and publishes the new epoch in one + * transaction. Reservations and refunds take that same row lock. + */ + public static boolean resetMysqlLimitWithPurchaseFence(VotingPluginMain plugin, String limitColumn, + String resetGeneration) { + if (!canRecoverSharedMysqlPurchases(plugin)) return false; + AtomicBoolean reset = new AtomicBoolean(); + withSharedMysqlCacheResetFence(() -> { + // Do not dump cached absolute values around the direct journal update. + SharedMysqlCacheReconciler.invalidateAll(plugin, limitColumn); + try { + MySQL table = plugin.getMysql(); + table.checkColumn(limitColumn, DataType.INTEGER); + SharedMysqlPurchaseJournal.forTable(table).resetLimit(limitColumn, resetGeneration); + reset.set(true); + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to atomically reset MySQL vote shop limit: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + } finally { + SharedMysqlCacheReconciler.invalidateAllAndRefresh(plugin, limitColumn); + } + }); + return reset.get(); + } + + static void withSharedMysqlCacheResetFence(Runnable action) { + SharedMysqlCacheReconciler.withResetFence(action); + } + + static void withSharedMysqlCacheDumpFence(Runnable action) { + SharedMysqlCacheReconciler.withCacheDumpFence(action); + } + + /** Runs bounded stale-purchase recovery from the plugin lifecycle executor. */ + public static void recoverSharedMysqlPurchases(VotingPluginMain plugin) { + if (!canRecoverSharedMysqlPurchases(plugin)) return; + try { + recoverSharedMysqlPurchases(plugin, SharedMysqlPurchaseJournal.forTable(plugin.getMysql())); + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to recover pending shared MySQL vote shop purchases: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + } + } + + static void recoverSharedMysqlPurchases(VotingPluginMain plugin, SharedMysqlPurchaseJournal journal) + throws SQLException { + retryPendingCompensationMarkers(plugin, journal); + for (SharedMysqlPurchaseJournal.RefundedPurchase refund : journal.recoverAndCleanup(System.currentTimeMillis())) { + SharedMysqlCacheReconciler.invalidateAndRefresh(plugin, refund.uuid(), refund.pointsColumn(), + refund.limitColumn()); + } + } + + VoteShopPurchaseResult debitSharedMysql(VotingPluginUser user, VoteShopItem item) { + // This package-visible synchronous helper has no reward lifecycle to settle + // later. Keep its conditional debit self-contained; asynchronous purchases + // exclusively use reserveSharedMysqlPurchase() below so they can retain a + // durable PENDING record until the reward hook is settled or refunded. + MySQL table = plugin.getMysql(); + String pointsColumn = user.getPointsPath(); + String limitColumn = item.getLimit() > 0 ? "VoteShopLimit" + item.getIdentifier() : null; + drainPurchaseCache(user, pointsColumn); + if (limitColumn != null) table.checkColumn(limitColumn, DataType.INTEGER); + StringBuilder sql = new StringBuilder("UPDATE ").append(table.qi(table.getTableName())).append(" SET ") + .append(table.qi(pointsColumn)).append(" = ").append(table.qi(pointsColumn)).append(" - ?"); + if (limitColumn != null) { + sql.append(", ").append(table.qi(limitColumn)).append(" = COALESCE(") + .append(table.qi(limitColumn)).append(", 0) + 1"); + } + sql.append(" WHERE ").append(table.qi("uuid")) + .append(table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?") + .append(" AND ").append(table.qi(pointsColumn)).append(" >= ?"); + if (limitColumn != null) sql.append(" AND COALESCE(").append(table.qi(limitColumn)).append(", 0) < ?"); + + boolean debited = false; + try (Connection connection = requireConnection(table); + PreparedStatement statement = connection.prepareStatement(sql.toString())) { + statement.setInt(1, item.getCost()); + statement.setString(2, user.getUUID()); + statement.setInt(3, item.getCost()); + if (limitColumn != null) statement.setInt(4, item.getLimit()); + debited = statement.executeUpdate() == 1; + } catch (SQLException failure) { + // JDBC can fail after a server has applied the conditional update. Drop + // snapshots recreated during that unknown outcome so a later cache dump + // cannot restore the pre-debit values. + refreshPurchaseCache(user, pointsColumn, limitColumn); + plugin.getLogger().severe("Unable to atomically debit vote shop points: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + return VoteShopPurchaseResult.FAILED; + } + if (debited) { + // The conditional debit connection has been closed before NO_CACHE reads. + refreshPurchaseCache(user, pointsColumn, limitColumn); + return VoteShopPurchaseResult.SUCCESS; + } + return sharedMysqlFailure(user, item, limitColumn); + } + + private static Connection requireConnection(MySQL table) throws SQLException { + Connection connection = table.getMysql().getConnectionManager().getConnection(); + if (connection == null) throw new SQLException("Unable to acquire shared MySQL connection"); + return connection; + } + + private SharedPurchaseDebit reserveSharedMysqlPurchase(VotingPluginUser user, VoteShopItem item, + LimitGeneration limitGeneration) { + MySQL table = plugin.getMysql(); + String pointsColumn = user.getPointsPath(); + String limitColumn = item.getLimit() > 0 ? "VoteShopLimit" + item.getIdentifier() : null; + drainPurchaseCache(user, pointsColumn); + if (limitColumn != null) { + table.checkColumn(limitColumn, DataType.INTEGER); + } + try { + SharedMysqlPurchaseJournal journal = SharedMysqlPurchaseJournal.forTable(table); + String purchaseId = UUID.randomUUID().toString(); + if (journal.reserve(purchaseId, user.getUUID(), pointsColumn, limitColumn, item.getCost(), item.getLimit(), + limitGeneration.value(), limitGeneration.expiresAt(), System.currentTimeMillis())) { + // reserve() returns only after its transaction and connection are closed; + // NO_CACHE reads must not contend with its one-connection pool handle. + refreshPurchaseCache(user, pointsColumn, limitColumn); + return new SharedPurchaseDebit(VoteShopPurchaseResult.SUCCESS, journal, purchaseId, pointsColumn, + limitColumn); + } + } catch (SQLException failure) { + // reserve() can throw after its commit acknowledgement and confirmation + // both fail. The debit may therefore be durable even though this caller + // reports FAILED; drop snapshots recreated during that transaction so a + // later cache dump cannot restore the pre-reservation values. + refreshPurchaseCache(user, pointsColumn, limitColumn); + plugin.getLogger().severe("Unable to atomically debit vote shop points: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + return new SharedPurchaseDebit(VoteShopPurchaseResult.FAILED, null, null, null, null); + } + return new SharedPurchaseDebit(sharedMysqlFailure(user, item, limitColumn), null, null, null, null); + } + + private void drainPurchaseCache(VotingPluginUser user, String pointsColumn) { + withSharedMysqlCacheDumpFence(() -> { + if (!user.isCached()) return; + UserDataCache cache = user.getCache(); + if (cache == null) return; + synchronized (cache) { + // dump() waits for a cache batch that has already left its queue. Strip an + // async point prediction first so it cannot be persisted ahead of this debit. + SharedMysqlCacheReconciler.discardOptimisticPoint(cache, pointsColumn); + cache.dump(); + plugin.getUserManager().getDataManager().removeCache(UUID.fromString(user.getUUID()), null); + } + }); + } + + private SharedMysqlPurchaseJournal.ClaimOutcome claimSharedMysqlPurchase(SharedPurchaseDebit debit) { + try { + return debit.journal().claimReward(debit.purchaseId(), System.currentTimeMillis()); + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to claim a pending vote shop purchase: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + return SharedMysqlPurchaseJournal.ClaimOutcome.INDETERMINATE; + } + } + + private CompletableFuture claimSharedMysqlPurchaseAsync( + SharedPurchaseDebit debit) { + CompletableFuture result = new CompletableFuture<>(); + try { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, + () -> result.complete(claimSharedMysqlPurchase(debit))); + } catch (RuntimeException schedulingFailure) { + result.completeExceptionally(schedulingFailure); + } + return result; + } + + private void completeSharedMysqlPurchase(SharedPurchaseDebit debit) { + try { + debit.journal().complete(debit.purchaseId()); + } catch (SQLException failure) { + // A HOOK_STARTED record is intentionally retained for reconciliation: + // the arbitrary reward hook may already have side effects. + plugin.getLogger().severe("Unable to settle a completed vote shop purchase: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + } + } + + private VoteShopPurchaseResult sharedMysqlFailure(VotingPluginUser user, VoteShopItem item, String limitColumn) { + if (limitColumn != null && user.getUserData().getInt(limitColumn, UserDataFetchMode.NO_CACHE) >= item.getLimit()) { + return VoteShopPurchaseResult.LIMIT_REACHED; + } + return VoteShopPurchaseResult.NOT_ENOUGH_POINTS; + } + + private void refreshPurchaseCache(VotingPluginUser user, String pointsColumn, String limitColumn) { + // The shared-MySQL mutation already committed. Invalidate only the fields it + // changed; adding absolute values to the cache would turn a concurrent + // snapshot into a dirty write that can overwrite another backend's update. + SharedMysqlCacheReconciler.invalidateAndRefresh(plugin, user.getUUID(), pointsColumn, limitColumn); + } + + private static void rememberPendingCompensationMarker(VotingPluginMain plugin, String purchaseId) { + if (plugin == null || purchaseId == null) return; + try { + compensationStore(plugin).record(purchaseId); + } catch (IOException persistenceFailure) { + plugin.getLogger().severe("Unable to persist a vote shop compensation marker: " + + persistenceFailure.getClass().getSimpleName()); + plugin.debug(persistenceFailure); + } + } + + private static void retryPendingCompensationMarkers(VotingPluginMain plugin, + SharedMysqlPurchaseJournal journal) { + SharedMysqlCompensationStore store = compensationStore(plugin); + final java.util.List pending; + try { + pending = store.loadBatch(); + } catch (IOException loadFailure) { + plugin.debug(loadFailure); + return; + } + for (String purchaseId : pending) { + try { + journal.markCompensating(purchaseId); + store.remove(purchaseId); + } catch (SQLException retryFailure) { + plugin.debug(retryFailure); + } catch (IOException removalFailure) { + plugin.debug(removalFailure); + } + } + } + + private static SharedMysqlCompensationStore compensationStore(VotingPluginMain plugin) { + return new SharedMysqlCompensationStore(plugin.getDataFolder().toPath()); + } + + private LimitGeneration limitGeneration(VoteShopItem item, long nowMillis) { + if (item.getLimit() <= 0) return LimitGeneration.NONE; + return limitGeneration(plugin, item.getIdentifier(), nowMillis); + } + + /** Stable identifier shared by every backend processing the same reset period. */ + public static String currentLimitGenerationId(VotingPluginMain plugin, String identifier) { + return limitGeneration(plugin, identifier, System.currentTimeMillis()).value(); + } + + private static LimitGeneration limitGeneration(VotingPluginMain plugin, String identifier, long nowMillis) { + boolean daily = plugin.getShopFile().getVoteShopResetDaily(identifier); + boolean weekly = plugin.getShopFile().getVoteShopResetWeekly(identifier); + boolean monthly = plugin.getShopFile().getVoteShopResetMonthly(identifier); + ZoneId timeZone = configuredTimeZone(plugin); + int hourOffset = plugin.getOptions().getTimeHourOffSet(); + LocalDateTime current = networkCurrentTime(nowMillis, timeZone, hourOffset); + return limitGeneration(current, nowMillis, daily, weekly, monthly, + plugin.getOptions().getTimeWeekOffSet(), timeZone, hourOffset); + } + + private static ZoneId configuredTimeZone(VotingPluginMain plugin) { + return networkTimeZone(plugin.getOptions().getTimeZone()); + } + + static ZoneId networkTimeZone(String configured) { + if (configured == null || configured.isBlank()) return ZoneId.of("UTC"); + try { + return ZoneId.of(configured); + } catch (RuntimeException invalidZone) { + return ZoneId.of("UTC"); + } + } + + static LocalDateTime networkCurrentTime(long nowMillis, ZoneId timeZone, int hourOffset) { + return LocalDateTime.ofInstant(Instant.ofEpochMilli(nowMillis), timeZone).plusHours(hourOffset); + } + + static LimitGeneration limitGeneration(LocalDateTime current, long nowMillis, boolean daily, boolean weekly, + boolean monthly, int weekOffset) { + return limitGeneration(current, nowMillis, daily, weekly, monthly, weekOffset, ZoneId.of("UTC"), 0); + } + + private static LimitGeneration limitGeneration(LocalDateTime current, long nowMillis, boolean daily, boolean weekly, + boolean monthly, int weekOffset, ZoneId timeZone, int hourOffset) { + if (!daily && !weekly && !monthly) return LimitGeneration.NONE; + LocalDateTime next = null; + StringBuilder generation = new StringBuilder(); + if (daily) { + next = current.toLocalDate().plusDays(1).atStartOfDay(); + generation.append("D:").append(current.toLocalDate()); + } + if (weekly) { + LocalDateTime weekBoundary = current.toLocalDate().plusDays(1).atStartOfDay(); + int week = networkWeekNumber(current, weekOffset); + while (networkWeekNumber(weekBoundary, weekOffset) == week) { + weekBoundary = weekBoundary.plusDays(1); + } + if (next == null || weekBoundary.isBefore(next)) next = weekBoundary; + if (generation.length() > 0) generation.append('|'); + generation.append(weeklyGenerationId(current, weekOffset)); + } + if (monthly) { + LocalDateTime monthBoundary = current.toLocalDate().withDayOfMonth(1).plusMonths(1).atStartOfDay(); + if (next == null || monthBoundary.isBefore(next)) next = monthBoundary; + if (generation.length() > 0) generation.append('|'); + generation.append("M:").append(current.getYear()).append('-').append(current.getMonthValue()); + } + long expiresAt = next.minusHours(hourOffset).atZone(timeZone).toInstant().toEpochMilli(); + if (expiresAt <= nowMillis) expiresAt = nowMillis + Math.max(1L, Duration.between(current, next).toMillis()); + return new LimitGeneration(generation.toString(), expiresAt); + } + + static String weeklyGenerationId(LocalDateTime current, int weekOffset) { + LocalDateTime weekTime = current.plusDays(weekOffset); + return "W:" + weekTime.get(NETWORK_WEEK_FIELDS.weekBasedYear()) + '-' + + weekTime.get(NETWORK_WEEK_FIELDS.weekOfWeekBasedYear()); + } + + private static int networkWeekNumber(LocalDateTime time, int weekOffset) { + return time.plusDays(weekOffset).get(NETWORK_WEEK_FIELDS.weekOfWeekBasedYear()); + } + + record SharedPurchaseDebit(VoteShopPurchaseResult result, SharedMysqlPurchaseJournal journal, + String purchaseId, String pointsColumn, String limitColumn) { + } + + record LimitGeneration(String value, long expiresAt) { + private static final LimitGeneration NONE = new LimitGeneration( + SharedMysqlPurchaseJournal.NO_LIMIT_RESET_GENERATION, 0L); + } + + Object purchaseLock(String uuid) { + return PURCHASE_LOCKS[(uuid == null ? 0 : uuid.hashCode()) & (PURCHASE_LOCK_STRIPES - 1)]; + } + + private static Object[] createPurchaseLocks() { + Object[] locks = new Object[PURCHASE_LOCK_STRIPES]; + for (int i = 0; i < locks.length; i++) { + locks[i] = new Object(); + } + return locks; } /** @@ -145,6 +940,20 @@ public boolean hasPermission(Player player, String permission) { */ public void sendFailureMessage(Player player, VotingPluginUser user, VoteShopItem item, VoteShopPurchaseResult result) { + if (result == VoteShopPurchaseResult.SHOP_DISABLED) { + player.sendMessage(com.bencodez.simpleapi.messages.MessageAPI.colorize("&cVote shop disabled")); + return; + } + if (result == VoteShopPurchaseResult.FAILED) { + player.sendMessage(com.bencodez.simpleapi.messages.MessageAPI.colorize( + "&cUnable to complete this purchase; please try again.")); + return; + } + if (result == VoteShopPurchaseResult.RECONCILIATION_REQUIRED) { + player.sendMessage(com.bencodez.simpleapi.messages.MessageAPI.colorize( + "&cThis purchase requires administrator review; do not retry it.")); + return; + } if (result == VoteShopPurchaseResult.LIMIT_REACHED) { user.sendMessage(definition.getLimitReachedMessage()); return; diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/CommandLoaderSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/CommandLoaderSchedulingTest.java new file mode 100644 index 0000000000..77f85e4a19 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/CommandLoaderSchedulingTest.java @@ -0,0 +1,131 @@ +package com.bencodez.votingplugin.commands; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +import com.bencodez.simpleapi.scheduler.BukkitScheduler; +import com.bencodez.simpleapi.folialib.FoliaLib; +import com.bencodez.simpleapi.folialib.enums.EntityTaskResult; +import com.bencodez.simpleapi.folialib.impl.ServerImplementation; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.config.Config; +import com.bencodez.votingplugin.user.PointTransferResult; +import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.util.BukkitCompletionScheduler; + +class CommandLoaderSchedulingTest { + @Test + void playerCommandCompletionUsesTheSendersEntityLane() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + configureEntityScheduler(scheduler); + Player sender = mock(Player.class); + Runnable completion = () -> { }; + + new CommandLoader(plugin).runForCommandSender(sender, completion); + + verify(scheduler).runTask(eq(plugin), any(Runnable.class), eq(sender)); + verify(scheduler, never()).runTask(eq(plugin), any(Runnable.class)); + } + + @Test + void consoleCommandCompletionUsesTheGlobalLane() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + configureEntityScheduler(scheduler); + CommandSender sender = mock(CommandSender.class); + Runnable completion = () -> { }; + + new CommandLoader(plugin).runForCommandSender(sender, completion); + + verify(scheduler).runTask(eq(plugin), any(Runnable.class)); + verify(scheduler, never()).runTask(eq(plugin), any(Runnable.class), any(Player.class)); + } + + @Test + void onlineVotingUserCompletionUsesTheRecipientsEntityLane() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + configureEntityScheduler(scheduler); + VotingPluginUser user = mock(VotingPluginUser.class); + Player recipient = mock(Player.class); + when(user.getPlayer()).thenReturn(recipient); + Runnable completion = () -> { }; + + new CommandLoader(plugin).runForVotingUser(user, completion); + + verify(scheduler).runTask(eq(plugin), any(Runnable.class), eq(recipient)); + verify(scheduler, never()).runTask(eq(plugin), any(Runnable.class)); + } + + @Test + void offlineVotingUserCompletionUsesTheGlobalLane() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + VotingPluginUser user = mock(VotingPluginUser.class); + Runnable completion = () -> { }; + + new CommandLoader(plugin).runForVotingUser(user, completion); + + verify(scheduler).runTask(eq(plugin), any(Runnable.class)); + verify(scheduler, never()).runTask(eq(plugin), any(Runnable.class), any(Player.class)); + } + + @Test + void transferFailureMessagesDoNotDiagnoseAvailabilityAsInsufficientPoints() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + Config config = mock(Config.class); + when(plugin.getConfigFile()).thenReturn(config); + when(config.getFormatCommandsVoteGivePointsNotEnoughPoints()).thenReturn("insufficient"); + when(config.getFormatCommandsVoteGivePointsUnavailable()).thenReturn("retry"); + when(config.getFormatCommandsVoteGivePointsPendingConfirmation()).thenReturn("pending; do not retry"); + CommandLoader loader = new CommandLoader(plugin); + + org.junit.jupiter.api.Assertions.assertEquals("insufficient", + loader.transferFailureMessage(PointTransferResult.INSUFFICIENT_POINTS)); + org.junit.jupiter.api.Assertions.assertEquals("retry", loader.transferFailureMessage(PointTransferResult.CANCELLED)); + org.junit.jupiter.api.Assertions.assertEquals("retry", loader.transferFailureMessage(PointTransferResult.UNAVAILABLE)); + org.junit.jupiter.api.Assertions.assertEquals("pending; do not retry", + loader.transferFailureMessage(PointTransferResult.PENDING_CONFIRMATION)); + } + + @Test + void durableClaimRecoveryRunsOnlyWhenTheGlobalSchedulerRejectsBeforeTaskStart() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doThrow(new IllegalStateException("stopping")).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + AtomicBoolean taskRan = new AtomicBoolean(); + AtomicBoolean rejected = new AtomicBoolean(); + + BukkitCompletionScheduler.run(plugin, null, () -> taskRan.set(true), () -> rejected.set(true)); + + org.junit.jupiter.api.Assertions.assertFalse(taskRan.get()); + org.junit.jupiter.api.Assertions.assertTrue(rejected.get()); + } + + private static void configureEntityScheduler(BukkitScheduler scheduler) { + FoliaLib folia = mock(FoliaLib.class); + ServerImplementation entityScheduler = mock(ServerImplementation.class); + when(scheduler.getFoliaLib()).thenReturn(folia); + when(folia.getImpl()).thenReturn(entityScheduler); + when(entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SUCCESS)); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/player/VoteShopConfirmTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/player/VoteShopConfirmTest.java new file mode 100644 index 0000000000..55ff3d3406 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/player/VoteShopConfirmTest.java @@ -0,0 +1,16 @@ +package com.bencodez.votingplugin.commands.gui.player; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class VoteShopConfirmTest { + @Test + void confirmationCanSubmitOnlyOnePurchase() { + VoteShopConfirm confirmation = new VoteShopConfirm(null, null, null, null, null); + + assertTrue(confirmation.beginPurchase()); + assertFalse(confirmation.beginPurchase()); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java index ce30fc1890..babff77a74 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java @@ -860,9 +860,14 @@ class BackendConfigurationServiceTest { Files.writeString(directory.resolve("SpecialRewards.yml"), "VoteParty:\n Enabled: false\n"); BackendConfigurationService.QuickPreview party = service.previewQuickSetup("vote-party", Map.of( - "votesRequired", "25", "command", "give %player% diamond 1", "broadcast", "Party!", + "enabled", "false", "votesRequired", "25", "command", "give %player% diamond 1", "broadcast", "Party!", "giveAllPlayers", "false", "onlineOnly", "true")); assertTrue(party.proposal().content().contains("VotesRequired: 25")); + assertTrue(party.proposal().content().contains("Enabled: false")); + BackendConfigurationService.QuickPreview legacyParty = service.previewQuickSetup("vote-party", Map.of( + "votesRequired", "25", "command", "", "broadcast", "", + "giveAllPlayers", "false", "onlineOnly", "true")); + assertTrue(legacyParty.proposal().content().contains("Enabled: false")); } @Test void guidedSettingsReadTheInstalledValuesInsteadOfAssumingDefaults() throws Exception { @@ -886,6 +891,10 @@ class BackendConfigurationServiceTest { assertEquals("EMERALD", service.readQuickSetup("vote-site", Map.of("name", "PMC")) .options().get("material")); assertEquals("2", service.readQuickSetup("vote-party", Map.of()).options().get("rewardCommandCount")); + assertEquals("true", service.readQuickSetup("vote-party", Map.of("enabled", "false")) + .options().get("enabled")); + assertThrows(IllegalArgumentException.class, + () -> service.readQuickSetup("vote-party", Map.of("enabled", "not-a-boolean"))); } @Test void oversizedInstalledGuidedValuesFailInsteadOfWedgingResultSubmission() throws Exception { @@ -966,7 +975,7 @@ class BackendConfigurationServiceTest { assertFalse(reward.proposal().content().contains("New message")); BackendConfigurationService.QuickPreview party = service.previewQuickSetup("vote-party", Map.of( - "votesRequired", "20", "command", "new party", "broadcast", "Party!", + "enabled", "true", "votesRequired", "20", "command", "new party", "broadcast", "Party!", "giveAllPlayers", "false", "onlineOnly", "true")); assertTrue(party.proposal().content().contains("existing party")); assertTrue(party.proposal().content().contains("new party")); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java index ca9d1e9428..11c607db9b 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java @@ -214,6 +214,8 @@ class BackendControlConnectorProtocolTest { .anyMatch(value -> "config.file-comments.v1".equals(value.getAsString()))); assertTrue(advertised.asList().stream() .anyMatch(value -> "config.vote-sites-sync.v1".equals(value.getAsString()))); + assertTrue(advertised.asList().stream() + .anyMatch(value -> "config.quick-setup.v2".equals(value.getAsString()))); assertTrue(advertised.asList().stream() .anyMatch(value -> "config.reward-files.v1".equals(value.getAsString()))); assertTrue(advertised.asList().stream() @@ -258,10 +260,32 @@ class BackendControlConnectorProtocolTest { } @Test void voteSitesSyncRequiresBothNegotiatedCapabilities() { - assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("sync-vote-sites", true, false)); - assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("sync-vote-sites", false, true)); - assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("sync-vote-sites", true, true)); - assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("common-settings", true, false)); + assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("sync-vote-sites", true, false, false)); + assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("sync-vote-sites", false, false, true)); + assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("sync-vote-sites", true, false, true)); + assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("common-settings", true, false, false)); + } + + @Test void votePartyRequiresItsVersionedCapability() { + assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", true, false, false)); + assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", false, true, false)); + assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", true, true, false)); + } + + @Test void legacyVotePartyOptionsUseV1ButEnabledRequiresV2() { + assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", true, false, false, + Map.of("threshold", "10"))); + assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", true, false, false, + Map.of("enabled", "true"))); + assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", false, true, false, + Map.of("enabled", "true"))); + assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", true, true, false, + Map.of("enabled", "true"))); + Map state = Map.of("enabled", "false", "votesRequired", "20"); + assertEquals(Map.of("votesRequired", "20"), + BackendControlConnector.resultQuickReadOptions("vote-party", state, Map.of())); + assertEquals(state, BackendControlConnector.resultQuickReadOptions("vote-party", state, + Map.of("enabled", "false"))); } @Test void rewardBuilderResultsKeepOnlyTheSafeRecoveryTarget() { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/events/VoteShopPurchaseEventTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/events/VoteShopPurchaseEventTest.java new file mode 100644 index 0000000000..7df46ed239 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/events/VoteShopPurchaseEventTest.java @@ -0,0 +1,22 @@ +package com.bencodez.votingplugin.events; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import com.bencodez.votingplugin.user.VotingPluginUser; + +class VoteShopPurchaseEventTest { + @Test + void entityLanePurchaseCanBeMarkedSynchronousWithoutChangingTheLegacyConstructor() { + UUID uuid = UUID.randomUUID(); + VotingPluginUser user = mock(VotingPluginUser.class); + + assertFalse(new VoteShopPurchaseEvent(uuid, "voter", user, "reward", 5, false).isAsynchronous()); + assertTrue(new VoteShopPurchaseEvent(uuid, "voter", user, "reward", 5).isAsynchronous()); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/rewards/builtin/RewardPointsTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/rewards/builtin/RewardPointsTest.java new file mode 100644 index 0000000000..d4abd11339 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/rewards/builtin/RewardPointsTest.java @@ -0,0 +1,97 @@ +package com.bencodez.votingplugin.rewards.builtin; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.HashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.rewards.Reward; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.user.UserManager; +import com.bencodez.votingplugin.user.VotingPluginUser; + +class RewardPointsTest { + @Test + void publishesStorageAwarePointTotalWithoutBlockingTheRewardLane() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + UserManager manager = mock(UserManager.class); + AdvancedCoreUser advancedUser = mock(AdvancedCoreUser.class); + VotingPluginUser user = mock(VotingPluginUser.class); + when(plugin.getVotingPluginUserManager()).thenReturn(manager); + when(manager.getVotingPluginUser(advancedUser)).thenReturn(user); + when(user.addPointsStorageAware(5)).thenReturn(73); + + String result = new RewardPoints(plugin).onRewardRequest(mock(Reward.class), advancedUser, 5, + new HashMap<>()); + + assertEquals("73", result); + verify(user).addPointsStorageAware(5); + verify(user, never()).addPoints(5); + } + + @Test + void asyncRewardWaitsForCommittedPointTotal() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + UserManager manager = mock(UserManager.class); + AdvancedCoreUser advancedUser = mock(AdvancedCoreUser.class); + VotingPluginUser user = mock(VotingPluginUser.class); + CompletableFuture committed = new CompletableFuture<>(); + when(plugin.getVotingPluginUserManager()).thenReturn(manager); + when(manager.getVotingPluginUser(advancedUser)).thenReturn(user); + when(user.addPointsStorageAwareAsync(5)).thenReturn(committed); + RewardPoints points = new RewardPoints(plugin); + + CompletableFuture result = points.onRewardRequestAsync(mock(Reward.class), advancedUser, 5, + new HashMap<>()).toCompletableFuture(); + + assertEquals(false, result.isDone()); + committed.complete(73); + assertEquals("73", result.join()); + verify(user).addPointsStorageAwareAsync(5); + verify(user, never()).addPointsStorageAware(5); + } + + @Test + void asyncRewardPropagatesPersistenceFailure() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + UserManager manager = mock(UserManager.class); + AdvancedCoreUser advancedUser = mock(AdvancedCoreUser.class); + VotingPluginUser user = mock(VotingPluginUser.class); + when(plugin.getVotingPluginUserManager()).thenReturn(manager); + when(manager.getVotingPluginUser(advancedUser)).thenReturn(user); + when(user.addPointsStorageAwareAsync(5)).thenReturn( + CompletableFuture.failedFuture(new IllegalStateException("write failed"))); + + CompletableFuture result = new RewardPoints(plugin) + .onRewardRequestAsync(mock(Reward.class), advancedUser, 5, new HashMap<>()).toCompletableFuture(); + + org.junit.jupiter.api.Assertions.assertThrows(CompletionException.class, result::join); + } + + @Test + void durableReplayCheckpointAcknowledgesTheMatchingPointOperation() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + UserManager manager = mock(UserManager.class); + AdvancedCoreUser advancedUser = mock(AdvancedCoreUser.class); + VotingPluginUser user = mock(VotingPluginUser.class); + when(plugin.getVotingPluginUserManager()).thenReturn(manager); + when(manager.getVotingPluginUser(advancedUser)).thenReturn(user); + when(user.getUUID()).thenReturn("player-uuid"); + when(user.acknowledgeStorageAwarePointOperation(org.mockito.ArgumentMatchers.anyString())) + .thenReturn(CompletableFuture.completedFuture(null)); + + new RewardPoints(plugin).onReplayCheckpointPersisted(mock(Reward.class), advancedUser, "occurrence-1", + "AsyncReward/0").toCompletableFuture().join(); + + verify(user).acknowledgeStorageAwarePointOperation( + "1d257d984bf6531c07e366b02a5373043961a6e44c23528f91d027c0d2c83f64"); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconcilerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconcilerTest.java new file mode 100644 index 0000000000..399f2e19d9 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconcilerTest.java @@ -0,0 +1,40 @@ +package com.bencodez.votingplugin.user; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.simpleapi.sql.data.DataValue; +import com.bencodez.votingplugin.VotingPluginMain; + +class SharedMysqlCacheReconcilerTest { + @Test + void invalidateAllCopiesTheLiveRegistryBeforeWalkingCaches() { + UUID uuid = UUID.fromString("00000000-0000-0000-0000-000000000001"); + UserDataCache cache = mock(UserDataCache.class); + HashMap values = new HashMap<>(); + values.put("VoteShopLimitdaily", mock(DataValue.class)); + when(cache.getCache()).thenReturn(values); + + ConcurrentHashMap liveCaches = new ConcurrentHashMap<>() { + @Override + public java.util.Collection values() { + throw new AssertionError("reset invalidation must not walk the live values view"); + } + }; + liveCaches.put(uuid, cache); + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn(liveCaches); + + SharedMysqlCacheReconciler.invalidateAll(plugin, "VoteShopLimitdaily"); + + assertFalse(values.containsKey("VoteShopLimitdaily")); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java new file mode 100644 index 0000000000..6653187f8d --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -0,0 +1,778 @@ +package com.bencodez.votingplugin.user; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.ArgumentMatchers.any; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.UUID; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.UserData; +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; +import com.bencodez.simpleapi.sql.data.DataValue; +import com.bencodez.simpleapi.sql.data.DataValueInt; +import com.bencodez.votingplugin.VotingPluginMain; + +class SharedMysqlPointMutatorTest { + @Test + void transferApprovalUsesLegacyEntitySchedulerWhenFoliaIsUnavailable() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + ScheduledExecutorService persistence = mock(ScheduledExecutorService.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(scheduler.getFoliaLib()).thenReturn(null); + when(plugin.getTimer()).thenReturn(persistence); + when(plugin.getUserManager().getDataManager().getUserDataCache()) + .thenReturn(new java.util.concurrent.ConcurrentHashMap<>()); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class), any(org.bukkit.entity.Player.class)); + doAnswer(invocation -> { + invocation.getArgument(0, Runnable.class).run(); + return null; + }).when(persistence).execute(any(Runnable.class)); + + org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + VotingPluginUser source = mock(VotingPluginUser.class); + when(source.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + when(source.getPointsPath()).thenReturn("Points_server_a"); + when(source.getPlayer()).thenReturn(player); + VotingPluginUser target = mock(VotingPluginUser.class); + when(target.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002"); + when(target.getPointsPath()).thenReturn("Points_server_b"); + when(target.getPlayer()).thenReturn(player); + SharedPointTransferJournal journal = mock(SharedPointTransferJournal.class); + when(journal.claimHookWithConfirmation(eq("transfer-1"), eq("owner"), org.mockito.ArgumentMatchers.anyLong())) + .thenReturn(SharedPointTransferJournal.ClaimOutcome.CLAIMED); + when(journal.settleWithConfirmation(eq("transfer-1"), eq("owner"), anyString(), anyString(), anyString(), + anyString(), eq(10), eq(10))).thenReturn(SharedPointTransferJournal.SettlementOutcome.COMPLETED); + AtomicReference completion = new AtomicReference<>(); + java.util.function.Consumer resultConsumer = completion::set; + + Method claim = SharedMysqlPointMutator.class.getDeclaredMethod("claimTransferForApproval", + VotingPluginUser.class, VotingPluginUser.class, org.bukkit.entity.Player.class, + org.bukkit.entity.Player.class, int.class, java.util.function.IntFunction.class, + java.util.function.Consumer.class, SharedPointTransferJournal.class, String.class, String.class, String.class, + String.class); + claim.setAccessible(true); + claim.invoke(new SharedMysqlPointMutator(plugin), source, target, player, player, 10, + (java.util.function.IntFunction) value -> value, resultConsumer, journal, "transfer-1", "owner", + "Points_server_a", "Points_server_b"); + + assertEquals(PointTransferResult.SUCCESS, completion.get()); + verify(scheduler, times(2)).runTask(eq(plugin), any(Runnable.class), eq(player)); + } + + @Test + void pointMutationDumpHoldsTheSharedLimitResetFence() throws Exception { + MySQL table = mock(MySQL.class); + com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, + org.mockito.Mockito.RETURNS_DEEP_STUBS); + Connection connection = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + when(table.getTableName()).thenReturn("VotingPlugin_Users"); + when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); + when(table.getMysql()).thenReturn(sql); + when(sql.getConnectionManager().getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + when(statement.executeUpdate()).thenReturn(1); + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getMysql()).thenReturn(table); + VotingPluginUser user = mock(VotingPluginUser.class); + when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + when(user.getPointsPath()).thenReturn("Points"); + when(user.isCached()).thenReturn(true); + UserDataCache cache = mock(UserDataCache.class); + when(user.getCache()).thenReturn(cache); + + java.util.concurrent.CountDownLatch dumpEntered = new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.CountDownLatch releaseDump = new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.CountDownLatch resetEntered = new java.util.concurrent.CountDownLatch(1); + org.mockito.Mockito.doAnswer(invocation -> { + dumpEntered.countDown(); + assertTrue(releaseDump.await(2, TimeUnit.SECONDS)); + return null; + }).when(cache).dump(); + java.util.concurrent.ExecutorService workers = java.util.concurrent.Executors.newFixedThreadPool(2); + try { + java.util.concurrent.Future mutation = workers.submit( + () -> new SharedMysqlPointMutator(plugin).setCommitted(user, 20)); + assertTrue(dumpEntered.await(1, TimeUnit.SECONDS)); + java.util.concurrent.Future reset = workers.submit( + () -> SharedMysqlCacheReconciler.withResetFence(resetEntered::countDown)); + + assertFalse(resetEntered.await(100, TimeUnit.MILLISECONDS), + "a limit reset must wait for an in-flight point-mutation cache dump"); + releaseDump.countDown(); + assertTrue(mutation.get(1, TimeUnit.SECONDS)); + reset.get(1, TimeUnit.SECONDS); + assertEquals(0, resetEntered.getCount()); + } finally { + releaseDump.countDown(); + workers.shutdownNow(); + } + } + + @Test + void indeterminateTransferReservationInvalidatesRecreatedSourcePoints() throws Exception { + MySQL table = mock(MySQL.class); + com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, + org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(table.getTableName()).thenReturn("VotingPlugin_Users"); + when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); + when(table.getMysql()).thenReturn(sql); + when(sql.getConnectionManager().getConnection()).thenReturn(null); + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getMysql()).thenReturn(table); + ScheduledExecutorService persistence = mock(ScheduledExecutorService.class); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getTimer()).thenReturn(persistence); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + org.mockito.Mockito.doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + + String sourceUuid = "00000000-0000-0000-0000-000000000001"; + VotingPluginUser source = mock(VotingPluginUser.class); + when(source.getUUID()).thenReturn(sourceUuid); + when(source.getPointsPath()).thenReturn("Points"); + VotingPluginUser target = mock(VotingPluginUser.class); + when(target.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002"); + when(target.getPointsPath()).thenReturn("Points"); + UserDataCache recreatedCache = mock(UserDataCache.class); + HashMap recreatedValues = new HashMap<>(); + recreatedValues.put("Points", new DataValueInt(20)); + recreatedValues.put("DailyTotal", new DataValueInt(4)); + when(recreatedCache.getCache()).thenReturn(recreatedValues); + when(plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn( + new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(UUID.fromString(sourceUuid), recreatedCache))); + AtomicReference result = new AtomicReference<>(); + + new SharedMysqlPointMutator(plugin).transferWithBukkitApproval(source, target, 10, value -> value, result::set); + ArgumentCaptor reservation = ArgumentCaptor.forClass(Runnable.class); + verify(persistence).execute(reservation.capture()); + reservation.getValue().run(); + + assertFalse(recreatedValues.containsKey("Points")); + assertTrue(recreatedValues.containsKey("DailyTotal")); + assertEquals(PointTransferResult.UNAVAILABLE, result.get()); + } + + @Test + void indeterminateClaimedRefundStillInvalidatesSourcePoints() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + org.mockito.Mockito.doAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + VotingPluginUser source = mock(VotingPluginUser.class); + String sourceUuid = "00000000-0000-0000-0000-000000000001"; + when(source.getUUID()).thenReturn(sourceUuid); + when(source.getPointsPath()).thenReturn("Points"); + UserDataCache cache = mock(UserDataCache.class); + HashMap values = new HashMap<>(); + values.put("Points", new DataValueInt(10)); + when(cache.getCache()).thenReturn(values); + when(plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn( + new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of( + java.util.UUID.fromString(sourceUuid), cache))); + SharedPointTransferJournal journal = mock(SharedPointTransferJournal.class); + when(journal.refundHookStarted("transfer-1", source.getUUID(), "Points", 10)) + .thenThrow(new java.sql.SQLException("lost acknowledgement and confirmation")); + AtomicReference result = new AtomicReference<>(); + + new SharedMysqlPointMutator(plugin).refundClaimedAfterSchedulingFailure(source, null, result::set, journal, + "transfer-1", "Points", 10, new RejectedExecutionException("worker stopped")); + + assertFalse(values.containsKey("Points")); + assertEquals(PointTransferResult.UNAVAILABLE, result.get()); + } + + @Test + void recoveryInvalidatesOnlyRefundedColumnsAfterJdbcCompletes() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + UserDataCache cache = mock(UserDataCache.class); + HashMap values = new HashMap<>(); + values.put("Points", mock(DataValue.class)); + values.put("VoteShopLimitdaily", mock(DataValue.class)); + values.put("DailyTotal", mock(DataValue.class)); + when(plugin.getUserManager().getDataManager().getUserDataCache()) + .thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of( + java.util.UUID.fromString("00000000-0000-0000-0000-000000000001"), cache))); + when(cache.getCache()).thenReturn(values); + + SharedMysqlCacheReconciler.invalidate(plugin, "00000000-0000-0000-0000-000000000001", "Points", + "VoteShopLimitdaily"); + + assertFalse(values.containsKey("Points")); + assertFalse(values.containsKey("VoteShopLimitdaily")); + assertTrue(values.containsKey("DailyTotal")); + } + + @Test + void userManagerSchedulesOneBoundedSharedTransferRecoveryPerLifecycle() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + ScheduledExecutorService persistence = mock(ScheduledExecutorService.class); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(false); + when(plugin.getTimer()).thenReturn(persistence); + + UserManager manager = new UserManager(plugin); + manager.startSharedPointTransferRecovery(); + manager.startSharedPointTransferRecovery(); + + verify(persistence).execute(any(Runnable.class)); + verify(persistence).scheduleWithFixedDelay(any(Runnable.class), org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(TimeUnit.MINUTES)); + } + + @Test + void userManagerSchedulesRecoveryAtStartupEvenWithPerServerPoints() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + ScheduledExecutorService persistence = mock(ScheduledExecutorService.class); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); + when(plugin.getTimer()).thenReturn(persistence); + + UserManager manager = new UserManager(plugin); + manager.startSharedPointTransferRecovery(); // Old shared-point rows still need recovery. + manager.startSharedPointTransferRecovery(); // Later reload must not duplicate lifecycle work. + + verify(persistence, times(1)).execute(any(Runnable.class)); + verify(persistence, times(1)).scheduleWithFixedDelay(any(Runnable.class), + org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(TimeUnit.MINUTES)); + } + + @Test + void transferRecoveryEligibilityIgnoresCurrentPerServerPointsSetting() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); + + assertTrue(SharedMysqlPointMutator.canRecoverSharedMysqlPointJournals(plugin)); + assertFalse(SharedMysqlPointMutator.usesSharedMysqlPoints(plugin)); + } + + @Test + void pointAdditionAcknowledgementSurvivesPerServerModeSwitch() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + ScheduledExecutorService persistence = mock(ScheduledExecutorService.class); + MySQL table = mock(MySQL.class); + com.bencodez.simpleapi.sql.mysql.MySQL sql = + mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + Connection connection = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); + when(plugin.getTimer()).thenReturn(persistence); + when(plugin.getMysql()).thenReturn(table); + when(table.getTableName()).thenReturn("VotingPlugin_Ack_Mode_Switch"); + when(table.qi(anyString())).thenAnswer(call -> "`" + call.getArgument(0) + "`"); + when(table.getMysql()).thenReturn(sql); + when(sql.getConnectionManager().getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + + CompletableFuture completion = new SharedMysqlPointMutator(plugin) + .acknowledgePointAddition("reward-operation").toCompletableFuture(); + assertFalse(completion.isDone()); + ArgumentCaptor work = ArgumentCaptor.forClass(Runnable.class); + verify(persistence).execute(work.capture()); + work.getValue().run(); + completion.join(); + + verify(statement).setString(1, "ACKNOWLEDGED"); + verify(statement).setString(3, "reward-operation"); + verify(statement).setString(4, "COMPLETED"); + } + + @Test + void rejectedRecoverySchedulingCanRetryLater() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + ScheduledExecutorService persistence = mock(ScheduledExecutorService.class); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(false); + when(plugin.getTimer()).thenReturn(persistence); + doThrow(new RejectedExecutionException("stopping")).doNothing() + .when(persistence).execute(any(Runnable.class)); + + UserManager manager = new UserManager(plugin); + manager.startSharedPointTransferRecovery(); + manager.startSharedPointTransferRecovery(); + + verify(persistence, times(2)).execute(any(Runnable.class)); + verify(persistence).scheduleWithFixedDelay(any(Runnable.class), org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(TimeUnit.MINUTES)); + } + + @Test + void scheduledRecoveryLogsRuntimeFailureWithoutCancellingFixedDelayTask() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + ScheduledExecutorService persistence = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(persistence); + when(plugin.getStorageType()).thenThrow(new IllegalStateException("storage unavailable")); + + SharedMysqlPointMutator.scheduleTransferRecovery(plugin); + + ArgumentCaptor scheduled = ArgumentCaptor.forClass(Runnable.class); + verify(persistence).scheduleWithFixedDelay(scheduled.capture(), org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(TimeUnit.MINUTES)); + org.junit.jupiter.api.Assertions.assertDoesNotThrow(scheduled.getValue()::run); + verify(plugin.getLogger()).severe("Unable to recover shared MySQL point journals: IllegalStateException"); + } + + @Test + void removeReportsARejectedConditionalDebit() throws Exception { + MySQL table = mock(MySQL.class); + com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, + org.mockito.Mockito.RETURNS_DEEP_STUBS); + Connection connection = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + when(table.getTableName()).thenReturn("VotingPlugin_Users"); + when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); + when(table.getMysql()).thenReturn(sql); + when(sql.getConnectionManager().getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + when(statement.executeUpdate()).thenReturn(0); + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getMysql()).thenReturn(table); + VotingPluginUser user = mock(VotingPluginUser.class); + when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + when(user.getPointsPath()).thenReturn("Points"); + + assertFalse(new SharedMysqlPointMutator(plugin).remove(user, 10)); + } + + @Test + void pointMutationToleratesCacheRemovalDuringDatabaseWrite() throws Exception { + MySQL table = mock(MySQL.class); + com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, + org.mockito.Mockito.RETURNS_DEEP_STUBS); + Connection connection = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + when(table.getTableName()).thenReturn("VotingPlugin_Users"); + when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); + when(table.getMysql()).thenReturn(sql); + when(sql.getConnectionManager().getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + when(statement.executeUpdate()).thenReturn(1); + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getMysql()).thenReturn(table); + VotingPluginUser user = mock(VotingPluginUser.class); + when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + when(user.getPointsPath()).thenReturn("Points"); + when(user.isCached()).thenReturn(false, true); + + assertTrue(new SharedMysqlPointMutator(plugin).remove(user, 10)); + + verify(statement).executeUpdate(); + verify(plugin.getUserManager().getDataManager(), never()).removeCache(any(), any()); + } + + @Test + void asynchronousRemoveDoesNotAcquireJdbcOnTheCallerThread() throws Exception { + MySQL table = mock(MySQL.class); + com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, + org.mockito.Mockito.RETURNS_DEEP_STUBS); + Connection connection = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + when(table.getTableName()).thenReturn("VotingPlugin_Users"); + when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); + when(table.getMysql()).thenReturn(sql); + when(sql.getConnectionManager().getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getMysql()).thenReturn(table); + ScheduledExecutorService persistence = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(persistence); + VotingPluginUser user = mock(VotingPluginUser.class); + UserData data = mock(UserData.class); + when(user.getUserData()).thenReturn(data); + when(data.getInt("Points", UserDataFetchMode.TEMP_ONLY)).thenReturn(20); + when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + when(user.getPointsPath()).thenReturn("Points"); + + assertTrue(new SharedMysqlPointMutator(plugin).remove(user, 10, true)); + ArgumentCaptor work = ArgumentCaptor.forClass(Runnable.class); + verify(persistence).execute(work.capture()); + verify(sql.getConnectionManager(), never()).getConnection(); + verify(data).getInt("Points", UserDataFetchMode.TEMP_ONLY); + + work.getValue().run(); + verify(sql.getConnectionManager()).getConnection(); + verify(statement).executeUpdate(); + } + + @Test + void asynchronousAddUsesOnlyCachedPointsOnTheCallerThread() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + ScheduledExecutorService persistence = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(persistence); + VotingPluginUser user = mock(VotingPluginUser.class); + UserDataCache cache = mock(UserDataCache.class); + DataValue points = mock(DataValue.class); + java.util.HashMap values = new java.util.HashMap<>(); + values.put("Points", points); + when(user.getCache()).thenReturn(cache); + when(user.isCached()).thenReturn(true); + when(cache.getCache()).thenReturn(values); + when(points.isInt()).thenReturn(true); + when(points.getInt()).thenReturn(20); + when(user.getPointsPath()).thenReturn("Points"); + + assertEquals(30, new SharedMysqlPointMutator(plugin).add(user, 10, true)); + + verify(cache, times(3)).getCache(); + assertEquals(30, values.get("Points").getInt()); + verify(user, never()).getPoints(); + verify(persistence).execute(any(Runnable.class)); + } + + @Test + void asynchronousAddDoesNotFlushItsOptimisticPointsPrediction() throws Exception { + MySQL table = mock(MySQL.class); + com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, + org.mockito.Mockito.RETURNS_DEEP_STUBS); + Connection connection = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + when(table.getTableName()).thenReturn("VotingPlugin_Users"); + when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); + when(table.getMysql()).thenReturn(sql); + when(sql.getConnectionManager().getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + when(statement.executeUpdate()).thenReturn(1); + + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getMysql()).thenReturn(table); + ScheduledExecutorService persistence = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(persistence); + VotingPluginUser user = mock(VotingPluginUser.class); + when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + when(user.getPointsPath()).thenReturn("Points"); + when(user.isCached()).thenReturn(true); + UserDataCache cache = mock(UserDataCache.class); + HashMap values = new HashMap<>(); + values.put("Points", new DataValueInt(20)); + values.put("DailyTotal", new DataValueInt(4)); + when(user.getCache()).thenReturn(cache); + when(cache.getCache()).thenReturn(values); + org.mockito.Mockito.doAnswer(invocation -> { + assertFalse(values.containsKey("Points"), "the predicted value must not be persisted by dump"); + assertTrue(values.containsKey("DailyTotal"), "unrelated pending values must still be flushed"); + return null; + }).when(cache).dump(); + + assertEquals(30, new SharedMysqlPointMutator(plugin).add(user, 10, true)); + assertEquals(30, values.get("Points").getInt()); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(persistence).execute(task.capture()); + + task.getValue().run(); + + verify(cache).dump(); + verify(statement).executeUpdate(); + } + + @Test + void clearingAnOfflineUserCacheCannotFlushAnOptimisticPointsPrediction() { + VotingPluginUser user = mock(VotingPluginUser.class); + UserDataCache cache = mock(UserDataCache.class); + HashMap values = new HashMap<>(); + DataValue prediction = new DataValueInt(30); + values.put("Points", prediction); + when(user.isCached()).thenReturn(true); + when(user.getCache()).thenReturn(cache); + when(user.getPointsPath()).thenReturn("Points"); + when(cache.getCache()).thenReturn(values); + doCallRealMethod().when(user).clearCache(); + doAnswer(invocation -> { + assertFalse(values.containsKey("Points"), + "the prediction must be removed before clearCache can dump it"); + return null; + }).when(cache).clearCache(); + SharedMysqlCacheReconciler.recordOptimisticPoint(cache, "Points", prediction); + + user.clearCache(); + + verify(cache).clearCache(); + } + + @Test + void addUsesAtomicDatabaseArithmeticInsteadOfAnAbsoluteCachedWrite() throws Exception { + MySQL table = mock(MySQL.class); + com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, + org.mockito.Mockito.RETURNS_DEEP_STUBS); + Connection connection = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + PreparedStatement read = mock(PreparedStatement.class); + java.sql.ResultSet result = mock(java.sql.ResultSet.class); + when(table.getTableName()).thenReturn("VotingPlugin_Users"); + when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); + when(table.getMysql()).thenReturn(sql); + when(sql.getConnectionManager().getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement, read); + when(result.next()).thenReturn(true); + when(result.getInt(1)).thenReturn(73); + when(read.executeQuery()).thenReturn(result); + when(statement.executeUpdate()).thenReturn(1); + + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(false); + when(plugin.getMysql()).thenReturn(table); + ScheduledExecutorService persistence = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(persistence); + VotingPluginUser user = mock(VotingPluginUser.class); + when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + when(user.getPointsPath()).thenReturn("Points"); + UserData data = mock(UserData.class); + when(user.getUserData()).thenReturn(data); + when(data.getInt("Points", UserDataFetchMode.NO_CACHE)).thenReturn(10); + + assertEquals(73, new SharedMysqlPointMutator(plugin).add(user, 10, false)); + + ArgumentCaptor query = ArgumentCaptor.forClass(String.class); + verify(connection, times(2)).prepareStatement(query.capture()); + assertTrue(query.getAllValues().get(0).contains("`Points` = `Points` + ?")); + assertTrue(query.getAllValues().get(1).contains("SELECT `Points`")); + verify(statement).setInt(1, 10); + verify(statement).executeUpdate(); + verify(read).executeQuery(); + verify(data, never()).getInt("Points", UserDataFetchMode.NO_CACHE); + verify(persistence, never()).execute(any(Runnable.class)); + } + + @Test + void committedAddIsNotReportedRetryableWhenFollowUpReadFails() throws Exception { + MySQL table = mock(MySQL.class); + com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, + org.mockito.Mockito.RETURNS_DEEP_STUBS); + Connection connection = mock(Connection.class); + PreparedStatement update = mock(PreparedStatement.class); + PreparedStatement read = mock(PreparedStatement.class); + when(table.getTableName()).thenReturn("VotingPlugin_Users"); + when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); + when(table.getMysql()).thenReturn(sql); + when(sql.getConnectionManager().getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(update, read); + when(update.executeUpdate()).thenReturn(1); + when(read.executeQuery()).thenThrow(new java.sql.SQLException("connection lost after update")); + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getMysql()).thenReturn(table); + VotingPluginUser user = mock(VotingPluginUser.class); + when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + when(user.getPointsPath()).thenReturn("Points"); + when(user.getPoints()).thenReturn(10); + + SharedMysqlPointMutator.AddResult result = new SharedMysqlPointMutator(plugin).addCommitted(user, 5); + + assertTrue(result.success(), "a committed update must not invite a duplicate retry"); + assertEquals(10, result.total(), "the stale total is safer than reporting a retryable failure"); + verify(update).executeUpdate(); + verify(read).executeQuery(); + org.mockito.InOrder closeBeforeFallback = inOrder(connection, user); + closeBeforeFallback.verify(connection).close(); + closeBeforeFallback.verify(user).getPoints(); + } + + @Test + void committedAddDefersEmptyReadFallbackUntilConnectionCloses() throws Exception { + MySQL table = mock(MySQL.class); + com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, + org.mockito.Mockito.RETURNS_DEEP_STUBS); + Connection connection = mock(Connection.class); + PreparedStatement update = mock(PreparedStatement.class); + PreparedStatement read = mock(PreparedStatement.class); + java.sql.ResultSet empty = mock(java.sql.ResultSet.class); + when(table.getTableName()).thenReturn("VotingPlugin_Users"); + when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); + when(table.getMysql()).thenReturn(sql); + when(sql.getConnectionManager().getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(update, read); + when(update.executeUpdate()).thenReturn(1); + when(read.executeQuery()).thenReturn(empty); + when(empty.next()).thenReturn(false); + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getMysql()).thenReturn(table); + VotingPluginUser user = mock(VotingPluginUser.class); + when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + when(user.getPointsPath()).thenReturn("Points"); + when(user.getPoints()).thenReturn(17); + + SharedMysqlPointMutator.AddResult result = new SharedMysqlPointMutator(plugin).addCommitted(user, 5); + + assertTrue(result.success()); + assertEquals(17, result.total()); + org.mockito.InOrder closeBeforeFallback = inOrder(connection, user); + closeBeforeFallback.verify(connection).close(); + closeBeforeFallback.verify(user).getPoints(); + } + + @Test + void capUsesLeastSoItCannotRestoreAConcurrentDebit() throws Exception { + MySQL table = mock(MySQL.class); + com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, + org.mockito.Mockito.RETURNS_DEEP_STUBS); + Connection connection = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + when(table.getTableName()).thenReturn("VotingPlugin_Users"); + when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); + when(table.getMysql()).thenReturn(sql); + when(sql.getConnectionManager().getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getMysql()).thenReturn(table); + VotingPluginUser user = mock(VotingPluginUser.class); + when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + when(user.getPointsPath()).thenReturn("Points"); + + new SharedMysqlPointMutator(plugin).cap(user, 100, false); + + ArgumentCaptor query = ArgumentCaptor.forClass(String.class); + verify(connection).prepareStatement(query.capture()); + assertTrue(query.getValue().contains("`Points` = LEAST(`Points`, ?)")); + } + + @Test + void addAndCapUsesOneAtomicPersistenceMutation() throws Exception { + MySQL table = mock(MySQL.class); + com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, + org.mockito.Mockito.RETURNS_DEEP_STUBS); + Connection connection = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + when(table.getTableName()).thenReturn("VotingPlugin_Users"); + when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); + when(table.getMysql()).thenReturn(sql); + when(sql.getConnectionManager().getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getMysql()).thenReturn(table); + ScheduledExecutorService persistence = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(persistence); + VotingPluginUser user = mock(VotingPluginUser.class); + when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + when(user.getPointsPath()).thenReturn("Points"); + UserDataCache cache = mock(UserDataCache.class); + HashMap values = new HashMap<>(); + values.put("Points", new DataValueInt(95)); + when(user.isCached()).thenReturn(true); + when(user.getCache()).thenReturn(cache); + when(cache.getCache()).thenReturn(values); + UUID uuid = UUID.fromString("00000000-0000-0000-0000-000000000001"); + when(plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn( + new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(uuid, cache))); + org.mockito.Mockito.doAnswer(invocation -> { + assertFalse(values.containsKey("Points"), "the capped prediction must not be dumped before SQL caps it"); + return null; + }).when(cache).dump(); + + new SharedMysqlPointMutator(plugin).addAndCap(user, 10, 100, true); + assertEquals(100, values.get("Points").getInt()); + + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(persistence).execute(task.capture()); + verify(persistence, times(1)).execute(any(Runnable.class)); + verify(sql.getConnectionManager(), never()).getConnection(); + task.getValue().run(); + + ArgumentCaptor query = ArgumentCaptor.forClass(String.class); + verify(connection).prepareStatement(query.capture()); + assertTrue(query.getValue().contains("`Points` = LEAST(`Points` + ?, ?)")); + verify(statement).setInt(1, 10); + verify(statement).setInt(2, 100); + verify(statement).setString(3, "00000000-0000-0000-0000-000000000001"); + verify(statement).executeUpdate(); + assertFalse(values.containsKey("Points")); + } + + @Test + void rejectedAddAndCapSubmissionDiscardsItsPrediction() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + ScheduledExecutorService persistence = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(persistence); + doThrow(new RejectedExecutionException("saturated")).when(persistence).execute(any(Runnable.class)); + VotingPluginUser user = mock(VotingPluginUser.class); + when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + when(user.getPointsPath()).thenReturn("Points"); + UserDataCache cache = mock(UserDataCache.class); + HashMap values = new HashMap<>(); + values.put("Points", new DataValueInt(95)); + when(user.isCached()).thenReturn(true); + when(user.getCache()).thenReturn(cache); + when(cache.getCache()).thenReturn(values); + UUID uuid = UUID.fromString("00000000-0000-0000-0000-000000000001"); + when(plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn( + new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(uuid, cache))); + + new SharedMysqlPointMutator(plugin).addAndCap(user, 10, 100, true); + + assertFalse(values.containsKey("Points")); + verify(plugin.getMysql(), never()).getMysql(); + } + + @Test + void transferCreditsOnlyAfterConditionalDebitSucceeds() throws Exception { + MySQL table = mock(MySQL.class); + com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, + org.mockito.Mockito.RETURNS_DEEP_STUBS); + Connection connection = mock(Connection.class); + PreparedStatement debit = mock(PreparedStatement.class); + PreparedStatement credit = mock(PreparedStatement.class); + when(table.getTableName()).thenReturn("VotingPlugin_Users"); + when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); + when(table.getMysql()).thenReturn(sql); + when(sql.getConnectionManager().getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(debit, credit); + when(debit.executeUpdate()).thenReturn(1); + when(credit.executeUpdate()).thenReturn(1); + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getMysql()).thenReturn(table); + VotingPluginUser source = mock(VotingPluginUser.class); + VotingPluginUser target = mock(VotingPluginUser.class); + when(source.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + when(target.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002"); + when(source.getPointsPath()).thenReturn("Points"); + when(target.getPointsPath()).thenReturn("Points"); + + assertTrue(new SharedMysqlPointMutator(plugin).transfer(source, target, 10)); + + verify(debit).executeUpdate(); + verify(credit).executeUpdate(); + verify(connection).commit(); + verify(connection, times(0)).rollback(); + } + +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java new file mode 100644 index 0000000000..260619de17 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java @@ -0,0 +1,599 @@ +package com.bencodez.votingplugin.user; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; + +class SharedPointAdditionJournalTest { + @Test + void conditionalDebitIsJournaledAndCanBeRetriedWithoutASecondDebit() throws Exception { + Fixture fixture = fixture(); + Connection missing = missingLookup(); + Attempt debit = successfulAttempt(7); + Connection completed = completedLookup("player", "Points", -3, 7); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(missing, debit.connection(), completed); + + SharedPointAdditionJournal journal = new SharedPointAdditionJournal(fixture.table, false); + assertEquals(7, journal.subtract("admin-remove", "player", "Points", 3, 100L).total()); + assertEquals(7, journal.subtract("admin-remove", "player", "Points", 3, 101L).total()); + + verify(debit.credit(), times(1)).executeUpdate(); + verify(debit.credit()).setInt(1, -3); + verify(debit.credit()).setInt(3, 3); + } + + @Test + void conditionalDebitRejectsMissingOrInsufficientUserWithoutCompletingTheJournal() throws Exception { + Fixture fixture = fixture(); + Connection missing = missingLookup(); + Connection attempt = mock(Connection.class); + PreparedStatement insert = mock(PreparedStatement.class); + PreparedStatement debit = mock(PreparedStatement.class); + PreparedStatement read = mock(PreparedStatement.class); + PreparedStatement complete = mock(PreparedStatement.class); + when(debit.executeUpdate()).thenReturn(0); + when(attempt.prepareStatement(anyString())).thenReturn(insert, debit, read, complete); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(missing, attempt); + + SharedPointAdditionJournal journal = new SharedPointAdditionJournal(fixture.table, false); + assertThrows(SharedPointAdditionJournal.DebitRejectedException.class, + () -> journal.subtract("admin-remove", "player", "Points", 11, 100L)); + verify(attempt, atLeastOnce()).rollback(); + verify(complete, org.mockito.Mockito.never()).executeUpdate(); + } + + @Test + void lostCommitAcknowledgementAndFailedConfirmationRetryCreditsExactlyOnce() throws Exception { + Fixture fixture = fixture(); + PreparedStatement missingLookup = mock(PreparedStatement.class); + ResultSet missing = mock(ResultSet.class); + when(missing.next()).thenReturn(false); + when(missingLookup.executeQuery()).thenReturn(missing); + when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(missingLookup); + + PreparedStatement insert = mock(PreparedStatement.class); + PreparedStatement credit = mock(PreparedStatement.class); + PreparedStatement read = mock(PreparedStatement.class); + PreparedStatement complete = mock(PreparedStatement.class); + ResultSet total = mock(ResultSet.class); + when(credit.executeUpdate()).thenReturn(1); + when(total.next()).thenReturn(true); + when(total.getInt(1)).thenReturn(15); + when(read.executeQuery()).thenReturn(total); + when(complete.executeUpdate()).thenReturn(1); + when(fixture.firstAttempt.prepareStatement(anyString())).thenReturn(insert, credit, read, complete); + doThrow(new java.sql.SQLException("commit acknowledgement lost")).when(fixture.firstAttempt).commit(); + when(fixture.failedConfirmation.prepareStatement(anyString())) + .thenThrow(new java.sql.SQLException("confirmation unavailable")); + + PreparedStatement retryLookup = mock(PreparedStatement.class); + ResultSet completed = completedRow("player", "Points", 5, 15); + when(retryLookup.executeQuery()).thenReturn(completed); + when(fixture.retryLookup.prepareStatement(anyString())).thenReturn(retryLookup); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.initialLookup, fixture.firstAttempt, + fixture.failedConfirmation, fixture.retryLookup); + + SharedPointAdditionJournal journal = new SharedPointAdditionJournal(fixture.table, false); + assertThrows(java.sql.SQLException.class, () -> journal.add("reward-operation", "player", "Points", 5, 100L)); + + SharedPointAdditionJournal.AdditionResult result = journal.add("reward-operation", "player", "Points", 5, + 101L); + assertEquals(15, result.total()); + verify(credit, times(1)).executeUpdate(); + verify(fixture.firstAttempt, atLeastOnce()).close(); + verify(retryLookup).setString(1, "reward-operation"); + } + + @Test + void completedOperationRejectsAConflictingRetryInsteadOfChangingPoints() throws Exception { + Fixture fixture = fixture(); + PreparedStatement lookup = mock(PreparedStatement.class); + ResultSet completed = completedRow("player", "Points", 5, 15); + when(lookup.executeQuery()).thenReturn(completed); + when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(lookup); + + SharedPointAdditionJournal journal = new SharedPointAdditionJournal(fixture.table, false); + assertThrows(java.sql.SQLException.class, () -> journal.add("reward-operation", "player", "Points", 6, 100L)); + verify(fixture.firstAttempt, org.mockito.Mockito.never()).prepareStatement(anyString()); + } + + @Test + void completedOperationCanBeFoundBeforeReplayingTheReceiveHook() throws Exception { + Fixture fixture = fixture(); + PreparedStatement lookup = mock(PreparedStatement.class); + ResultSet completed = completedRow("player", "Points", 7, 17); + when(lookup.executeQuery()).thenReturn(completed); + when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(lookup); + + SharedPointAdditionJournal.AdditionResult result = new SharedPointAdditionJournal(fixture.table, false) + .findCompleted("reward-operation", "player", "Points"); + + assertEquals(17, result.total()); + verify(lookup).setString(1, "reward-operation"); + verify(fixture.firstAttempt, org.mockito.Mockito.never()).prepareStatement(anyString()); + } + + @Test + void claimedHookPreventsAnotherBackendFromReplayingTheReceiveEvent() throws Exception { + Fixture fixture = fixture(); + Connection missing = missingLookup(); + Connection claim = mock(Connection.class); + PreparedStatement insert = mock(PreparedStatement.class); + when(claim.prepareStatement(anyString())).thenReturn(insert); + Connection otherBackend = mock(Connection.class); + PreparedStatement lookup = mock(PreparedStatement.class); + ResultSet claimed = hookStartedRow("player", "Points", 5, "first-backend"); + when(lookup.executeQuery()).thenReturn(claimed); + when(otherBackend.prepareStatement(anyString())).thenReturn(lookup); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(missing, claim, otherBackend); + + SharedPointAdditionJournal journal = new SharedPointAdditionJournal(fixture.table, false); + assertTrue(journal.claimHook("reward-operation", "player", "Points", 5, "first-backend", 100L).claimed()); + SharedPointAdditionJournal.HookClaim duplicate = journal.claimHook("reward-operation", "player", "Points", 5, + "second-backend", 101L); + + assertFalse(duplicate.claimed()); + assertFalse(duplicate.completed()); + assertTrue(duplicate.requiresReconciliation()); + verify(insert, times(1)).executeUpdate(); + } + + @Test + void rejectedOrStoppedHookClaimIsDurablyMarkedForManualReconciliation() throws Exception { + Fixture fixture = fixture(); + PreparedStatement select = mock(PreparedStatement.class); + PreparedStatement update = mock(PreparedStatement.class); + ResultSet claimed = hookStartedRow("player", "Points", 5, "first-backend"); + when(select.executeQuery()).thenReturn(claimed); + when(update.executeUpdate()).thenReturn(1); + when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(select, update); + + new SharedPointAdditionJournal(fixture.table, false).markIndeterminate("reward-operation", "player", + "Points", 5, "first-backend"); + + verify(update).setString(1, "INDETERMINATE"); + verify(update).setString(2, "reward-operation"); + verify(update).setString(3, "HOOK_STARTED"); + verify(update).setString(4, "first-backend"); + verify(fixture.initialLookup).commit(); + } + + @Test + void provenUnstartedHookClaimIsReleasedForASafeRetry() throws Exception { + Fixture fixture = fixture(); + PreparedStatement select = mock(PreparedStatement.class); + PreparedStatement delete = mock(PreparedStatement.class); + ResultSet claimed = hookStartedRow("player", "Points", 5, "first-backend"); + when(select.executeQuery()).thenReturn(claimed); + when(delete.executeUpdate()).thenReturn(1); + when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(select, delete); + + new SharedPointAdditionJournal(fixture.table, false).releaseUnstartedHook("reward-operation", "player", + "Points", 5, "first-backend"); + + verify(delete).setString(1, "reward-operation"); + verify(delete).setString(2, "HOOK_STARTED"); + verify(delete).setString(3, "first-backend"); + verify(delete).executeUpdate(); + verify(fixture.initialLookup).commit(); + } + + @Test + void ambiguousHookClaimReleasesTheExactUnstartedOwnerBeforeFailing() throws Exception { + Fixture fixture = fixture(); + Connection missing = missingLookup(); + Connection claim = mock(Connection.class); + Connection confirmation = mock(Connection.class); + Connection release = mock(Connection.class); + PreparedStatement insert = mock(PreparedStatement.class); + PreparedStatement select = mock(PreparedStatement.class); + PreparedStatement delete = mock(PreparedStatement.class); + when(claim.prepareStatement(anyString())).thenReturn(insert); + doThrow(new java.sql.SQLException("claim acknowledgement lost")).when(claim).commit(); + when(confirmation.prepareStatement(anyString())) + .thenThrow(new java.sql.SQLException("confirmation unavailable")); + ResultSet claimed = hookStartedRow("player", "Points", 5, "first-backend"); + when(select.executeQuery()).thenReturn(claimed); + when(delete.executeUpdate()).thenReturn(1); + when(release.prepareStatement(anyString())).thenReturn(select, delete); + when(fixture.sql.getConnectionManager().getConnection()) + .thenReturn(missing, claim, confirmation, release); + + assertThrows(java.sql.SQLException.class, () -> new SharedPointAdditionJournal(fixture.table, false) + .claimHook("reward-operation", "player", "Points", 5, "first-backend", 100L)); + + verify(delete).setString(1, "reward-operation"); + verify(delete).setString(2, "HOOK_STARTED"); + verify(delete).setString(3, "first-backend"); + verify(delete).executeUpdate(); + verify(release).commit(); + } + + @Test + void ambiguousUnstartedHookReleaseCommitIsConfirmedAsSafeAfterRestart() throws Exception { + Fixture fixture = fixture(); + Connection release = mock(Connection.class); + Connection confirmation = mock(Connection.class); + PreparedStatement select = mock(PreparedStatement.class); + PreparedStatement delete = mock(PreparedStatement.class); + PreparedStatement lookup = mock(PreparedStatement.class); + ResultSet claimed = hookStartedRow("player", "Points", 5, "first-backend"); + ResultSet missing = mock(ResultSet.class); + when(claimed.next()).thenReturn(true); + when(select.executeQuery()).thenReturn(claimed); + when(delete.executeUpdate()).thenReturn(1); + when(release.prepareStatement(anyString())).thenReturn(select, delete); + doThrow(new java.sql.SQLException("commit acknowledgement lost")).when(release).commit(); + when(missing.next()).thenReturn(false); + when(lookup.executeQuery()).thenReturn(missing); + when(confirmation.prepareStatement(anyString())).thenReturn(lookup); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(release, confirmation); + + new SharedPointAdditionJournal(fixture.table, false).releaseUnstartedHook("reward-operation", "player", + "Points", 5, "first-backend"); + + verify(release, atLeastOnce()).close(); + verify(lookup).setString(1, "reward-operation"); + } + + @Test + void restartedBackendReportsIndeterminateClaimInsteadOfWaitingForADeadOwner() throws Exception { + Fixture fixture = fixture(); + PreparedStatement lookup = mock(PreparedStatement.class); + ResultSet indeterminate = hookStartedRow("player", "Points", 5, "stopped-backend"); + when(indeterminate.getString(4)).thenReturn("INDETERMINATE"); + when(lookup.executeQuery()).thenReturn(indeterminate); + when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(lookup); + + SharedPointAdditionJournal.HookClaim claim = new SharedPointAdditionJournal(fixture.table, false) + .claimHook("reward-operation", "player", "Points", 5, "restarted-backend", 101L); + + assertFalse(claim.claimed()); + assertTrue(claim.requiresReconciliation()); + } + + @Test + void liveForeignHookClaimIsNotPreemptedBeforeItsRecoveryLeaseExpires() throws Exception { + Fixture fixture = fixture(); + PreparedStatement lookup = mock(PreparedStatement.class); + ResultSet live = hookStartedRow("player", "Points", 5, "live-backend", 100L); + when(lookup.executeQuery()).thenReturn(live); + when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(lookup); + + SharedPointAdditionJournal.HookClaim claim = new SharedPointAdditionJournal(fixture.table, false) + .claimHook("reward-operation", "player", "Points", 5, "replacement", + 100L + SharedPointAdditionJournal.HOOK_RECOVERY_LEASE_MILLIS - 1L); + + assertFalse(claim.claimed()); + assertTrue(claim.requiresReconciliation()); + verify(fixture.sql.getConnectionManager(), times(1)).getConnection(); + } + + @Test + void staleForeignHookClaimTransitionsDurablyToReconciliation() throws Exception { + Fixture fixture = fixture(); + PreparedStatement lookup = mock(PreparedStatement.class); + ResultSet stale = hookStartedRow("player", "Points", 5, "stopped-backend", 100L); + when(lookup.executeQuery()).thenReturn(stale); + when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(lookup); + Connection transition = mock(Connection.class); + PreparedStatement update = mock(PreparedStatement.class); + when(update.executeUpdate()).thenReturn(1); + when(transition.prepareStatement(anyString())).thenReturn(update); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.initialLookup, transition); + long now = 100L + SharedPointAdditionJournal.HOOK_RECOVERY_LEASE_MILLIS; + + SharedPointAdditionJournal.HookClaim claim = new SharedPointAdditionJournal(fixture.table, false) + .claimHook("reward-operation", "player", "Points", 5, "replacement", now); + + assertFalse(claim.claimed()); + assertTrue(claim.requiresReconciliation()); + verify(update).setString(1, "INDETERMINATE"); + verify(update).setString(2, "reward-operation"); + verify(update).setString(3, "HOOK_STARTED"); + verify(update).setLong(4, 100L); + verify(transition).commit(); + } + + @Test + void cancelledHookSettlesWithARepresentableZeroCredit() throws Exception { + Fixture fixture = fixture(); + PreparedStatement select = mock(PreparedStatement.class); + PreparedStatement read = mock(PreparedStatement.class); + PreparedStatement complete = mock(PreparedStatement.class); + ResultSet claimed = hookStartedRow("player", "Points", 5, "owner"); + ResultSet total = mock(ResultSet.class); + when(total.next()).thenReturn(true); + when(total.getInt(1)).thenReturn(12); + when(select.executeQuery()).thenReturn(claimed); + when(read.executeQuery()).thenReturn(total); + when(complete.executeUpdate()).thenReturn(1); + when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(select, read, complete); + + assertEquals(12, new SharedPointAdditionJournal(fixture.table, false).settleClaim("reward-operation", + "player", "Points", "Points", 5, "owner", null).total()); + + verify(complete).setInt(1, 0); + verify(complete, org.mockito.Mockito.never()).setNull(org.mockito.ArgumentMatchers.eq(1), + org.mockito.ArgumentMatchers.anyInt()); + } + + @Test + void perServerSettlementCreditsTheLocalColumnAndCompletesTheSharedClaimAtomically() throws Exception { + Fixture fixture = fixture(); + PreparedStatement select = mock(PreparedStatement.class); + PreparedStatement credit = mock(PreparedStatement.class); + PreparedStatement read = mock(PreparedStatement.class); + PreparedStatement complete = mock(PreparedStatement.class); + ResultSet claimed = hookStartedRow("player", "Points", 5, "owner"); + ResultSet total = mock(ResultSet.class); + when(credit.executeUpdate()).thenReturn(1); + when(total.next()).thenReturn(true); + when(total.getInt(1)).thenReturn(17); + when(select.executeQuery()).thenReturn(claimed); + when(read.executeQuery()).thenReturn(total); + when(complete.executeUpdate()).thenReturn(1); + when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(select, credit, read, complete); + + assertEquals(17, new SharedPointAdditionJournal(fixture.table, false).settleClaim("reward-operation", + "player", "Points", "lobby_Points", 5, "owner", Integer.valueOf(3)).total()); + + verify(credit).setInt(1, 3); + verify(credit).setString(2, "player"); + verify(complete).setInt(1, 3); + verify(complete).setString(2, "COMPLETED"); + verify(complete).setInt(3, 17); + org.mockito.ArgumentCaptor statements = org.mockito.ArgumentCaptor.forClass(String.class); + verify(fixture.initialLookup, times(4)).prepareStatement(statements.capture()); + assertTrue(statements.getAllValues().stream().anyMatch(statement -> statement.startsWith( + "UPDATE `VotingPlugin_Users` SET `lobby_Points` = COALESCE(`lobby_Points`, 0) + ?"))); + assertTrue(statements.getAllValues().stream().anyMatch(statement -> statement.startsWith( + "SELECT `lobby_Points` FROM `VotingPlugin_Users`"))); + assertTrue(statements.getAllValues().stream().noneMatch(statement -> statement.startsWith( + "UPDATE `VotingPlugin_Users` SET `Points` = `Points` + ?"))); + verify(fixture.initialLookup).commit(); + } + + @Test + void failedPerServerLocalCreditRollsBackBeforeTheJournalCanComplete() throws Exception { + Fixture fixture = fixture(); + PreparedStatement select = mock(PreparedStatement.class); + PreparedStatement credit = mock(PreparedStatement.class); + PreparedStatement read = mock(PreparedStatement.class); + PreparedStatement complete = mock(PreparedStatement.class); + ResultSet claimed = hookStartedRow("player", "Points", 5, "owner"); + when(select.executeQuery()).thenReturn(claimed); + when(credit.executeUpdate()).thenThrow(new java.sql.SQLException("local write failed")); + when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(select, credit, read, complete); + + assertThrows(java.sql.SQLException.class, () -> new SharedPointAdditionJournal(fixture.table, false) + .settleClaim("reward-operation", "player", "Points", "lobby_Points", 5, "owner", + Integer.valueOf(3))); + + verify(fixture.initialLookup, atLeastOnce()).rollback(); + verify(complete, org.mockito.Mockito.never()).executeUpdate(); + } + + @Test + void settlementRejectsAnUnsafePhysicalCreditColumnBeforeOpeningSql() throws Exception { + Fixture fixture = fixture(); + + assertThrows(java.sql.SQLException.class, () -> new SharedPointAdditionJournal(fixture.table, false) + .settleClaim("reward-operation", "player", "Points", "lobby\0Points", 5, "owner", + Integer.valueOf(3))); + verify(fixture.sql.getConnectionManager(), org.mockito.Mockito.never()).getConnection(); + } + + @Test + void claimedHookRejectsAConflictingRequestedAmountBeforeAnotherEventCanRun() throws Exception { + Fixture fixture = fixture(); + PreparedStatement lookup = mock(PreparedStatement.class); + ResultSet claimed = hookStartedRow("player", "Points", 5, "first-backend"); + when(lookup.executeQuery()).thenReturn(claimed); + when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(lookup); + + SharedPointAdditionJournal journal = new SharedPointAdditionJournal(fixture.table, false); + assertThrows(java.sql.SQLException.class, + () -> journal.claimHook("reward-operation", "player", "Points", 6, "second-backend", 101L)); + verify(fixture.firstAttempt, org.mockito.Mockito.never()).prepareStatement(anyString()); + } + + @Test + void distinctRewardOccurrencesCreditIndependentlyWhileRetryingOneDoesNot() throws Exception { + Fixture fixture = fixture(); + Connection firstLookup = missingLookup(); + Attempt firstAttempt = successfulAttempt(15); + Connection secondLookup = missingLookup(); + Attempt secondAttempt = successfulAttempt(20); + Connection retryLookup = completedLookup("player", "Points", 5, 15); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(firstLookup, firstAttempt.connection(), secondLookup, + secondAttempt.connection(), retryLookup); + + SharedPointAdditionJournal journal = new SharedPointAdditionJournal(fixture.table, false); + assertEquals(15, journal.add("occurrence-one/stage", "player", "Points", 5, 100L).total()); + assertEquals(20, journal.add("occurrence-two/stage", "player", "Points", 5, 101L).total()); + assertEquals(15, journal.add("occurrence-one/stage", "player", "Points", 5, 102L).total()); + + verify(firstAttempt.credit(), times(1)).executeUpdate(); + verify(secondAttempt.credit(), times(1)).executeUpdate(); + } + + @Test + void journalTableNameRemainsPortableForLongSourceNames() { + String name = SharedPointAdditionJournal.journalTableName("u".repeat(80)); + assertTrue(name.matches("vp_pa_[0-9a-f]{32}")); + assertEquals("VotingPlugin_Users_PointAdditions", + SharedPointAdditionJournal.journalTableName("VotingPlugin_Users")); + } + + @Test + void onlyAcknowledgedOrEphemeralAdminEntriesExpireInABoundedRetentionBatch() throws Exception { + Fixture fixture = fixture(); + PreparedStatement select = mock(PreparedStatement.class); + PreparedStatement delete = mock(PreparedStatement.class); + ResultSet completed = mock(ResultSet.class); + when(completed.next()).thenReturn(true, true, false); + when(completed.getString(1)).thenReturn("old-one", "old-two"); + when(select.executeQuery()).thenReturn(completed); + when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(select, delete); + + long now = TimeUnit.DAYS.toMillis(10); + new SharedPointAdditionJournal(fixture.table, false).cleanupAcknowledged(now); + + verify(select).setString(1, "ACKNOWLEDGED"); + verify(select).setString(2, "COMPLETED"); + verify(select).setString(3, "admin-points/%"); + verify(select).setString(4, "admin-bulk-points/%"); + verify(select).setString(5, "admin-bulk-remove/%"); + verify(select).setString(6, "remove-points/%"); + verify(select).setLong(7, now - SharedPointAdditionJournal.COMPLETED_RETENTION_MILLIS); + verify(select).setInt(8, 100); + verify(delete, times(2)).setString(3, "ACKNOWLEDGED"); + verify(delete, times(2)).setString(4, "COMPLETED"); + verify(delete, times(2)).executeUpdate(); + } + + @Test + void durableReplayCheckpointAcknowledgesAnAdditionBeforeRetentionStarts() throws Exception { + Fixture fixture = fixture(); + PreparedStatement update = mock(PreparedStatement.class); + when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(update); + + new SharedPointAdditionJournal(fixture.table, false).acknowledge("reward-operation", 123L); + + verify(update).setString(1, "ACKNOWLEDGED"); + verify(update).setLong(2, 123L); + verify(update).setString(3, "reward-operation"); + verify(update).setString(4, "COMPLETED"); + verify(update).executeUpdate(); + } + + @Test + void schemaIndexesTheBoundedCleanupPredicate() throws Exception { + Fixture fixture = fixture(); + PreparedStatement createTable = mock(PreparedStatement.class); + PreparedStatement addRequestedAmount = mock(PreparedStatement.class); + PreparedStatement addHookOwner = mock(PreparedStatement.class); + PreparedStatement createIndex = mock(PreparedStatement.class); + when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(createTable, addRequestedAmount, + addHookOwner, createIndex); + + new SharedPointAdditionJournal(fixture.table, true); + + org.mockito.ArgumentCaptor statements = org.mockito.ArgumentCaptor.forClass(String.class); + verify(fixture.initialLookup, times(4)).prepareStatement(statements.capture()); + assertTrue(statements.getAllValues().get(3).contains("(`state`, `created_at`)")); + } + + private static ResultSet completedRow(String uuid, String pointsColumn, int amount, int total) throws Exception { + ResultSet row = mock(ResultSet.class); + when(row.next()).thenReturn(true); + when(row.getString(1)).thenReturn(uuid); + when(row.getString(2)).thenReturn(pointsColumn); + when(row.getInt(3)).thenReturn(amount); + when(row.getString(4)).thenReturn("COMPLETED"); + when(row.getObject(5)).thenReturn(Integer.valueOf(total)); + when(row.getInt(5)).thenReturn(total); + return row; + } + + private static ResultSet hookStartedRow(String uuid, String pointsColumn, int requestedAmount, String owner) + throws Exception { + return hookStartedRow(uuid, pointsColumn, requestedAmount, owner, 0L); + } + + private static ResultSet hookStartedRow(String uuid, String pointsColumn, int requestedAmount, String owner, + long createdAt) throws Exception { + ResultSet row = mock(ResultSet.class); + when(row.next()).thenReturn(true); + when(row.getString(1)).thenReturn(uuid); + when(row.getString(2)).thenReturn(pointsColumn); + when(row.getInt(3)).thenReturn(requestedAmount); + when(row.getString(4)).thenReturn("HOOK_STARTED"); + when(row.getObject(5)).thenReturn(null); + when(row.getObject(6)).thenReturn(Integer.valueOf(requestedAmount)); + when(row.getInt(6)).thenReturn(requestedAmount); + when(row.getString(7)).thenReturn(owner); + when(row.getLong(8)).thenReturn(createdAt); + return row; + } + + private static Connection missingLookup() throws Exception { + Connection connection = mock(Connection.class); + PreparedStatement lookup = mock(PreparedStatement.class); + ResultSet missing = mock(ResultSet.class); + when(missing.next()).thenReturn(false); + when(lookup.executeQuery()).thenReturn(missing); + when(connection.prepareStatement(anyString())).thenReturn(lookup); + return connection; + } + + private static Connection completedLookup(String uuid, String pointsColumn, int amount, int total) throws Exception { + Connection connection = mock(Connection.class); + PreparedStatement lookup = mock(PreparedStatement.class); + ResultSet completed = completedRow(uuid, pointsColumn, amount, total); + when(lookup.executeQuery()).thenReturn(completed); + when(connection.prepareStatement(anyString())).thenReturn(lookup); + return connection; + } + + private static Attempt successfulAttempt(int total) throws Exception { + Connection connection = mock(Connection.class); + PreparedStatement insert = mock(PreparedStatement.class); + PreparedStatement credit = mock(PreparedStatement.class); + PreparedStatement read = mock(PreparedStatement.class); + PreparedStatement complete = mock(PreparedStatement.class); + ResultSet result = mock(ResultSet.class); + when(credit.executeUpdate()).thenReturn(1); + when(result.next()).thenReturn(true); + when(result.getInt(1)).thenReturn(total); + when(read.executeQuery()).thenReturn(result); + when(complete.executeUpdate()).thenReturn(1); + when(connection.prepareStatement(anyString())).thenReturn(insert, credit, read, complete); + return new Attempt(connection, credit); + } + + private record Attempt(Connection connection, PreparedStatement credit) {} + + private static Fixture fixture() throws Exception { + Fixture fixture = new Fixture(); + fixture.table = mock(MySQL.class); + fixture.sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + fixture.initialLookup = mock(Connection.class); + fixture.firstAttempt = mock(Connection.class); + fixture.failedConfirmation = mock(Connection.class); + fixture.retryLookup = mock(Connection.class); + when(fixture.table.getTableName()).thenReturn("VotingPlugin_Users"); + when(fixture.table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); + when(fixture.table.getMysql()).thenReturn(fixture.sql); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.initialLookup, fixture.firstAttempt, + fixture.failedConfirmation, fixture.retryLookup); + return fixture; + } + + private static final class Fixture { + MySQL table; + com.bencodez.simpleapi.sql.mysql.MySQL sql; + Connection initialLookup; + Connection firstAttempt; + Connection failedConfirmation; + Connection retryLookup; + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointTransferJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointTransferJournalTest.java new file mode 100644 index 0000000000..a9ecefd553 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointTransferJournalTest.java @@ -0,0 +1,491 @@ +package com.bencodez.votingplugin.user; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; + +class SharedPointTransferJournalTest { + @Test + void journalTableNameIsPortableAndCollisionResistantForLongSourceNames() { + String source = "u".repeat(80); + String journalTable = SharedPointTransferJournal.journalTableName(source); + + assertEquals(journalTable, SharedPointTransferJournal.journalTableName(source)); + assertTrue(journalTable.getBytes(java.nio.charset.StandardCharsets.UTF_8).length <= 63); + assertTrue(journalTable.matches("vp_pt_[0-9a-f]{32}")); + assertNotEquals(journalTable, SharedPointTransferJournal.journalTableName(source + "x")); + assertTrue(SharedPointTransferJournal.journalTableName("é".repeat(30)).matches("vp_pt_[0-9a-f]{32}")); + assertEquals("VotingPlugin_Users_PointTransfers", + SharedPointTransferJournal.journalTableName("VotingPlugin_Users")); + } + + @Test + void schemaInitializationIsOncePerLiveMysqlHandle() throws Exception { + Fixture fixture = fixture(); + assertNotNull(SharedPointTransferJournal.forTable(fixture.table)); + assertNotNull(SharedPointTransferJournal.forTable(fixture.table)); + verify(fixture.sql.getConnectionManager(), times(1)).getConnection(); + } + + @Test + void reservationDebitsAndJournalsInOneShortTransaction() throws Exception { + Fixture fixture = fixture(); + PreparedStatement lookup = mock(PreparedStatement.class); + ResultSet missing = mock(ResultSet.class); + PreparedStatement insert = mock(PreparedStatement.class); + PreparedStatement debit = mock(PreparedStatement.class); + when(missing.next()).thenReturn(false); + when(lookup.executeQuery()).thenReturn(missing); + when(debit.executeUpdate()).thenReturn(1); + when(fixture.lookup.prepareStatement(anyString())).thenReturn(lookup); + when(fixture.reservation.prepareStatement(anyString())).thenReturn(insert, debit); + + SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table); + assertTrue(journal.reserve("transfer-1", "source", "Points", 10, "target", 10, 100L)); + + verify(insert).setString(7, "RESERVED"); + verify(debit).setInt(1, 10); + verify(fixture.reservation).commit(); + // commitAndConfirm closes before its confirmation lookup; the enclosing + // try-with-resources then closes the same JDBC handle idempotently. + verify(fixture.reservation, atLeastOnce()).close(); + } + + @Test + void insufficientSourceRollsBackTheJournalInsertAndDoesNotRunAHook() throws Exception { + Fixture fixture = fixture(); + PreparedStatement lookup = mock(PreparedStatement.class); + ResultSet missing = mock(ResultSet.class); + PreparedStatement insert = mock(PreparedStatement.class); + PreparedStatement debit = mock(PreparedStatement.class); + when(missing.next()).thenReturn(false); + when(lookup.executeQuery()).thenReturn(missing); + when(debit.executeUpdate()).thenReturn(0); + when(fixture.lookup.prepareStatement(anyString())).thenReturn(lookup); + when(fixture.reservation.prepareStatement(anyString())).thenReturn(insert, debit); + + SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table); + assertFalse(journal.reserve("transfer-2", "source", "Points", 10, "target", 10, 100L)); + + verify(fixture.reservation).rollback(); + } + + @Test + void cancelledHookRefundsExactlyTheReservedDebit() throws Exception { + Fixture fixture = fixture(); + PreparedStatement select = mock(PreparedStatement.class); + ResultSet row = row("HOOK_STARTED", "owner-1"); + PreparedStatement refund = mock(PreparedStatement.class); + PreparedStatement journalUpdate = mock(PreparedStatement.class); + when(select.executeQuery()).thenReturn(row); + when(refund.executeUpdate()).thenReturn(1); + when(journalUpdate.executeUpdate()).thenReturn(1); + when(fixture.lookup.prepareStatement(anyString())).thenReturn(select, refund, journalUpdate); + + SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table); + assertFalse(journal.settle("transfer-3", "owner-1", "source", "Points", "target", "Points", 10, null)); + + verify(refund).setInt(1, 10); + verify(refund).setString(2, "source"); + verify(journalUpdate).setString(1, "REFUNDED"); + verify(fixture.lookup).commit(); + } + + @Test + void rejectedApprovalTaskCanRefundAClaimedTransfer() throws Exception { + Fixture fixture = fixture(); + PreparedStatement select = mock(PreparedStatement.class); + ResultSet row = row("HOOK_STARTED", "owner-1"); + PreparedStatement refund = mock(PreparedStatement.class); + PreparedStatement journalUpdate = mock(PreparedStatement.class); + when(select.executeQuery()).thenReturn(row); + when(refund.executeUpdate()).thenReturn(1); + when(journalUpdate.executeUpdate()).thenReturn(1); + when(fixture.lookup.prepareStatement(anyString())).thenReturn(select, refund, journalUpdate); + + SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table); + assertTrue(journal.refundHookStarted("transfer-rejected", "source", "Points", 10)); + + verify(fixture.lookup).setAutoCommit(false); + verify(refund).setInt(1, 10); + verify(journalUpdate).setString(1, "REFUNDED"); + verify(fixture.lookup).commit(); + } + + @Test + void compensationMarkerIsDurableAfterTheSchedulerFenceRejectsTheHook() throws Exception { + Fixture fixture = fixture(); + PreparedStatement update = mock(PreparedStatement.class); + when(update.executeUpdate()).thenReturn(1); + when(fixture.lookup.prepareStatement(anyString())).thenReturn(update); + + SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table); + assertTrue(journal.markCompensating("transfer-compensating")); + + verify(update).setString(1, "COMPENSATING"); + verify(update).setString(2, "transfer-compensating"); + verify(update).setString(3, "HOOK_STARTED"); + verify(update).setString(4, "COMPENSATING"); + verify(fixture.lookup).commit(); + } + + @Test + void acceptedHookCreditsAdjustedAmountAndMarksTerminalState() throws Exception { + Fixture fixture = fixture(); + PreparedStatement select = mock(PreparedStatement.class); + ResultSet row = row("HOOK_STARTED", "owner-2"); + PreparedStatement credit = mock(PreparedStatement.class); + PreparedStatement journalUpdate = mock(PreparedStatement.class); + when(select.executeQuery()).thenReturn(row); + when(credit.executeUpdate()).thenReturn(1); + when(journalUpdate.executeUpdate()).thenReturn(1); + when(fixture.lookup.prepareStatement(anyString())).thenReturn(select, credit, journalUpdate); + + SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table); + assertTrue(journal.settle("transfer-4", "owner-2", "source", "Points", "target", "Points", 10, 4)); + + verify(credit).setInt(1, 4); + verify(credit).setString(2, "target"); + verify(journalUpdate).setString(1, "COMPLETED"); + verify(journalUpdate).setInt(2, 4); + verify(fixture.lookup).commit(); + } + + @Test + void ambiguousReservationCommitIsConfirmedAfterConnectionIsReleased() throws Exception { + Fixture fixture = fixture(); + PreparedStatement initialLookup = mock(PreparedStatement.class); + ResultSet missing = mock(ResultSet.class); + PreparedStatement insert = mock(PreparedStatement.class); + PreparedStatement debit = mock(PreparedStatement.class); + PreparedStatement confirmationLookup = mock(PreparedStatement.class); + ResultSet confirmed = transferRow("source", "target", 10, 10, "RESERVED"); + when(missing.next()).thenReturn(false); + when(initialLookup.executeQuery()).thenReturn(missing); + when(debit.executeUpdate()).thenReturn(1); + when(fixture.lookup.prepareStatement(anyString())).thenReturn(initialLookup); + when(fixture.reservation.prepareStatement(anyString())).thenReturn(insert, debit); + Connection confirmation = mock(Connection.class); + when(confirmation.prepareStatement(anyString())).thenReturn(confirmationLookup); + when(confirmationLookup.executeQuery()).thenReturn(confirmed); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.schema, fixture.lookup, + fixture.reservation, confirmation); + doThrow(new java.sql.SQLException("ack lost")).when(fixture.reservation).commit(); + + SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table); + assertTrue(journal.reserve("transfer-5", "source", "Points", 10, "target", 10, 100L)); + + // Confirmation must happen after the possibly-broken handle is released; + // the outer try-with-resources may then close it idempotently once more. + verify(fixture.reservation, atLeastOnce()).close(); + verify(confirmationLookup).executeQuery(); + } + + @Test + void ambiguousClaimCommitIsConfirmedBeforeTheHookMayRun() throws Exception { + Fixture fixture = fixture(); + Connection claim = mock(Connection.class); + Connection confirmation = mock(Connection.class); + PreparedStatement claimSelect = mock(PreparedStatement.class); + PreparedStatement claimUpdate = mock(PreparedStatement.class); + PreparedStatement confirmationLookup = mock(PreparedStatement.class); + ResultSet reserved = row("RESERVED", null); + ResultSet confirmed = transferRow("source", "target", 10, 10, "HOOK_STARTED"); + when(claim.prepareStatement(anyString())).thenReturn(claimSelect, claimUpdate); + when(claimSelect.executeQuery()).thenReturn(reserved); + when(claimUpdate.executeUpdate()).thenReturn(1); + doThrow(new java.sql.SQLException("ack lost")).when(claim).commit(); + when(confirmation.prepareStatement(anyString())).thenReturn(confirmationLookup); + when(confirmationLookup.executeQuery()).thenReturn(confirmed); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.schema, claim, confirmation); + + SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table); + assertTrue(journal.claimHookWithConfirmation("transfer-claim", "owner-claim", 100L) + == SharedPointTransferJournal.ClaimOutcome.CLAIMED); + + verify(claim, atLeastOnce()).close(); + verify(confirmationLookup).executeQuery(); + } + + @Test + void ambiguousClaimThatCannotBeConfirmedRemainsIndeterminate() throws Exception { + Fixture fixture = fixture(); + Connection claim = mock(Connection.class); + Connection unavailable = mock(Connection.class); + PreparedStatement claimSelect = mock(PreparedStatement.class); + PreparedStatement claimUpdate = mock(PreparedStatement.class); + ResultSet reserved = row("RESERVED", null); + when(claim.prepareStatement(anyString())).thenReturn(claimSelect, claimUpdate); + when(claimSelect.executeQuery()).thenReturn(reserved); + when(claimUpdate.executeUpdate()).thenReturn(1); + doThrow(new java.sql.SQLException("ack lost")).when(claim).commit(); + when(unavailable.prepareStatement(anyString())).thenThrow(new java.sql.SQLException("database unavailable")); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.schema, claim, unavailable, + unavailable, unavailable); + + SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table); + assertTrue(journal.claimHookWithConfirmation("transfer-unknown", "owner", 100L) + == SharedPointTransferJournal.ClaimOutcome.INDETERMINATE); + } + + @Test + void settlementRetriesTheSameTransferAfterAmbiguousCommitAndFailedConfirmation() throws Exception { + Fixture fixture = fixture(); + Connection firstSettlement = mock(Connection.class); + Connection unavailableConfirmation = mock(Connection.class); + Connection confirmedSettlement = mock(Connection.class); + PreparedStatement firstSelect = mock(PreparedStatement.class); + PreparedStatement credit = mock(PreparedStatement.class); + PreparedStatement firstUpdate = mock(PreparedStatement.class); + PreparedStatement confirmedSelect = mock(PreparedStatement.class); + ResultSet hookStarted = row("HOOK_STARTED", "owner-settle"); + ResultSet completed = row("COMPLETED", "owner-settle"); + when(firstSettlement.prepareStatement(anyString())).thenReturn(firstSelect, credit, firstUpdate); + when(firstSelect.executeQuery()).thenReturn(hookStarted); + when(credit.executeUpdate()).thenReturn(1); + when(firstUpdate.executeUpdate()).thenReturn(1); + doThrow(new java.sql.SQLException("ack lost")).when(firstSettlement).commit(); + when(confirmedSettlement.prepareStatement(anyString())).thenReturn(confirmedSelect); + when(confirmedSelect.executeQuery()).thenReturn(completed); + when(unavailableConfirmation.prepareStatement(anyString())) + .thenThrow(new java.sql.SQLException("confirmation unavailable")); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.schema, firstSettlement) + .thenReturn(unavailableConfirmation, confirmedSettlement); + + SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table); + assertTrue(journal.settleWithConfirmation("transfer-settle", "owner-settle", "source", "Points", "target", + "Points", 10, 4) == SharedPointTransferJournal.SettlementOutcome.COMPLETED); + + verify(credit).executeUpdate(); + verify(confirmedSelect).executeQuery(); + } + + @Test + void settlementRemainsIndeterminateWhenTheSameTransferCannotBeReconfirmed() throws Exception { + Fixture fixture = fixture(); + Connection unavailable = mock(Connection.class); + when(unavailable.prepareStatement(anyString())).thenThrow(new java.sql.SQLException("database unavailable")); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.schema, unavailable, unavailable, + unavailable); + + SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table); + assertTrue(journal.settleWithConfirmation("transfer-unknown", "owner", "source", "Points", "target", "Points", + 10, 4) == SharedPointTransferJournal.SettlementOutcome.INDETERMINATE); + } + + @Test + void recoveryRefundsAnExpiredReservationUsingItsPersistedSourceColumn() throws Exception { + Fixture fixture = fixture(); + Connection reservedCandidates = mock(Connection.class); + Connection recovery = mock(Connection.class); + Connection cleanup = mock(Connection.class); + PreparedStatement reservedCandidateQuery = mock(PreparedStatement.class); + PreparedStatement recoverySelect = mock(PreparedStatement.class); + PreparedStatement recoveryRefund = mock(PreparedStatement.class); + PreparedStatement recoveryUpdate = mock(PreparedStatement.class); + PreparedStatement cleanupSelect = mock(PreparedStatement.class); + PreparedStatement cleanupDelete = mock(PreparedStatement.class); + ResultSet expiredReservation = ids("expired-reservation"); + ResultSet reservedRecovery = recoveryRow("RESERVED", 1L, "source", "Points", 10); + ResultSet noCleanupCandidates = ids(); + when(reservedCandidates.prepareStatement(anyString())).thenReturn(reservedCandidateQuery); + when(reservedCandidateQuery.executeQuery()).thenReturn(expiredReservation); + when(recovery.prepareStatement(anyString())).thenReturn(recoverySelect, recoveryRefund, recoveryUpdate); + when(recoverySelect.executeQuery()).thenReturn(reservedRecovery); + when(recoveryRefund.executeUpdate()).thenReturn(1); + when(recoveryUpdate.executeUpdate()).thenReturn(1); + when(cleanup.prepareStatement(anyString())).thenReturn(cleanupSelect, cleanupDelete); + when(cleanupSelect.executeQuery()).thenReturn(noCleanupCandidates); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.schema, reservedCandidates, recovery, + cleanup); + + SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table); + var refunded = journal.recoverAndCleanup(SharedPointTransferJournal.RESERVED_RECOVERY_AGE_MILLIS + 2L); + + verify(recoveryRefund).setString(2, "source"); + verify(recoveryRefund).setInt(1, 10); + verify(recoveryUpdate).setString(1, "REFUNDED"); + verify(recovery).commit(); + assertEquals(1, refunded.size()); + assertEquals("source", refunded.get(0).uuid()); + assertEquals("Points", refunded.get(0).pointsColumn()); + } + + @Test + void recoveryRefundsACompensatingTransferImmediatelyUsingItsPersistedSourceColumn() throws Exception { + Fixture fixture = fixture(); + Connection recoverableCandidates = mock(Connection.class); + Connection recovery = mock(Connection.class); + Connection cleanup = mock(Connection.class); + PreparedStatement recoverableCandidateQuery = mock(PreparedStatement.class); + PreparedStatement recoverySelect = mock(PreparedStatement.class); + PreparedStatement recoveryRefund = mock(PreparedStatement.class); + PreparedStatement recoveryUpdate = mock(PreparedStatement.class); + PreparedStatement cleanupSelect = mock(PreparedStatement.class); + PreparedStatement cleanupDelete = mock(PreparedStatement.class); + ResultSet expiredCompensation = ids("expired-compensation"); + ResultSet compensationRecovery = recoveryRow("COMPENSATING", 1L, "source", "Points", 10); + ResultSet noCleanupCandidates = ids(); + when(recoverableCandidates.prepareStatement(anyString())).thenReturn(recoverableCandidateQuery); + when(recoverableCandidateQuery.executeQuery()).thenReturn(expiredCompensation); + when(recovery.prepareStatement(anyString())).thenReturn(recoverySelect, recoveryRefund, recoveryUpdate); + when(recoverySelect.executeQuery()).thenReturn(compensationRecovery); + when(recoveryRefund.executeUpdate()).thenReturn(1); + when(recoveryUpdate.executeUpdate()).thenReturn(1); + when(cleanup.prepareStatement(anyString())).thenReturn(cleanupSelect, cleanupDelete); + when(cleanupSelect.executeQuery()).thenReturn(noCleanupCandidates); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.schema, recoverableCandidates, + recovery, cleanup); + + SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table); + var refunded = journal.recoverAndCleanup(0L); + + verify(recoverableCandidateQuery).setString(3, "COMPENSATING"); + verify(recoveryRefund).setString(2, "source"); + verify(recoveryUpdate).setString(1, "REFUNDED"); + verify(recovery).commit(); + assertEquals(1, refunded.size()); + } + + @Test + void recoveryRechecksAndNeverRefundsAHookStartedRow() throws Exception { + Fixture fixture = fixture(); + Connection reservedCandidates = mock(Connection.class); + Connection recovery = mock(Connection.class); + Connection cleanup = mock(Connection.class); + PreparedStatement reservedCandidateQuery = mock(PreparedStatement.class); + PreparedStatement recoverySelect = mock(PreparedStatement.class); + PreparedStatement recoveryRefund = mock(PreparedStatement.class); + PreparedStatement cleanupSelect = mock(PreparedStatement.class); + PreparedStatement cleanupDelete = mock(PreparedStatement.class); + ResultSet claimedCandidate = ids("claimed-transfer"); + ResultSet claimedHook = recoveryRow("HOOK_STARTED", 1L, "source", "Points", 10); + ResultSet noCleanupCandidates = ids(); + when(reservedCandidates.prepareStatement(anyString())).thenReturn(reservedCandidateQuery); + when(reservedCandidateQuery.executeQuery()).thenReturn(claimedCandidate); + when(recovery.prepareStatement(anyString())).thenReturn(recoverySelect, recoveryRefund); + when(recoverySelect.executeQuery()).thenReturn(claimedHook); + when(cleanup.prepareStatement(anyString())).thenReturn(cleanupSelect, cleanupDelete); + when(cleanupSelect.executeQuery()).thenReturn(noCleanupCandidates); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.schema, reservedCandidates, + recovery, cleanup); + + SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table); + journal.recoverAndCleanup(1L); + + verify(recoveryRefund, org.mockito.Mockito.never()).executeUpdate(); + } + + @Test + void cleanupDeletesOnlyTheSelectedBoundedTerminalRows() throws Exception { + Fixture fixture = fixture(); + Connection reservedCandidates = mock(Connection.class); + Connection cleanup = mock(Connection.class); + PreparedStatement reservedCandidateQuery = mock(PreparedStatement.class); + PreparedStatement cleanupSelect = mock(PreparedStatement.class); + PreparedStatement cleanupDelete = mock(PreparedStatement.class); + ResultSet noReservedCandidates = ids(); + ResultSet oldCompleted = ids("old-completed"); + when(reservedCandidates.prepareStatement(anyString())).thenReturn(reservedCandidateQuery); + when(reservedCandidateQuery.executeQuery()).thenReturn(noReservedCandidates); + when(cleanup.prepareStatement(anyString())).thenReturn(cleanupSelect, cleanupDelete); + when(cleanupSelect.executeQuery()).thenReturn(oldCompleted); + when(cleanupDelete.executeUpdate()).thenReturn(1); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.schema, reservedCandidates, cleanup); + + SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table); + journal.recoverAndCleanup(SharedPointTransferJournal.TERMINAL_RETENTION_MILLIS + 2L); + + verify(cleanupDelete).setString(1, "old-completed"); + verify(cleanupSelect).setInt(4, 100); + verify(cleanupDelete).executeUpdate(); + } + + private static ResultSet row(String state, String owner) throws Exception { + ResultSet row = mock(ResultSet.class); + when(row.next()).thenReturn(true); + when(row.getString(1)).thenReturn(state); + when(row.getString(2)).thenReturn(owner); + return row; + } + + private static ResultSet transferRow(String source, String target, int debit, int requestedCredit, String state) + throws Exception { + ResultSet row = mock(ResultSet.class); + when(row.next()).thenReturn(true); + when(row.getString(1)).thenReturn(source); + when(row.getString(2)).thenReturn(target); + when(row.getInt(3)).thenReturn(debit); + when(row.getInt(4)).thenReturn(requestedCredit); + when(row.getString(5)).thenReturn(state); + return row; + } + + private static ResultSet ids(String... transferIds) throws Exception { + ResultSet rows = mock(ResultSet.class); + Boolean[] next = new Boolean[transferIds.length + 1]; + for (int index = 0; index < transferIds.length; index++) { + next[index] = Boolean.TRUE; + } + next[transferIds.length] = Boolean.FALSE; + when(rows.next()).thenReturn(next[0], java.util.Arrays.copyOfRange(next, 1, next.length)); + for (int index = 0; index < transferIds.length; index++) { + when(rows.getString(1)).thenReturn(transferIds[index]); + } + return rows; + } + + private static ResultSet recoveryRow(String state, long createdAt, String sourceUuid, String sourceColumn, + int debitPoints) throws Exception { + ResultSet row = mock(ResultSet.class); + when(row.next()).thenReturn(true); + when(row.getString(1)).thenReturn(state); + when(row.getLong(2)).thenReturn(createdAt); + when(row.getString(3)).thenReturn(sourceUuid); + when(row.getString(4)).thenReturn(sourceColumn); + when(row.getInt(5)).thenReturn(debitPoints); + return row; + } + + private static Fixture fixture() throws Exception { + Fixture fixture = new Fixture(); + fixture.table = mock(MySQL.class); + fixture.sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, + org.mockito.Mockito.RETURNS_DEEP_STUBS); + fixture.schema = mock(Connection.class); + fixture.lookup = mock(Connection.class); + fixture.reservation = mock(Connection.class); + when(fixture.table.getTableName()).thenReturn("VotingPlugin_Users"); + when(fixture.table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); + when(fixture.table.getMysql()).thenReturn(fixture.sql); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.schema, fixture.lookup, + fixture.reservation); + when(fixture.schema.prepareStatement(anyString())).thenReturn(mock(PreparedStatement.class)); + return fixture; + } + + private static final class Fixture { + MySQL table; + com.bencodez.simpleapi.sql.mysql.MySQL sql; + Connection schema; + Connection lookup; + Connection reservation; + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java new file mode 100644 index 0000000000..14c3c66d8d --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -0,0 +1,2003 @@ +package com.bencodez.votingplugin.user; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.HashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.bukkit.entity.Player; +import org.bukkit.Bukkit; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.mockito.MockedStatic; + +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.UserData; +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.simpleapi.sql.mysql.ConnectionManager; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; +import com.bencodez.simpleapi.folialib.FoliaLib; +import com.bencodez.simpleapi.folialib.enums.EntityTaskResult; +import com.bencodez.simpleapi.folialib.impl.ServerImplementation; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.events.PlayerReceivePointsEvent; + +class VotingPluginUserPointSchedulingTest { + @Test + void bulkPointOperationIdsAreDeterministicDistinctAndFitTheJournalSchema() { + String first = VotingPluginUser.bulkPointOperationId("admin-bulk-points/", "batch", "player-a"); + String retry = VotingPluginUser.bulkPointOperationId("admin-bulk-points/", "batch", "player-a"); + String other = VotingPluginUser.bulkPointOperationId("admin-bulk-points/", "batch", "player-b"); + + assertEquals(first, retry); + assertFalse(first.equals(other)); + assertTrue(first.length() <= 64); + } + + @Test + void ordinaryBulkPointArithmeticUsesAuthoritativeUserReads() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + VotingPluginUser user = mock(VotingPluginUser.class); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + + VotingPluginUser.addPointsStorageAware(plugin, java.util.List.of(user), 5, + (ignored, success) -> { }); + VotingPluginUser.setPointsStorageAware(plugin, java.util.List.of(user), 11, + (ignored, success) -> { }); + VotingPluginUser.removePointsStorageAware(plugin, java.util.List.of(user), 3, + (ignored, success) -> { }); + + verify(user, org.mockito.Mockito.times(2)).userDataFetechMode(UserDataFetchMode.NO_CACHE); + verify(user).addPointsStorageAware(eq(5), org.mockito.ArgumentMatchers.>any()); + verify(user).setPoints(11); + verify(user).removePoints(eq(3), org.mockito.ArgumentMatchers.>any()); + } + @Test + void sharedBulkPointMutationsUseOnePersistenceSubmission() throws Exception { + PointFixture fixture = pointFixture(); + VotingPluginUser second = mock(VotingPluginUser.class); + java.util.List users = java.util.List.of(fixture.user, second); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + VotingPluginUser.addPointsStorageAware(fixture.plugin, users, 5, (user, success) -> { }); + VotingPluginUser.setPointsStorageAware(fixture.plugin, users, 42, (user, success) -> { }); + VotingPluginUser.removePointsStorageAware(fixture.plugin, users, 3, (user, success) -> { }); + } + + verify(fixture.persistence, org.mockito.Mockito.times(3)).execute(any(Runnable.class)); + verify(fixture.sql.getConnectionManager(), never()).getConnection(); + } + + @Test + void rejectedSharedBulkMutationCompletesEveryUserAsFailed() throws Exception { + PointFixture fixture = pointFixture(); + VotingPluginUser second = mock(VotingPluginUser.class); + Player secondPlayer = mock(Player.class); + when(second.getPlayer()).thenReturn(secondPlayer); + java.util.List results = new java.util.ArrayList<>(); + doThrow(new RejectedExecutionException()).when(fixture.persistence).execute(any(Runnable.class)); + doAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }).when(fixture.scheduler).runTask(eq(fixture.plugin), any(Runnable.class), any(Player.class)); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + VotingPluginUser.addPointsStorageAware(fixture.plugin, java.util.List.of(fixture.user, second), 5, + (user, success) -> results.add(success)); + } + + assertEquals(java.util.List.of(false, false), results); + verify(fixture.scheduler).runTask(eq(fixture.plugin), any(Runnable.class), eq(fixture.player)); + verify(fixture.scheduler).runTask(eq(fixture.plugin), any(Runnable.class), eq(secondPlayer)); + } + + @Test + void sharedBulkMutationCapturesPlayersBeforePersistenceWork() throws Exception { + PointFixture fixture = pointFixture(); + VotingPluginUser second = mock(VotingPluginUser.class); + Player secondPlayer = mock(Player.class); + when(second.getPlayer()).thenReturn(secondPlayer); + PluginManager pluginManager = mock(PluginManager.class); + doAnswer(invocation -> { + invocation.getArgument(0).setCancelled(true); + return null; + }).when(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + VotingPluginUser.addPointsStorageAware(fixture.plugin, java.util.List.of(fixture.user, second), 5, + (user, success) -> { }); + } + + verify(fixture.user).getPlayer(); + verify(second).getPlayer(); + org.mockito.Mockito.clearInvocations(fixture.user, second); + ArgumentCaptor persistence = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistence.capture()); + persistence.getValue().run(); + verify(fixture.user, never()).getPlayer(); + verify(second, never()).getPlayer(); + verify(fixture.scheduler).runTask(eq(fixture.plugin), any(Runnable.class), eq(fixture.player)); + verify(fixture.scheduler).runTask(eq(fixture.plugin), any(Runnable.class), eq(secondPlayer)); + } + + @Test + void sharedBulkAddPreservesPerUserCancellationBeforePersistence() throws Exception { + PointFixture fixture = pointFixture(); + java.util.List results = new java.util.ArrayList<>(); + PluginManager pluginManager = mock(PluginManager.class); + doAnswer(invocation -> { + invocation.getArgument(0).setCancelled(true); + return null; + }).when(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + VotingPluginUser.addPointsStorageAware(fixture.plugin, java.util.List.of(fixture.user), 5, + (user, success) -> results.add(success)); + } + + ArgumentCaptor persistenceTask = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistenceTask.capture()); + persistenceTask.getValue().run(); + verify(fixture.sql.getConnectionManager(), never()).getConnection(); + verify(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); + } + + @Test + void sharedBulkPointMutationResubmitsBoundedChunks() throws Exception { + PointFixture fixture = pointFixture(); + PluginManager pluginManager = mock(PluginManager.class); + doAnswer(invocation -> { + invocation.getArgument(0).setCancelled(true); + return null; + }).when(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + VotingPluginUser.addPointsStorageAware(fixture.plugin, + java.util.Collections.nCopies(65, fixture.user), 5, (user, success) -> { }); + } + + ArgumentCaptor persistenceTasks = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistenceTasks.capture()); + persistenceTasks.getValue().run(); + verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(persistenceTasks.capture()); + persistenceTasks.getAllValues().get(persistenceTasks.getAllValues().size() - 1).run(); + verify(fixture.sql.getConnectionManager(), never()).getConnection(); + verify(fixture.scheduler, org.mockito.Mockito.times(65)).runTask(eq(fixture.plugin), any(Runnable.class), + eq(fixture.player)); + } + + @Test + void storageAwareSetDoesNotUseJdbcOnCallerThread() throws Exception { + PointFixture fixture = pointFixture(); + java.util.List results = new java.util.ArrayList<>(); + + fixture.user.setPointsStorageAware(42, results::add); + + ArgumentCaptor persistenceTask = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistenceTask.capture()); + verify(fixture.sql.getConnectionManager(), never()).getConnection(); + } + + @Test + void storageAwareAddStaysSynchronousOutsideSharedMysql() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + VotingPluginUser user = mock(VotingPluginUser.class, CALLS_REAL_METHODS); + Field pluginField = VotingPluginUser.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(user, plugin); + doReturn(15).when(user).addPoints(10, false); + + assertEquals(15, user.addPointsStorageAware(10)); + + verify(user).addPoints(10, false); + verify(user, never()).addPoints(10, true); + } + + @Test + void nonSharedTransferCreditsBeforeReportingSuccess() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + VotingPluginUser source = mock(VotingPluginUser.class, CALLS_REAL_METHODS); + VotingPluginUser target = mock(VotingPluginUser.class); + Field pluginField = VotingPluginUser.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(source, plugin); + doReturn(true).when(source).removePoints(10); + AtomicReference result = new AtomicReference<>(); + + source.transferPoints(target, 10, result::set); + + InOrder order = inOrder(source, target); + order.verify(source).removePoints(10); + order.verify(target).addPoints(10); + assertEquals(Boolean.TRUE, result.get()); + } + + @Test + void votePointAwardQueuesSharedMysqlMutationOffTheServerLane() throws Exception { + PointFixture fixture = pointFixture(); + UserData data = mock(UserData.class); + doReturn(data).when(fixture.user).getUserData(); + when(data.getInt("Points", UserDataFetchMode.TEMP_ONLY)).thenReturn(10); + when(fixture.plugin.getConfigFile().getPointsOnVote()).thenReturn(5); + when(fixture.plugin.getConfigFile().getLimitVotePoints()).thenReturn(0); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + PluginManager pluginManager = mock(PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + fixture.user.addPoints(); + } + + verify(fixture.persistence).execute(any(Runnable.class)); + verify(fixture.sql.getConnectionManager(), never()).getConnection(); + } + + @Test + void votePointAwardCombinesSharedAdditionAndCapInOnePersistenceTask() throws Exception { + PointFixture fixture = pointFixture(); + UserDataCache cache = mock(UserDataCache.class); + HashMap values = new HashMap<>(); + values.put("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(98)); + doReturn(true).when(fixture.user).isCached(); + doReturn(cache).when(fixture.user).getCache(); + when(cache.getCache()).thenReturn(values); + java.util.UUID userUuid = java.util.UUID.fromString("00000000-0000-0000-0000-000000000001"); + when(fixture.plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn( + new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(userUuid, cache))); + when(fixture.plugin.getConfigFile().getPointsOnVote()).thenReturn(5); + when(fixture.plugin.getConfigFile().getLimitVotePoints()).thenReturn(100); + PluginManager pluginManager = mock(PluginManager.class); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + fixture.user.addPoints(); + } + assertEquals(100, values.get("Points").getInt()); + + ArgumentCaptor persistenceTask = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistenceTask.capture()); + verify(fixture.persistence, org.mockito.Mockito.times(1)).execute(any(Runnable.class)); + persistenceTask.getValue().run(); + assertFalse(values.containsKey("Points")); + + verify(fixture.connection).prepareStatement(org.mockito.ArgumentMatchers.argThat( + query -> query.contains("`Points` = LEAST(`Points` + ?, ?)"))); + verify(fixture.statement).setInt(1, 5); + verify(fixture.statement).setInt(2, 100); + } + + @Test + void rejectedInitialSharedTransferSubmissionCompletesAsFailure() throws Exception { + TransferSchedulingFixture fixture = transferSchedulingFixture(); + AtomicReference result = new AtomicReference<>(); + doThrow(new RejectedExecutionException("stopping")).when(fixture.persistence).execute(any(Runnable.class)); + + fixture.user.transferPointsWithResult(fixture.target, 10, result::set); + + ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); + verifyNoInteractions(fixture.manager); + completion.getValue().run(); + assertEquals(PointTransferResult.UNAVAILABLE, result.get()); + } + + @Test + void indeterminateSharedTransferClaimDoesNotReportSuccessBeforeApproval(@TempDir Path temporaryDirectory) throws Exception { + SagaFixture fixture = sagaFixture(true); + when(fixture.plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile()); + Connection unavailable = mock(Connection.class); + when(unavailable.prepareStatement(anyString())).thenThrow(new java.sql.SQLException("unavailable")); + when(fixture.manager.getConnection()).thenReturn(fixture.schema, fixture.recoveryReserved, fixture.cleanup, + fixture.lookup, fixture.reservation, fixture.claim).thenAnswer(invocation -> unavailable); + doThrow(new java.sql.SQLException("claim acknowledgement lost")).when(fixture.claim).commit(); + AtomicReference result = new AtomicReference<>(); + + fixture.user.transferPoints(fixture.target, 10, result::set); + ArgumentCaptor persistence = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistence.capture()); + persistence.getValue().run(); + ArgumentCaptor gate = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), gate.capture()); + gate.getValue().run(); + ArgumentCaptor claim = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(claim.capture()); + claim.getAllValues().get(1).run(); + + ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); + verify(fixture.entityScheduler).runAtEntityWithFallback(eq(fixture.player), any(), any(Runnable.class)); + completion.getValue().run(); + assertEquals(Boolean.FALSE, result.get()); + assertEquals(1, new SharedPointTransferCompensationStore(temporaryDirectory).loadBatch().size()); + } + + @Test + void sharedAddReturnsTheCommittedDatabaseBalanceInsteadOfAPredictedWrapperTotal() throws Exception { + PointFixture fixture = pointFixture(); + UserData data = mock(UserData.class); + PreparedStatement read = mock(PreparedStatement.class); + ResultSet result = mock(ResultSet.class); + doReturn(data).when(fixture.user).getUserData(); + when(fixture.statement.executeUpdate()).thenReturn(1); + when(data.getInt("Points", UserDataFetchMode.NO_CACHE)).thenReturn(10); + when(fixture.connection.prepareStatement(anyString())).thenReturn(fixture.statement, read); + when(read.executeQuery()).thenReturn(result); + when(result.next()).thenReturn(true); + when(result.getInt(1)).thenReturn(73); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + PluginManager pluginManager = mock(PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + + assertEquals(73, fixture.user.addPoints(5)); + } + + InOrder mutationThenRead = inOrder(fixture.statement, read); + mutationThenRead.verify(fixture.statement).executeUpdate(); + mutationThenRead.verify(read).executeQuery(); + verify(data, never()).getInt("Points", UserDataFetchMode.NO_CACHE); + } + + @Test + void storageAwareAddReportsOnlyAfterCommittedSharedWrite() throws Exception { + PointFixture fixture = pointFixture(); + UserData data = mock(UserData.class); + PreparedStatement read = mock(PreparedStatement.class); + ResultSet result = mock(ResultSet.class); + doReturn(data).when(fixture.user).getUserData(); + when(fixture.statement.executeUpdate()).thenReturn(1); + when(fixture.connection.prepareStatement(anyString())).thenReturn(fixture.statement, read); + when(read.executeQuery()).thenReturn(result); + when(result.next()).thenReturn(true); + when(result.getInt(1)).thenReturn(23); + AtomicReference success = new AtomicReference<>(); + AtomicReference total = new AtomicReference<>(); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + PluginManager pluginManager = mock(PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + fixture.user.addPointsStorageAware(5, (written, committed) -> { + success.set(written); + total.set(committed); + }); + } + + assertTrue(success.get() == null, "the command callback must wait for persistence"); + ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistenceWork.capture()); + persistenceWork.getValue().run(); + ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); + completion.getValue().run(); + assertEquals(Boolean.TRUE, success.get()); + assertEquals(23, total.get()); + } + + @Test + void storageAwareAsyncStageWaitsForCommittedSharedWrite() throws Exception { + PointFixture fixture = pointFixture(); + UserData data = mock(UserData.class); + PreparedStatement read = mock(PreparedStatement.class); + ResultSet result = mock(ResultSet.class); + doReturn(data).when(fixture.user).getUserData(); + when(fixture.statement.executeUpdate()).thenReturn(1); + when(fixture.connection.prepareStatement(anyString())).thenReturn(fixture.statement, read); + when(read.executeQuery()).thenReturn(result); + when(result.next()).thenReturn(true); + when(result.getInt(1)).thenReturn(23); + CompletableFuture completion; + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + completion = fixture.user.addPointsStorageAwareAsync(5).toCompletableFuture(); + } + + assertFalse(completion.isDone(), "the reward stage must wait for persistence"); + ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistenceWork.capture()); + persistenceWork.getValue().run(); + assertEquals(23, completion.join()); + verifyNoInteractions(fixture.scheduler); + } + + @Test + void durableSharedAsyncRetryCompletesBeforeFiringTheReceiveEvent() throws Exception { + PointFixture fixture = pointFixture(); + PreparedStatement createTable = mock(PreparedStatement.class); + PreparedStatement createIndex = mock(PreparedStatement.class); + PreparedStatement lookup = mock(PreparedStatement.class); + ResultSet completed = mock(ResultSet.class); + when(completed.next()).thenReturn(true); + when(completed.getString(1)).thenReturn("00000000-0000-0000-0000-000000000001"); + when(completed.getString(2)).thenReturn("Points"); + when(completed.getInt(3)).thenReturn(7); + when(completed.getString(4)).thenReturn("COMPLETED"); + when(completed.getObject(5)).thenReturn(Integer.valueOf(23)); + when(completed.getInt(5)).thenReturn(23); + when(lookup.executeQuery()).thenReturn(completed); + when(fixture.connection.prepareStatement(anyString())).thenReturn(createTable, createIndex, lookup); + PluginManager pluginManager = mock(PluginManager.class); + CompletableFuture completion; + CompletableFuture concurrentRetry; + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + completion = fixture.user.addPointsStorageAwareAsync(5, "reward-operation").toCompletableFuture(); + concurrentRetry = fixture.user.addPointsStorageAwareAsync(5, "reward-operation").toCompletableFuture(); + } + + assertFalse(completion.isDone()); + assertEquals(completion, concurrentRetry); + verify(fixture.sql.getConnectionManager(), never()).getConnection(); + ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(1)).execute(persistenceWork.capture()); + persistenceWork.getValue().run(); + assertEquals(23, completion.join()); + verifyNoInteractions(pluginManager); + verifyNoInteractions(fixture.scheduler); + } + + @Test + void durableSharedAsyncRetrySurvivesSwitchToPerServerPoints() throws Exception { + PointFixture fixture = pointFixture(); + when(fixture.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); + doReturn("lobby_Points").when(fixture.user).getPointsPath(); + PreparedStatement createTable = mock(PreparedStatement.class); + PreparedStatement createIndex = mock(PreparedStatement.class); + PreparedStatement lookup = mock(PreparedStatement.class); + ResultSet completed = mock(ResultSet.class); + when(completed.next()).thenReturn(true); + when(completed.getString(1)).thenReturn("00000000-0000-0000-0000-000000000001"); + when(completed.getString(2)).thenReturn("Points"); + when(completed.getInt(3)).thenReturn(7); + when(completed.getString(4)).thenReturn("COMPLETED"); + when(completed.getObject(5)).thenReturn(Integer.valueOf(23)); + when(completed.getInt(5)).thenReturn(23); + when(lookup.executeQuery()).thenReturn(completed); + when(fixture.connection.prepareStatement(anyString())).thenReturn(createTable, createIndex, lookup); + PluginManager pluginManager = mock(PluginManager.class); + CompletableFuture completion; + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + completion = fixture.user.addPointsStorageAwareAsync(5, "reward-operation").toCompletableFuture(); + } + + assertFalse(completion.isDone()); + ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistenceWork.capture()); + persistenceWork.getValue().run(); + assertEquals(23, completion.join()); + verifyNoInteractions(pluginManager); + verifyNoInteractions(fixture.scheduler); + verify(fixture.user, never()).setPoints(anyInt(), eq(false)); + } + + @Test + void perServerPointAdditionRunsOnlyAfterHistoricJournalMissAndBukkitHandoff() throws Exception { + PointFixture fixture = pointFixture(); + when(fixture.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); + doReturn("1Lobby West_Points").when(fixture.user).getPointsPath(); + PreparedStatement schema = mock(PreparedStatement.class); + PreparedStatement lookup = mock(PreparedStatement.class); + PreparedStatement claimInsert = mock(PreparedStatement.class); + PreparedStatement settleSelect = mock(PreparedStatement.class); + PreparedStatement settleCredit = mock(PreparedStatement.class); + PreparedStatement settleRead = mock(PreparedStatement.class); + PreparedStatement settleComplete = mock(PreparedStatement.class); + ResultSet missing = mock(ResultSet.class); + when(missing.next()).thenReturn(false); + when(lookup.executeQuery()).thenReturn(missing); + AtomicReference owner = new AtomicReference<>(); + doAnswer(invocation -> { + owner.set(invocation.getArgument(1)); + return null; + }).when(claimInsert).setString(eq(8), anyString()); + ResultSet claimed = mock(ResultSet.class); + when(claimed.next()).thenReturn(true); + when(claimed.getString(1)).thenReturn("00000000-0000-0000-0000-000000000001"); + when(claimed.getString(2)).thenReturn("Points"); + when(claimed.getInt(3)).thenReturn(5); + when(claimed.getString(4)).thenReturn("HOOK_STARTED"); + when(claimed.getObject(5)).thenReturn(null); + when(claimed.getObject(6)).thenReturn(Integer.valueOf(5)); + when(claimed.getInt(6)).thenReturn(5); + when(claimed.getString(7)).thenAnswer(invocation -> owner.get()); + when(settleSelect.executeQuery()).thenReturn(claimed); + when(settleCredit.executeUpdate()).thenReturn(1); + ResultSet completedTotal = mock(ResultSet.class); + when(completedTotal.next()).thenReturn(true); + when(completedTotal.getInt(1)).thenReturn(15); + when(settleRead.executeQuery()).thenReturn(completedTotal); + when(settleComplete.executeUpdate()).thenReturn(1); + when(fixture.connection.prepareStatement(anyString())).thenReturn(schema, schema, schema, schema, lookup, + claimInsert, settleSelect, settleCredit, settleRead, settleComplete); + PluginManager pluginManager = mock(PluginManager.class); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + CompletableFuture completion = fixture.user + .addPointsStorageAwareAsync(5, "per-server-operation").toCompletableFuture(); + ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistenceWork.capture()); + verifyNoInteractions(pluginManager); + verify(fixture.user, never()).setPoints(anyInt(), eq(false)); + + persistenceWork.getValue().run(); + ArgumentCaptor bukkitWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), bukkitWork.capture(), eq(fixture.player)); + assertFalse(completion.isDone()); + verifyNoInteractions(pluginManager); + verify(fixture.user, never()).setPoints(anyInt(), eq(false)); + + bukkitWork.getValue().run(); + assertFalse(completion.isDone()); + verify(fixture.table, never()).checkColumn("1Lobby West_Points", com.bencodez.simpleapi.sql.DataType.INTEGER); + ArgumentCaptor settlementWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(settlementWork.capture()); + settlementWork.getAllValues().get(1).run(); + assertEquals(15, completion.join()); + verify(pluginManager).callEvent(org.mockito.ArgumentMatchers.argThat(event -> + event instanceof PlayerReceivePointsEvent && !event.isAsynchronous())); + org.mockito.InOrder settlementOrder = org.mockito.Mockito.inOrder(fixture.table, settleCredit); + settlementOrder.verify(fixture.table).checkColumn("1Lobby West_Points", com.bencodez.simpleapi.sql.DataType.INTEGER); + settlementOrder.verify(settleCredit).executeUpdate(); + verify(settleCredit).setInt(1, 5); + verify(settleCredit).setString(2, "00000000-0000-0000-0000-000000000001"); + verify(claimInsert).setString(3, "Points"); + verify(fixture.connection).prepareStatement(org.mockito.ArgumentMatchers.contains("`1Lobby West_Points` = COALESCE(`1Lobby West_Points`, 0) + ?")); + } + } + + @Test + void perServerPointAdditionDoesNotSettleJournalBeforeLocalPersistenceCompletes() throws Exception { + PointFixture fixture = pointFixture(); + when(fixture.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); + doReturn("lobby_Points").when(fixture.user).getPointsPath(); + CountDownLatch localCreditStarted = new CountDownLatch(1); + CountDownLatch releaseLocalCredit = new CountDownLatch(1); + PreparedStatement schema = mock(PreparedStatement.class); + PreparedStatement lookup = mock(PreparedStatement.class); + PreparedStatement claimInsert = mock(PreparedStatement.class); + PreparedStatement settleSelect = mock(PreparedStatement.class); + PreparedStatement settleCredit = mock(PreparedStatement.class); + PreparedStatement settleRead = mock(PreparedStatement.class); + PreparedStatement settleComplete = mock(PreparedStatement.class); + ResultSet missing = mock(ResultSet.class); + when(missing.next()).thenReturn(false); + when(lookup.executeQuery()).thenReturn(missing); + AtomicReference owner = new AtomicReference<>(); + doAnswer(invocation -> { + owner.set(invocation.getArgument(1)); + return null; + }).when(claimInsert).setString(eq(8), anyString()); + ResultSet claimed = mock(ResultSet.class); + when(claimed.next()).thenReturn(true); + when(claimed.getString(1)).thenReturn("00000000-0000-0000-0000-000000000001"); + when(claimed.getString(2)).thenReturn("Points"); + when(claimed.getInt(3)).thenReturn(5); + when(claimed.getString(4)).thenReturn("HOOK_STARTED"); + when(claimed.getObject(5)).thenReturn(null); + when(claimed.getObject(6)).thenReturn(Integer.valueOf(5)); + when(claimed.getInt(6)).thenReturn(5); + when(claimed.getString(7)).thenAnswer(invocation -> owner.get()); + when(settleSelect.executeQuery()).thenReturn(claimed); + doAnswer(invocation -> { + localCreditStarted.countDown(); + assertTrue(releaseLocalCredit.await(5, TimeUnit.SECONDS)); + throw new java.sql.SQLException("local credit failed"); + }).when(settleCredit).executeUpdate(); + when(settleComplete.executeUpdate()).thenReturn(1); + when(fixture.connection.prepareStatement(anyString())).thenReturn(schema, schema, schema, schema, lookup, + claimInsert, settleSelect, settleCredit, settleRead, settleComplete); + PluginManager pluginManager = mock(PluginManager.class); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + CompletableFuture completion = fixture.user + .addPointsStorageAwareAsync(5, "per-server-operation").toCompletableFuture(); + ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistenceWork.capture()); + persistenceWork.getValue().run(); + + ArgumentCaptor bukkitWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), bukkitWork.capture(), eq(fixture.player)); + bukkitWork.getValue().run(); + + ArgumentCaptor settlementWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(settlementWork.capture()); + CompletableFuture runningSettlement = CompletableFuture.runAsync(settlementWork.getAllValues().get(1)); + assertTrue(localCreditStarted.await(5, TimeUnit.SECONDS)); + verify(settleComplete, never()).executeUpdate(); + releaseLocalCredit.countDown(); + runningSettlement.get(5, TimeUnit.SECONDS); + + assertTrue(completion.isCompletedExceptionally()); + verify(settleComplete, never()).executeUpdate(); + verify(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); + } + } + + @Test + void perServerPointAdditionFailsWithoutWritingWhenBukkitHandoffIsRejected() throws Exception { + PointFixture fixture = pointFixture(); + when(fixture.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); + doReturn("lobby_Points").when(fixture.user).getPointsPath(); + PreparedStatement schema = mock(PreparedStatement.class); + PreparedStatement lookup = mock(PreparedStatement.class); + PreparedStatement claimInsert = mock(PreparedStatement.class); + PreparedStatement releaseSelect = mock(PreparedStatement.class); + PreparedStatement releaseDelete = mock(PreparedStatement.class); + ResultSet missing = mock(ResultSet.class); + when(missing.next()).thenReturn(false); + when(lookup.executeQuery()).thenReturn(missing); + AtomicReference owner = new AtomicReference<>(); + doAnswer(invocation -> { + owner.set(invocation.getArgument(1)); + return null; + }).when(claimInsert).setString(eq(8), anyString()); + ResultSet claimed = mock(ResultSet.class); + when(claimed.next()).thenReturn(true); + when(claimed.getString(1)).thenReturn("00000000-0000-0000-0000-000000000001"); + when(claimed.getString(2)).thenReturn("Points"); + when(claimed.getInt(3)).thenReturn(5); + when(claimed.getString(4)).thenReturn("HOOK_STARTED"); + when(claimed.getObject(5)).thenReturn(null); + when(claimed.getObject(6)).thenReturn(Integer.valueOf(5)); + when(claimed.getInt(6)).thenReturn(5); + when(claimed.getString(7)).thenAnswer(invocation -> owner.get()); + when(releaseSelect.executeQuery()).thenReturn(claimed); + when(releaseDelete.executeUpdate()).thenReturn(1); + when(fixture.connection.prepareStatement(anyString())).thenReturn(schema, schema, schema, schema, lookup, + claimInsert, releaseSelect, releaseDelete); + when(fixture.entityScheduler.runAtEntityWithFallback(eq(fixture.player), any(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); + doThrow(new RejectedExecutionException("stopping")).when(fixture.scheduler) + .runTask(eq(fixture.plugin), any(Runnable.class)); + PluginManager pluginManager = mock(PluginManager.class); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + CompletableFuture completion = fixture.user + .addPointsStorageAwareAsync(5, "per-server-operation").toCompletableFuture(); + ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistenceWork.capture()); + persistenceWork.getValue().run(); + + ArgumentCaptor releaseWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(releaseWork.capture()); + releaseWork.getAllValues().get(1).run(); + assertTrue(completion.isCompletedExceptionally()); + verifyNoInteractions(pluginManager); + verify(fixture.user, never()).setPoints(anyInt(), eq(false)); + verify(releaseDelete).setString(2, "HOOK_STARTED"); + } + } + + @Test + void retiredEntitySchedulerQueuesUnstartedHookReleaseWithoutJdbcOnCompletionLane() throws Exception { + PointFixture fixture = pointFixture(); + CompletableFuture entityCompletion = new CompletableFuture<>(); + when(fixture.entityScheduler.runAtEntityWithFallback(eq(fixture.player), any(), any(Runnable.class))) + .thenReturn(entityCompletion); + PreparedStatement statement = fixture.statement; + ResultSet missing = mock(ResultSet.class); + when(missing.next()).thenReturn(false); + ResultSet claimed = mock(ResultSet.class); + when(claimed.next()).thenReturn(true); + when(claimed.getString(1)).thenReturn("00000000-0000-0000-0000-000000000001"); + when(claimed.getString(2)).thenReturn("Points"); + when(claimed.getInt(3)).thenReturn(5); + when(claimed.getString(4)).thenReturn("HOOK_STARTED"); + when(claimed.getObject(5)).thenReturn(null); + when(claimed.getObject(6)).thenReturn(Integer.valueOf(5)); + when(claimed.getInt(6)).thenReturn(5); + AtomicReference owner = new AtomicReference<>(); + doAnswer(invocation -> { + owner.set(invocation.getArgument(1)); + return null; + }).when(statement).setString(eq(8), anyString()); + when(claimed.getString(7)).thenAnswer(invocation -> owner.get()); + when(claimed.getLong(8)).thenReturn(1L); + when(statement.executeQuery()).thenReturn(missing, claimed); + when(statement.executeUpdate()).thenReturn(1); + doThrow(new RejectedExecutionException("retired")).when(fixture.scheduler) + .runTask(eq(fixture.plugin), any(Runnable.class), eq(fixture.player)); + doThrow(new RejectedExecutionException("stopping")).when(fixture.scheduler) + .runTask(eq(fixture.plugin), any(Runnable.class)); + + CompletableFuture completion = fixture.user.addPointsStorageAwareAsync(5, "reward-operation") + .toCompletableFuture(); + ArgumentCaptor claimedWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(claimedWork.capture()); + claimedWork.getValue().run(); + + com.bencodez.simpleapi.sql.mysql.ConnectionManager manager = fixture.sql.getConnectionManager(); + org.mockito.Mockito.clearInvocations(fixture.persistence, manager, fixture.connection, statement); + entityCompletion.complete(EntityTaskResult.SCHEDULER_RETIRED); + + ArgumentCaptor releaseWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(releaseWork.capture()); + verifyNoInteractions(manager, fixture.connection, statement); + assertFalse(completion.isDone()); + releaseWork.getValue().run(); + + assertTrue(completion.isCompletedExceptionally()); + verify(fixture.scheduler, never()).runTaskAsynchronously(eq(fixture.plugin), any(Runnable.class)); + verify(fixture.connection).commit(); + verify(statement).setString(2, "HOOK_STARTED"); + } + + @Test + void retiredEntitySchedulerRetainsClaimWhenNoDatabaseSafeReleaseWorkerAcceptsWork() throws Exception { + PointFixture fixture = pointFixture(); + CompletableFuture entityCompletion = new CompletableFuture<>(); + when(fixture.entityScheduler.runAtEntityWithFallback(eq(fixture.player), any(), any(Runnable.class))) + .thenReturn(entityCompletion); + PreparedStatement statement = fixture.statement; + ResultSet missing = mock(ResultSet.class); + when(missing.next()).thenReturn(false); + ResultSet claimed = mock(ResultSet.class); + when(claimed.next()).thenReturn(true); + when(claimed.getString(1)).thenReturn("00000000-0000-0000-0000-000000000001"); + when(claimed.getString(2)).thenReturn("Points"); + when(claimed.getInt(3)).thenReturn(5); + when(claimed.getString(4)).thenReturn("HOOK_STARTED"); + when(claimed.getObject(5)).thenReturn(null); + when(claimed.getObject(6)).thenReturn(Integer.valueOf(5)); + when(claimed.getInt(6)).thenReturn(5); + AtomicReference owner = new AtomicReference<>(); + doAnswer(invocation -> { + owner.set(invocation.getArgument(1)); + return null; + }).when(statement).setString(eq(8), anyString()); + when(claimed.getString(7)).thenAnswer(invocation -> owner.get()); + when(claimed.getLong(8)).thenReturn(1L); + when(statement.executeQuery()).thenReturn(missing, claimed); + when(statement.executeUpdate()).thenReturn(1); + doThrow(new RejectedExecutionException("retired")).when(fixture.scheduler) + .runTask(eq(fixture.plugin), any(Runnable.class), eq(fixture.player)); + doThrow(new RejectedExecutionException("stopping")).when(fixture.scheduler) + .runTask(eq(fixture.plugin), any(Runnable.class)); + + CompletableFuture completion = fixture.user.addPointsStorageAwareAsync(5, "reward-operation") + .toCompletableFuture(); + ArgumentCaptor claimedWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(claimedWork.capture()); + claimedWork.getValue().run(); + + com.bencodez.simpleapi.sql.mysql.ConnectionManager manager = fixture.sql.getConnectionManager(); + org.mockito.Mockito.clearInvocations(fixture.persistence, manager, fixture.connection, statement); + doThrow(new RejectedExecutionException("stopping")).when(fixture.persistence).execute(any(Runnable.class)); + doThrow(new RejectedExecutionException("disabling")).when(fixture.scheduler) + .runTaskAsynchronously(eq(fixture.plugin), any(Runnable.class)); + entityCompletion.complete(EntityTaskResult.SCHEDULER_RETIRED); + + assertTrue(completion.isCompletedExceptionally()); + verify(fixture.scheduler).runTaskAsynchronously(eq(fixture.plugin), any(Runnable.class)); + verifyNoInteractions(manager, fixture.connection, statement); + } + + @Test + void sharedAsyncAddReturnsThePredictedEventAdjustedTotalWithoutJdbcOnTheCaller() throws Exception { + PointFixture fixture = pointFixture(); + UserData data = mock(UserData.class); + doReturn(data).when(fixture.user).getUserData(); + when(data.getInt("Points", UserDataFetchMode.TEMP_ONLY)).thenReturn(10); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + PluginManager pluginManager = mock(PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + doAnswer(invocation -> { + PlayerReceivePointsEvent event = invocation.getArgument(0); + event.setPoints(7); + return null; + }).when(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); + + assertEquals(17, fixture.user.addPoints(5, true)); + } + + verify(fixture.persistence).execute(any(Runnable.class)); + verify(fixture.sql.getConnectionManager(), never()).getConnection(); + verify(data).getInt("Points", UserDataFetchMode.TEMP_ONLY); + verify(fixture.user, never()).getPoints(); + } + + @Test + void consecutiveSharedAsyncAddsComposeThroughTheOptimisticCache() throws Exception { + PointFixture fixture = pointFixture(); + UserDataCache cache = mock(UserDataCache.class); + HashMap values = new HashMap<>(); + values.put("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(10)); + doReturn(cache).when(fixture.user).getCache(); + doReturn(true).when(fixture.user).isCached(); + when(cache.getCache()).thenReturn(values); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + assertEquals(15, fixture.user.addPointsStorageAware(5)); + assertEquals(22, fixture.user.addPointsStorageAware(7)); + } + + assertEquals(22, values.get("Points").getInt()); + verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(any(Runnable.class)); + verify(fixture.sql.getConnectionManager(), never()).getConnection(); + } + + @Test + void storageAwareSharedAddQueuesJdbcOffTheCallingLane() throws Exception { + PointFixture fixture = pointFixture(); + UserData data = mock(UserData.class); + doReturn(data).when(fixture.user).getUserData(); + when(data.getInt("Points", UserDataFetchMode.TEMP_ONLY)).thenReturn(10); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + PluginManager pluginManager = mock(PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + + assertEquals(15, fixture.user.addPointsStorageAware(5)); + } + + verify(fixture.persistence).execute(any(Runnable.class)); + verify(fixture.sql.getConnectionManager(), never()).getConnection(); + } + + @Test + void rejectedAsyncAddDiscardsOptimisticPointsAndKeepsCallerAlive() throws Exception { + PointFixture fixture = pointFixture(); + UserDataCache cache = mock(UserDataCache.class); + HashMap values = new HashMap<>(); + values.put("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(10)); + doReturn(true).when(fixture.user).isCached(); + doReturn(cache).when(fixture.user).getCache(); + when(cache.getCache()).thenReturn(values); + when(fixture.plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn( + new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of( + java.util.UUID.fromString("00000000-0000-0000-0000-000000000001"), cache))); + doThrow(new RejectedExecutionException("full")).when(fixture.persistence).execute(any(Runnable.class)); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + assertEquals(10, fixture.user.addPointsStorageAware(5)); + } + + assertFalse(values.containsKey("Points")); + verify(fixture.sql.getConnectionManager(), never()).getConnection(); + } + + @Test + void rejectedSingleUserPointCallbacksCompleteAsFailures() throws Exception { + PointFixture fixture = pointFixture(); + AtomicReference addResult = new AtomicReference<>(); + AtomicReference removeResult = new AtomicReference<>(); + doThrow(new RejectedExecutionException("full")).when(fixture.persistence).execute(any(Runnable.class)); + doAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }).when(fixture.scheduler).runTask(eq(fixture.plugin), any(Runnable.class), eq(fixture.player)); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + fixture.user.addPointsStorageAware(5, (success, ignored) -> addResult.set(success)); + fixture.user.removePoints(5, removeResult::set); + } + + assertEquals(Boolean.FALSE, addResult.get()); + assertEquals(Boolean.FALSE, removeResult.get()); + verify(fixture.sql.getConnectionManager(), never()).getConnection(); + } + + @Test + void sharedRemoveSkipsStaleCachedPointPrecheck() throws Exception { + PointFixture fixture = pointFixture(); + doReturn(0).when(fixture.user).getPoints(); + when(fixture.statement.executeUpdate()).thenReturn(1); + + assertTrue(fixture.user.removePoints(10)); + verify(fixture.user, never()).getPoints(); + verify(fixture.statement).executeUpdate(); + } + + @Test + void nullSharedPointConnectionIsReportedAsASqlFailure() throws Exception { + PointFixture fixture = pointFixture(); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn((Connection) null); + + assertFalse(fixture.user.removePoints(10)); + verify(fixture.plugin.getLogger()).severe(org.mockito.ArgumentMatchers.contains("SQLException")); + } + + @Test + void nullSharedAddConnectionCompletesCallbackWithoutASecondLookup() throws Exception { + PointFixture fixture = pointFixture(); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn((Connection) null); + doAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }).when(fixture.scheduler).runTask(eq(fixture.plugin), any(Runnable.class), eq(fixture.player)); + AtomicReference success = new AtomicReference<>(); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + fixture.user.addPointsStorageAware(5, (written, ignored) -> success.set(written)); + } + ArgumentCaptor persistence = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistence.capture()); + persistence.getValue().run(); + + assertEquals(Boolean.FALSE, success.get()); + verify(fixture.user, never()).getPoints(); + verify(fixture.sql.getConnectionManager()).getConnection(); + } + + @Test + void nullTransferJournalConnectionCompletesTheTransferAsFailure() throws Exception { + TransferSchedulingFixture fixture = transferSchedulingFixture(); + when(fixture.manager.getConnection()).thenReturn((Connection) null); + AtomicReference result = new AtomicReference<>(); + doAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }).when(fixture.scheduler).runTask(eq(fixture.plugin), any(Runnable.class), eq(fixture.player)); + + fixture.user.transferPointsWithResult(fixture.target, 10, result::set); + ArgumentCaptor persistence = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistence.capture()); + persistence.getValue().run(); + + assertEquals(PointTransferResult.UNAVAILABLE, result.get()); + verify(fixture.plugin.getLogger()).severe(org.mockito.ArgumentMatchers.contains("SQLException")); + } + + @Test + void sharedAbsoluteSetUsesTheDirectMysqlMutator() throws Exception { + PointFixture fixture = pointFixture(); + UserData userData = mock(UserData.class); + doReturn(userData).when(fixture.user).getUserData(); + + fixture.user.setPoints(42); + + verify(fixture.statement).setInt(1, 42); + verify(fixture.statement).executeUpdate(); + verify(userData, never()).setInt(anyString(), eq(42), eq(false)); + } + + @Test + void sharedPointMutationInvalidatesOnlyPointsFromACacheRecreatedDuringJdbc() throws Exception { + PointFixture fixture = pointFixture(); + when(fixture.statement.executeUpdate()).thenReturn(1); + UserDataCache recreatedCache = mock(UserDataCache.class); + HashMap values = new HashMap<>(); + values.put("Points", mock(com.bencodez.simpleapi.sql.data.DataValue.class)); + values.put("VoteStreak", mock(com.bencodez.simpleapi.sql.data.DataValue.class)); + when(recreatedCache.getCache()).thenReturn(values); + doReturn(false, true).when(fixture.user).isCached(); + doReturn(recreatedCache).when(fixture.user).getCache(); + java.util.UUID userUuid = java.util.UUID.fromString(fixture.user.getUUID()); + when(fixture.plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn( + new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(userUuid, recreatedCache))); + + assertTrue(fixture.user.removePoints(10)); + + assertFalse(values.containsKey("Points")); + assertTrue(values.containsKey("VoteStreak")); + verify(fixture.plugin.getUserManager().getDataManager(), never()).removeCache(any(), any()); + } + + @Test + void sharedAsyncRemoveKeepsJdbcOffTheCallerThread() throws Exception { + PointFixture fixture = pointFixture(); + UserData data = mock(UserData.class); + doReturn(data).when(fixture.user).getUserData(); + when(data.getInt("Points", UserDataFetchMode.TEMP_ONLY)).thenReturn(20); + when(fixture.statement.executeUpdate()).thenReturn(1); + + assertTrue(fixture.user.removePoints(10, true)); + + ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistenceWork.capture()); + verify(fixture.sql.getConnectionManager(), never()).getConnection(); + verify(data).getInt("Points", UserDataFetchMode.TEMP_ONLY); + verify(fixture.user, never()).getPoints(); + persistenceWork.getValue().run(); + verify(fixture.sql.getConnectionManager()).getConnection(); + verify(fixture.statement).executeUpdate(); + } + + @Test + void sharedRemoveConsumerRunsJdbcOnPersistenceExecutorAndReportsOnEntity() throws Exception { + PointFixture fixture = pointFixture(); + PreparedStatement lookup = mock(PreparedStatement.class); + ResultSet missing = mock(ResultSet.class); + when(lookup.executeQuery()).thenReturn(missing); + PreparedStatement insert = mock(PreparedStatement.class); + PreparedStatement debit = mock(PreparedStatement.class); + PreparedStatement read = mock(PreparedStatement.class); + PreparedStatement complete = mock(PreparedStatement.class); + when(debit.executeUpdate()).thenReturn(1); + ResultSet total = mock(ResultSet.class); + when(total.next()).thenReturn(true); + when(total.getInt(1)).thenReturn(10); + when(read.executeQuery()).thenReturn(total); + when(complete.executeUpdate()).thenReturn(1); + when(fixture.connection.prepareStatement(anyString())).thenAnswer(invocation -> { + String sql = invocation.getArgument(0, String.class); + if (sql.startsWith("SELECT `player_uuid`")) return lookup; + if (sql.startsWith("INSERT INTO `VotingPlugin_Users_PointAdditions`")) return insert; + if (sql.startsWith("UPDATE `VotingPlugin_Users` SET")) return debit; + if (sql.startsWith("SELECT `Points` FROM `VotingPlugin_Users`")) return read; + if (sql.startsWith("UPDATE `VotingPlugin_Users_PointAdditions` SET `state`")) return complete; + return fixture.statement; + }); + AtomicReference result = new AtomicReference<>(); + + fixture.user.removePoints(10, result::set); + + ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistenceWork.capture()); + verify(fixture.sql.getConnectionManager(), never()).getConnection(); + persistenceWork.getValue().run(); + + ArgumentCaptor entityWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), entityWork.capture(), eq(fixture.player)); + assertTrue(result.get() == null); + entityWork.getValue().run(); + assertTrue(result.get()); + verify(complete, org.mockito.Mockito.times(2)).executeUpdate(); + verify(fixture.sql.getConnectionManager(), org.mockito.Mockito.atLeastOnce()).getConnection(); + } + + @Test + void sharedTransferRunsRecipientApprovalOnBukkitSchedulerBeforeSettlement() throws Exception { + TransferSchedulingFixture fixture = transferSchedulingFixture(); + AtomicReference result = new AtomicReference<>(); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + PluginManager pluginManager = mock(PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + doAnswer(invocation -> { + PlayerReceivePointsEvent event = invocation.getArgument(0); + assertFalse(event.isAsynchronous()); + event.setPoints(4); + return null; + }).when(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); + + fixture.user.transferPoints(fixture.target, 10, result::set); + verify(fixture.user).getPlayer(); + verify(fixture.target).getPlayer(); + org.mockito.Mockito.clearInvocations(fixture.user, fixture.target); + ArgumentCaptor firstPersistence = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(firstPersistence.capture()); + firstPersistence.getValue().run(); + + ArgumentCaptor approval = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), approval.capture()); + verify(fixture.claim, never()).prepareStatement(any(String.class)); + assertEquals(null, result.get()); + approval.getValue().run(); + ArgumentCaptor persistence = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(persistence.capture()); + persistence.getAllValues().get(1).run(); + @SuppressWarnings("rawtypes") + ArgumentCaptor event = ArgumentCaptor.forClass(java.util.function.Consumer.class); + verify(fixture.entityScheduler).runAtEntityWithFallback(eq(fixture.targetPlayer), event.capture(), any(Runnable.class)); + event.getValue().accept(null); + verify(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); + + ArgumentCaptor settlement = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(3)).execute(settlement.capture()); + settlement.getAllValues().get(2).run(); + verify(fixture.user, never()).getPlayer(); + verify(fixture.target, never()).getPlayer(); + } + + assertEquals(null, result.get()); + } + + @Test + void rejectedApprovalSettlementSubmissionUsesBukkitAsyncFallbackWithApprovedAmount() throws Exception { + SagaFixture fixture = sagaFixture(true); + AtomicReference result = new AtomicReference<>(); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + PluginManager pluginManager = mock(PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + doAnswer(invocation -> { + invocation.getArgument(0).setPoints(4); + return null; + }).when(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); + fixture.user.transferPoints(fixture.target, 10, result::set); + ArgumentCaptor reservation = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(reservation.capture()); + reservation.getValue().run(); + ArgumentCaptor gate = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), gate.capture()); + gate.getValue().run(); + ArgumentCaptor claim = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(claim.capture()); + claim.getAllValues().get(1).run(); + doThrow(new RejectedExecutionException("stopping")).when(fixture.persistence).execute(any(Runnable.class)); + @SuppressWarnings("rawtypes") + ArgumentCaptor approval = ArgumentCaptor.forClass(java.util.function.Consumer.class); + verify(fixture.entityScheduler).runAtEntityWithFallback(eq(fixture.targetPlayer), approval.capture(), any(Runnable.class)); + approval.getValue().accept(null); + + ArgumentCaptor asyncSettlement = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTaskAsynchronously(eq(fixture.plugin), asyncSettlement.capture()); + verify(fixture.settlementPoint, never()).executeUpdate(); + asyncSettlement.getValue().run(); + verify(fixture.settlementPoint).setInt(1, 4); + verify(fixture.settlementPoint).executeUpdate(); + ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); + completion.getValue().run(); + } + + assertEquals(Boolean.TRUE, result.get()); + } + + @Test + void rejectedApprovalSettlementSchedulersReportPendingConfirmation() throws Exception { + SagaFixture fixture = sagaFixture(true); + AtomicReference result = new AtomicReference<>(); + + fixture.user.transferPointsWithResult(fixture.target, 10, result::set); + ArgumentCaptor reservation = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(reservation.capture()); + reservation.getValue().run(); + ArgumentCaptor gate = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), gate.capture()); + gate.getValue().run(); + ArgumentCaptor claim = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(claim.capture()); + claim.getAllValues().get(1).run(); + doThrow(new RejectedExecutionException("stopping")).when(fixture.persistence).execute(any(Runnable.class)); + doThrow(new RejectedExecutionException("disabling")).when(fixture.scheduler) + .runTaskAsynchronously(eq(fixture.plugin), any(Runnable.class)); + @SuppressWarnings("rawtypes") + ArgumentCaptor approval = ArgumentCaptor.forClass(java.util.function.Consumer.class); + verify(fixture.entityScheduler).runAtEntityWithFallback(eq(fixture.targetPlayer), approval.capture(), any(Runnable.class)); + approval.getValue().accept(null); + + verify(fixture.scheduler).runTaskAsynchronously(eq(fixture.plugin), any(Runnable.class)); + verify(fixture.settlementPoint, never()).executeUpdate(); + ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); + completion.getValue().run(); + assertEquals(PointTransferResult.PENDING_CONFIRMATION, result.get()); + } + + @Test + void legacyTransferCallbackTreatsPendingConfirmationAsSuccessfulToSuppressRetry() { + assertTrue(VotingPluginUser.completesLegacyTransfer(PointTransferResult.PENDING_CONFIRMATION)); + assertFalse(VotingPluginUser.completesLegacyTransfer(PointTransferResult.UNAVAILABLE)); + } + + @Test + void retiredApprovalSchedulerRefundsClaimedTransferBeforeTheHookCanRun() throws Exception { + SagaFixture fixture = sagaFixture(true); + configureRejectedSagaConnections(fixture); + when(fixture.entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); + AtomicReference result = new AtomicReference<>(); + + fixture.user.transferPoints(fixture.target, 10, result::set); + ArgumentCaptor persistence = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistence.capture()); + persistence.getValue().run(); + ArgumentCaptor gate = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), gate.capture()); + gate.getValue().run(); + ArgumentCaptor claimed = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(claimed.capture()); + claimed.getAllValues().get(1).run(); + ArgumentCaptor refunded = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(3)).execute(refunded.capture()); + refunded.getAllValues().get(2).run(); + + verify(fixture.settlementPoint).setInt(1, 10); + verify(fixture.settlementPoint).executeUpdate(); + ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler, org.mockito.Mockito.times(2)).runTask(eq(fixture.plugin), completion.capture()); + completion.getAllValues().get(1).run(); + assertEquals(Boolean.FALSE, result.get()); + } + + @Test + void rejectedClaimedTransferCompensationUsesAsyncFallback() throws Exception { + SagaFixture fixture = sagaFixture(true); + configureRejectedSagaConnections(fixture); + when(fixture.entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); + AtomicReference result = new AtomicReference<>(); + + fixture.user.transferPoints(fixture.target, 10, result::set); + ArgumentCaptor persistence = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistence.capture()); + persistence.getValue().run(); + ArgumentCaptor gate = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), gate.capture()); + gate.getValue().run(); + ArgumentCaptor claimed = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(claimed.capture()); + doThrow(new RejectedExecutionException("stopping")).when(fixture.persistence).execute(any(Runnable.class)); + claimed.getAllValues().get(1).run(); + + ArgumentCaptor asyncRefund = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTaskAsynchronously(eq(fixture.plugin), asyncRefund.capture()); + verify(fixture.settlementPoint, never()).executeUpdate(); + asyncRefund.getValue().run(); + verify(fixture.settlementPoint).setInt(1, 10); + verify(fixture.settlementPoint).executeUpdate(); + ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler, org.mockito.Mockito.times(2)).runTask(eq(fixture.plugin), completion.capture()); + completion.getAllValues().get(1).run(); + assertEquals(Boolean.FALSE, result.get()); + } + + @Test + void rejectedClaimedTransferRetainsDurableCompensationWhenBothFallbackSchedulersReject( + @TempDir Path temporaryDirectory) throws Exception { + SagaFixture fixture = sagaFixture(true); + when(fixture.plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile()); + configureRejectedSagaConnections(fixture); + when(fixture.entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); + AtomicReference result = new AtomicReference<>(); + + fixture.user.transferPoints(fixture.target, 10, result::set); + ArgumentCaptor persistence = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistence.capture()); + persistence.getValue().run(); + ArgumentCaptor gate = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), gate.capture()); + gate.getValue().run(); + ArgumentCaptor claimed = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(claimed.capture()); + doThrow(new RejectedExecutionException("stopping")).when(fixture.persistence).execute(any(Runnable.class)); + doThrow(new RejectedExecutionException("disabling")).when(fixture.scheduler) + .runTaskAsynchronously(eq(fixture.plugin), any(Runnable.class)); + claimed.getAllValues().get(1).run(); + + verify(fixture.compensationUpdate, never()).setString(anyInt(), anyString()); + verify(fixture.scheduler).runTaskAsynchronously(eq(fixture.plugin), any(Runnable.class)); + verify(fixture.settlementPoint, never()).executeUpdate(); + assertEquals(1, new SharedPointTransferCompensationStore(temporaryDirectory).loadBatch().size()); + ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler, org.mockito.Mockito.times(2)).runTask(eq(fixture.plugin), completion.capture()); + completion.getAllValues().get(1).run(); + assertEquals(Boolean.FALSE, result.get()); + } + + @Test + void failedClaimedTransferCompensationKeepsRecoveryMarkerWhenMysqlFenceAndRefundFail( + @TempDir Path temporaryDirectory) throws Exception { + SagaFixture fixture = sagaFixture(true); + when(fixture.plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile()); + configureRejectedSagaConnections(fixture); + when(fixture.entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); + + fixture.user.transferPoints(fixture.target, 10, ignored -> { }); + ArgumentCaptor persistence = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistence.capture()); + persistence.getValue().run(); + ArgumentCaptor gate = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), gate.capture()); + gate.getValue().run(); + ArgumentCaptor claimed = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(claimed.capture()); + claimed.getAllValues().get(1).run(); + ArgumentCaptor compensation = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(3)).execute(compensation.capture()); + + Connection unavailable = mock(Connection.class); + doThrow(new java.sql.SQLException("database unavailable")).when(unavailable).prepareStatement(anyString()); + when(fixture.manager.getConnection()).thenReturn(unavailable); + compensation.getAllValues().get(2).run(); + + assertEquals(1, new SharedPointTransferCompensationStore(temporaryDirectory).loadBatch().size()); + } + + @Test + void failedTransferCompensationMarkerIsRetriedByRecovery(@TempDir Path temporaryDirectory) throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile()); + SharedPointTransferJournal journal = mock(SharedPointTransferJournal.class); + when(journal.markCompensating("transfer-1")) + .thenThrow(new java.sql.SQLException("down")) + .thenReturn(true); + when(journal.recoverAndCleanup(anyLong())).thenReturn(java.util.List.of()); + SharedPointTransferCompensationStore store = + new SharedPointTransferCompensationStore(temporaryDirectory); + store.record("transfer-1"); + + SharedMysqlPointMutator.recoverTransfers(plugin, journal); + assertEquals(java.util.List.of("transfer-1"), store.loadBatch()); + SharedMysqlPointMutator.recoverTransfers(plugin, journal); + + assertTrue(store.loadBatch().isEmpty()); + verify(journal, org.mockito.Mockito.times(2)).markCompensating("transfer-1"); + verify(journal, org.mockito.Mockito.times(2)).recoverAndCleanup(anyLong()); + } + + @Test + void rejectedPersistenceClaimLeavesReservedTransferForOffThreadRecovery() throws Exception { + SagaFixture fixture = sagaFixture(true); + AtomicReference result = new AtomicReference<>(); + + fixture.user.transferPoints(fixture.target, 10, result::set); + ArgumentCaptor persistence = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistence.capture()); + persistence.getValue().run(); + + ArgumentCaptor gate = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), gate.capture()); + doThrow(new RejectedExecutionException("full")).when(fixture.persistence).execute(any(Runnable.class)); + gate.getValue().run(); + + // The gate runs on Bukkit's lane. A rejected persistence submission must + // not synchronously acquire JDBC to refund; the durable RESERVED row is + // recovered by the existing off-thread periodic/startup recovery. + verifyNoInteractions(fixture.claim); + ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); + completion.getValue().run(); + assertEquals(Boolean.FALSE, result.get()); + } + + @Test + void offlineTargetApprovalFallsBackToTheOnlineSourceEntityLane() throws Exception { + SagaFixture fixture = sagaFixture(true); + when(fixture.target.getPlayer()).thenReturn(null); + AtomicReference result = new AtomicReference<>(); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + PluginManager pluginManager = mock(PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + fixture.user.transferPoints(fixture.target, 10, result::set); + ArgumentCaptor reservation = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(reservation.capture()); + reservation.getValue().run(); + runTransferApprovalGate(fixture.persistence, fixture.scheduler, fixture.plugin, + fixture.entityScheduler, fixture.player); + } + + verify(fixture.entityScheduler).runAtEntityWithFallback(eq(fixture.player), any(), any(Runnable.class)); + } + + @Test + void sharedTransferReportsCompletionOnSourceEntityScheduler() throws Exception { + SagaFixture fixture = sagaFixture(true); + AtomicReference result = new AtomicReference<>(); + AtomicReference eventThread = new AtomicReference<>(); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + PluginManager pluginManager = mock(PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + doAnswer(invocation -> { + eventThread.set(Thread.currentThread()); + return null; + }).when(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); + fixture.user.transferPoints(fixture.target, 10, result::set); + ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistenceWork.capture()); + persistenceWork.getValue().run(); + runTransferApprovalGate(fixture.persistence, fixture.scheduler, fixture.plugin, + fixture.entityScheduler, fixture.targetPlayer); + Thread bukkitThread = eventThread.get(); + ArgumentCaptor settlementWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(3)).execute(settlementWork.capture()); + settlementWork.getAllValues().get(2).run(); + ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); + assertTrue(result.get() == null); + completion.getValue().run(); + assertEquals(bukkitThread, eventThread.get(), "the receive hook must run on Bukkit's scheduler lane"); + } + assertTrue(result.get()); + } + + @Test + void sharedTransferCreditsTheEventAdjustedRecipientAmountAtomically() throws Exception { + SagaFixture fixture = sagaFixture(true); + AtomicReference result = new AtomicReference<>(); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + PluginManager pluginManager = mock(PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + doAnswer(invocation -> { + PlayerReceivePointsEvent event = invocation.getArgument(0); + event.setPoints(4); + return null; + }).when(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); + fixture.user.transferPoints(fixture.target, 10, result::set); + + ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistenceWork.capture()); + persistenceWork.getValue().run(); + runTransferApprovalGate(fixture.persistence, fixture.scheduler, fixture.plugin, + fixture.entityScheduler, fixture.targetPlayer); + ArgumentCaptor settlementWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(3)).execute(settlementWork.capture()); + settlementWork.getAllValues().get(2).run(); + ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); + completion.getValue().run(); + InOrder transferOrder = inOrder(fixture.debit, pluginManager, fixture.settlementPoint); + transferOrder.verify(fixture.debit).executeUpdate(); + transferOrder.verify(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); + transferOrder.verify(fixture.settlementPoint).executeUpdate(); + } + + assertTrue(result.get()); + verify(fixture.settlementPoint).setInt(1, 4); + } + + @Test + void indeterminateSettlementConfirmationReportsPendingConfirmation() throws Exception { + SagaFixture fixture = sagaFixture(true); + Connection unavailable = mock(Connection.class); + when(unavailable.prepareStatement(anyString())).thenThrow(new java.sql.SQLException("confirmation unavailable")); + when(fixture.manager.getConnection()).thenReturn(fixture.schema, fixture.recoveryReserved, fixture.cleanup, + fixture.lookup, fixture.reservation, fixture.claim, fixture.settlement, + unavailable, unavailable, unavailable); + doThrow(new java.sql.SQLException("settlement acknowledgement lost")).when(fixture.settlement).commit(); + AtomicReference result = new AtomicReference<>(); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + fixture.user.transferPointsWithResult(fixture.target, 10, result::set); + ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistenceWork.capture()); + persistenceWork.getValue().run(); + runTransferApprovalGate(fixture.persistence, fixture.scheduler, fixture.plugin, + fixture.entityScheduler, fixture.targetPlayer); + ArgumentCaptor settlementWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(3)).execute(settlementWork.capture()); + settlementWork.getAllValues().get(2).run(); + ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); + completion.getValue().run(); + } + + assertEquals(PointTransferResult.PENDING_CONFIRMATION, result.get()); + } + + @Test + void postApprovalCacheFailureReportsPendingConfirmation() throws Exception { + SagaFixture fixture = sagaFixture(true); + UserDataCache targetCache = mock(UserDataCache.class); + when(fixture.target.isCached()).thenReturn(false, true); + when(fixture.target.getCache()).thenReturn(targetCache); + when(targetCache.getCache()).thenReturn(new HashMap<>()); + doThrow(new IllegalStateException("cache dump failed")).when(targetCache).dump(); + AtomicReference result = new AtomicReference<>(); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + fixture.user.transferPointsWithResult(fixture.target, 10, result::set); + ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistenceWork.capture()); + persistenceWork.getValue().run(); + runTransferApprovalGate(fixture.persistence, fixture.scheduler, fixture.plugin, + fixture.entityScheduler, fixture.targetPlayer); + ArgumentCaptor settlementWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(3)).execute(settlementWork.capture()); + settlementWork.getAllValues().get(2).run(); + ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); + completion.getValue().run(); + } + + assertEquals(PointTransferResult.PENDING_CONFIRMATION, result.get()); + verify(fixture.settlementPoint, never()).executeUpdate(); + } + + @Test + void initialTransferCacheFailureCompletesAsUnavailable() throws Exception { + SagaFixture fixture = sagaFixture(true); + UserDataCache sourceCache = mock(UserDataCache.class); + doReturn(true).when(fixture.user).isCached(); + doReturn(sourceCache).when(fixture.user).getCache(); + when(sourceCache.getCache()).thenReturn(new HashMap<>()); + doThrow(new IllegalStateException("cache dump failed")).when(sourceCache).dump(); + AtomicReference result = new AtomicReference<>(); + + fixture.user.transferPointsWithResult(fixture.target, 10, result::set); + ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistenceWork.capture()); + persistenceWork.getValue().run(); + ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); + completion.getValue().run(); + + assertEquals(PointTransferResult.UNAVAILABLE, result.get()); + verify(fixture.debit, never()).executeUpdate(); + } + + @Test + void sharedTransferDoesNotFireRecipientEventWhenConditionalDebitFails() throws Exception { + SagaFixture fixture = sagaFixture(false); + AtomicReference result = new AtomicReference<>(); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + PluginManager pluginManager = mock(PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + fixture.user.transferPoints(fixture.target, 10, result::set); + + ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistenceWork.capture()); + persistenceWork.getValue().run(); + ArgumentCaptor entityWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), entityWork.capture(), eq(fixture.player)); + entityWork.getValue().run(); + + verify(pluginManager, never()).callEvent(any(PlayerReceivePointsEvent.class)); + verify(fixture.settlementPoint, never()).executeUpdate(); + } + + assertFalse(result.get()); + } + + @Test + void cancelledSharedTransferRollsBackTheConditionalDebit() throws Exception { + SagaFixture fixture = sagaFixture(true); + AtomicReference result = new AtomicReference<>(); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + PluginManager pluginManager = mock(PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + doAnswer(invocation -> { + PlayerReceivePointsEvent event = invocation.getArgument(0); + event.setCancelled(true); + return null; + }).when(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); + fixture.user.transferPoints(fixture.target, 10, result::set); + + ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistenceWork.capture()); + persistenceWork.getValue().run(); + runTransferApprovalGate(fixture.persistence, fixture.scheduler, fixture.plugin, + fixture.entityScheduler, fixture.targetPlayer); + ArgumentCaptor settlementWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(3)).execute(settlementWork.capture()); + settlementWork.getAllValues().get(2).run(); + ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); + completion.getValue().run(); + + verify(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); + verify(fixture.settlementPoint).executeUpdate(); + verify(fixture.settlementPoint).setInt(1, 10); + verify(fixture.settlementPoint).setString(2, fixture.user.getUUID()); + verify(fixture.settlement).commit(); + } + + assertTrue(Boolean.FALSE.equals(result.get())); + } + + @Test + void sharedTransferClosesReservationBeforeListenerDatabaseReadAndSettlesAdjustment() throws Exception { + TransferSchedulingFixture fixture = transferSchedulingFixture(); + VotingPluginUser target = mock(VotingPluginUser.class, CALLS_REAL_METHODS); + Field pluginField = VotingPluginUser.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(target, fixture.plugin); + doReturn("00000000-0000-0000-0000-000000000002").when(target).getUUID(); + doReturn("Points").when(target).getPointsPath(); + doReturn(fixture.targetPlayer).when(target).getPlayer(); + UserDataCache recreatedCache = mock(UserDataCache.class); + doReturn(false, true).when(target).isCached(); + doReturn(recreatedCache).when(target).getCache(); + java.util.HashMap recreatedValues = new java.util.HashMap<>(); + recreatedValues.put("Points", mock(com.bencodez.simpleapi.sql.data.DataValue.class)); + recreatedValues.put("DailyTotal", mock(com.bencodez.simpleapi.sql.data.DataValue.class)); + when(recreatedCache.getCache()).thenReturn(recreatedValues); + when(fixture.plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn( + new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of( + java.util.UUID.fromString("00000000-0000-0000-0000-000000000002"), recreatedCache))); + UserData targetData = mock(UserData.class); + doReturn(targetData).when(target).getUserData(); + doAnswer(invocation -> { + try (Connection ignored = fixture.manager.getConnection()) { + return 37; + } + }).when(targetData).getInt("Points"); + AtomicReference result = new AtomicReference<>(); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + PluginManager pluginManager = mock(PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + doAnswer(invocation -> { + PlayerReceivePointsEvent event = invocation.getArgument(0); + assertEquals(37, event.getPlayer().getPoints()); + event.setPoints(4); + return null; + }).when(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); + + fixture.user.transferPoints(target, 10, result::set); + ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistenceWork.capture()); + persistenceWork.getValue().run(); + runTransferApprovalGate(fixture.persistence, fixture.scheduler, fixture.plugin, + fixture.entityScheduler, fixture.targetPlayer); + ArgumentCaptor settlementWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(3)).execute(settlementWork.capture()); + settlementWork.getAllValues().get(2).run(); + ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); + completion.getValue().run(); + } + + InOrder order = org.mockito.Mockito.inOrder(fixture.reservation, fixture.claim, fixture.listenerRead, + recreatedCache, fixture.settlement, fixture.plugin.getUserManager().getDataManager()); + order.verify(fixture.reservation).close(); + order.verify(fixture.claim).close(); + order.verify(fixture.listenerRead).close(); + order.verify(recreatedCache).dump(); + order.verify(fixture.settlement).commit(); + order.verify(recreatedCache).getCache(); + assertFalse(recreatedValues.containsKey("Points")); + assertTrue(recreatedValues.containsKey("DailyTotal"), + "settlement must preserve unrelated changes in a concurrently recreated cache"); + assertEquals(Boolean.TRUE, result.get()); + } + + /** Runs the gate, off-thread journal claim, and Bukkit approval callback in order. */ + private static void runTransferApprovalGate(ScheduledExecutorService persistence, BukkitScheduler scheduler, + VotingPluginMain plugin, ServerImplementation entityScheduler, Player player) { + ArgumentCaptor gate = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTask(eq(plugin), gate.capture()); + gate.getValue().run(); + + ArgumentCaptor claim = ArgumentCaptor.forClass(Runnable.class); + verify(persistence, org.mockito.Mockito.times(2)).execute(claim.capture()); + claim.getAllValues().get(1).run(); + + @SuppressWarnings("rawtypes") + ArgumentCaptor approval = ArgumentCaptor.forClass(java.util.function.Consumer.class); + verify(entityScheduler).runAtEntityWithFallback(eq(player), approval.capture(), any(Runnable.class)); + approval.getValue().accept(null); + } + + private static PointFixture pointFixture() throws Exception { + PointFixture fixture = new PointFixture(); + fixture.plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + fixture.persistence = mock(ScheduledExecutorService.class); + fixture.scheduler = mock(BukkitScheduler.class); + fixture.entityScheduler = configureEntityScheduler(fixture.plugin, fixture.scheduler); + fixture.player = mock(Player.class); + fixture.targetPlayer = mock(Player.class); + fixture.table = mock(MySQL.class); + fixture.sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, + org.mockito.Mockito.RETURNS_DEEP_STUBS); + fixture.connection = mock(Connection.class); + fixture.statement = mock(PreparedStatement.class); + when(fixture.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(fixture.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(false); + when(fixture.plugin.getMysql()).thenReturn(fixture.table); + when(fixture.plugin.getTimer()).thenReturn(fixture.persistence); + when(fixture.plugin.getBukkitScheduler()).thenReturn(fixture.scheduler); + when(fixture.table.getTableName()).thenReturn("VotingPlugin_Users"); + when(fixture.table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); + when(fixture.table.getMysql()).thenReturn(fixture.sql); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.connection); + when(fixture.connection.prepareStatement(anyString())).thenReturn(fixture.statement); + fixture.user = mock(VotingPluginUser.class, CALLS_REAL_METHODS); + Field pluginField = VotingPluginUser.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(fixture.user, fixture.plugin); + doReturn("00000000-0000-0000-0000-000000000001").when(fixture.user).getUUID(); + doReturn("Points").when(fixture.user).getPointsPath(); + doReturn(fixture.player).when(fixture.user).getPlayer(); + doReturn(false).when(fixture.user).isCached(); + doReturn(null).when(fixture.user).getCache(); + return fixture; + } + + private static SagaFixture sagaFixture(boolean debitSucceeds) throws Exception { + SagaFixture fixture = new SagaFixture(); + fixture.plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + fixture.persistence = mock(ScheduledExecutorService.class); + fixture.scheduler = mock(BukkitScheduler.class); + fixture.entityScheduler = configureEntityScheduler(fixture.plugin, fixture.scheduler); + fixture.player = mock(Player.class); + fixture.targetPlayer = mock(Player.class); + fixture.table = mock(MySQL.class); + fixture.sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class); + fixture.manager = mock(ConnectionManager.class); + fixture.schema = mock(Connection.class); + fixture.recoveryReserved = mock(Connection.class); + fixture.cleanup = mock(Connection.class); + fixture.lookup = mock(Connection.class); + fixture.reservation = mock(Connection.class); + fixture.claim = mock(Connection.class); + fixture.compensation = mock(Connection.class); + fixture.settlement = mock(Connection.class); + when(fixture.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(fixture.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(false); + when(fixture.plugin.getMysql()).thenReturn(fixture.table); + when(fixture.plugin.getTimer()).thenReturn(fixture.persistence); + when(fixture.plugin.getBukkitScheduler()).thenReturn(fixture.scheduler); + when(fixture.table.getTableName()).thenReturn("VotingPlugin_Users"); + when(fixture.table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); + when(fixture.table.getMysql()).thenReturn(fixture.sql); + when(fixture.sql.getConnectionManager()).thenReturn(fixture.manager); + when(fixture.manager.getConnection()).thenReturn(fixture.schema, fixture.recoveryReserved, fixture.cleanup, + fixture.lookup, fixture.reservation, fixture.claim, fixture.settlement); + when(fixture.schema.prepareStatement(anyString())).thenReturn(mock(PreparedStatement.class)); + configureJournalMaintenance(fixture.recoveryReserved, fixture.cleanup); + + PreparedStatement lookup = mock(PreparedStatement.class); + ResultSet missing = mock(ResultSet.class); + when(missing.next()).thenReturn(false); + when(lookup.executeQuery()).thenReturn(missing); + when(fixture.lookup.prepareStatement(anyString())).thenReturn(lookup); + PreparedStatement insert = mock(PreparedStatement.class); + fixture.debit = mock(PreparedStatement.class); + when(fixture.debit.executeUpdate()).thenReturn(debitSucceeds ? 1 : 0); + when(fixture.reservation.prepareStatement(anyString())).thenReturn(insert, fixture.debit); + + PreparedStatement claimSelect = mock(PreparedStatement.class); + fixture.claimUpdate = mock(PreparedStatement.class); + AtomicReference owner = new AtomicReference<>(); + doAnswer(invocation -> { + owner.set(invocation.getArgument(1)); + return null; + }).when(fixture.claimUpdate).setString(eq(2), anyString()); + ResultSet reserved = mock(ResultSet.class); + when(reserved.next()).thenReturn(true); + when(reserved.getString(1)).thenReturn("RESERVED"); + when(reserved.getString(2)).thenReturn(null); + when(claimSelect.executeQuery()).thenReturn(reserved); + when(fixture.claimUpdate.executeUpdate()).thenReturn(1); + when(fixture.claim.prepareStatement(anyString())).thenReturn(claimSelect, fixture.claimUpdate); + fixture.compensationUpdate = mock(PreparedStatement.class); + when(fixture.compensationUpdate.executeUpdate()).thenReturn(1); + when(fixture.compensation.prepareStatement(anyString())).thenReturn(fixture.compensationUpdate); + + PreparedStatement settleSelect = mock(PreparedStatement.class); + fixture.settlementPoint = mock(PreparedStatement.class); + PreparedStatement settleJournal = mock(PreparedStatement.class); + ResultSet started = mock(ResultSet.class); + when(started.next()).thenReturn(true); + when(started.getString(1)).thenReturn("HOOK_STARTED"); + when(started.getString(2)).thenAnswer(invocation -> owner.get()); + when(settleSelect.executeQuery()).thenReturn(started); + when(fixture.settlementPoint.executeUpdate()).thenReturn(1); + when(settleJournal.executeUpdate()).thenReturn(1); + when(fixture.settlement.prepareStatement(anyString())).thenReturn(settleSelect, fixture.settlementPoint, + settleJournal); + + fixture.user = mock(VotingPluginUser.class, CALLS_REAL_METHODS); + Field pluginField = VotingPluginUser.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(fixture.user, fixture.plugin); + doReturn("00000000-0000-0000-0000-000000000001").when(fixture.user).getUUID(); + doReturn("Points").when(fixture.user).getPointsPath(); + doReturn(fixture.player).when(fixture.user).getPlayer(); + doReturn(false).when(fixture.user).isCached(); + fixture.target = mock(VotingPluginUser.class); + when(fixture.target.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002"); + when(fixture.target.getPointsPath()).thenReturn("Points"); + when(fixture.target.getPlayer()).thenReturn(fixture.targetPlayer); + return fixture; + } + + private static ServerImplementation configureEntityScheduler(VotingPluginMain plugin, BukkitScheduler scheduler) { + FoliaLib folia = mock(FoliaLib.class); + ServerImplementation entityScheduler = mock(ServerImplementation.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(scheduler.getFoliaLib()).thenReturn(folia); + when(folia.getImpl()).thenReturn(entityScheduler); + when(entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SUCCESS)); + return entityScheduler; + } + + private static TransferSchedulingFixture transferSchedulingFixture() throws Exception { + TransferSchedulingFixture fixture = new TransferSchedulingFixture(); + fixture.plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + fixture.persistence = mock(ScheduledExecutorService.class); + fixture.scheduler = mock(BukkitScheduler.class); + fixture.entityScheduler = configureEntityScheduler(fixture.plugin, fixture.scheduler); + fixture.player = mock(Player.class); + fixture.targetPlayer = mock(Player.class); + fixture.table = mock(MySQL.class); + fixture.sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class); + fixture.manager = mock(ConnectionManager.class); + fixture.schema = mock(Connection.class); + fixture.recoveryReserved = mock(Connection.class); + fixture.cleanup = mock(Connection.class); + fixture.lookup = mock(Connection.class); + fixture.reservation = mock(Connection.class); + fixture.claim = mock(Connection.class); + fixture.listenerRead = mock(Connection.class); + fixture.settlement = mock(Connection.class); + when(fixture.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(fixture.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(false); + when(fixture.plugin.getMysql()).thenReturn(fixture.table); + when(fixture.plugin.getTimer()).thenReturn(fixture.persistence); + when(fixture.plugin.getBukkitScheduler()).thenReturn(fixture.scheduler); + when(fixture.table.getTableName()).thenReturn("VotingPlugin_Users"); + when(fixture.table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); + when(fixture.table.getMysql()).thenReturn(fixture.sql); + when(fixture.sql.getConnectionManager()).thenReturn(fixture.manager); + when(fixture.manager.getConnection()).thenReturn(fixture.schema, fixture.recoveryReserved, fixture.cleanup, + fixture.lookup, fixture.reservation, fixture.claim, fixture.listenerRead, fixture.settlement); + + when(fixture.schema.prepareStatement(anyString())).thenReturn(mock(PreparedStatement.class)); + configureJournalMaintenance(fixture.recoveryReserved, fixture.cleanup); + PreparedStatement lookup = mock(PreparedStatement.class); + ResultSet missing = mock(ResultSet.class); + when(missing.next()).thenReturn(false); + when(lookup.executeQuery()).thenReturn(missing); + when(fixture.lookup.prepareStatement(anyString())).thenReturn(lookup); + PreparedStatement insert = mock(PreparedStatement.class); + PreparedStatement debit = mock(PreparedStatement.class); + when(debit.executeUpdate()).thenReturn(1); + when(fixture.reservation.prepareStatement(anyString())).thenReturn(insert, debit); + PreparedStatement claimSelect = mock(PreparedStatement.class); + PreparedStatement claimUpdate = mock(PreparedStatement.class); + AtomicReference owner = new AtomicReference<>(); + doAnswer(invocation -> { + owner.set(invocation.getArgument(1)); + return null; + }).when(claimUpdate).setString(eq(2), anyString()); + ResultSet reserved = mock(ResultSet.class); + when(reserved.next()).thenReturn(true); + when(reserved.getString(1)).thenReturn("RESERVED"); + when(reserved.getString(2)).thenReturn(null); + when(claimSelect.executeQuery()).thenReturn(reserved); + when(claimUpdate.executeUpdate()).thenReturn(1); + when(fixture.claim.prepareStatement(anyString())).thenReturn(claimSelect, claimUpdate); + PreparedStatement settleSelect = mock(PreparedStatement.class); + PreparedStatement settleCredit = mock(PreparedStatement.class); + PreparedStatement settleJournal = mock(PreparedStatement.class); + ResultSet started = mock(ResultSet.class); + when(started.next()).thenReturn(true); + when(started.getString(1)).thenReturn("HOOK_STARTED"); + when(started.getString(2)).thenAnswer(invocation -> owner.get()); + when(settleSelect.executeQuery()).thenReturn(started); + when(settleCredit.executeUpdate()).thenReturn(1); + when(settleJournal.executeUpdate()).thenReturn(1); + when(fixture.settlement.prepareStatement(anyString())).thenReturn(settleSelect, settleCredit, settleJournal); + fixture.user = mock(VotingPluginUser.class, CALLS_REAL_METHODS); + Field pluginField = VotingPluginUser.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(fixture.user, fixture.plugin); + doReturn("00000000-0000-0000-0000-000000000001").when(fixture.user).getUUID(); + doReturn("Points").when(fixture.user).getPointsPath(); + doReturn(fixture.player).when(fixture.user).getPlayer(); + doReturn(false).when(fixture.user).isCached(); + fixture.target = mock(VotingPluginUser.class); + when(fixture.target.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002"); + when(fixture.target.getPointsPath()).thenReturn("Points"); + when(fixture.target.isCached()).thenReturn(false); + when(fixture.target.getPlayer()).thenReturn(fixture.targetPlayer); + return fixture; + } + + private static void configureJournalMaintenance(Connection reservedCandidates, Connection cleanup) throws Exception { + PreparedStatement reservedQuery = mock(PreparedStatement.class); + PreparedStatement cleanupQuery = mock(PreparedStatement.class); + PreparedStatement cleanupDelete = mock(PreparedStatement.class); + ResultSet noRows = mock(ResultSet.class); + ResultSet noCleanupRows = mock(ResultSet.class); + when(noRows.next()).thenReturn(false); + when(noCleanupRows.next()).thenReturn(false); + when(reservedCandidates.prepareStatement(anyString())).thenReturn(reservedQuery); + when(reservedQuery.executeQuery()).thenReturn(noRows); + when(cleanup.prepareStatement(anyString())).thenReturn(cleanupQuery, cleanupDelete); + when(cleanupQuery.executeQuery()).thenReturn(noCleanupRows); + } + + private static void configureRejectedSagaConnections(SagaFixture fixture) { + when(fixture.manager.getConnection()).thenReturn(fixture.schema, fixture.recoveryReserved, fixture.cleanup, + fixture.lookup, fixture.reservation, fixture.claim, fixture.compensation, fixture.settlement); + } + + private static final class PointFixture { + VotingPluginMain plugin; + ScheduledExecutorService persistence; + BukkitScheduler scheduler; + ServerImplementation entityScheduler; + Player player; + Player targetPlayer; + MySQL table; + com.bencodez.simpleapi.sql.mysql.MySQL sql; + Connection connection; + PreparedStatement statement; + VotingPluginUser user; + } + + private static final class SagaFixture { + VotingPluginMain plugin; + ScheduledExecutorService persistence; + BukkitScheduler scheduler; + ServerImplementation entityScheduler; + Player player; + Player targetPlayer; + MySQL table; + com.bencodez.simpleapi.sql.mysql.MySQL sql; + ConnectionManager manager; + Connection schema; + Connection recoveryReserved; + Connection cleanup; + Connection lookup; + Connection reservation; + Connection claim; + Connection compensation; + Connection settlement; + PreparedStatement debit; + PreparedStatement claimUpdate; + PreparedStatement compensationUpdate; + PreparedStatement settlementPoint; + VotingPluginUser user; + VotingPluginUser target; + } + + private static final class TransferSchedulingFixture { + VotingPluginMain plugin; + ScheduledExecutorService persistence; + BukkitScheduler scheduler; + ServerImplementation entityScheduler; + Player player; + Player targetPlayer; + MySQL table; + com.bencodez.simpleapi.sql.mysql.MySQL sql; + ConnectionManager manager; + Connection schema; + Connection recoveryReserved; + Connection cleanup; + Connection lookup; + Connection reservation; + Connection claim; + Connection listenerRead; + Connection settlement; + VotingPluginUser user; + VotingPluginUser target; + } + +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java new file mode 100644 index 0000000000..ec43050a0a --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java @@ -0,0 +1,80 @@ +package com.bencodez.votingplugin.user; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.UserData; +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.votingplugin.VotingPluginMain; + +class VotingPluginUserVoteShopLimitTest { + @Test + void sharedMysqlLimitsUseNonBlockingUserCacheWhenAvailable() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(false); + UserData data = mock(UserData.class); + when(data.getInt("VoteShopLimitdaily", UserDataFetchMode.CACHE_ONLY)).thenReturn(3); + AdvancedCoreUser base = mock(AdvancedCoreUser.class); + when(base.getUserData()).thenReturn(data); + when(base.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + when(base.getPlayerName()).thenReturn("Player"); + + VotingPluginUser user = spy(new VotingPluginUser(plugin, base)); + doReturn(data).when(user).getData(); + doReturn(true).when(user).isCached(); + assertEquals(3, user.getVoteShopIdentifierLimit("daily")); + + verify(data).getInt("VoteShopLimitdaily", UserDataFetchMode.CACHE_ONLY); + verify(data, never()).getInt("VoteShopLimitdaily", UserDataFetchMode.NO_CACHE); + } + + @Test + void sharedMysqlLimitsUseOnlyTemporaryDataWhenUserIsNotCached() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(false); + UserData data = mock(UserData.class); + when(data.getInt("VoteShopLimitdaily", UserDataFetchMode.TEMP_ONLY)).thenReturn(3); + AdvancedCoreUser base = mock(AdvancedCoreUser.class); + when(base.getUserData()).thenReturn(data); + when(base.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + when(base.getPlayerName()).thenReturn("Player"); + + VotingPluginUser user = spy(new VotingPluginUser(plugin, base)); + doReturn(data).when(user).getData(); + assertEquals(3, user.getVoteShopIdentifierLimit("daily")); + user.setVoteShopIdentifierLimit("daily", 4); + + verify(data).getInt("VoteShopLimitdaily", UserDataFetchMode.TEMP_ONLY); + verify(data, never()).getInt("VoteShopLimitdaily", UserDataFetchMode.NO_CACHE); + verify(data).setInt("VoteShopLimitdaily", 4, false); + } + + @Test + void perServerMysqlLimitsAlsoUseDirectWritesBecauseTheirColumnIsShared() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); + UserData data = mock(UserData.class); + AdvancedCoreUser base = mock(AdvancedCoreUser.class); + when(base.getUserData()).thenReturn(data); + when(base.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + when(base.getPlayerName()).thenReturn("Player"); + + VotingPluginUser user = spy(new VotingPluginUser(plugin, base)); + doReturn(data).when(user).getData(); + user.setVoteShopIdentifierLimit("daily", 4); + + verify(data).setInt("VoteShopLimitdaily", 4, false); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/BukkitCompletionSchedulerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/BukkitCompletionSchedulerTest.java new file mode 100644 index 0000000000..175c91407d --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/BukkitCompletionSchedulerTest.java @@ -0,0 +1,103 @@ +package com.bencodez.votingplugin.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicInteger; + +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +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; + +class BukkitCompletionSchedulerTest { + @Test + void retiredEntityRunsCompletionOnceOnGlobalFallback() { + 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 completions = new AtomicInteger(); + + BukkitCompletionScheduler.run(fixture.plugin, fixture.player, completions::incrementAndGet); + + assertEquals(1, completions.get()); + verify(fixture.scheduler).runTask(eq(fixture.plugin), any(Runnable.class)); + } + + @Test + void alreadyRetiredSchedulerRunsCompletionOnceOnGlobalFallback() { + Fixture fixture = fixture(); + when(fixture.entityScheduler.runAtEntityWithFallback(eq(fixture.player), any(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); + AtomicInteger completions = new AtomicInteger(); + + BukkitCompletionScheduler.run(fixture.plugin, fixture.player, completions::incrementAndGet); + + assertEquals(1, completions.get()); + verify(fixture.scheduler).runTask(eq(fixture.plugin), any(Runnable.class)); + } + + @Test + void missingSchedulerStatusRunsCompletionOnceOnGlobalFallback() { + Fixture fixture = fixture(); + when(fixture.entityScheduler.runAtEntityWithFallback(eq(fixture.player), any(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + AtomicInteger completions = new AtomicInteger(); + + BukkitCompletionScheduler.run(fixture.plugin, fixture.player, completions::incrementAndGet); + + assertEquals(1, completions.get()); + verify(fixture.scheduler).runTask(eq(fixture.plugin), any(Runnable.class)); + } + + @Test + void rejectedEveryFallbackCallsRejectedOnlyWhenTaskNeverBegan() { + Fixture fixture = fixture(); + when(fixture.entityScheduler.runAtEntityWithFallback(eq(fixture.player), any(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); + doThrow(new RejectedExecutionException("stopping")).when(fixture.scheduler).runTask(eq(fixture.plugin), + any(Runnable.class)); + AtomicInteger completed = new AtomicInteger(); + AtomicInteger rejected = new AtomicInteger(); + + BukkitCompletionScheduler.run(fixture.plugin, fixture.player, completed::incrementAndGet, rejected::incrementAndGet); + + assertEquals(0, completed.get()); + assertEquals(1, rejected.get()); + } + + private static Fixture fixture() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + FoliaLib folia = mock(FoliaLib.class); + ServerImplementation entityScheduler = mock(ServerImplementation.class); + Player player = mock(Player.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(scheduler.getFoliaLib()).thenReturn(folia); + when(folia.getImpl()).thenReturn(entityScheduler); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + return new Fixture(plugin, scheduler, entityScheduler, player); + } + + private record Fixture(VotingPluginMain plugin, BukkitScheduler scheduler, + ServerImplementation entityScheduler, Player player) { + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/VoteShopManagerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/VoteShopManagerTest.java new file mode 100644 index 0000000000..0454b0521b --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/VoteShopManagerTest.java @@ -0,0 +1,47 @@ +package com.bencodez.votingplugin.voteshop; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.doThrow; +import org.mockito.ArgumentCaptor; + +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import com.bencodez.votingplugin.VotingPluginMain; + +class VoteShopManagerTest { + @Test + void schedulesStartupAndBoundedPeriodicSharedPurchaseRecovery() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(timer); + + VoteShopManager.scheduleSharedPurchaseRecovery(plugin); + + verify(timer).execute(any(Runnable.class)); + verify(timer).scheduleWithFixedDelay(any(Runnable.class), anyLong(), anyLong(), + org.mockito.ArgumentMatchers.eq(TimeUnit.MINUTES)); + } + + @Test + void periodicRecoveryContainsRuntimeFailures() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(timer); + doThrow(new IllegalStateException("storage unavailable")).when(plugin).getStorageType(); + + VoteShopManager.scheduleSharedPurchaseRecovery(plugin); + ArgumentCaptor scheduled = ArgumentCaptor.forClass(Runnable.class); + verify(timer).scheduleWithFixedDelay(scheduled.capture(), anyLong(), anyLong(), + org.mockito.ArgumentMatchers.eq(TimeUnit.MINUTES)); + org.junit.jupiter.api.Assertions.assertDoesNotThrow(scheduled.getValue()::run); + verify(plugin.getLogger()).severe( + "Unable to recover shared MySQL vote shop purchases: IllegalStateException"); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java new file mode 100644 index 0000000000..b92a484319 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -0,0 +1,590 @@ +package com.bencodez.votingplugin.voteshop.service; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; + +class SharedMysqlPurchaseJournalTest { + @Test + void journalTableNameIsPortableAndCollisionResistantForLongSourceNames() { + String source = "u".repeat(80); + String journalTable = SharedMysqlPurchaseJournal.journalTableName(source); + + assertEquals(journalTable, SharedMysqlPurchaseJournal.journalTableName(source)); + assertTrue(journalTable.getBytes(java.nio.charset.StandardCharsets.UTF_8).length <= 63); + assertTrue(journalTable.matches("vp_vsp_[0-9a-f]{32}")); + assertNotEquals(journalTable, SharedMysqlPurchaseJournal.journalTableName(source + "x")); + assertTrue(SharedMysqlPurchaseJournal.journalTableName("é".repeat(30)).matches("vp_vsp_[0-9a-f]{32}")); + assertEquals("VotingPlugin_Users_VoteShopPurchases", + SharedMysqlPurchaseJournal.journalTableName("VotingPlugin_Users")); + assertTrue(SharedMysqlPurchaseJournal.epochTableName(source).matches("vp_vse_[0-9a-f]{32}")); + assertEquals("VotingPlugin_Users_VoteShopLimitEpochs", + SharedMysqlPurchaseJournal.epochTableName("VotingPlugin_Users")); + } + + @Test + void reservationPersistsPendingDebitInTheSameTransaction() throws Exception { + Fixture fixture = fixture(); + PreparedStatement markerInsert = mock(PreparedStatement.class); + PreparedStatement markerSelect = mock(PreparedStatement.class); + PreparedStatement insert = mock(PreparedStatement.class); + PreparedStatement debit = mock(PreparedStatement.class); + ResultSet epoch = mock(ResultSet.class); + when(epoch.next()).thenReturn(true); + when(epoch.getLong(1)).thenReturn(3L); + when(markerSelect.executeQuery()).thenReturn(epoch); + when(debit.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, insert, debit); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + assertTrue(journal.reserve("purchase-1", "player", "Points", "VoteShopLimitdaily", 10, 1, + SharedMysqlPurchaseJournal.NO_LIMIT_RESET_GENERATION, 0L, 100L)); + + verify(insert).setString(7, SharedMysqlPurchaseJournal.NO_LIMIT_RESET_GENERATION); + verify(insert).setLong(9, 3L); + verify(insert).setString(10, "PENDING"); + verify(debit).setInt(1, 10); + verify(fixture.work).commit(); + } + + @Test + void recoveryRefundsOnlyExpiredPendingPurchase() throws Exception { + Fixture fixture = fixture(); + Connection candidates = mock(Connection.class); + Connection compensatingCandidates = mock(Connection.class); + Connection refund = mock(Connection.class); + Connection cleanup = mock(Connection.class); + PreparedStatement candidateStatement = mock(PreparedStatement.class); + PreparedStatement compensatingStatement = mock(PreparedStatement.class); + PreparedStatement select = mock(PreparedStatement.class); + PreparedStatement credit = mock(PreparedStatement.class); + PreparedStatement terminal = mock(PreparedStatement.class); + PreparedStatement cleanupSelect = mock(PreparedStatement.class); + PreparedStatement cleanupDelete = mock(PreparedStatement.class); + ResultSet expiredPending = ids("expired-pending"); + ResultSet noCompensating = ids(); + ResultSet pending = pendingRow(); + ResultSet noTerminalRows = ids(); + when(candidates.prepareStatement(anyString())).thenReturn(candidateStatement); + when(candidateStatement.executeQuery()).thenReturn(expiredPending); + when(compensatingCandidates.prepareStatement(anyString())).thenReturn(compensatingStatement); + when(compensatingStatement.executeQuery()).thenReturn(noCompensating); + when(refund.prepareStatement(anyString())).thenReturn(select, credit, terminal); + when(select.executeQuery()).thenReturn(pending); + when(credit.executeUpdate()).thenReturn(1); + when(terminal.executeUpdate()).thenReturn(1); + when(cleanup.prepareStatement(anyString())).thenReturn(cleanupSelect, cleanupDelete); + when(cleanupSelect.executeQuery()).thenReturn(noTerminalRows); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(candidates, refund, compensatingCandidates, cleanup); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + var refunded = journal.recoverAndCleanup(SharedMysqlPurchaseJournal.PENDING_RECOVERY_AGE_MILLIS + 1L); + + verify(credit).setInt(1, 10); + verify(credit).setString(2, "player"); + verify(terminal).setString(1, "REFUNDED"); + verify(refund).commit(); + assertEquals(1, refunded.size()); + assertEquals("player", refunded.get(0).uuid()); + assertEquals("Points", refunded.get(0).pointsColumn()); + assertEquals("VoteShopLimitdaily", refunded.get(0).limitColumn()); + } + + @Test + void hookStartedPurchaseIsNeverRefundedByCompensation() throws Exception { + Fixture fixture = fixture(); + PreparedStatement select = mock(PreparedStatement.class); + ResultSet hookStarted = hookStartedRow(); + when(fixture.work.prepareStatement(anyString())).thenReturn(select); + when(select.executeQuery()).thenReturn(hookStarted); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + assertFalse(journal.refundPending("claimed-purchase")); + } + + @Test + void legacyRefundRestoresPointsWithoutDecrementingAResettableLimit() throws Exception { + Fixture fixture = fixture(); + PreparedStatement select = mock(PreparedStatement.class); + PreparedStatement refund = mock(PreparedStatement.class); + PreparedStatement terminal = mock(PreparedStatement.class); + ResultSet pending = pendingRow(); + when(pending.getString(6)).thenReturn("D:2026-09-08"); + when(pending.getLong(7)).thenReturn(Long.MAX_VALUE); + when(fixture.work.prepareStatement(anyString())).thenReturn(select, refund, terminal); + when(select.executeQuery()).thenReturn(pending); + when(refund.executeUpdate()).thenReturn(1); + when(terminal.executeUpdate()).thenReturn(1); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + assertTrue(journal.refundPending("old-generation", 100L)); + + org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class); + verify(fixture.work, org.mockito.Mockito.times(3)).prepareStatement(sql.capture()); + assertTrue(sql.getAllValues().get(1).contains("`Points` = `Points` + ?")); + assertFalse(sql.getAllValues().get(1).contains("`VoteShopLimitdaily` = GREATEST")); + } + + @Test + void reservationLocksTheCurrentEpochBeforeItsConditionalDebit() throws Exception { + Fixture fixture = fixture(); + PreparedStatement markerInsert = mock(PreparedStatement.class); + PreparedStatement markerSelect = mock(PreparedStatement.class); + PreparedStatement insert = mock(PreparedStatement.class); + PreparedStatement debit = mock(PreparedStatement.class); + ResultSet epoch = mock(ResultSet.class); + when(epoch.next()).thenReturn(true); + when(epoch.getLong(1)).thenReturn(7L); + when(markerSelect.executeQuery()).thenReturn(epoch); + when(debit.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, insert, debit); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + assertTrue(journal.reserve("epoch-purchase", "player", "Points", "VoteShopLimitdaily", 10, 1, + "D:2026-09-08", 100L, 10L)); + + verify(insert).setLong(9, 7L); + org.mockito.InOrder lockBeforeDebit = org.mockito.Mockito.inOrder(markerSelect, debit); + lockBeforeDebit.verify(markerSelect).executeQuery(); + lockBeforeDebit.verify(debit).executeUpdate(); + } + + @Test + void epochMismatchRefundRestoresPointsWithoutTouchingTheNewLimit() throws Exception { + Fixture fixture = fixture(); + PreparedStatement select = mock(PreparedStatement.class); + PreparedStatement epochSelect = mock(PreparedStatement.class); + PreparedStatement refund = mock(PreparedStatement.class); + PreparedStatement terminal = mock(PreparedStatement.class); + ResultSet pending = pendingRow(); + ResultSet currentEpoch = mock(ResultSet.class); + when(pending.getObject(8)).thenReturn(4L); + when(currentEpoch.next()).thenReturn(true); + when(currentEpoch.getLong(1)).thenReturn(5L); + when(select.executeQuery()).thenReturn(pending); + when(epochSelect.executeQuery()).thenReturn(currentEpoch); + when(refund.executeUpdate()).thenReturn(1); + when(terminal.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(select, epochSelect, refund, terminal); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + assertTrue(journal.refundPending("old-epoch", 100L)); + + org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class); + verify(fixture.work, org.mockito.Mockito.times(4)).prepareStatement(sql.capture()); + assertFalse(sql.getAllValues().get(2).contains("`VoteShopLimitdaily` = GREATEST")); + } + + @Test + void matchingEpochRefundReleasesTheReservedLimit() throws Exception { + Fixture fixture = fixture(); + PreparedStatement select = mock(PreparedStatement.class); + PreparedStatement epochSelect = mock(PreparedStatement.class); + PreparedStatement refund = mock(PreparedStatement.class); + PreparedStatement terminal = mock(PreparedStatement.class); + ResultSet pending = pendingRow(); + ResultSet currentEpoch = mock(ResultSet.class); + when(pending.getObject(8)).thenReturn(5L); + when(currentEpoch.next()).thenReturn(true); + when(currentEpoch.getLong(1)).thenReturn(5L); + when(select.executeQuery()).thenReturn(pending); + when(epochSelect.executeQuery()).thenReturn(currentEpoch); + when(refund.executeUpdate()).thenReturn(1); + when(terminal.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(select, epochSelect, refund, terminal); + + assertTrue(new SharedMysqlPurchaseJournal(fixture.table, false).refundPending("current-epoch", 100L)); + + org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class); + verify(fixture.work, org.mockito.Mockito.times(4)).prepareStatement(sql.capture()); + assertTrue(sql.getAllValues().get(2).contains("`VoteShopLimitdaily` = GREATEST")); + } + + @Test + void resetRollsBackWhenItsEpochAdvanceDoesNotAffectTheMarker() throws Exception { + Fixture fixture = fixture(); + PreparedStatement markerInsert = mock(PreparedStatement.class); + PreparedStatement markerSelect = mock(PreparedStatement.class); + PreparedStatement wipe = mock(PreparedStatement.class); + PreparedStatement advance = mock(PreparedStatement.class); + ResultSet epoch = mock(ResultSet.class); + when(epoch.next()).thenReturn(true); + when(epoch.getLong(1)).thenReturn(2L); + when(markerSelect.executeQuery()).thenReturn(epoch); + when(advance.executeUpdate()).thenReturn(0); + when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, wipe, advance); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + org.junit.jupiter.api.Assertions.assertThrows(java.sql.SQLException.class, + () -> journal.resetLimit("VoteShopLimitdaily", "D:2026-09-08")); + + verify(fixture.work).rollback(); + verify(fixture.work, org.mockito.Mockito.never()).commit(); + } + + @Test + void repeatedResetGenerationDoesNotWipeNewPeriodPurchases() throws Exception { + Fixture fixture = fixture(); + PreparedStatement markerInsert = mock(PreparedStatement.class); + PreparedStatement markerSelect = mock(PreparedStatement.class); + ResultSet epoch = mock(ResultSet.class); + when(epoch.next()).thenReturn(true); + when(epoch.getLong(1)).thenReturn(3L); + when(epoch.getString(2)).thenReturn("D:2026-09-08"); + when(markerSelect.executeQuery()).thenReturn(epoch); + when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect); + + new SharedMysqlPurchaseJournal(fixture.table, false).resetLimit( + "VoteShopLimitdaily", "D:2026-09-08"); + + verify(fixture.work, org.mockito.Mockito.times(2)).prepareStatement(anyString()); + verify(fixture.work).rollback(); + verify(fixture.work, org.mockito.Mockito.never()).commit(); + } + + @Test + void ambiguousResetCommitIsConfirmedAfterTheConnectionIsReleased() throws Exception { + Fixture fixture = fixture(); + Connection reset = mock(Connection.class); + Connection confirmation = mock(Connection.class); + PreparedStatement markerInsert = mock(PreparedStatement.class); + PreparedStatement markerSelect = mock(PreparedStatement.class); + PreparedStatement wipe = mock(PreparedStatement.class); + PreparedStatement advance = mock(PreparedStatement.class); + PreparedStatement confirm = mock(PreparedStatement.class); + ResultSet epoch = mock(ResultSet.class); + ResultSet confirmedEpoch = mock(ResultSet.class); + when(epoch.next()).thenReturn(true); + when(epoch.getLong(1)).thenReturn(2L); + when(markerSelect.executeQuery()).thenReturn(epoch); + when(advance.executeUpdate()).thenReturn(1); + when(reset.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, wipe, advance); + when(confirmedEpoch.next()).thenReturn(true); + when(confirmedEpoch.getLong(1)).thenReturn(3L); + when(confirmedEpoch.getString(2)).thenReturn("D:2026-09-08"); + when(confirm.executeQuery()).thenReturn(confirmedEpoch); + when(confirmation.prepareStatement(anyString())).thenReturn(confirm); + AtomicBoolean resetClosed = new AtomicBoolean(); + doThrow(new java.sql.SQLException("commit acknowledgement lost")).when(reset).commit(); + org.mockito.Mockito.doAnswer(ignored -> { + resetClosed.set(true); + return null; + }).when(reset).close(); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(reset).thenAnswer(ignored -> { + assertTrue(resetClosed.get()); + return confirmation; + }); + + new SharedMysqlPurchaseJournal(fixture.table, false).resetLimit("VoteShopLimitdaily", "D:2026-09-08"); + + verify(reset, atLeastOnce()).close(); + verify(confirm).executeQuery(); + } + + @Test + void refundStillReleasesALimitThatHasNoConfiguredReset() throws Exception { + Fixture fixture = fixture(); + PreparedStatement select = mock(PreparedStatement.class); + PreparedStatement refund = mock(PreparedStatement.class); + PreparedStatement terminal = mock(PreparedStatement.class); + ResultSet pending = pendingRow(); + when(pending.getString(6)).thenReturn(SharedMysqlPurchaseJournal.NO_LIMIT_RESET_GENERATION); + when(fixture.work.prepareStatement(anyString())).thenReturn(select, refund, terminal); + when(select.executeQuery()).thenReturn(pending); + when(refund.executeUpdate()).thenReturn(1); + when(terminal.executeUpdate()).thenReturn(1); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + assertTrue(journal.refundPending("unbounded-generation", 100L)); + + org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class); + verify(fixture.work, org.mockito.Mockito.times(3)).prepareStatement(sql.capture()); + assertTrue(sql.getAllValues().get(1).contains("`VoteShopLimitdaily` = GREATEST")); + } + + @Test + void schedulerProvenUnstartedHookCanBeRefunded() throws Exception { + Fixture fixture = fixture(); + PreparedStatement markCompensating = mock(PreparedStatement.class); + PreparedStatement select = mock(PreparedStatement.class); + PreparedStatement credit = mock(PreparedStatement.class); + PreparedStatement terminal = mock(PreparedStatement.class); + ResultSet compensating = pendingRow("COMPENSATING"); + when(fixture.work.prepareStatement(anyString())).thenReturn(markCompensating, select, credit, terminal); + when(markCompensating.executeUpdate()).thenReturn(1); + when(select.executeQuery()).thenReturn(compensating); + when(credit.executeUpdate()).thenReturn(1); + when(terminal.executeUpdate()).thenReturn(1); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + assertTrue(journal.refundUnstartedReward("scheduler-rejected")); + + verify(fixture.work, org.mockito.Mockito.times(2)).setAutoCommit(false); + verify(terminal).setString(1, "REFUNDED"); + } + + @Test + void refundAcceptsQuotedConfiguredLimitIdentifier() throws Exception { + Fixture fixture = fixture(); + PreparedStatement select = mock(PreparedStatement.class); + PreparedStatement refund = mock(PreparedStatement.class); + PreparedStatement terminal = mock(PreparedStatement.class); + ResultSet pending = pendingRow(); + when(pending.getString(4)).thenReturn("VoteShopLimitDaily Reward `special`"); + when(pending.getString(6)).thenReturn(SharedMysqlPurchaseJournal.NO_LIMIT_RESET_GENERATION); + when(fixture.work.prepareStatement(anyString())).thenReturn(select, refund, terminal); + when(select.executeQuery()).thenReturn(pending); + when(refund.executeUpdate()).thenReturn(1); + when(terminal.executeUpdate()).thenReturn(1); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + boolean refunded = journal.refundPending("quoted-limit", 100L); + verify(refund).executeUpdate(); + verify(terminal).executeUpdate(); + assertTrue(refunded); + + org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class); + verify(fixture.work, org.mockito.Mockito.times(3)).prepareStatement(sql.capture()); + assertTrue(sql.getAllValues().get(1).contains("VoteShopLimitDaily Reward `special`")); + } + + @Test + void failedCompensationIsRetriedWithTheDurableMarker() throws Exception { + Fixture fixture = fixture(); + PreparedStatement markFirst = mock(PreparedStatement.class); + PreparedStatement selectFirst = mock(PreparedStatement.class); + PreparedStatement creditFirst = mock(PreparedStatement.class); + PreparedStatement markSecond = mock(PreparedStatement.class); + PreparedStatement selectSecond = mock(PreparedStatement.class); + PreparedStatement creditSecond = mock(PreparedStatement.class); + PreparedStatement terminalSecond = mock(PreparedStatement.class); + ResultSet compensating = pendingRow("COMPENSATING"); + when(fixture.work.prepareStatement(anyString())).thenReturn(markFirst, selectFirst, creditFirst, + markSecond, selectSecond, creditSecond, terminalSecond); + when(markFirst.executeUpdate()).thenReturn(1); + when(selectFirst.executeQuery()).thenReturn(compensating); + doThrow(new java.sql.SQLException("temporary database outage")).when(creditFirst).executeUpdate(); + when(markSecond.executeUpdate()).thenReturn(1); + when(selectSecond.executeQuery()).thenReturn(compensating); + when(creditSecond.executeUpdate()).thenReturn(1); + when(terminalSecond.executeUpdate()).thenReturn(1); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + assertTrue(journal.refundUnstartedReward("retry-compensation")); + + verify(markFirst).setString(1, "COMPENSATING"); + verify(markSecond).setString(1, "COMPENSATING"); + verify(creditSecond).executeUpdate(); + verify(terminalSecond).setString(1, "REFUNDED"); + } + + @Test + void ambiguousReservationCommitIsConfirmedAfterItsConnectionIsReleased() throws Exception { + Fixture fixture = fixture(); + Connection reservation = mock(Connection.class); + Connection confirmation = mock(Connection.class); + PreparedStatement insert = mock(PreparedStatement.class); + PreparedStatement debit = mock(PreparedStatement.class); + PreparedStatement select = mock(PreparedStatement.class); + ResultSet committed = mock(ResultSet.class); + when(reservation.prepareStatement(anyString())).thenReturn(insert, debit); + when(debit.executeUpdate()).thenReturn(1); + when(confirmation.prepareStatement(anyString())).thenReturn(select); + when(select.executeQuery()).thenReturn(committed); + when(committed.next()).thenReturn(true); + when(committed.getString(1)).thenReturn("PENDING"); + AtomicBoolean reservationClosed = new AtomicBoolean(); + org.mockito.Mockito.doAnswer(ignored -> { + reservationClosed.set(true); + return null; + }).when(reservation).close(); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(reservation).thenAnswer(ignored -> { + assertTrue(reservationClosed.get(), "The ambiguous reservation handle must be released before confirmation"); + return confirmation; + }); + doThrow(new java.sql.SQLException("commit acknowledgement lost")).when(reservation).commit(); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + assertTrue(journal.reserve("purchase-ambiguous", "player", "Points", null, 10, 0, + null, 0L, 100L)); + + verify(reservation, atLeastOnce()).close(); + verify(confirmation).prepareStatement(anyString()); + } + + @Test + void ambiguousClaimUpdateIsConfirmedBeforeRewardMayRun() throws Exception { + Fixture fixture = fixture(); + Connection claimConnection = mock(Connection.class); + Connection confirmation = mock(Connection.class); + PreparedStatement claim = mock(PreparedStatement.class); + PreparedStatement select = mock(PreparedStatement.class); + ResultSet committed = mock(ResultSet.class); + when(claimConnection.prepareStatement(anyString())).thenReturn(claim); + when(confirmation.prepareStatement(anyString())).thenReturn(select); + when(select.executeQuery()).thenReturn(committed); + when(committed.next()).thenReturn(true); + when(committed.getString(1)).thenReturn("HOOK_STARTED"); + AtomicBoolean claimConnectionClosed = new AtomicBoolean(); + org.mockito.Mockito.doAnswer(ignored -> { + claimConnectionClosed.set(true); + return null; + }).when(claimConnection).close(); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(claimConnection).thenAnswer(ignored -> { + assertTrue(claimConnectionClosed.get(), "The ambiguous claim handle must be released before confirmation"); + return confirmation; + }); + doThrow(new java.sql.SQLException("update acknowledgement lost")).when(claim).executeUpdate(); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + assertEquals(SharedMysqlPurchaseJournal.ClaimOutcome.CLAIMED, + journal.claimReward("purchase-ambiguous-claim", 200L)); + + verify(claimConnection, atLeastOnce()).close(); + verify(confirmation).prepareStatement(anyString()); + } + + @Test + void ambiguousRefundCommitIsConfirmedBeforeReturningSuccess() throws Exception { + Fixture fixture = fixture(); + Connection refundConnection = mock(Connection.class); + Connection confirmation = mock(Connection.class); + PreparedStatement selectRefund = mock(PreparedStatement.class); + PreparedStatement credit = mock(PreparedStatement.class); + PreparedStatement terminal = mock(PreparedStatement.class); + PreparedStatement selectConfirmation = mock(PreparedStatement.class); + ResultSet pending = pendingRow(); + ResultSet refunded = mock(ResultSet.class); + when(refundConnection.prepareStatement(anyString())).thenReturn(selectRefund, credit, terminal); + when(selectRefund.executeQuery()).thenReturn(pending); + when(credit.executeUpdate()).thenReturn(1); + when(terminal.executeUpdate()).thenReturn(1); + when(confirmation.prepareStatement(anyString())).thenReturn(selectConfirmation); + when(selectConfirmation.executeQuery()).thenReturn(refunded); + when(refunded.next()).thenReturn(true); + when(refunded.getString(1)).thenReturn("REFUNDED"); + AtomicBoolean refundClosed = new AtomicBoolean(); + org.mockito.Mockito.doAnswer(ignored -> { + refundClosed.set(true); + return null; + }).when(refundConnection).close(); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(refundConnection).thenAnswer(ignored -> { + assertTrue(refundClosed.get(), "The ambiguous refund handle must be released before confirmation"); + return confirmation; + }); + doThrow(new java.sql.SQLException("commit acknowledgement lost")).when(refundConnection).commit(); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + assertTrue(journal.refundPending("purchase-ambiguous-refund", 100L)); + + verify(refundConnection, atLeastOnce()).close(); + verify(confirmation).prepareStatement(anyString()); + } + + @Test + void unstartedRewardRetryRecognizesAnAlreadyCommittedRefund() throws Exception { + Fixture fixture = fixture(); + Connection mark = mock(Connection.class); + Connection refund = mock(Connection.class); + Connection failedConfirmation = mock(Connection.class); + Connection retryMark = mock(Connection.class); + Connection finalConfirmation = mock(Connection.class); + PreparedStatement markStatement = mock(PreparedStatement.class); + PreparedStatement refundSelect = mock(PreparedStatement.class); + PreparedStatement credit = mock(PreparedStatement.class); + PreparedStatement terminal = mock(PreparedStatement.class); + PreparedStatement failedSelect = mock(PreparedStatement.class); + PreparedStatement retryMarkStatement = mock(PreparedStatement.class); + PreparedStatement finalSelect = mock(PreparedStatement.class); + ResultSet compensating = pendingRow("COMPENSATING"); + ResultSet refunded = mock(ResultSet.class); + when(mark.prepareStatement(anyString())).thenReturn(markStatement); + when(markStatement.executeUpdate()).thenReturn(1); + when(refund.prepareStatement(anyString())).thenReturn(refundSelect, credit, terminal); + when(refundSelect.executeQuery()).thenReturn(compensating); + when(credit.executeUpdate()).thenReturn(1); + when(terminal.executeUpdate()).thenReturn(1); + doThrow(new java.sql.SQLException("commit acknowledgement lost")).when(refund).commit(); + when(failedConfirmation.prepareStatement(anyString())).thenReturn(failedSelect); + doThrow(new java.sql.SQLException("confirmation unavailable")).when(failedSelect).executeQuery(); + when(retryMark.prepareStatement(anyString())).thenReturn(retryMarkStatement); + when(retryMarkStatement.executeUpdate()).thenReturn(0); + when(finalConfirmation.prepareStatement(anyString())).thenReturn(finalSelect); + when(finalSelect.executeQuery()).thenReturn(refunded); + when(refunded.next()).thenReturn(true); + when(refunded.getString(1)).thenReturn("REFUNDED"); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(mark, refund, failedConfirmation, + retryMark, finalConfirmation); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + assertTrue(journal.refundUnstartedReward("already-refunded")); + + verify(finalSelect).executeQuery(); + } + + private static Fixture fixture() throws Exception { + Fixture fixture = new Fixture(); + fixture.table = mock(MySQL.class); + fixture.sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + fixture.work = mock(Connection.class); + when(fixture.table.getTableName()).thenReturn("VotingPlugin_Users"); + when(fixture.table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); + when(fixture.table.getMysql()).thenReturn(fixture.sql); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.work); + return fixture; + } + + private static ResultSet ids(String... values) throws Exception { + ResultSet rows = mock(ResultSet.class); + Boolean[] next = new Boolean[values.length + 1]; + for (int index = 0; index < values.length; index++) next[index] = Boolean.TRUE; + next[values.length] = Boolean.FALSE; + when(rows.next()).thenReturn(next[0], java.util.Arrays.copyOfRange(next, 1, next.length)); + if (values.length > 0) when(rows.getString(1)).thenReturn(values[0]); + return rows; + } + + private static ResultSet pendingRow() throws Exception { + return pendingRow("PENDING"); + } + + private static ResultSet pendingRow(String state) throws Exception { + ResultSet row = mock(ResultSet.class); + when(row.next()).thenReturn(true); + when(row.getString(1)).thenReturn(state); + when(row.getString(2)).thenReturn("player"); + when(row.getString(3)).thenReturn("Points"); + when(row.getString(4)).thenReturn("VoteShopLimitdaily"); + when(row.getInt(5)).thenReturn(10); + return row; + } + + private static ResultSet hookStartedRow() throws Exception { + ResultSet row = mock(ResultSet.class); + when(row.next()).thenReturn(true); + when(row.getString(1)).thenReturn("HOOK_STARTED"); + return row; + } + + private static final class Fixture { + private MySQL table; + private com.bencodez.simpleapi.sql.mysql.MySQL sql; + private Connection work; + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java new file mode 100644 index 0000000000..18fd5964a3 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -0,0 +1,1500 @@ +package com.bencodez.votingplugin.voteshop.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.HashMap; +import java.util.Locale; +import java.util.TimeZone; +import java.util.UUID; + +import org.bukkit.configuration.file.FileConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; + +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.UserData; +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.simpleapi.sql.data.DataValue; +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; +import com.bencodez.advancedcore.api.rewards.RewardHandler; +import com.bencodez.simpleapi.folialib.enums.EntityTaskResult; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.user.SharedMysqlCacheReconciler; +import com.bencodez.votingplugin.voteshop.shop.VoteShopDefinition; +import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.voteshop.shop.VoteShopItem; + +class VoteShopPurchaseServiceTest { + @Test + void purchaseRecoveryEligibilityIgnoresCurrentPerServerPointsSetting() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); + + assertTrue(VoteShopPurchaseService.canRecoverSharedMysqlPurchases(plugin)); + } + + @Test + void missingOrInvalidNetworkTimeZoneUsesUtc() { + assertEquals(ZoneId.of("UTC"), VoteShopPurchaseService.networkTimeZone(null)); + assertEquals(ZoneId.of("UTC"), VoteShopPurchaseService.networkTimeZone(" ")); + assertEquals(ZoneId.of("UTC"), VoteShopPurchaseService.networkTimeZone("not/a-zone")); + assertEquals(ZoneId.of("America/Regina"), + VoteShopPurchaseService.networkTimeZone("America/Regina")); + } + + @Test + void utcLimitGenerationDoesNotDependOnJvmDefaultTimeZone() { + TimeZone previous = TimeZone.getDefault(); + LocalDateTime current = LocalDateTime.of(2026, 9, 8, 23, 30); + long now = current.atZone(ZoneId.of("UTC")).toInstant().toEpochMilli(); + try { + TimeZone.setDefault(TimeZone.getTimeZone("Pacific/Honolulu")); + LocalDateTime honoluluCurrent = VoteShopPurchaseService.networkCurrentTime( + now, VoteShopPurchaseService.networkTimeZone(""), 0); + VoteShopPurchaseService.LimitGeneration honolulu = VoteShopPurchaseService.limitGeneration( + honoluluCurrent, now, true, true, true, 0); + TimeZone.setDefault(TimeZone.getTimeZone("Pacific/Kiritimati")); + LocalDateTime kiritimatiCurrent = VoteShopPurchaseService.networkCurrentTime( + now, VoteShopPurchaseService.networkTimeZone("invalid"), 0); + VoteShopPurchaseService.LimitGeneration kiritimati = VoteShopPurchaseService.limitGeneration( + kiritimatiCurrent, now, true, true, true, 0); + assertEquals(current, honoluluCurrent); + assertEquals(current, kiritimatiCurrent); + assertEquals(honolulu, kiritimati); + } finally { + TimeZone.setDefault(previous); + } + } + + @Test + void unconfirmedRewardClaimIsCompensatedBeforeTheRewardCanStart() { + assertTrue(VoteShopPurchaseService.requiresCompensation( + SharedMysqlPurchaseJournal.ClaimOutcome.INDETERMINATE)); + assertTrue(VoteShopPurchaseService.requiresCompensation( + SharedMysqlPurchaseJournal.ClaimOutcome.NOT_CLAIMED)); + assertFalse(VoteShopPurchaseService.requiresCompensation( + SharedMysqlPurchaseJournal.ClaimOutcome.CLAIMED)); + } + + @Test + void retainsSynchronousPurchaseDescriptorsForBinaryCompatibility() throws Exception { + assertEquals(VoteShopPurchaseResult.class, VoteShopPurchaseService.class + .getMethod("purchase", org.bukkit.entity.Player.class, VotingPluginUser.class, VoteShopItem.class) + .getReturnType()); + assertEquals(VoteShopPurchaseResult.class, com.bencodez.votingplugin.voteshop.VoteShopManager.class + .getMethod("purchase", org.bukkit.entity.Player.class, VotingPluginUser.class, VoteShopItem.class) + .getReturnType()); + } + + @Test + void limitGenerationUsesTheEarliestConfiguredResetBoundary() { + LocalDateTime current = LocalDateTime.of(2026, 9, 8, 12, 0); + long now = current.atZone(ZoneId.of("UTC")).toInstant().toEpochMilli(); + VoteShopPurchaseService.LimitGeneration generation = VoteShopPurchaseService.limitGeneration( + current, now, true, true, true, 0); + + assertTrue(generation.value().contains("D:2026-09-08")); + assertTrue(generation.value().contains("W:")); + assertTrue(generation.value().contains("M:2026-9")); + assertEquals(now + 43_200_000L, generation.expiresAt()); + } + + @Test + void weeklyGenerationChangesAtEveryConfiguredWeekBoundary() { + LocalDateTime current = LocalDateTime.of(2026, 9, 8, 12, 0); + String currentGeneration = VoteShopPurchaseService.weeklyGenerationId(current, 0); + LocalDateTime nextBoundary = current.toLocalDate().plusDays(1).atStartOfDay(); + while (VoteShopPurchaseService.weeklyGenerationId(nextBoundary, 0).equals(currentGeneration)) { + nextBoundary = nextBoundary.plusDays(1); + } + + assertEquals(VoteShopPurchaseService.weeklyGenerationId(current, 0), + VoteShopPurchaseService.weeklyGenerationId(nextBoundary.minusNanos(1), 0)); + assertNotEquals(VoteShopPurchaseService.weeklyGenerationId(current, 0), + VoteShopPurchaseService.weeklyGenerationId(nextBoundary, 0)); + } + + @Test + void weeklyGenerationAndBoundaryIgnoreJvmDefaultLocale() { + Locale previous = Locale.getDefault(); + LocalDateTime current = LocalDateTime.of(2027, 1, 3, 12, 0); + long now = current.atZone(ZoneId.of("UTC")).toInstant().toEpochMilli(); + try { + Locale.setDefault(Locale.US); + String usGeneration = VoteShopPurchaseService.weeklyGenerationId(current, 0); + VoteShopPurchaseService.LimitGeneration usLimit = VoteShopPurchaseService.limitGeneration( + current, now, false, true, false, 0); + Locale.setDefault(Locale.GERMANY); + String germanGeneration = VoteShopPurchaseService.weeklyGenerationId(current, 0); + VoteShopPurchaseService.LimitGeneration germanLimit = VoteShopPurchaseService.limitGeneration( + current, now, false, true, false, 0); + + assertEquals(usGeneration, germanGeneration); + assertEquals(usLimit, germanLimit); + } finally { + Locale.setDefault(previous); + } + } + + @Test + void sharedMysqlResetUsesTheJournalEpochTransaction() throws Exception { + MySQL table = mock(MySQL.class); + com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, + org.mockito.Mockito.RETURNS_DEEP_STUBS); + Connection schemaConnection = mock(Connection.class); + Connection resetConnection = mock(Connection.class); + PreparedStatement schema = mock(PreparedStatement.class); + PreparedStatement generation = mock(PreparedStatement.class); + PreparedStatement generationExpiry = mock(PreparedStatement.class); + PreparedStatement epochColumn = mock(PreparedStatement.class); + PreparedStatement epochTable = mock(PreparedStatement.class); + PreparedStatement epochGeneration = mock(PreparedStatement.class); + PreparedStatement index = mock(PreparedStatement.class); + PreparedStatement markerInsert = mock(PreparedStatement.class); + PreparedStatement markerSelect = mock(PreparedStatement.class); + PreparedStatement wipe = mock(PreparedStatement.class); + PreparedStatement advance = mock(PreparedStatement.class); + ResultSet epoch = mock(ResultSet.class); + UserDataCache initialCache = mock(UserDataCache.class); + UserDataCache recreatedCache = mock(UserDataCache.class); + VotingPluginUser refreshedUser = mock(VotingPluginUser.class); + UUID cachedUuid = UUID.fromString("00000000-0000-0000-0000-000000000001"); + HashMap initialValues = new HashMap<>(); + initialValues.put("VoteShopLimitdaily", mock(DataValue.class)); + HashMap recreatedValues = new HashMap<>(); + recreatedValues.put("VoteShopLimitdaily", mock(DataValue.class)); + recreatedValues.put("DailyTotal", mock(DataValue.class)); + var liveCaches = new java.util.concurrent.ConcurrentHashMap(); + liveCaches.put(cachedUuid, initialCache); + when(table.getTableName()).thenReturn("VotingPlugin_Users"); + when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); + when(table.getMysql()).thenReturn(sql); + when(sql.getConnectionManager().getConnection()).thenReturn(schemaConnection, resetConnection); + when(schemaConnection.prepareStatement(anyString())).thenReturn(schema, generation, generationExpiry, + epochColumn, epochTable, epochGeneration, index); + when(resetConnection.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, wipe, advance); + when(epoch.next()).thenReturn(true); + when(epoch.getLong(1)).thenReturn(11L); + when(markerSelect.executeQuery()).thenReturn(epoch); + when(advance.executeUpdate()).thenReturn(1); + when(initialCache.getCache()).thenReturn(initialValues); + when(recreatedCache.getCache()).thenReturn(recreatedValues); + doAnswer(invocation -> { + liveCaches.put(cachedUuid, recreatedCache); + return 1; + }).when(wipe).executeUpdate(); + VotingPluginMain plugin = sharedMysqlPlugin(table); + when(plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn(liveCaches); + when(plugin.getVotingPluginUserManager().getVotingPluginUser(cachedUuid, false)).thenReturn(refreshedUser); + ArgumentCaptor refill = ArgumentCaptor.forClass(Runnable.class); + + VoteShopPurchaseService.resetSharedMysqlLimit(plugin, "VoteShopLimitdaily"); + + verify(table).checkColumn("VoteShopLimitdaily", com.bencodez.simpleapi.sql.DataType.INTEGER); + verify(resetConnection).commit(); + ArgumentCaptor sqlText = ArgumentCaptor.forClass(String.class); + verify(resetConnection, times(4)).prepareStatement(sqlText.capture()); + assertTrue(sqlText.getAllValues().get(2).contains("`VoteShopLimitdaily` = 0")); + verify(initialCache, never()).dump(); + assertFalse(initialValues.containsKey("VoteShopLimitdaily")); + assertFalse(recreatedValues.containsKey("VoteShopLimitdaily")); + assertTrue(recreatedValues.containsKey("DailyTotal")); + verify(plugin.getBukkitScheduler()).runTaskAsynchronously(eq(plugin), refill.capture()); + refill.getValue().run(); + verify(refreshedUser).cache(); + } + + @Test + void perServerMysqlResetWipesAndAdvancesLegacySharedPurchaseEpochInOneTransaction() throws Exception { + MySQL table = mock(MySQL.class); + com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, + org.mockito.Mockito.RETURNS_DEEP_STUBS); + Connection schemaConnection = mock(Connection.class); + Connection resetConnection = mock(Connection.class); + PreparedStatement schema = mock(PreparedStatement.class); + PreparedStatement generation = mock(PreparedStatement.class); + PreparedStatement generationExpiry = mock(PreparedStatement.class); + PreparedStatement epochColumn = mock(PreparedStatement.class); + PreparedStatement epochTable = mock(PreparedStatement.class); + PreparedStatement epochGeneration = mock(PreparedStatement.class); + PreparedStatement index = mock(PreparedStatement.class); + PreparedStatement markerInsert = mock(PreparedStatement.class); + PreparedStatement markerSelect = mock(PreparedStatement.class); + PreparedStatement wipe = mock(PreparedStatement.class); + PreparedStatement advance = mock(PreparedStatement.class); + ResultSet epoch = mock(ResultSet.class); + when(table.getTableName()).thenReturn("PerServerUsers"); + when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); + when(table.getMysql()).thenReturn(sql); + when(sql.getConnectionManager().getConnection()).thenReturn(schemaConnection, resetConnection); + when(schemaConnection.prepareStatement(anyString())).thenReturn(schema, generation, generationExpiry, + epochColumn, epochTable, epochGeneration, index); + when(resetConnection.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, wipe, advance); + when(epoch.next()).thenReturn(true); + when(epoch.getLong(1)).thenReturn(4L); + when(markerSelect.executeQuery()).thenReturn(epoch); + when(advance.executeUpdate()).thenReturn(1); + VotingPluginMain plugin = sharedMysqlPlugin(table); + when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); + when(plugin.getUserManager().getDataManager().getUserDataCache()) + .thenReturn(new java.util.concurrent.ConcurrentHashMap<>()); + + assertTrue(VoteShopPurchaseService.resetMysqlLimitWithPurchaseFence( + plugin, "VoteShopLimitdaily", "D:2026-09-13")); + + verify(table).checkColumn("VoteShopLimitdaily", com.bencodez.simpleapi.sql.DataType.INTEGER); + verify(resetConnection).commit(); + ArgumentCaptor sqlText = ArgumentCaptor.forClass(String.class); + verify(resetConnection, times(4)).prepareStatement(sqlText.capture()); + assertTrue(sqlText.getAllValues().stream().anyMatch(statement -> statement.contains("`VoteShopLimitdaily` = 0"))); + verify(wipe).executeUpdate(); + verify(advance).setLong(1, 5L); + verify(advance).setString(2, "D:2026-09-13"); + InOrder serializedReset = inOrder(markerSelect, wipe, advance, resetConnection); + serializedReset.verify(markerSelect).executeQuery(); + serializedReset.verify(wipe).executeUpdate(); + serializedReset.verify(advance).executeUpdate(); + serializedReset.verify(resetConnection).commit(); + } + + @Test + void sharedMysqlResetWaitsForAnInFlightCacheDump() throws Exception { + CountDownLatch dumpEntered = new CountDownLatch(1); + CountDownLatch releaseDump = new CountDownLatch(1); + CountDownLatch resetAttempted = new CountDownLatch(1); + CountDownLatch resetEntered = new CountDownLatch(1); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + Future dump = workers.submit(() -> VoteShopPurchaseService.withSharedMysqlCacheDumpFence(() -> { + dumpEntered.countDown(); + try { + assertTrue(releaseDump.await(2, TimeUnit.SECONDS)); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError(interrupted); + } + })); + assertTrue(dumpEntered.await(1, TimeUnit.SECONDS)); + + Future reset = workers.submit(() -> { + resetAttempted.countDown(); + VoteShopPurchaseService.withSharedMysqlCacheResetFence(resetEntered::countDown); + }); + assertTrue(resetAttempted.await(1, TimeUnit.SECONDS)); + assertFalse(resetEntered.await(100, TimeUnit.MILLISECONDS), + "a reset must not commit while an older cache dump can still write"); + + releaseDump.countDown(); + dump.get(1, TimeUnit.SECONDS); + reset.get(1, TimeUnit.SECONDS); + assertEquals(0, resetEntered.getCount()); + } finally { + releaseDump.countDown(); + workers.shutdownNow(); + } + } + + @Test + void localPurchaseRefreshesCacheBeforeCheckingPointsWhenConfigured() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getConfigFile().isExtraVoteShopCheck()).thenReturn(true); + VoteShopDefinition definition = mock(VoteShopDefinition.class); + when(definition.isEnabled()).thenReturn(true); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getPermission()).thenReturn(""); + when(item.getCost()).thenReturn(10); + VotingPluginUser user = mock(VotingPluginUser.class); + when(user.getPoints()).thenReturn(0); + AtomicReference result = new AtomicReference<>(); + + new VoteShopPurchaseService(plugin, definition).purchase(mock(org.bukkit.entity.Player.class), user, item, + result::set); + + assertEquals(VoteShopPurchaseResult.NOT_ENOUGH_POINTS, result.get()); + InOrder refreshBeforeValidation = inOrder(user); + refreshBeforeValidation.verify(user).cache(); + refreshBeforeValidation.verify(user).getPoints(); + } + + @Test + void sharedMysqlGuiValidationDoesNotReadOrRefreshDynamicUserState() { + VotingPluginMain plugin = sharedMysqlPlugin(mock(MySQL.class)); + VoteShopDefinition definition = mock(VoteShopDefinition.class); + when(definition.isEnabled()).thenReturn(true); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getPermission()).thenReturn(""); + when(item.getLimit()).thenReturn(1); + when(item.getIdentifier()).thenReturn("daily"); + when(item.getCost()).thenReturn(10); + VotingPluginUser user = mock(VotingPluginUser.class); + VoteShopPurchaseService service = new VoteShopPurchaseService(plugin, definition); + + service.refreshUserForPurchaseValidation(user, item, true); + VoteShopPurchaseResult result = service.validatePurchase(mock(org.bukkit.entity.Player.class), user, item); + + assertEquals(VoteShopPurchaseResult.SUCCESS, result); + verify(user, never()).cache(); + verify(user, never()).getVoteShopIdentifierLimit(anyString()); + verify(user, never()).getPoints(); + } + + @Test + void perServerMysqlLimitedPurchaseStillDefersSharedLimitValidationToTheJournal() { + VotingPluginMain plugin = sharedMysqlPlugin(mock(MySQL.class)); + when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); + VoteShopDefinition definition = mock(VoteShopDefinition.class); + when(definition.isEnabled()).thenReturn(true); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getPermission()).thenReturn(""); + when(item.getLimit()).thenReturn(1); + when(item.getIdentifier()).thenReturn("daily"); + when(item.getCost()).thenReturn(10); + VotingPluginUser user = purchaseUser(); + when(user.getVoteShopIdentifierLimit("daily")).thenReturn(1); + when(user.getPoints()).thenReturn(0); + VoteShopPurchaseService service = new VoteShopPurchaseService(plugin, definition); + service.refreshUserForPurchaseValidation(user, item, true); + + assertEquals(VoteShopPurchaseResult.SUCCESS, + service.validatePurchase(mock(org.bukkit.entity.Player.class), user, item)); + + verify(user, never()).cache(); + verify(user, never()).getVoteShopIdentifierLimit(anyString()); + verify(user, never()).getPoints(); + } + + @Test + void claimedRewardUsesLegacyEntitySchedulerWhenFoliaIsUnavailable() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + RewardHandler rewardHandler = mock(RewardHandler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(scheduler.getFoliaLib()).thenReturn(null); + when(plugin.getRewardHandler()).thenReturn(rewardHandler); + when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(persistenceExecutor); + org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + when(player.getUniqueId()).thenReturn(UUID.fromString("00000000-0000-0000-0000-000000000001")); + when(player.getName()).thenReturn("player"); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + VotingPluginUser user = purchaseUser(); + when(user.getPlayerName()).thenReturn("player"); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getIdentifier()).thenReturn("daily"); + when(item.getCost()).thenReturn(10); + when(item.getRewardsPath()).thenReturn("Shop.daily.Rewards"); + when(item.getPurchaseMessage()).thenReturn("Purchased"); + SharedMysqlPurchaseJournal journal = mock(SharedMysqlPurchaseJournal.class); + VoteShopPurchaseService.SharedPurchaseDebit debit = new VoteShopPurchaseService.SharedPurchaseDebit( + VoteShopPurchaseResult.SUCCESS, journal, "purchase-1", "Points", "VoteShopLimitdaily"); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(org.bukkit.Bukkit.class)) { + bukkit.when(org.bukkit.Bukkit::getPluginManager).thenReturn(mock(org.bukkit.plugin.PluginManager.class)); + new VoteShopPurchaseService(plugin, mock(VoteShopDefinition.class)).scheduleClaimedReward(player, user, item, + new HashMap<>(), mock(FileConfiguration.class), ignored -> { }, debit); + } + + verify(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + verify(rewardHandler).giveReward(eq(user), any(FileConfiguration.class), eq("Shop.daily.Rewards"), any()); + } + + @Test + void rejectedInitialSharedMysqlSubmissionCompletesAsFailed() { + MySQL table = mock(MySQL.class); + VotingPluginMain plugin = sharedMysqlPlugin(table); + ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getTimer()).thenReturn(persistenceExecutor); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + org.mockito.Mockito.doThrow(new java.util.concurrent.RejectedExecutionException("saturated")) + .when(persistenceExecutor).execute(any(Runnable.class)); + org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + VoteShopDefinition definition = mock(VoteShopDefinition.class); + when(definition.isEnabled()).thenReturn(true); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getPermission()).thenReturn(""); + AtomicReference result = new AtomicReference<>(); + + new VoteShopPurchaseService(plugin, definition).purchase(player, purchaseUser(), item, result::set); + + assertEquals(VoteShopPurchaseResult.FAILED, result.get()); + verify(persistenceExecutor).execute(any(Runnable.class)); + verify(table, never()).getMysql(); + } + + @Test + void sharedMysqlCacheDrainFailureCompletesAsFailed() { + MySQL table = mock(MySQL.class); + VotingPluginMain plugin = sharedMysqlPlugin(table); + ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getTimer()).thenReturn(persistenceExecutor); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + VoteShopDefinition definition = mock(VoteShopDefinition.class); + when(definition.isEnabled()).thenReturn(true); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getPermission()).thenReturn(""); + VotingPluginUser user = purchaseUser(); + UserDataCache cache = mock(UserDataCache.class); + when(user.isCached()).thenReturn(true); + when(user.getCache()).thenReturn(cache); + when(cache.getCache()).thenReturn(new HashMap<>()); + org.mockito.Mockito.doThrow(new IllegalStateException("cache dump failed")).when(cache).dump(); + AtomicReference result = new AtomicReference<>(); + + new VoteShopPurchaseService(plugin, definition).purchase(player, user, item, result::set); + ArgumentCaptor databaseWork = ArgumentCaptor.forClass(Runnable.class); + verify(persistenceExecutor).execute(databaseWork.capture()); + databaseWork.getValue().run(); + + assertEquals(VoteShopPurchaseResult.FAILED, result.get()); + verify(table, never()).getMysql(); + } + + @Test + void sharedMysqlDebitIsRefundedWhenEntitySchedulerRetiresWithoutFallback() throws Exception { + MySQL table = mock(MySQL.class); + com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, + org.mockito.Mockito.RETURNS_DEEP_STUBS); + Connection schemaConnection = mock(Connection.class); + Connection pendingConnection = mock(Connection.class); + Connection compensatingConnection = mock(Connection.class); + Connection cleanupConnection = mock(Connection.class); + Connection debitConnection = mock(Connection.class); + Connection refundConnection = mock(Connection.class); + PreparedStatement schema = mock(PreparedStatement.class); + PreparedStatement schemaGeneration = mock(PreparedStatement.class); + PreparedStatement schemaGenerationExpiry = mock(PreparedStatement.class); + PreparedStatement schemaIndex = mock(PreparedStatement.class); + PreparedStatement pending = mock(PreparedStatement.class); + PreparedStatement compensating = mock(PreparedStatement.class); + PreparedStatement cleanupSelect = mock(PreparedStatement.class); + PreparedStatement cleanupDelete = mock(PreparedStatement.class); + PreparedStatement reserve = mock(PreparedStatement.class); + PreparedStatement debit = mock(PreparedStatement.class); + PreparedStatement refundMark = mock(PreparedStatement.class); + PreparedStatement refundSelect = mock(PreparedStatement.class); + PreparedStatement refund = mock(PreparedStatement.class); + PreparedStatement refundUpdate = mock(PreparedStatement.class); + ResultSet noPendingRows = emptyRows(); + ResultSet noCompensatingRows = emptyRows(); + ResultSet noTerminalRows = emptyRows(); + ResultSet pendingPurchase = purchaseRow("COMPENSATING", "Points", null, 10); + when(table.getTableName()).thenReturn("VotingPlugin_Users"); + when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); + when(table.getMysql()).thenReturn(sql); + when(sql.getConnectionManager().getConnection()).thenReturn(schemaConnection, debitConnection, refundConnection); + when(schemaConnection.prepareStatement(anyString())).thenReturn(schema, schemaGeneration, + schemaGenerationExpiry, schemaIndex); + when(pendingConnection.prepareStatement(anyString())).thenReturn(pending); + when(pending.executeQuery()).thenReturn(noPendingRows); + when(compensatingConnection.prepareStatement(anyString())).thenReturn(compensating); + when(compensating.executeQuery()).thenReturn(noCompensatingRows); + when(cleanupConnection.prepareStatement(anyString())).thenReturn(cleanupSelect, cleanupDelete); + when(cleanupSelect.executeQuery()).thenReturn(noTerminalRows); + when(debitConnection.prepareStatement(anyString())).thenReturn(reserve, debit); + when(refundConnection.prepareStatement(anyString())).thenReturn(refundMark, refundSelect, refund, refundUpdate); + when(refundSelect.executeQuery()).thenReturn(pendingPurchase); + when(debit.executeUpdate()).thenReturn(1); + when(refundMark.executeUpdate()).thenReturn(1); + when(refund.executeUpdate()).thenReturn(1); + when(refundUpdate.executeUpdate()).thenReturn(1); + VotingPluginMain plugin = sharedMysqlPlugin(table); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + com.bencodez.simpleapi.folialib.FoliaLib folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class); + com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler = + mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(scheduler.getFoliaLib()).thenReturn(folia); + when(folia.getImpl()).thenReturn(entityScheduler); + ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(persistenceExecutor); + when(plugin.isEnabled()).thenReturn(true); + VoteShopDefinition definition = mock(VoteShopDefinition.class); + when(definition.isEnabled()).thenReturn(true); + when(definition.getTitle()).thenReturn("Vote Shop"); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getCost()).thenReturn(10); + when(item.getLimit()).thenReturn(0); + VotingPluginUser user = purchaseUser(); + AtomicInteger completions = new AtomicInteger(); + AtomicReference completionResult = new AtomicReference<>(); + + org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + when(entityScheduler.runAtEntityWithFallback(org.mockito.ArgumentMatchers.eq(player), any(), + any(Runnable.class))).thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); + new VoteShopPurchaseService(plugin, definition).purchase(player, user, item, result -> { + completionResult.set(result); + completions.incrementAndGet(); + }); + ArgumentCaptor work = ArgumentCaptor.forClass(Runnable.class); + verify(persistenceExecutor).execute(work.capture()); + ExecutorService worker = Executors.newSingleThreadExecutor(); + Future purchase = worker.submit(work.getValue()); + @SuppressWarnings("rawtypes") + ArgumentCaptor scheduled = ArgumentCaptor.forClass(java.util.function.Consumer.class); + ArgumentCaptor retirement = ArgumentCaptor.forClass(Runnable.class); + verify(entityScheduler, org.mockito.Mockito.timeout(1000)).runAtEntityWithFallback( + org.mockito.ArgumentMatchers.eq(player), scheduled.capture(), retirement.capture()); + purchase.get(5, TimeUnit.SECONDS); + ArgumentCaptor compensation = ArgumentCaptor.forClass(Runnable.class); + verify(persistenceExecutor, org.mockito.Mockito.timeout(1000).times(2)).execute(compensation.capture()); + compensation.getAllValues().get(1).run(); + + ArgumentCaptor refundSql = ArgumentCaptor.forClass(String.class); + verify(refundConnection, times(4)).prepareStatement(refundSql.capture()); + assertTrue(refundSql.getAllValues().get(2).contains("`Points` = `Points` + ?")); + verify(refund).setInt(1, 10); + verify(refund, times(1)).executeUpdate(); + // Schema, reservation, cache refresh, and refund are the only database connections in the + // scheduler-retirement path. Another checkout would be the reward claim and would make the debit unrecoverable. + verify(sql.getConnectionManager(), times(4)).getConnection(); + verify(entityScheduler, times(2)).runAtEntityWithFallback( + org.mockito.ArgumentMatchers.eq(player), any(), any(Runnable.class)); + ArgumentCaptor fallbackCompletion = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTask(eq(plugin), fallbackCompletion.capture()); + fallbackCompletion.getValue().run(); + scheduled.getValue().accept(null); + assertEquals(1, completions.get(), "a compensated purchase must complete exactly once"); + assertEquals(VoteShopPurchaseResult.FAILED, completionResult.get()); + verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), any(), any()); + worker.shutdownNow(); + } + + @Test + void disabledShopResultStillSendsFeedback() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + VoteShopDefinition definition = mock(VoteShopDefinition.class); + org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + + new VoteShopPurchaseService(plugin, definition).sendFailureMessage(player, mock(VotingPluginUser.class), null, + VoteShopPurchaseResult.SHOP_DISABLED); + + verify(player).sendMessage(anyString()); + } + + @Test + void rejectedClaimedRewardQueuesDurableRefundAndFencesLateCallback() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + com.bencodez.simpleapi.folialib.FoliaLib folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class); + com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler = + mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class); + ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + RewardHandler rewardHandler = mock(RewardHandler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(scheduler.getFoliaLib()).thenReturn(folia); + when(folia.getImpl()).thenReturn(entityScheduler); + when(plugin.getTimer()).thenReturn(persistenceExecutor); + when(plugin.getRewardHandler()).thenReturn(rewardHandler); + @SuppressWarnings("rawtypes") + ArgumentCaptor callback = ArgumentCaptor.forClass(java.util.function.Consumer.class); + when(entityScheduler.runAtEntityWithFallback(any(), callback.capture(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); + SharedMysqlPurchaseJournal journal = mock(SharedMysqlPurchaseJournal.class); + when(journal.markCompensating("purchase-1")).thenReturn(true); + when(journal.refundCompensatingReward("purchase-1")).thenReturn(false); + VoteShopPurchaseService.SharedPurchaseDebit debit = new VoteShopPurchaseService.SharedPurchaseDebit( + VoteShopPurchaseResult.SUCCESS, journal, "purchase-1", "Points", null); + VoteShopPurchaseService service = new VoteShopPurchaseService(plugin, mock(VoteShopDefinition.class)); + VotingPluginUser user = mock(VotingPluginUser.class); + + service.scheduleClaimedReward(mock(org.bukkit.entity.Player.class), user, mock(VoteShopItem.class), + new java.util.HashMap<>(), mock(FileConfiguration.class), ignored -> {}, debit); + + ArgumentCaptor compensation = ArgumentCaptor.forClass(Runnable.class); + verify(persistenceExecutor).execute(compensation.capture()); + verify(journal, never()).markCompensating(anyString()); + compensation.getValue().run(); + verify(journal).markCompensating("purchase-1"); + verify(journal).refundCompensatingReward("purchase-1"); + callback.getValue().accept(null); + verify(rewardHandler, never()).giveReward(any(), any(), any(), any()); + } + + @Test + void indeterminateRefundInvalidatesPointAndLimitSnapshots() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + VotingPluginUser user = mock(VotingPluginUser.class); + String uuid = "00000000-0000-0000-0000-000000000001"; + when(user.getUUID()).thenReturn(uuid); + UserDataCache cache = mock(UserDataCache.class); + java.util.HashMap values = new java.util.HashMap<>(); + values.put("Points", mock(DataValue.class)); + values.put("VoteShopLimit-item", mock(DataValue.class)); + when(cache.getCache()).thenReturn(values); + when(plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn( + new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(UUID.fromString(uuid), cache))); + SharedMysqlPurchaseJournal journal = mock(SharedMysqlPurchaseJournal.class); + when(journal.refundCompensatingReward("purchase-1")) + .thenThrow(new java.sql.SQLException("commit acknowledgement lost")); + VoteShopPurchaseService.SharedPurchaseDebit debit = new VoteShopPurchaseService.SharedPurchaseDebit( + VoteShopPurchaseResult.SUCCESS, journal, "purchase-1", "Points", "VoteShopLimit-item"); + + java.lang.reflect.Method refund = VoteShopPurchaseService.class.getDeclaredMethod( + "refundCompensatingMysqlDebit", VotingPluginUser.class, + VoteShopPurchaseService.SharedPurchaseDebit.class); + refund.setAccessible(true); + refund.invoke(new VoteShopPurchaseService(plugin, mock(VoteShopDefinition.class)), user, debit); + + assertFalse(values.containsKey("Points")); + assertFalse(values.containsKey("VoteShopLimit-item")); + verify(journal).refundCompensatingReward("purchase-1"); + } + + @Test + void rejectedCompensationExecutorStillRunsTheDurableRefund() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + com.bencodez.simpleapi.folialib.FoliaLib folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class); + com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler = + mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class); + ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(scheduler.getFoliaLib()).thenReturn(folia); + when(folia.getImpl()).thenReturn(entityScheduler); + when(plugin.getTimer()).thenReturn(persistenceExecutor); + when(entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); + org.mockito.Mockito.doThrow(new java.util.concurrent.RejectedExecutionException("stopping")) + .when(persistenceExecutor).execute(any(Runnable.class)); + SharedMysqlPurchaseJournal journal = mock(SharedMysqlPurchaseJournal.class); + when(journal.markCompensating("purchase-1")).thenReturn(true); + when(journal.refundCompensatingReward("purchase-1")).thenReturn(false); + VoteShopPurchaseService.SharedPurchaseDebit debit = new VoteShopPurchaseService.SharedPurchaseDebit( + VoteShopPurchaseResult.SUCCESS, journal, "purchase-1", "Points", null); + + new VoteShopPurchaseService(plugin, mock(VoteShopDefinition.class)).scheduleClaimedReward( + mock(org.bukkit.entity.Player.class), mock(VotingPluginUser.class), mock(VoteShopItem.class), + new java.util.HashMap<>(), mock(FileConfiguration.class), ignored -> {}, debit); + + ArgumentCaptor asyncRefund = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTaskAsynchronously(eq(plugin), asyncRefund.capture()); + verify(journal, never()).refundUnstartedReward(anyString()); + asyncRefund.getValue().run(); + verify(journal).refundCompensatingReward("purchase-1"); + } + + @Test + void synchronousAsyncClaimRejectionSchedulesCompensationOffTheEntityLane() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + com.bencodez.simpleapi.folialib.FoliaLib folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class); + com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler = + mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class); + ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(scheduler.getFoliaLib()).thenReturn(folia); + when(folia.getImpl()).thenReturn(entityScheduler); + when(plugin.getTimer()).thenReturn(persistenceExecutor); + doAnswer(invocation -> { + @SuppressWarnings("rawtypes") + java.util.function.Consumer callback = invocation.getArgument(1, java.util.function.Consumer.class); + callback.accept(null); + return CompletableFuture.completedFuture(EntityTaskResult.SUCCESS); + }).when(entityScheduler).runAtEntityWithFallback(any(), any(), any(Runnable.class)); + org.mockito.Mockito.doThrow(new java.util.concurrent.RejectedExecutionException("stopping")) + .when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + SharedMysqlPurchaseJournal journal = mock(SharedMysqlPurchaseJournal.class); + when(journal.markCompensating("purchase-1")).thenReturn(true); + when(journal.refundCompensatingReward("purchase-1")).thenReturn(false); + VoteShopPurchaseService.SharedPurchaseDebit debit = new VoteShopPurchaseService.SharedPurchaseDebit( + VoteShopPurchaseResult.SUCCESS, journal, "purchase-1", "Points", null); + VoteShopPurchaseService service = new VoteShopPurchaseService(plugin, mock(VoteShopDefinition.class)); + + java.lang.reflect.Method complete = VoteShopPurchaseService.class.getDeclaredMethod("completeSharedMysqlPurchase", + org.bukkit.entity.Player.class, VotingPluginUser.class, VoteShopItem.class, HashMap.class, + FileConfiguration.class, java.util.function.Consumer.class, + VoteShopPurchaseService.SharedPurchaseDebit.class); + complete.setAccessible(true); + complete.invoke(service, mock(org.bukkit.entity.Player.class), mock(VotingPluginUser.class), mock(VoteShopItem.class), + new HashMap<>(), mock(FileConfiguration.class), (java.util.function.Consumer) ignored -> { }, + debit); + + ArgumentCaptor compensation = ArgumentCaptor.forClass(Runnable.class); + verify(persistenceExecutor).execute(compensation.capture()); + verify(journal, never()).markCompensating(anyString()); + compensation.getValue().run(); + verify(journal).markCompensating("purchase-1"); + } + + @Test + void rejectedClaimedRewardSettlementUsesAsyncFallbackAndCompletes() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + com.bencodez.simpleapi.folialib.FoliaLib folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class); + com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler = + mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class); + ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + RewardHandler rewardHandler = mock(RewardHandler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(scheduler.getFoliaLib()).thenReturn(folia); + when(folia.getImpl()).thenReturn(entityScheduler); + when(plugin.getTimer()).thenReturn(persistenceExecutor); + when(plugin.getRewardHandler()).thenReturn(rewardHandler); + when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + org.mockito.Mockito.doThrow(new java.util.concurrent.RejectedExecutionException("saturated")) + .when(persistenceExecutor).execute(any(Runnable.class)); + + org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + when(player.getUniqueId()).thenReturn(UUID.fromString("00000000-0000-0000-0000-000000000001")); + when(player.getName()).thenReturn("player"); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + @SuppressWarnings("rawtypes") + ArgumentCaptor rewardCallback = ArgumentCaptor.forClass(java.util.function.Consumer.class); + when(entityScheduler.runAtEntityWithFallback(eq(player), rewardCallback.capture(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SUCCESS)); + + VotingPluginUser user = mock(VotingPluginUser.class); + when(user.getPlayerName()).thenReturn("player"); + when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getIdentifier()).thenReturn("item"); + when(item.getCost()).thenReturn(10); + when(item.getRewardsPath()).thenReturn("Shop.item.Rewards"); + when(item.getPurchaseMessage()).thenReturn("Purchased"); + SharedMysqlPurchaseJournal journal = mock(SharedMysqlPurchaseJournal.class); + VoteShopPurchaseService.SharedPurchaseDebit debit = new VoteShopPurchaseService.SharedPurchaseDebit( + VoteShopPurchaseResult.SUCCESS, journal, "purchase-1", "Points", null); + AtomicReference result = new AtomicReference<>(); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(org.bukkit.Bukkit.class)) { + bukkit.when(org.bukkit.Bukkit::getPluginManager).thenReturn(mock(org.bukkit.plugin.PluginManager.class)); + new VoteShopPurchaseService(plugin, mock(VoteShopDefinition.class)).scheduleClaimedReward(player, user, item, + new HashMap<>(), mock(FileConfiguration.class), result::set, debit); + rewardCallback.getValue().accept(null); + } + + ArgumentCaptor fallback = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTaskAsynchronously(eq(plugin), fallback.capture()); + fallback.getValue().run(); + verify(rewardHandler).giveReward(eq(user), any(FileConfiguration.class), eq("Shop.item.Rewards"), any()); + verify(journal).complete("purchase-1"); + assertEquals(VoteShopPurchaseResult.SUCCESS, result.get()); + } + + @Test + void claimedRewardFailureCompletesCallerAndRetainsJournalForReconciliation() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + com.bencodez.simpleapi.folialib.FoliaLib folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class); + com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler = + mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class); + ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + RewardHandler rewardHandler = mock(RewardHandler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(scheduler.getFoliaLib()).thenReturn(folia); + when(folia.getImpl()).thenReturn(entityScheduler); + when(plugin.getTimer()).thenReturn(persistenceExecutor); + when(plugin.getRewardHandler()).thenReturn(rewardHandler); + when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + org.mockito.Mockito.doThrow(new IllegalStateException("reward failed")) + .when(rewardHandler).giveReward(any(), any(), any(), any()); + + org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + VotingPluginUser user = mock(VotingPluginUser.class); + when(user.getPlayerName()).thenReturn("player"); + when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getIdentifier()).thenReturn("item"); + when(item.getCost()).thenReturn(10); + when(item.getRewardsPath()).thenReturn("Shop.item.Rewards"); + @SuppressWarnings("rawtypes") + ArgumentCaptor rewardCallback = ArgumentCaptor.forClass(java.util.function.Consumer.class); + when(entityScheduler.runAtEntityWithFallback(eq(player), rewardCallback.capture(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SUCCESS)); + SharedMysqlPurchaseJournal journal = mock(SharedMysqlPurchaseJournal.class); + VoteShopPurchaseService.SharedPurchaseDebit debit = new VoteShopPurchaseService.SharedPurchaseDebit( + VoteShopPurchaseResult.SUCCESS, journal, "purchase-1", "Points", null); + AtomicInteger completions = new AtomicInteger(); + AtomicReference result = new AtomicReference<>(); + + new VoteShopPurchaseService(plugin, mock(VoteShopDefinition.class)).scheduleClaimedReward(player, user, item, + new HashMap<>(), mock(FileConfiguration.class), completion -> { + result.set(completion); + completions.incrementAndGet(); + }, debit); + + assertThrows(IllegalStateException.class, () -> rewardCallback.getValue().accept(null)); + assertEquals(1, completions.get()); + assertEquals(VoteShopPurchaseResult.RECONCILIATION_REQUIRED, result.get()); + verify(journal, never()).complete(anyString()); + verify(journal, never()).refundCompensatingReward(anyString()); + verify(persistenceExecutor, never()).execute(any(Runnable.class)); + } + + @Test + void rejectedCompensationSchedulersLeaveADurableRecoveryMarker(@TempDir Path temporaryDirectory) throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + com.bencodez.simpleapi.folialib.FoliaLib folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class); + com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler = + mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class); + ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(scheduler.getFoliaLib()).thenReturn(folia); + when(folia.getImpl()).thenReturn(entityScheduler); + when(plugin.getTimer()).thenReturn(persistenceExecutor); + when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile()); + when(entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); + org.mockito.Mockito.doThrow(new java.util.concurrent.RejectedExecutionException("stopping")) + .when(persistenceExecutor).execute(any(Runnable.class)); + org.mockito.Mockito.doThrow(new java.util.concurrent.RejectedExecutionException("disabling")) + .when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + SharedMysqlPurchaseJournal journal = mock(SharedMysqlPurchaseJournal.class); + when(journal.markCompensating("purchase-1")).thenReturn(true); + VoteShopPurchaseService.SharedPurchaseDebit debit = new VoteShopPurchaseService.SharedPurchaseDebit( + VoteShopPurchaseResult.SUCCESS, journal, "purchase-1", "Points", null); + + new VoteShopPurchaseService(plugin, mock(VoteShopDefinition.class)).scheduleClaimedReward( + mock(org.bukkit.entity.Player.class), mock(VotingPluginUser.class), mock(VoteShopItem.class), + new java.util.HashMap<>(), mock(FileConfiguration.class), ignored -> {}, debit); + + verify(journal, never()).markCompensating(anyString()); + verify(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + verify(journal, never()).refundCompensatingReward(anyString()); + assertEquals(java.util.List.of("purchase-1"), + new SharedMysqlCompensationStore(temporaryDirectory).loadBatch()); + } + + @Test + void failedCompensationMarkerIsRetriedByPeriodicRecovery(@TempDir Path temporaryDirectory) throws Exception { + VotingPluginMain plugin = mockPluginForCompensation(temporaryDirectory); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = plugin.getBukkitScheduler(); + com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler = + plugin.getBukkitScheduler().getFoliaLib().getImpl(); + when(entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); + SharedMysqlPurchaseJournal journal = mock(SharedMysqlPurchaseJournal.class); + when(journal.markCompensating("purchase-1")) + .thenThrow(new java.sql.SQLException("down")) + .thenThrow(new java.sql.SQLException("still down")) + .thenReturn(true); + when(journal.recoverAndCleanup(anyLong())).thenReturn(java.util.List.of()); + VoteShopPurchaseService.SharedPurchaseDebit debit = new VoteShopPurchaseService.SharedPurchaseDebit( + VoteShopPurchaseResult.SUCCESS, journal, "purchase-1", "Points", null); + + new VoteShopPurchaseService(plugin, mock(VoteShopDefinition.class)).scheduleClaimedReward( + mock(org.bukkit.entity.Player.class), mock(VotingPluginUser.class), mock(VoteShopItem.class), + new java.util.HashMap<>(), mock(FileConfiguration.class), ignored -> {}, debit); + ArgumentCaptor compensation = ArgumentCaptor.forClass(Runnable.class); + verify(plugin.getTimer()).execute(compensation.capture()); + compensation.getValue().run(); + assertEquals(java.util.List.of("purchase-1"), + new SharedMysqlCompensationStore(temporaryDirectory).loadBatch()); + VoteShopPurchaseService.recoverSharedMysqlPurchases(plugin, journal); + assertEquals(java.util.List.of("purchase-1"), + new SharedMysqlCompensationStore(temporaryDirectory).loadBatch()); + VoteShopPurchaseService.recoverSharedMysqlPurchases(plugin, journal); + assertTrue(new SharedMysqlCompensationStore(temporaryDirectory).loadBatch().isEmpty()); + + verify(journal, times(3)).markCompensating("purchase-1"); + verify(journal, times(2)).recoverAndCleanup(anyLong()); + verify(scheduler, never()).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + } + + private static VotingPluginMain mockPluginForCompensation(Path dataDirectory) { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getDataFolder()).thenReturn(dataDirectory.toFile()); + com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler = + plugin.getBukkitScheduler().getFoliaLib().getImpl(); + when(entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); + return plugin; + } + + @Test + void sharedMysqlDebitWaitsForAndRemovesExistingCache() throws Exception { + MySQL table = mock(MySQL.class); + com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, + org.mockito.Mockito.RETURNS_DEEP_STUBS); + Connection connection = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + when(table.getTableName()).thenReturn("VotingPlugin_Users"); + when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); + when(table.getMysql()).thenReturn(sql); + when(sql.getConnectionManager().getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + when(statement.executeUpdate()).thenReturn(1); + VotingPluginMain plugin = sharedMysqlPlugin(table); + VotingPluginUser user = purchaseUser(); + UserDataCache cache = mock(UserDataCache.class); + when(user.isCached()).thenReturn(true, false); + when(user.getCache()).thenReturn(cache); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getCost()).thenReturn(10); + when(item.getLimit()).thenReturn(0); + + assertEquals(VoteShopPurchaseResult.SUCCESS, + new VoteShopPurchaseService(plugin, null).debitSharedMysql(user, item)); + + verify(cache).dump(); + verify(plugin.getUserManager().getDataManager()).removeCache( + java.util.UUID.fromString("00000000-0000-0000-0000-000000000001"), null); + } + + @Test + void sharedMysqlDebitStripsOptimisticPointsBeforeDumpingOtherCachedFields() throws Exception { + MySQL table = mock(MySQL.class); + com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, + org.mockito.Mockito.RETURNS_DEEP_STUBS); + Connection connection = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + when(table.getTableName()).thenReturn("VotingPlugin_Users"); + when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); + when(table.getMysql()).thenReturn(sql); + when(sql.getConnectionManager().getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + when(statement.executeUpdate()).thenReturn(1); + VotingPluginMain plugin = sharedMysqlPlugin(table); + VotingPluginUser user = purchaseUser(); + UserDataCache cache = mock(UserDataCache.class); + DataValue prediction = mock(DataValue.class); + DataValue dailyTotal = mock(DataValue.class); + HashMap values = new HashMap<>(); + values.put("Points", prediction); + values.put("DailyTotal", dailyTotal); + when(user.isCached()).thenReturn(true, false); + when(user.getCache()).thenReturn(cache); + when(cache.getCache()).thenReturn(values); + java.lang.reflect.Method record = SharedMysqlCacheReconciler.class.getDeclaredMethod( + "recordOptimisticPoint", UserDataCache.class, String.class, DataValue.class); + record.setAccessible(true); + record.invoke(null, cache, "Points", prediction); + doAnswer(invocation -> { + assertFalse(values.containsKey("Points")); + assertSame(dailyTotal, values.get("DailyTotal")); + return null; + }).when(cache).dump(); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getCost()).thenReturn(10); + when(item.getLimit()).thenReturn(0); + + assertEquals(VoteShopPurchaseResult.SUCCESS, + new VoteShopPurchaseService(plugin, null).debitSharedMysql(user, item)); + + verify(cache).dump(); + } + + @Test + void sharedMysqlDebitClosesItsConnectionBeforeRefreshingTheCache() throws Exception { + MySQL table = mock(MySQL.class); + com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, + org.mockito.Mockito.RETURNS_DEEP_STUBS); + Connection connection = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + UserDataCache cache = mock(UserDataCache.class); + when(table.getTableName()).thenReturn("VotingPlugin_Users"); + when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); + when(table.getMysql()).thenReturn(sql); + when(sql.getConnectionManager().getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + when(statement.executeUpdate()).thenReturn(1); + VotingPluginMain plugin = sharedMysqlPlugin(table); + VotingPluginUser user = purchaseUser(); + UUID userUuid = UUID.fromString(user.getUUID()); + HashMap cachedValues = new HashMap<>(); + cachedValues.put("Points", mock(DataValue.class)); + when(plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn( + new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(userUuid, cache))); + when(cache.getCache()).thenReturn(cachedValues); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getCost()).thenReturn(10); + when(item.getLimit()).thenReturn(0); + + assertEquals(VoteShopPurchaseResult.SUCCESS, + new VoteShopPurchaseService(plugin, null).debitSharedMysql(user, item)); + + InOrder closeBeforeRefresh = inOrder(connection, cache); + closeBeforeRefresh.verify(connection).close(); + closeBeforeRefresh.verify(cache).getCache(); + assertFalse(cachedValues.containsKey("Points")); + verify(cache, never()).addChange(any(), org.mockito.ArgumentMatchers.anyBoolean()); + } + + @Test + void sharedMysqlFailureReleasesDebitConnectionBeforeClassifyingTheLimit() throws Exception { + MySQL table = mock(MySQL.class); + com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, + org.mockito.Mockito.RETURNS_DEEP_STUBS); + Connection connection = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + when(table.getTableName()).thenReturn("VotingPlugin_Users"); + when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); + when(table.getMysql()).thenReturn(sql); + when(sql.getConnectionManager().getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + when(statement.executeUpdate()).thenReturn(0); + VotingPluginUser user = purchaseUser(); + UserData data = mock(UserData.class); + when(user.getUserData()).thenReturn(data); + when(data.getInt("VoteShopLimitdaily", UserDataFetchMode.NO_CACHE)).thenReturn(1); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getCost()).thenReturn(10); + when(item.getLimit()).thenReturn(1); + when(item.getIdentifier()).thenReturn("daily"); + + assertEquals(VoteShopPurchaseResult.LIMIT_REACHED, + new VoteShopPurchaseService(sharedMysqlPlugin(table), null).debitSharedMysql(user, item)); + + InOrder closeThenClassify = inOrder(connection, data); + closeThenClassify.verify(connection).close(); + closeThenClassify.verify(data).getInt("VoteShopLimitdaily", UserDataFetchMode.NO_CACHE); + } + + @Test + void perServerMysqlLimitedPurchaseQueuesJournalReservationOffCallingThread() throws Exception { + MySQL table = mock(MySQL.class); + com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, + org.mockito.Mockito.RETURNS_DEEP_STUBS); + VotingPluginMain plugin = sharedMysqlPlugin(table); + when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); + ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(persistenceExecutor); + when(table.getMysql()).thenReturn(sql); + VoteShopDefinition definition = mock(VoteShopDefinition.class); + when(definition.isEnabled()).thenReturn(true); + when(definition.getTitle()).thenReturn("Vote Shop"); + VotingPluginUser user = purchaseUser(); + when(user.getPoints()).thenReturn(0); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getCost()).thenReturn(10); + when(item.getLimit()).thenReturn(1); + when(user.getVoteShopIdentifierLimit(anyString())).thenReturn(1); + + new VoteShopPurchaseService(plugin, definition).purchase(mock(org.bukkit.entity.Player.class), user, item, + result -> { }); + + verify(persistenceExecutor).execute(any(Runnable.class)); + verify(sql.getConnectionManager(), never()).getConnection(); + } + + @Test + void sharedMysqlReservationDatabaseFailureReportsFailed() throws Exception { + MySQL table = mock(MySQL.class); + com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, + org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(table.getTableName()).thenReturn("VotingPlugin_Users"); + when(table.getMysql()).thenReturn(sql); + when(sql.getConnectionManager().getConnection()).thenReturn(null); + VotingPluginMain plugin = sharedMysqlPlugin(table); + ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getTimer()).thenReturn(persistenceExecutor); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + UUID uuid = UUID.fromString("00000000-0000-0000-0000-000000000001"); + UserDataCache recreatedCache = mock(UserDataCache.class); + HashMap recreatedValues = new HashMap<>(); + recreatedValues.put("Points", mock(DataValue.class)); + recreatedValues.put("VoteShopLimititem", mock(DataValue.class)); + recreatedValues.put("DailyTotal", mock(DataValue.class)); + when(recreatedCache.getCache()).thenReturn(recreatedValues); + when(plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn( + new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(uuid, recreatedCache))); + org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + VoteShopDefinition definition = mock(VoteShopDefinition.class); + when(definition.isEnabled()).thenReturn(true); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getPermission()).thenReturn(""); + when(item.getLimit()).thenReturn(1); + when(item.getIdentifier()).thenReturn("item"); + AtomicReference result = new AtomicReference<>(); + + new VoteShopPurchaseService(plugin, definition).purchase(player, purchaseUser(), item, result::set); + ArgumentCaptor databaseWork = ArgumentCaptor.forClass(Runnable.class); + verify(persistenceExecutor).execute(databaseWork.capture()); + databaseWork.getValue().run(); + + assertEquals(VoteShopPurchaseResult.FAILED, result.get()); + assertFalse(recreatedValues.containsKey("Points")); + assertFalse(recreatedValues.containsKey("VoteShopLimititem")); + assertTrue(recreatedValues.containsKey("DailyTotal")); + verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), any(), any()); + } + + @Test + void legacySharedMysqlPurchaseReportsPendingUntilDebitCompletes() { + MySQL table = mock(MySQL.class); + VotingPluginMain plugin = sharedMysqlPlugin(table); + ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(persistenceExecutor); + VoteShopDefinition definition = mock(VoteShopDefinition.class); + when(definition.isEnabled()).thenReturn(true); + VoteShopItem item = mock(VoteShopItem.class); + + VoteShopPurchaseResult result = new VoteShopPurchaseService(plugin, definition) + .purchase(mock(org.bukkit.entity.Player.class), purchaseUser(), item); + + assertEquals(VoteShopPurchaseResult.PENDING, result); + verify(persistenceExecutor).execute(any(Runnable.class)); + } + + @Test + void sharedPurchaseKeepsRewardConfigurationFromBeforeShopReload() throws Exception { + MySQL table = mock(MySQL.class); + com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, + org.mockito.Mockito.RETURNS_DEEP_STUBS); + Connection schemaConnection = mock(Connection.class); + Connection pendingConnection = mock(Connection.class); + Connection compensatingConnection = mock(Connection.class); + Connection cleanupConnection = mock(Connection.class); + Connection reserveConnection = mock(Connection.class); + Connection claimConnection = mock(Connection.class); + Connection completeConnection = mock(Connection.class); + PreparedStatement schema = mock(PreparedStatement.class); + PreparedStatement schemaGeneration = mock(PreparedStatement.class); + PreparedStatement schemaGenerationExpiry = mock(PreparedStatement.class); + PreparedStatement schemaIndex = mock(PreparedStatement.class); + PreparedStatement pending = mock(PreparedStatement.class); + PreparedStatement compensating = mock(PreparedStatement.class); + PreparedStatement cleanupSelect = mock(PreparedStatement.class); + PreparedStatement cleanupDelete = mock(PreparedStatement.class); + PreparedStatement reserve = mock(PreparedStatement.class); + PreparedStatement debit = mock(PreparedStatement.class); + PreparedStatement claim = mock(PreparedStatement.class); + PreparedStatement completeSelect = mock(PreparedStatement.class); + PreparedStatement completeUpdate = mock(PreparedStatement.class); + ResultSet noPendingRows = emptyRows(); + ResultSet noCompensatingRows = emptyRows(); + ResultSet noTerminalRows = emptyRows(); + ResultSet hookStartedPurchase = purchaseRow("HOOK_STARTED", "Points", null, 10); + when(table.getTableName()).thenReturn("VotingPlugin_Users"); + when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); + when(table.getMysql()).thenReturn(sql); + when(sql.getConnectionManager().getConnection()).thenReturn(schemaConnection, reserveConnection, + claimConnection, completeConnection); + when(schemaConnection.prepareStatement(anyString())).thenReturn(schema, schemaGeneration, + schemaGenerationExpiry, schemaIndex); + when(pendingConnection.prepareStatement(anyString())).thenReturn(pending); + when(pending.executeQuery()).thenReturn(noPendingRows); + when(compensatingConnection.prepareStatement(anyString())).thenReturn(compensating); + when(compensating.executeQuery()).thenReturn(noCompensatingRows); + when(cleanupConnection.prepareStatement(anyString())).thenReturn(cleanupSelect, cleanupDelete); + when(cleanupSelect.executeQuery()).thenReturn(noTerminalRows); + when(reserveConnection.prepareStatement(anyString())).thenReturn(reserve, debit); + when(debit.executeUpdate()).thenReturn(1); + AtomicReference claimThread = new AtomicReference<>(); + when(claimConnection.prepareStatement(anyString())).thenAnswer(invocation -> { + claimThread.set(Thread.currentThread().getName()); + return claim; + }); + when(claim.executeUpdate()).thenReturn(1); + when(completeConnection.prepareStatement(anyString())).thenReturn(completeSelect, completeUpdate); + when(completeSelect.executeQuery()).thenReturn(hookStartedPurchase); + when(completeUpdate.executeUpdate()).thenReturn(1); + VotingPluginMain plugin = sharedMysqlPlugin(table); + when(plugin.isEnabled()).thenReturn(true); + RewardHandler rewardHandler = mock(RewardHandler.class); + when(plugin.getRewardHandler()).thenReturn(rewardHandler); + ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(persistenceExecutor); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + com.bencodez.simpleapi.folialib.FoliaLib folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class); + com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler = + mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + AtomicReference claimWork = new AtomicReference<>(); + doAnswer(invocation -> { + claimWork.set(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + when(scheduler.getFoliaLib()).thenReturn(folia); + when(folia.getImpl()).thenReturn(entityScheduler); + when(entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SUCCESS)); + VoteShopDefinition definition = mock(VoteShopDefinition.class); + when(definition.isEnabled()).thenReturn(true); + when(definition.getTitle()).thenReturn("Vote Shop"); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getCost()).thenReturn(10); + when(item.getLimit()).thenReturn(0); + when(item.getIdentifier()).thenReturn("old-item"); + when(item.getIdentifierName()).thenReturn("Old item"); + when(item.getRewardsPath()).thenReturn("Shop.old-item.Rewards"); + when(item.getPurchaseMessage()).thenReturn(""); + VotingPluginUser user = purchaseUser(); + org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + FileConfiguration oldShopData = mock(FileConfiguration.class); + FileConfiguration reloadedShopData = mock(FileConfiguration.class); + when(plugin.getShopFile().getData()).thenReturn(oldShopData, reloadedShopData); + org.bukkit.plugin.PluginManager pluginManager = mock(org.bukkit.plugin.PluginManager.class); + AtomicReference result = new AtomicReference<>(); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(org.bukkit.Bukkit.class)) { + bukkit.when(org.bukkit.Bukkit::getPluginManager).thenReturn(pluginManager); + new VoteShopPurchaseService(plugin, definition).purchase(player, user, item, result::set); + ArgumentCaptor work = ArgumentCaptor.forClass(Runnable.class); + verify(persistenceExecutor).execute(work.capture()); + // Simulate a reload replacing ShopFile's active configuration before the + // delayed database/entity work gets to the reward executor. + ExecutorService worker = Executors.newSingleThreadExecutor(); + Future purchase = worker.submit(work.getValue()); + @SuppressWarnings("rawtypes") + ArgumentCaptor rewardCallback = ArgumentCaptor.forClass(java.util.function.Consumer.class); + verify(entityScheduler, org.mockito.Mockito.timeout(1000)).runAtEntityWithFallback(any(), + rewardCallback.capture(), any(Runnable.class)); + // A stopped JVM at this point must leave the durable row PENDING: the + // scheduler has accepted the reward callback but has not yet run it. + verify(claimConnection, never()).prepareStatement(anyString()); + rewardCallback.getValue().accept(null); + assertNotNull(claimWork.get(), "the entity gate must hand JDBC work to the async scheduler"); + verify(claimConnection, never()).prepareStatement(anyString()); + Thread claimWorker = new Thread(claimWork.get(), "vote-shop-claim-worker"); + claimWorker.start(); + claimWorker.join(1000); + assertFalse(claimWorker.isAlive()); + InOrder callbackBeforeClaim = inOrder(entityScheduler, claimConnection); + callbackBeforeClaim.verify(entityScheduler).runAtEntityWithFallback(any(), any(), any(Runnable.class)); + callbackBeforeClaim.verify(claimConnection).prepareStatement(anyString()); + verify(completeConnection, never()).prepareStatement(anyString()); + purchase.get(5, TimeUnit.SECONDS); + @SuppressWarnings("rawtypes") + ArgumentCaptor entityCallbacks = + ArgumentCaptor.forClass(java.util.function.Consumer.class); + verify(entityScheduler, times(2)).runAtEntityWithFallback(any(), entityCallbacks.capture(), any(Runnable.class)); + entityCallbacks.getAllValues().get(1).accept(null); + ArgumentCaptor scheduledWork = ArgumentCaptor.forClass(Runnable.class); + verify(persistenceExecutor, times(2)).execute(scheduledWork.capture()); + scheduledWork.getAllValues().get(1).run(); + worker.shutdownNow(); + } + + assertEquals(VoteShopPurchaseResult.SUCCESS, result.get()); + assertEquals("vote-shop-claim-worker", claimThread.get(), "the entity callback must not perform JDBC"); + verify(rewardHandler).giveReward(eq(user), eq(oldShopData), eq("Shop.old-item.Rewards"), any()); + verify(rewardHandler, never()).giveReward(eq(user), eq(reloadedShopData), anyString(), any()); + } + + @Test + void sharedMysqlConditionAllowsOnlyOneBackendDebit() throws Exception { + AtomicInteger sharedBalance = new AtomicInteger(10); + CountDownLatch bothBackendsInsideUpdate = new CountDownLatch(2); + MySQL table = mock(MySQL.class); + com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, + org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(table.getTableName()).thenReturn("VotingPlugin_Users"); + when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); + when(table.getMysql()).thenReturn(sql); + + Connection firstConnection = mock(Connection.class); + Connection secondConnection = mock(Connection.class); + PreparedStatement firstStatement = conditionalDebitStatement(sharedBalance, bothBackendsInsideUpdate); + PreparedStatement secondStatement = conditionalDebitStatement(sharedBalance, bothBackendsInsideUpdate); + when(firstConnection.prepareStatement(anyString())).thenReturn(firstStatement); + when(secondConnection.prepareStatement(anyString())).thenReturn(secondStatement); + when(sql.getConnectionManager().getConnection()).thenReturn(firstConnection, secondConnection); + + VoteShopPurchaseService first = new VoteShopPurchaseService(sharedMysqlPlugin(table), null); + VoteShopPurchaseService second = new VoteShopPurchaseService(sharedMysqlPlugin(table), null); + VotingPluginUser firstUser = purchaseUser(); + VotingPluginUser secondUser = purchaseUser(); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getCost()).thenReturn(10); + when(item.getLimit()).thenReturn(0); + + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future firstResult = executor.submit(() -> first.debitSharedMysql(firstUser, item)); + Future secondResult = executor.submit(() -> second.debitSharedMysql(secondUser, item)); + VoteShopPurchaseResult one = firstResult.get(5, TimeUnit.SECONDS); + VoteShopPurchaseResult two = secondResult.get(5, TimeUnit.SECONDS); + assertTrue((one == VoteShopPurchaseResult.SUCCESS && two == VoteShopPurchaseResult.NOT_ENOUGH_POINTS) + || (two == VoteShopPurchaseResult.SUCCESS && one == VoteShopPurchaseResult.NOT_ENOUGH_POINTS)); + assertEquals(0, sharedBalance.get()); + } finally { + executor.shutdownNow(); + } + } + + private static PreparedStatement conditionalDebitStatement(AtomicInteger balance, CountDownLatch entered) + throws Exception { + PreparedStatement statement = mock(PreparedStatement.class); + when(statement.executeUpdate()).thenAnswer(invocation -> { + entered.countDown(); + entered.await(5, TimeUnit.SECONDS); + return balance.compareAndSet(10, 0) ? 1 : 0; + }); + return statement; + } + + private static ResultSet emptyRows() throws Exception { + ResultSet rows = mock(ResultSet.class); + when(rows.next()).thenReturn(false); + return rows; + } + + private static ResultSet purchaseRow(String state, String pointsColumn, String limitColumn, int cost) + throws Exception { + ResultSet row = mock(ResultSet.class); + when(row.next()).thenReturn(true); + when(row.getString(1)).thenReturn(state); + when(row.getString(2)).thenReturn("00000000-0000-0000-0000-000000000001"); + when(row.getString(3)).thenReturn(pointsColumn); + when(row.getString(4)).thenReturn(limitColumn); + when(row.getInt(5)).thenReturn(cost); + return row; + } + + private static VotingPluginMain sharedMysqlPlugin(MySQL table) { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(false); + when(plugin.getMysql()).thenReturn(table); + return plugin; + } + + private static VotingPluginUser purchaseUser() { + VotingPluginUser user = mock(VotingPluginUser.class); + when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + when(user.getPointsPath()).thenReturn("Points"); + return user; + } + + @Test + void wrappersForSamePlayerShareLockAcrossServiceReloads() { + VoteShopPurchaseService firstService = new VoteShopPurchaseService(null, null); + VoteShopPurchaseService reloadedService = new VoteShopPurchaseService(null, null); + String uuid = "00000000-0000-0000-0000-000000000001"; + assertSame(firstService.purchaseLock(uuid), reloadedService.purchaseLock(uuid)); + } + + @Test + void debitUsesAsynchronousPersistence() { + VoteShopPurchaseService service = new VoteShopPurchaseService(null, null); + VotingPluginUser user = mock(VotingPluginUser.class); + VoteShopItem item = mock(VoteShopItem.class); + when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + when(item.getCost()).thenReturn(10); + when(item.getLimit()).thenReturn(0); + when(user.removePoints(10, true)).thenReturn(true); + + assertEquals(VoteShopPurchaseResult.SUCCESS, service.debitForPurchase(user, item)); + verify(user).removePoints(10, true); + } + + @Test + void concurrentWrappersCannotDebitSamePlayerTogether() throws Exception { + VoteShopPurchaseService first = new VoteShopPurchaseService(null, null); + VoteShopPurchaseService second = new VoteShopPurchaseService(null, null); + VotingPluginUser user = mock(VotingPluginUser.class); + VoteShopItem item = mock(VoteShopItem.class); + when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + when(item.getCost()).thenReturn(10); + when(item.getLimit()).thenReturn(0); + CountDownLatch firstInsideDebit = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + AtomicInteger calls = new AtomicInteger(); + when(user.removePoints(10, true)).thenAnswer(invocation -> { + if (calls.incrementAndGet() == 1) { + firstInsideDebit.countDown(); + releaseFirst.await(5, TimeUnit.SECONDS); + return true; + } + return false; + }); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future one = executor.submit(() -> first.debitForPurchase(user, item)); + assertEquals(true, firstInsideDebit.await(5, TimeUnit.SECONDS)); + AtomicReference secondThread = new AtomicReference<>(); + Future two = executor.submit(() -> { + secondThread.set(Thread.currentThread()); + return second.debitForPurchase(user, item); + }); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (System.nanoTime() < deadline + && (secondThread.get() == null || secondThread.get().getState() != Thread.State.BLOCKED) + && !two.isDone()) { + Thread.onSpinWait(); + } + assertTrue(secondThread.get() != null && secondThread.get().getState() == Thread.State.BLOCKED, + "the second debit did not block on the shared purchase lock"); + assertEquals(1, calls.get()); + releaseFirst.countDown(); + assertEquals(VoteShopPurchaseResult.SUCCESS, one.get(5, TimeUnit.SECONDS)); + assertEquals(VoteShopPurchaseResult.NOT_ENOUGH_POINTS, two.get(5, TimeUnit.SECONDS)); + verify(user, times(2)).removePoints(10, true); + } finally { + releaseFirst.countDown(); + executor.shutdownNow(); + } + } +} diff --git a/docs/control-agent-contract.md b/docs/control-agent-contract.md index 37a7f080fc..60468f37cb 100644 --- a/docs/control-agent-contract.md +++ b/docs/control-agent-contract.md @@ -3,7 +3,8 @@ This is the compact source of truth for an AI agent or Control client implementing the Bukkit integration. The connector has two separate lanes: -- configuration operations use the negotiated `config.files.v1` / `config.quick-setup.v1` contract and may write only +- configuration operations use the negotiated `config.files.v1` / `config.quick-setup.v1` contracts; Vote Party + proposals that include `enabled` additionally require `config.quick-setup.v2`, and configuration operations may write only managed VotingPlugin YAML after preview and approval; - inspections use the optional `data.inspect.v1` contract and are always read-only. diff --git a/docs/control-connector.md b/docs/control-connector.md index 7dcdb27c24..ae40c78b30 100644 --- a/docs/control-connector.md +++ b/docs/control-connector.md @@ -189,7 +189,8 @@ The Bukkit connector owns separate single-thread daemon executors for presence/c inspections, and performs no Control I/O on the server thread. The inspection worker is cancelled on shutdown with a bounded five-second wait, so a slow database read does not hold the configuration lane or shutdown indefinitely. The connector reports a bounded list of installed plugin names for WebUI command suggestions and negotiates -`config.files.v1`, `config.quick-setup.v1`, and the separate read-only `data.inspect.v1` capability. It polls configuration +`config.files.v1`, `config.quick-setup.v1`, the Vote Party Enabled extension `config.quick-setup.v2`, and the separate +read-only `data.inspect.v1` capability. It polls configuration operations and inspections over distinct outbound queues. Repeated inspection transport or protocol failures use bounded exponential backoff from one second to five minutes, while the configuration and voting paths remain available. File apply schedules the VotingPlugin reload on the Bukkit thread and waits only on the connector worker. Control failure never blocks votes,