From 940f563b2e3184aa9a4155984b9301ac7cb31898 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:24:15 -0600 Subject: [PATCH 01/61] Make period changes resumable across shutdown --- .../votingplugin/data/ServerData.java | 150 +++++++- .../specialrewards/voteparty/VoteParty.java | 40 +- .../timequeue/TimeQueueHandler.java | 30 +- .../topvoter/TopVoterHandler.java | 362 +++++++++++++++++- .../votingplugin/tests/VotePartyTest.java | 18 + .../ServerDataTimeChangeRecoveryTest.java | 96 +++++ .../TimeQueueHandlerRejectionTest.java | 29 ++ .../TopVoterTimeChangeRecoveryTest.java | 40 ++ 8 files changed, 752 insertions(+), 13 deletions(-) create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index a255b8d581..97fd514db9 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -10,15 +10,24 @@ import org.bukkit.Bukkit; import org.bukkit.Location; -import org.bukkit.configuration.ConfigurationSection; - +import org.bukkit.configuration.ConfigurationSection; + +import com.bencodez.advancedcore.api.time.TimeChangeTransition; +import com.bencodez.advancedcore.api.time.TimeType; import com.bencodez.simpleapi.array.ArrayUtils; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.signs.SignHandler; import com.bencodez.votingplugin.timequeue.VoteTimeQueue; import com.bencodez.votingplugin.topvoter.TopVoter; -public class ServerData { +public class ServerData { + public record TimeChangeUserProgress(String uuid, int streakTarget, boolean rewardRequired, + boolean rewardComplete) { } + + private static final String TIME_CHANGE_RECOVERY = "TimeChangeRecovery"; + private static final List TIME_CHANGE_PHASES = List.of("START", "SNAPSHOT", "COPY_TOTALS", + "USER_UPDATES", "TOP_REWARDS", "VOTE_SHOP", "BUNGEE_WAIT", "TOTALS_RESET", "CACHE_CLEAR", + "POST_DATE", "COMPLETE"); private VotingPluginMain plugin = VotingPluginMain.plugin; @@ -387,9 +396,138 @@ public void removeSign(String sign) { /** * Save data. */ - public synchronized void saveData() { - plugin.getServerDataFile().saveData(); - } + public synchronized void saveData() { + plugin.getServerDataFile().saveData(); + } + + /** + * Starts or resumes the compact local checkpoint for a durable core time + * transition. Only the current transition for each time type is retained; + * per-recipient reward receipts are bounded by the top-voter recipient list. + * + * @param transition the core-owned durable transition + */ + public synchronized void beginTimeChangeRecovery(TimeChangeTransition transition) { + String path = timeChangeRecoveryPath(transition.getType()); + if (transition.getId().equals(getData().getString(path + ".Id", ""))) return; + getData().set(path, null); + getData().set(path + ".Id", transition.getId()); + getData().set(path + ".Period", transition.getPeriodKey()); + getData().set(path + ".Phase", "START"); + getData().set(path + ".Cursor", ""); + saveData(); + } + + /** Returns whether the named phase has been durably completed. */ + public synchronized boolean hasTimeChangePhase(TimeChangeTransition transition, String phase) { + String path = timeChangeRecoveryPath(transition.getType()); + if (!transition.getId().equals(getData().getString(path + ".Id", ""))) return false; + int requested = TIME_CHANGE_PHASES.indexOf(phase); + int completed = TIME_CHANGE_PHASES.indexOf(getData().getString(path + ".Phase", "START")); + return requested >= 0 && completed >= requested; + } + + /** Records the next completed recovery phase synchronously. */ + public synchronized void completeTimeChangePhase(TimeChangeTransition transition, String phase) { + String path = timeChangeRecoveryPath(transition.getType()); + if (!transition.getId().equals(getData().getString(path + ".Id", ""))) { + throw new IllegalStateException("Time change recovery transition does not match"); + } + getData().set(path + ".Phase", phase); + saveData(); + } + + /** Returns the last durably completed UUID in sorted user processing. */ + public synchronized String getTimeChangeCursor(TimeChangeTransition transition) { + String path = timeChangeRecoveryPath(transition.getType()); + if (!transition.getId().equals(getData().getString(path + ".Id", ""))) return ""; + return getData().getString(path + ".Cursor", ""); + } + + /** Advances the compact sorted-user cursor after that user's work is done. */ + public synchronized void completeTimeChangeUser(TimeChangeTransition transition, String uuid) { + String path = timeChangeRecoveryPath(transition.getType()); + if (!transition.getId().equals(getData().getString(path + ".Id", ""))) { + throw new IllegalStateException("Time change recovery transition does not match"); + } + getData().set(path + ".Cursor", uuid); + getData().set(path + ".CurrentUser", null); + saveData(); + } + + /** + * Persists the absolute streak target before changing a user. Only one user is + * in flight because the period walk is ordered and synchronous. + */ + public synchronized TimeChangeUserProgress prepareTimeChangeUserStreak(TimeChangeTransition transition, + String uuid, int streakTarget, boolean rewardRequired) { + String path = timeChangeRecoveryPath(transition.getType()); + if (!transition.getId().equals(getData().getString(path + ".Id", ""))) { + throw new IllegalStateException("Time change recovery transition does not match"); + } + String userPath = path + ".CurrentUser"; + if (!uuid.equals(getData().getString(userPath + ".Uuid", ""))) { + getData().set(userPath, null); + getData().set(userPath + ".Uuid", uuid); + getData().set(userPath + ".StreakTarget", streakTarget); + getData().set(userPath + ".RewardRequired", rewardRequired); + getData().set(userPath + ".RewardComplete", false); + saveData(); + } + return new TimeChangeUserProgress(uuid, getData().getInt(userPath + ".StreakTarget"), + getData().getBoolean(userPath + ".RewardRequired", false), + getData().getBoolean(userPath + ".RewardComplete", false)); + } + + /** Marks the in-flight user's streak reward call as returned successfully. */ + public synchronized void completeTimeChangeUserStreakReward(TimeChangeTransition transition, String uuid) { + String path = timeChangeRecoveryPath(transition.getType()); + String userPath = path + ".CurrentUser"; + if (!transition.getId().equals(getData().getString(path + ".Id", "")) + || !uuid.equals(getData().getString(userPath + ".Uuid", ""))) { + throw new IllegalStateException("Time change recovery user does not match"); + } + getData().set(userPath + ".RewardComplete", true); + saveData(); + } + + /** Checks the durable receipt for one top-voter reward recipient. */ + public synchronized boolean hasTimeChangeRewardReceipt(TimeChangeTransition transition, String uuid) { + String path = timeChangeRecoveryPath(transition.getType()); + return transition.getId().equals(getData().getString(path + ".Id", "")) + && getData().getBoolean(path + ".Rewards." + uuid, false); + } + + /** Persists a recipient receipt only after the existing reward API returns. */ + public synchronized void completeTimeChangeReward(TimeChangeTransition transition, String uuid) { + String path = timeChangeRecoveryPath(transition.getType()); + if (!transition.getId().equals(getData().getString(path + ".Id", ""))) { + throw new IllegalStateException("Time change recovery transition does not match"); + } + getData().set(path + ".Rewards." + uuid, true); + saveData(); + } + + /** Records idempotent non-reward listener effects such as VoteParty resets. */ + public synchronized boolean hasTimeChangeEffect(TimeChangeTransition transition, String effect) { + String path = timeChangeRecoveryPath(transition.getType()); + return transition.getId().equals(getData().getString(path + ".Id", "")) + && getData().getBoolean(path + ".Effects." + effect, false); + } + + /** Marks a completed non-reward listener effect. */ + public synchronized void completeTimeChangeEffect(TimeChangeTransition transition, String effect) { + String path = timeChangeRecoveryPath(transition.getType()); + if (!transition.getId().equals(getData().getString(path + ".Id", ""))) { + throw new IllegalStateException("Time change recovery transition does not match"); + } + getData().set(path + ".Effects." + effect, true); + saveData(); + } + + private String timeChangeRecoveryPath(TimeType type) { + return TIME_CHANGE_RECOVERY + "." + type.name(); + } /** * Saves the disabled reminders list. diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java index 5295042f99..568e428654 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java @@ -13,6 +13,7 @@ import com.bencodez.advancedcore.api.messages.PlaceholderUtils; import com.bencodez.advancedcore.api.misc.MiscUtils; +import com.bencodez.advancedcore.api.time.TimeChangeTransition; import com.bencodez.advancedcore.api.time.events.DayChangeEvent; import com.bencodez.advancedcore.api.time.events.MonthChangeEvent; import com.bencodez.advancedcore.api.time.events.WeekChangeEvent; @@ -253,7 +254,7 @@ public void giveRewards(VotingPluginUser orgUser, boolean forceBungee) { @EventHandler public void onDayChange(DayChangeEvent event) { if (plugin.getSpecialRewardsConfig().isVotePartyResetEachDay()) { - reset(true); + runRecoverableReset(event.getTransition(), "VotePartyDayReset", () -> reset(true)); } } @@ -265,11 +266,12 @@ public void onDayChange(DayChangeEvent event) { @EventHandler public void onMonthChange(MonthChangeEvent event) { if (plugin.getSpecialRewardsConfig().isVotePartyResetMonthly()) { - reset(true); + runRecoverableReset(event.getTransition(), "VotePartyMonthReset", () -> reset(true)); } if (plugin.getSpecialRewardsConfig().isVotePartyResetExtraVotesMonthly()) { - plugin.getServerData().setVotePartyExtraRequired(0); + runRecoverableReset(event.getTransition(), "VotePartyMonthExtraVotes", + () -> plugin.getServerData().setVotePartyExtraRequired(0)); } } @@ -281,11 +283,39 @@ public void onMonthChange(MonthChangeEvent event) { @EventHandler public void onWeekChange(WeekChangeEvent event) { if (plugin.getSpecialRewardsConfig().isVotePartyResetWeekly()) { - reset(true); + runRecoverableReset(event.getTransition(), "VotePartyWeekReset", () -> reset(true)); } if (plugin.getSpecialRewardsConfig().isVotePartyResetExtraVotesWeekly()) { - plugin.getServerData().setVotePartyExtraRequired(0); + runRecoverableReset(event.getTransition(), "VotePartyWeekExtraVotes", + () -> plugin.getServerData().setVotePartyExtraRequired(0)); + } + } + + private void runRecoverableReset(TimeChangeTransition transition, String effect, Runnable reset) { + if (transition == null) { + reset.run(); + return; + } + TimeChangeTransition.Lease lease = transition.retain(); + try { + if (transition.isCancellationRequested()) { + throw new java.util.concurrent.CancellationException("Time transition was cancelled"); + } + plugin.getServerData().beginTimeChangeRecovery(transition); + if (!plugin.getServerData().hasTimeChangeEffect(transition, effect)) { + reset.run(); + plugin.getServerData().completeTimeChangeEffect(transition, effect); + } + if (transition.isCancellationRequested()) { + throw new java.util.concurrent.CancellationException("Time transition was cancelled"); + } + lease.complete(); + } catch (Throwable failure) { + lease.fail(failure); + plugin.getLogger().warning("VoteParty time-change effect remains pending: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java index 1a82995ff7..22efdd7432 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java @@ -3,6 +3,7 @@ import java.time.LocalDateTime; import java.time.ZoneId; import java.util.Queue; +import java.util.concurrent.CancellationException; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -13,6 +14,7 @@ import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import com.bencodez.advancedcore.api.time.TimeChangeTransition; import com.bencodez.advancedcore.api.time.events.DateChangedEvent; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.events.PlayerVoteEvent; @@ -72,7 +74,23 @@ public void load() { */ @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void postTimeChange(DateChangedEvent event) { - scheduleQueueProcessing(5, TimeUnit.SECONDS); + TimeChangeTransition transition = event == null ? null : event.getTransition(); + if (transition == null) { + scheduleQueueProcessing(5, TimeUnit.SECONDS); + return; + } + TimeChangeTransition.Lease lease = transition.retain(); + try { + ensureTransitionActive(transition); + scheduleQueueProcessing(5, TimeUnit.SECONDS); + ensureTransitionActive(transition); + lease.complete(); + } catch (Throwable failure) { + lease.fail(failure); + plugin.getLogger().warning("Time-queue post-period scheduling remains pending: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + } } private void scheduleQueueProcessing(long delay, TimeUnit unit) { @@ -92,6 +110,10 @@ private void scheduleQueueProcessing(long delay, TimeUnit unit) { private void scheduleRetry() { if (timeChangeQueue.isEmpty() || !retryPending.compareAndSet(false, true)) return; + if (!plugin.isEnabled()) { + retryPending.set(false); + return; + } int attempt = Math.min(6, retryAttempts.getAndIncrement()); long delaySeconds = Math.min(60L, 1L << attempt); long delayTicks = delaySeconds * TICKS_PER_SECOND; @@ -106,6 +128,12 @@ private void scheduleRetry() { } } + private void ensureTransitionActive(TimeChangeTransition transition) { + if (!plugin.isEnabled() || transition.isCancellationRequested()) { + throw new CancellationException("Time transition was cancelled before time-queue scheduling completed"); + } + } + /** * Processes all votes in the queue. */ 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 36dfdc77d7..ca840270c7 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -14,8 +14,10 @@ import java.util.Map.Entry; import java.util.Set; import java.util.UUID; +import java.util.concurrent.CancellationException; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; @@ -23,6 +25,7 @@ import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import com.bencodez.advancedcore.api.time.TimeChangeTransition; import com.bencodez.advancedcore.api.time.TimeType; import com.bencodez.advancedcore.api.time.events.DateChangedEvent; import com.bencodez.advancedcore.api.time.events.DayChangeEvent; @@ -37,6 +40,7 @@ import com.bencodez.simpleapi.sql.Column; import com.bencodez.simpleapi.sql.DataType; import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.data.ServerData.TimeChangeUserProgress; import com.bencodez.votingplugin.user.VotingPluginUser; import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService; @@ -44,9 +48,20 @@ * Handles top voter rankings and statistics. */ public class TopVoterHandler implements Listener { + private static final String SNAPSHOT = "SNAPSHOT"; + private static final String COPY_TOTALS = "COPY_TOTALS"; + private static final String USER_UPDATES = "USER_UPDATES"; + private static final String TOP_REWARDS = "TOP_REWARDS"; + private static final String VOTE_SHOP = "VOTE_SHOP"; + private static final String BUNGEE_WAIT = "BUNGEE_WAIT"; + private static final String TOTALS_RESET = "TOTALS_RESET"; + private static final String CACHE_CLEAR = "CACHE_CLEAR"; + private static final String POST_DATE = "POST_DATE"; + private static final String COMPLETE = "COMPLETE"; private VotingPluginMain plugin; private final TopVoterLoader loader; + private final HashMap retainedTransitions = new HashMap<>(); /** * Constructs a new top voter handler. @@ -152,6 +167,10 @@ public void loadPreviousMonthTopVoters() { */ @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) public void onDateChanged(DateChangedEvent event) { + if (event.getTransition() != null) { + finishRecoverableDateChange(event); + return; + } plugin.setUpdate(true); plugin.update(); if (event.getTimeType().equals(TimeType.MONTH)) { @@ -168,6 +187,10 @@ public void onDateChanged(DateChangedEvent event) { */ @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onDayChange(DayChangeEvent event) { + if (event.getTransition() != null) { + processRecoverableChange(TopVoter.Daily, event.getTransition()); + return; + } synchronized (VotingPluginMain.plugin) { long startTime = System.currentTimeMillis(); if (plugin.getConfigFile().isStoreTopVotersDaily()) { @@ -269,6 +292,10 @@ public void onDayChange(DayChangeEvent event) { */ @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onMonthChange(MonthChangeEvent event) { + if (event.getTransition() != null) { + processRecoverableChange(TopVoter.Monthly, event.getTransition()); + return; + } long startTime = System.currentTimeMillis(); synchronized (VotingPluginMain.plugin) { plugin.getLogger().info("Saving TopVoters Monthly"); @@ -391,7 +418,27 @@ public void onMonthChange(MonthChangeEvent event) { */ @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) public void onPreDateChanged(PreDateChangedEvent event) { - if (event.getTimeType().equals(TimeType.DAY)) { + TimeChangeTransition transition = event.getTransition(); + if (transition != null) { + TimeChangeTransition.Lease lease = transition.retain(); + try { + ensureTransitionActive(transition); + applyPreDateChange(event.getTimeType()); + ensureTransitionActive(transition); + lease.complete(); + } catch (Throwable failure) { + lease.fail(failure); + plugin.getLogger().warning("Pre time-change work remains pending: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + } + return; + } + applyPreDateChange(event.getTimeType()); + } + + private void applyPreDateChange(TimeType type) { + if (type.equals(TimeType.DAY)) { plugin.getBannedPlayers().clear(); for (OfflinePlayer p : Bukkit.getBannedPlayers()) { plugin.getBannedPlayers().add(p.getUniqueId().toString()); @@ -408,6 +455,10 @@ public void onPreDateChanged(PreDateChangedEvent event) { */ @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onWeekChange(WeekChangeEvent event) { + if (event.getTransition() != null) { + processRecoverableChange(TopVoter.Weekly, event.getTransition()); + return; + } long startTime = System.currentTimeMillis(); synchronized (VotingPluginMain.plugin) { if (plugin.getConfigFile().isStoreTopVotersWeekly()) { @@ -510,6 +561,315 @@ public void onWeekChange(WeekChangeEvent event) { } } + /** + * Performs the existing period work with a durable cursor. The transition + * lease is deliberately retained until DateChangedEvent has applied its post + * effects, because AdvancedCore completes its marker only after that lease. + */ + private void processRecoverableChange(TopVoter top, TimeChangeTransition transition) { + TimeChangeTransition.Lease lease = transition.retain(); + try { + synchronized (VotingPluginMain.plugin) { + plugin.getServerData().beginTimeChangeRecovery(transition); + if (!plugin.getServerData().hasTimeChangePhase(transition, COMPLETE)) { + runRecoverablePeriod(top, transition); + } + ensureTransitionActive(transition); + synchronized (retainedTransitions) { + retainedTransitions.put(transition.getId(), lease); + } + } + } catch (Throwable failure) { + lease.fail(failure); + plugin.getLogger().warning("Time change recovery for " + top + " remains pending: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + } + } + + private void runRecoverablePeriod(TopVoter top, TimeChangeTransition transition) { + if (!plugin.getServerData().hasTimeChangePhase(transition, SNAPSHOT)) { + ensureTransitionActive(transition); + if ((top == TopVoter.Daily && plugin.getConfigFile().isStoreTopVotersDaily()) + || (top == TopVoter.Weekly && plugin.getConfigFile().isStoreTopVotersWeekly()) + || top == TopVoter.Monthly) { + plugin.getLogger().info("Saving TopVoters " + top); + storeTopVoters(top); + } + plugin.getServerData().completeTimeChangePhase(transition, SNAPSHOT); + } + + if (!plugin.getServerData().hasTimeChangePhase(transition, COPY_TOTALS)) { + ensureTransitionActive(transition); + if (top != TopVoter.Monthly || !bungeeHandleResets()) { + plugin.getUserManager().copyColumnData(top.getColumnName(), top.getLastColumnName()); + } + plugin.getServerData().completeTimeChangePhase(transition, COPY_TOTALS); + } + + if (!plugin.getServerData().hasTimeChangePhase(transition, USER_UPDATES)) { + processRecoverableUsers(top, transition); + plugin.getServerData().completeTimeChangePhase(transition, USER_UPDATES); + } + + if (!plugin.getServerData().hasTimeChangePhase(transition, TOP_REWARDS)) { + processRecoverableTopRewards(top, transition); + plugin.getServerData().completeTimeChangePhase(transition, TOP_REWARDS); + } + + if (!plugin.getServerData().hasTimeChangePhase(transition, VOTE_SHOP)) { + ensureTransitionActive(transition); + for (String shopIdent : plugin.getShopFile().getShopIdentifiers()) { + if (shouldResetVoteShop(top, shopIdent)) { + resetVoteShopLimit(shopIdent, + VoteShopPurchaseService.currentLimitGenerationId(plugin, shopIdent)); + } + } + plugin.getServerData().completeTimeChangePhase(transition, VOTE_SHOP); + } + + if (!plugin.getServerData().hasTimeChangePhase(transition, BUNGEE_WAIT)) { + waitForBungee(top, transition); + plugin.getServerData().completeTimeChangePhase(transition, BUNGEE_WAIT); + } + + if (!plugin.getServerData().hasTimeChangePhase(transition, TOTALS_RESET)) { + ensureTransitionActive(transition); + if (!bungeeHandleResets()) resetTotals(top); + plugin.getServerData().completeTimeChangePhase(transition, TOTALS_RESET); + } + + if (!plugin.getServerData().hasTimeChangePhase(transition, CACHE_CLEAR)) { + ensureTransitionActive(transition); + if (plugin.getStorageType().equals(UserStorage.MYSQL)) plugin.getMysql().clearCacheBasic(); + plugin.getServerData().completeTimeChangePhase(transition, CACHE_CLEAR); + } + } + + private void processRecoverableUsers(TopVoter top, TimeChangeTransition transition) { + if (!plugin.getConfigFile().isUseVoteStreaks() && !plugin.getConfigFile().isUseHighestTotals()) return; + AtomicReference cursor = new AtomicReference<>(plugin.getServerData().getTimeChangeCursor(transition)); + LocalDateTime lastMonthTime = top == TopVoter.Monthly ? previousMonthTime(transition) : null; + plugin.getUserManager().forEachUserKeys((uuid, columns) -> { + String value = uuid.toString(); + if (value.compareTo(cursor.get()) <= 0) return; + ensureTransitionActive(transition); + VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, false); + user.userDataFetechMode(UserDataFetchMode.TEMP_ONLY); + user.updateTempCacheWithColumns(columns); + try { + if (top == TopVoter.Daily) processDailyUser(user, transition, value); + else if (top == TopVoter.Weekly) processWeeklyUser(user, transition, value); + else processMonthlyUser(user, lastMonthTime, transition, value); + } finally { + user.clearTempCache(); + } + plugin.getServerData().completeTimeChangeUser(transition, value); + cursor.set(value); + }, count -> { }); + } + + private void processDailyUser(VotingPluginUser user, TimeChangeTransition transition, String uuid) { + if (plugin.getConfigFile().isUseVoteStreaks() + && !user.voteStreakUpdatedToday(LocalDateTime.now().minusDays(1)) && user.getDayVoteStreak() != 0) { + applyRecoverableStreak(user, transition, uuid, TopVoter.Daily, 0, false); + } + if (plugin.getConfigFile().isUseHighestTotals() + && user.getHighestDailyTotal() < user.getTotal(TopVoter.Daily)) { + user.setHighestDailyTotal(user.getTotal(TopVoter.Daily)); + } + } + + private void processWeeklyUser(VotingPluginUser user, TimeChangeTransition transition, String uuid) { + if (plugin.getConfigFile().isUseVoteStreaks()) { + if (user.getTotal(TopVoter.Weekly) == 0 && user.getWeekVoteStreak() != 0) { + applyRecoverableStreak(user, transition, uuid, TopVoter.Weekly, 0, false); + } else if (!plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage() + || user.hasPercentageTotal(TopVoter.Weekly, + plugin.getSpecialRewardsConfig().getVoteStreakRequirementWeek(), null)) { + applyRecoverableStreak(user, transition, uuid, TopVoter.Weekly, + user.getWeekVoteStreak() + 1, true); + } + } + if (plugin.getConfigFile().isUseHighestTotals() + && user.getHighestWeeklyTotal() < user.getTotal(TopVoter.Weekly)) { + user.setHighestWeeklyTotal(user.getTotal(TopVoter.Weekly)); + } + } + + private void processMonthlyUser(VotingPluginUser user, LocalDateTime lastMonthTime, + TimeChangeTransition transition, String uuid) { + if (plugin.getConfigFile().isUseVoteStreaks()) { + if (user.getTotal(TopVoter.Monthly, lastMonthTime) == 0 && user.getMonthVoteStreak() != 0) { + applyRecoverableStreak(user, transition, uuid, TopVoter.Monthly, 0, false); + } else if (!plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage() + || user.hasPercentageTotal(TopVoter.Monthly, + plugin.getSpecialRewardsConfig().getVoteStreakRequirementMonth(), lastMonthTime)) { + applyRecoverableStreak(user, transition, uuid, TopVoter.Monthly, + user.getMonthVoteStreak() + 1, true); + } + } + if (plugin.getConfigFile().isUseHighestTotals() + && user.getHighestMonthlyTotal() < user.getTotal(TopVoter.Monthly, lastMonthTime)) { + user.setHighestMonthlyTotal(user.getTotal(TopVoter.Monthly, lastMonthTime)); + } + } + + void applyRecoverableStreak(VotingPluginUser user, TimeChangeTransition transition, String uuid, + TopVoter top, int proposedTarget, boolean rewardRequired) { + TimeChangeUserProgress progress = plugin.getServerData().prepareTimeChangeUserStreak(transition, uuid, + proposedTarget, rewardRequired); + int current = switch (top) { + case Daily -> user.getDayVoteStreak(); + case Weekly -> user.getWeekVoteStreak(); + case Monthly -> user.getMonthVoteStreak(); + default -> proposedTarget; + }; + if (current != progress.streakTarget()) { + switch (top) { + case Daily -> user.setDayVoteStreak(progress.streakTarget()); + case Weekly -> user.setWeekVoteStreak(progress.streakTarget()); + case Monthly -> user.setMonthVoteStreak(progress.streakTarget()); + default -> { } + } + } + if (progress.rewardRequired() && !progress.rewardComplete()) { + plugin.getSpecialRewards().checkVoteStreak(null, user, + top == TopVoter.Weekly ? "Week" : "Month", plugin.getBungeeSettings().isUseBungeecoord()); + plugin.getServerData().completeTimeChangeUserStreakReward(transition, uuid); + } + } + + private void processRecoverableTopRewards(TopVoter top, TimeChangeTransition transition) { + if (!isTopRewardEnabled(top)) return; + HashMap places = handlePlaces(getPossibleRewardPlaces(top)); + int place = 0; + int lastTotal = -1; + for (Entry entry : topVotersFor(top, transition)) { + ensureTransitionActive(transition); + if (plugin.getConfigFile().isTopVoterAwardsTies()) { + if (entry.getValue().intValue() != lastTotal) place++; + } else place++; + if (places.containsKey(place) && !plugin.getServerData().hasTimeChangeRewardReceipt(transition, + entry.getKey().getUuid().toString())) { + VotingPluginUser user = entry.getKey().getUser(); + user.userDataFetechMode(UserDataFetchMode.NO_CACHE); + if (!plugin.getConfigFile().isTopVoterIgnorePermission() || !user.isTopVoterIgnore()) { + giveTopVoterAward(top, user, place, places.get(place)); + plugin.getServerData().completeTimeChangeReward(transition, entry.getKey().getUuid().toString()); + plugin.getLogger().info("Giving " + top + " top voter reward " + place + " to " + + entry.getKey().getPlayerName()); + } + } + lastTotal = entry.getValue().intValue(); + } + } + + private Iterable> topVotersFor(TopVoter top, + TimeChangeTransition transition) { + if (top == TopVoter.Monthly && plugin.getConfigFile().isUseMonthDateTotalsAsPrimaryTotal()) { + return getMonthlyTopVotersAtTime(previousMonthTime(transition)).entrySet(); + } + @SuppressWarnings("unchecked") + LinkedHashMap clone = (LinkedHashMap) plugin + .getTopVoter(top).clone(); + return clone.entrySet(); + } + + private LocalDateTime previousMonthTime(TimeChangeTransition transition) { + try { + return YearMonth.parse(transition.getPeriodKey()).minusMonths(1).atDay(15).atStartOfDay(); + } catch (RuntimeException invalidPeriod) { + plugin.debug(invalidPeriod); + return plugin.getTimeChecker().getTime().minusMonths(1); + } + } + + private boolean isTopRewardEnabled(TopVoter top) { + return switch (top) { + case Daily -> plugin.getSpecialRewardsConfig().isEnableDailyRewards(); + case Weekly -> plugin.getSpecialRewardsConfig().isEnableWeeklyAwards(); + case Monthly -> plugin.getSpecialRewardsConfig().isEnableMonthlyAwards(); + default -> false; + }; + } + + private Set getPossibleRewardPlaces(TopVoter top) { + return switch (top) { + case Daily -> plugin.getSpecialRewardsConfig().getDailyPossibleRewardPlaces(); + case Weekly -> plugin.getSpecialRewardsConfig().getWeeklyPossibleRewardPlaces(); + case Monthly -> plugin.getSpecialRewardsConfig().getMonthlyPossibleRewardPlaces(); + default -> Collections.emptySet(); + }; + } + + private void giveTopVoterAward(TopVoter top, VotingPluginUser user, int place, String reward) { + switch (top) { + case Daily -> user.giveDailyTopVoterAward(place, reward); + case Weekly -> user.giveWeeklyTopVoterAward(place, reward); + case Monthly -> user.giveMonthlyTopVoterAward(place, reward); + default -> { } + } + } + + private boolean shouldResetVoteShop(TopVoter top, String shopIdent) { + return switch (top) { + case Daily -> plugin.getShopFile().getVoteShopResetDaily(shopIdent); + case Weekly -> plugin.getShopFile().getVoteShopResetWeekly(shopIdent); + case Monthly -> plugin.getShopFile().getVoteShopResetMonthly(shopIdent); + default -> false; + }; + } + + private void waitForBungee(TopVoter top, TimeChangeTransition transition) { + boolean wait = plugin.getBungeeSettings().isUseBungeecoord() && !bungeeHandleResets(); + if (!wait) return; + ensureTransitionActive(transition); + plugin.debug("Delaying time change 10 seconds for other servers to catchup"); + try { + Thread.sleep(10000); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new CancellationException("Time change sleep was interrupted"); + } + ensureTransitionActive(transition); + } + + private void finishRecoverableDateChange(DateChangedEvent event) { + TimeChangeTransition transition = event.getTransition(); + TimeChangeTransition.Lease lease; + synchronized (retainedTransitions) { + lease = retainedTransitions.remove(transition.getId()); + } + if (lease == null) { + return; + } + try { + ensureTransitionActive(transition); + if (!plugin.getServerData().hasTimeChangePhase(transition, POST_DATE)) { + plugin.setUpdate(true); + plugin.update(); + if (transition.getType().equals(TimeType.MONTH)) loadLastMonth(); + if (plugin.getStorageType().equals(UserStorage.MYSQL)) plugin.getMysql().clearCacheBasic(); + plugin.getServerData().completeTimeChangePhase(transition, POST_DATE); + } + ensureTransitionActive(transition); + plugin.getServerData().completeTimeChangePhase(transition, COMPLETE); + lease.complete(); + } catch (Throwable failure) { + lease.fail(failure); + plugin.getLogger().warning("Post time-change recovery remains pending: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + } + } + + private void ensureTransitionActive(TimeChangeTransition transition) { + if (transition.isCancellationRequested()) { + throw new CancellationException("Time transition was cancelled before recovery completed"); + } + } + /** * Registers this handler as a listener. */ diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java index ebfa967a74..edb316bd36 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java @@ -26,6 +26,8 @@ import org.mockito.MockedStatic; import org.mockito.Mockito; +import com.bencodez.advancedcore.api.time.TimeChangeTransition; +import com.bencodez.advancedcore.api.time.events.DayChangeEvent; import com.bencodez.advancedcore.api.user.UserManager; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.config.Config; @@ -112,6 +114,22 @@ public void check_NotEnoughVotes_DoesNotTriggerVoteParty() { verify(voteParty, never()).giveRewards(any(), eq(false)); } + @Test + public void dayResetRecordsTransitionEffectBeforeCompletingLease() { + TimeChangeTransition transition = Mockito.mock(TimeChangeTransition.class); + TimeChangeTransition.Lease lease = Mockito.mock(TimeChangeTransition.Lease.class); + when(transition.retain()).thenReturn(lease); + when(transition.getType()).thenReturn(com.bencodez.advancedcore.api.time.TimeType.DAY); + when(transition.getId()).thenReturn("DAY:2026-09-21"); + when(plugin.getSpecialRewardsConfig().isVotePartyResetEachDay()).thenReturn(true); + + voteParty.onDayChange(new DayChangeEvent(transition)); + + verify(plugin.getServerData()).beginTimeChangeRecovery(transition); + verify(plugin.getServerData()).completeTimeChangeEffect(transition, "VotePartyDayReset"); + verify(lease).complete(); + } + @Test public void commandVoteParty_VotePartyEnabled_SendsMessage() { CommandSender sender = Mockito.mock(CommandSender.class); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java new file mode 100644 index 0000000000..3fd27a3a7b --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java @@ -0,0 +1,96 @@ +package com.bencodez.votingplugin.tests.data; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.time.TimeChangeTransition; +import com.bencodez.advancedcore.api.time.TimeType; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.data.ServerData; +import com.bencodez.votingplugin.data.ServerData.TimeChangeUserProgress; + +class ServerDataTimeChangeRecoveryTest { + @Test + void checkpointRetainsPhaseCursorAndRewardReceiptsForTheTransition() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); + YamlConfiguration yaml = new YamlConfiguration(); + when(plugin.getServerDataFile()).thenReturn(coreData); + when(coreData.getData()).thenReturn(yaml); + TimeChangeTransition transition = transition("WEEK:2026-W38", "2026-W38", TimeType.WEEK); + ServerData data = new ServerData(plugin); + + data.beginTimeChangeRecovery(transition); + assertFalse(data.hasTimeChangePhase(transition, "SNAPSHOT")); + data.completeTimeChangePhase(transition, "SNAPSHOT"); + data.completeTimeChangePhase(transition, "USER_UPDATES"); + data.completeTimeChangeUser(transition, "00000000-0000-0000-0000-000000000002"); + data.completeTimeChangeReward(transition, "00000000-0000-0000-0000-000000000001"); + + assertTrue(data.hasTimeChangePhase(transition, "SNAPSHOT")); + assertTrue(data.hasTimeChangePhase(transition, "COPY_TOTALS")); + assertTrue(data.hasTimeChangePhase(transition, "USER_UPDATES")); + assertTrue(data.getTimeChangeCursor(transition).endsWith("0002")); + assertTrue(data.hasTimeChangeRewardReceipt(transition, "00000000-0000-0000-0000-000000000001")); + verify(coreData, atLeastOnce()).saveData(); + } + + @Test + void newPeriodReplacesOnlyThatTimeTypesOldCheckpoint() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); + YamlConfiguration yaml = new YamlConfiguration(); + when(plugin.getServerDataFile()).thenReturn(coreData); + when(coreData.getData()).thenReturn(yaml); + ServerData data = new ServerData(plugin); + TimeChangeTransition oldTransition = transition("MONTH:2026-08", "2026-08", TimeType.MONTH); + TimeChangeTransition newTransition = transition("MONTH:2026-09", "2026-09", TimeType.MONTH); + + data.beginTimeChangeRecovery(oldTransition); + data.completeTimeChangeReward(oldTransition, "00000000-0000-0000-0000-000000000001"); + data.beginTimeChangeRecovery(newTransition); + + assertFalse(data.hasTimeChangeRewardReceipt(newTransition, "00000000-0000-0000-0000-000000000001")); + assertFalse(data.hasTimeChangePhase(oldTransition, "START")); + } + + @Test + void interruptedUserKeepsItsAbsoluteStreakTargetUntilCursorAdvances() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); + YamlConfiguration yaml = new YamlConfiguration(); + when(plugin.getServerDataFile()).thenReturn(coreData); + when(coreData.getData()).thenReturn(yaml); + ServerData data = new ServerData(plugin); + TimeChangeTransition transition = transition("WEEK:2026-W38", "2026-W38", TimeType.WEEK); + String uuid = "00000000-0000-0000-0000-000000000001"; + data.beginTimeChangeRecovery(transition); + + TimeChangeUserProgress first = data.prepareTimeChangeUserStreak(transition, uuid, 8, true); + TimeChangeUserProgress retry = data.prepareTimeChangeUserStreak(transition, uuid, 9, true); + + assertEquals(8, first.streakTarget()); + assertEquals(8, retry.streakTarget()); + assertFalse(retry.rewardComplete()); + data.completeTimeChangeUserStreakReward(transition, uuid); + assertTrue(data.prepareTimeChangeUserStreak(transition, uuid, 9, true).rewardComplete()); + data.completeTimeChangeUser(transition, uuid); + assertEquals(uuid, data.getTimeChangeCursor(transition)); + } + + private TimeChangeTransition transition(String id, String period, TimeType type) { + TimeChangeTransition transition = mock(TimeChangeTransition.class); + when(transition.getId()).thenReturn(id); + when(transition.getPeriodKey()).thenReturn(period); + when(transition.getType()).thenReturn(type); + return transition; + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java index 37ba7a49a9..9c3e184e46 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java @@ -23,6 +23,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import com.bencodez.advancedcore.api.time.TimeChangeTransition; import com.bencodez.advancedcore.api.time.events.DateChangedEvent; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.data.ServerData; @@ -46,6 +47,7 @@ void setUp() { when(plugin.getServerData()).thenReturn(serverData); when(plugin.getVoteTimer()).thenReturn(voteTimer); when(plugin.getLogger()).thenReturn(logger); + when(plugin.isEnabled()).thenReturn(true); when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of("0")); when(serverData.getTimedVoteCacheSection("0")).thenReturn(cachedVote); when(cachedVote.getString("Name")).thenReturn("Steve"); @@ -87,4 +89,31 @@ void rejectedProcessingSchedulesOneBoundedRetry() { org.mockito.ArgumentMatchers.eq(TimeUnit.SECONDS)); assertEquals(1, handler.getTimeChangeQueue().size()); } + + @Test + void disabledPluginDoesNotRegisterABukkitRetry() { + when(plugin.isEnabled()).thenReturn(false); + + new TimeQueueHandler(plugin); + + verify(plugin.getBukkitScheduler(), never()).runTaskLaterAsynchronously( + org.mockito.ArgumentMatchers.eq(plugin), any(Runnable.class), anyLong()); + } + + @Test + void cancelledDurableTransitionFailsItsLeaseWithoutScheduling() { + when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of()); + TimeQueueHandler handler = new TimeQueueHandler(plugin); + reset(voteTimer); + TimeChangeTransition transition = mock(TimeChangeTransition.class); + TimeChangeTransition.Lease lease = mock(TimeChangeTransition.Lease.class); + when(transition.retain()).thenReturn(lease); + when(transition.isCancellationRequested()).thenReturn(true); + + handler.postTimeChange(new DateChangedEvent(com.bencodez.advancedcore.api.time.TimeType.DAY, transition)); + + verify(voteTimer, never()).schedule(any(Runnable.class), anyLong(), any(TimeUnit.class)); + verify(lease).fail(any(java.util.concurrent.CancellationException.class)); + verify(lease, never()).complete(); + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java new file mode 100644 index 0000000000..3fdcb4425d --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java @@ -0,0 +1,40 @@ +package com.bencodez.votingplugin.topvoter; + +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 org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.time.TimeChangeTransition; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.data.ServerData; +import com.bencodez.votingplugin.data.ServerData.TimeChangeUserProgress; +import com.bencodez.votingplugin.specialrewards.SpecialRewards; +import com.bencodez.votingplugin.user.VotingPluginUser; + +class TopVoterTimeChangeRecoveryTest { + @Test + void retryUsesPersistedAbsoluteTargetAndDoesNotDuplicateCompletedStreakReward() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + ServerData serverData = mock(ServerData.class); + SpecialRewards specialRewards = mock(SpecialRewards.class); + VotingPluginUser user = mock(VotingPluginUser.class); + TimeChangeTransition transition = mock(TimeChangeTransition.class); + String uuid = "00000000-0000-0000-0000-000000000001"; + when(plugin.getServerData()).thenReturn(serverData); + when(plugin.getSpecialRewards()).thenReturn(specialRewards); + when(serverData.prepareTimeChangeUserStreak(transition, uuid, 9, true)) + .thenReturn(new TimeChangeUserProgress(uuid, 8, true, true)); + when(user.getWeekVoteStreak()).thenReturn(8); + + new TopVoterHandler(plugin).applyRecoverableStreak(user, transition, uuid, TopVoter.Weekly, 9, true); + + verify(user, never()).setWeekVoteStreak(org.mockito.ArgumentMatchers.anyInt()); + verify(specialRewards, never()).checkVoteStreak(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.eq(user), org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyBoolean()); + verify(serverData, never()).completeTimeChangeUserStreakReward(transition, uuid); + } +} From 7041db5081bd84130f43bd265cfd4b6b7c6ff283 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:46:22 -0600 Subject: [PATCH 02/61] Stabilize period recovery inputs --- .../votingplugin/data/ServerData.java | 77 +++++++++++++++++++ .../topvoter/TopVoterHandler.java | 49 +++++++++--- .../ServerDataTimeChangeRecoveryTest.java | 24 ++++++ .../TopVoterTimeChangeRecoveryTest.java | 41 ++++++++++ 4 files changed, 179 insertions(+), 12 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index 97fd514db9..5f6244ea62 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -23,6 +23,7 @@ public class ServerData { public record TimeChangeUserProgress(String uuid, int streakTarget, boolean rewardRequired, boolean rewardComplete) { } + public record TimeChangeRewardTarget(String uuid, String playerName, int place, String reward) { } private static final String TIME_CHANGE_RECOVERY = "TimeChangeRecovery"; private static final List TIME_CHANGE_PHASES = List.of("START", "SNAPSHOT", "COPY_TOTALS", @@ -491,6 +492,82 @@ public synchronized void completeTimeChangeUserStreakReward(TimeChangeTransition saveData(); } + /** + * Persists the ranked recipients and reward assignments selected at the period + * boundary. Retries always return this first durable selection. + */ + public synchronized List prepareTimeChangeRewardTargets( + TimeChangeTransition transition, List proposed) { + String path = timeChangeRecoveryPath(transition.getType()); + if (!transition.getId().equals(getData().getString(path + ".Id", ""))) { + throw new IllegalStateException("Time change recovery transition does not match"); + } + String targetsPath = path + ".RewardTargets"; + if (getData().getBoolean(targetsPath + ".Prepared", false)) { + return getTimeChangeRewardTargets(transition); + } + List snapshot = List.copyOf(proposed); + for (TimeChangeRewardTarget target : snapshot) { + validateRewardTarget(target); + } + getData().set(targetsPath, null); + getData().set(targetsPath + ".Count", snapshot.size()); + for (int index = 0; index < snapshot.size(); index++) { + TimeChangeRewardTarget target = snapshot.get(index); + String targetPath = targetsPath + ".Entries." + index; + getData().set(targetPath + ".Uuid", target.uuid()); + getData().set(targetPath + ".PlayerName", target.playerName()); + getData().set(targetPath + ".Place", target.place()); + getData().set(targetPath + ".Reward", target.reward()); + } + getData().set(targetsPath + ".Prepared", true); + try { + saveData(); + } catch (RuntimeException | Error failure) { + getData().set(targetsPath, null); + throw failure; + } + return snapshot; + } + + /** Returns the durable ranked reward selection for this transition. */ + public synchronized List getTimeChangeRewardTargets(TimeChangeTransition transition) { + String path = timeChangeRecoveryPath(transition.getType()); + String targetsPath = path + ".RewardTargets"; + if (!transition.getId().equals(getData().getString(path + ".Id", "")) + || !getData().getBoolean(targetsPath + ".Prepared", false)) { + return List.of(); + } + int count = getData().getInt(targetsPath + ".Count", -1); + if (count < 0) { + throw new IllegalStateException("Invalid time change reward target count"); + } + List targets = new ArrayList<>(count); + for (int index = 0; index < count; index++) { + String targetPath = targetsPath + ".Entries." + index; + TimeChangeRewardTarget target = new TimeChangeRewardTarget( + getData().getString(targetPath + ".Uuid", ""), + getData().getString(targetPath + ".PlayerName", ""), + getData().getInt(targetPath + ".Place", 0), + getData().getString(targetPath + ".Reward", "")); + validateRewardTarget(target); + targets.add(target); + } + return List.copyOf(targets); + } + + private void validateRewardTarget(TimeChangeRewardTarget target) { + if (target == null || target.playerName() == null || target.place() <= 0 + || target.reward() == null || target.reward().isEmpty()) { + throw new IllegalStateException("Invalid time change reward target"); + } + try { + UUID.fromString(target.uuid()); + } catch (RuntimeException invalidUuid) { + throw new IllegalStateException("Invalid time change reward target UUID", invalidUuid); + } + } + /** Checks the durable receipt for one top-voter reward recipient. */ public synchronized boolean hasTimeChangeRewardReceipt(TimeChangeTransition transition, String uuid) { String path = timeChangeRecoveryPath(transition.getType()); 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 ca840270c7..ee42f924d4 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -1,6 +1,7 @@ package com.bencodez.votingplugin.topvoter; import java.io.File; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.Month; import java.time.YearMonth; @@ -40,6 +41,7 @@ import com.bencodez.simpleapi.sql.Column; import com.bencodez.simpleapi.sql.DataType; import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardTarget; import com.bencodez.votingplugin.data.ServerData.TimeChangeUserProgress; import com.bencodez.votingplugin.user.VotingPluginUser; import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService; @@ -590,6 +592,8 @@ private void processRecoverableChange(TopVoter top, TimeChangeTransition transit private void runRecoverablePeriod(TopVoter top, TimeChangeTransition transition) { if (!plugin.getServerData().hasTimeChangePhase(transition, SNAPSHOT)) { ensureTransitionActive(transition); + plugin.getServerData().prepareTimeChangeRewardTargets(transition, + buildTopRewardSnapshot(top, transition)); if ((top == TopVoter.Daily && plugin.getConfigFile().isStoreTopVotersDaily()) || (top == TopVoter.Weekly && plugin.getConfigFile().isStoreTopVotersWeekly()) || top == TopVoter.Monthly) { @@ -671,7 +675,7 @@ private void processRecoverableUsers(TopVoter top, TimeChangeTransition transiti private void processDailyUser(VotingPluginUser user, TimeChangeTransition transition, String uuid) { if (plugin.getConfigFile().isUseVoteStreaks() - && !user.voteStreakUpdatedToday(LocalDateTime.now().minusDays(1)) && user.getDayVoteStreak() != 0) { + && !user.voteStreakUpdatedToday(previousDayTime(transition)) && user.getDayVoteStreak() != 0) { applyRecoverableStreak(user, transition, uuid, TopVoter.Daily, 0, false); } if (plugin.getConfigFile().isUseHighestTotals() @@ -741,8 +745,25 @@ void applyRecoverableStreak(VotingPluginUser user, TimeChangeTransition transiti } private void processRecoverableTopRewards(TopVoter top, TimeChangeTransition transition) { - if (!isTopRewardEnabled(top)) return; + for (TimeChangeRewardTarget target : plugin.getServerData().getTimeChangeRewardTargets(transition)) { + ensureTransitionActive(transition); + if (plugin.getServerData().hasTimeChangeRewardReceipt(transition, target.uuid())) continue; + VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser( + UUID.fromString(target.uuid()), target.playerName()); + user.userDataFetechMode(UserDataFetchMode.NO_CACHE); + if (!plugin.getConfigFile().isTopVoterIgnorePermission() || !user.isTopVoterIgnore()) { + giveTopVoterAward(top, user, target.place(), target.reward()); + plugin.getServerData().completeTimeChangeReward(transition, target.uuid()); + plugin.getLogger().info("Giving " + top + " top voter reward " + target.place() + " to " + + target.playerName()); + } + } + } + + List buildTopRewardSnapshot(TopVoter top, TimeChangeTransition transition) { + if (!isTopRewardEnabled(top)) return List.of(); HashMap places = handlePlaces(getPossibleRewardPlaces(top)); + List targets = new ArrayList<>(); int place = 0; int lastTotal = -1; for (Entry entry : topVotersFor(top, transition)) { @@ -750,19 +771,14 @@ private void processRecoverableTopRewards(TopVoter top, TimeChangeTransition tra if (plugin.getConfigFile().isTopVoterAwardsTies()) { if (entry.getValue().intValue() != lastTotal) place++; } else place++; - if (places.containsKey(place) && !plugin.getServerData().hasTimeChangeRewardReceipt(transition, - entry.getKey().getUuid().toString())) { - VotingPluginUser user = entry.getKey().getUser(); - user.userDataFetechMode(UserDataFetchMode.NO_CACHE); - if (!plugin.getConfigFile().isTopVoterIgnorePermission() || !user.isTopVoterIgnore()) { - giveTopVoterAward(top, user, place, places.get(place)); - plugin.getServerData().completeTimeChangeReward(transition, entry.getKey().getUuid().toString()); - plugin.getLogger().info("Giving " + top + " top voter reward " + place + " to " - + entry.getKey().getPlayerName()); - } + if (places.containsKey(place)) { + targets.add(new TimeChangeRewardTarget(entry.getKey().getUuid().toString(), + entry.getKey().getPlayerName() == null ? "" : entry.getKey().getPlayerName(), + place, places.get(place))); } lastTotal = entry.getValue().intValue(); } + return List.copyOf(targets); } private Iterable> topVotersFor(TopVoter top, @@ -785,6 +801,15 @@ private LocalDateTime previousMonthTime(TimeChangeTransition transition) { } } + LocalDateTime previousDayTime(TimeChangeTransition transition) { + try { + return LocalDate.parse(transition.getPeriodKey()).minusDays(1).atStartOfDay(); + } catch (RuntimeException invalidPeriod) { + plugin.debug(invalidPeriod); + return plugin.getTimeChecker().getTime().minusDays(1); + } + } + private boolean isTopRewardEnabled(TopVoter top) { return switch (top) { case Daily -> plugin.getSpecialRewardsConfig().isEnableDailyRewards(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java index 3fd27a3a7b..1a134aefe0 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java @@ -8,6 +8,8 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.List; + import org.bukkit.configuration.file.YamlConfiguration; import org.junit.jupiter.api.Test; @@ -15,6 +17,7 @@ import com.bencodez.advancedcore.api.time.TimeType; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.data.ServerData; +import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardTarget; import com.bencodez.votingplugin.data.ServerData.TimeChangeUserProgress; class ServerDataTimeChangeRecoveryTest { @@ -86,6 +89,27 @@ void interruptedUserKeepsItsAbsoluteStreakTargetUntilCursorAdvances() { assertEquals(uuid, data.getTimeChangeCursor(transition)); } + @Test + void retryKeepsTheFirstDurableRankedRewardSelection() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); + YamlConfiguration yaml = new YamlConfiguration(); + when(plugin.getServerDataFile()).thenReturn(coreData); + when(coreData.getData()).thenReturn(yaml); + ServerData data = new ServerData(plugin); + TimeChangeTransition transition = transition("DAY:2026-09-21", "2026-09-21", TimeType.DAY); + data.beginTimeChangeRecovery(transition); + List original = List.of( + new TimeChangeRewardTarget("00000000-0000-0000-0000-000000000001", "first", 1, "1"), + new TimeChangeRewardTarget("00000000-0000-0000-0000-000000000002", "second", 2, "2")); + List changed = List.of( + new TimeChangeRewardTarget("00000000-0000-0000-0000-000000000003", "late", 1, "1")); + + assertEquals(original, data.prepareTimeChangeRewardTargets(transition, original)); + assertEquals(original, data.prepareTimeChangeRewardTargets(transition, changed)); + assertEquals(original, new ServerData(plugin).getTimeChangeRewardTargets(transition)); + } + private TimeChangeTransition transition(String id, String period, TimeType type) { TimeChangeTransition transition = mock(TimeChangeTransition.class); when(transition.getId()).thenReturn(id); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java index 3fdcb4425d..08dfaf5b22 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java @@ -1,15 +1,23 @@ package com.bencodez.votingplugin.topvoter; +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.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Set; +import java.util.UUID; + import org.junit.jupiter.api.Test; import com.bencodez.advancedcore.api.time.TimeChangeTransition; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.data.ServerData; +import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardTarget; import com.bencodez.votingplugin.data.ServerData.TimeChangeUserProgress; import com.bencodez.votingplugin.specialrewards.SpecialRewards; import com.bencodez.votingplugin.user.VotingPluginUser; @@ -37,4 +45,37 @@ void retryUsesPersistedAbsoluteTargetAndDoesNotDuplicateCompletedStreakReward() org.mockito.ArgumentMatchers.anyBoolean()); verify(serverData, never()).completeTimeChangeUserStreakReward(transition, uuid); } + + @Test + void dailyRecoveryUsesTheDayBeforeTheStableTransitionDate() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + TimeChangeTransition transition = mock(TimeChangeTransition.class); + when(transition.getPeriodKey()).thenReturn("2026-09-21"); + + assertEquals(LocalDateTime.of(2026, 9, 20, 0, 0), + new TopVoterHandler(plugin).previousDayTime(transition)); + } + + @Test + void rewardSnapshotFixesRecipientsAndPlacesAtThePeriodBoundary() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + TimeChangeTransition transition = mock(TimeChangeTransition.class); + TopVoterPlayer first = new TopVoterPlayer( + UUID.fromString("00000000-0000-0000-0000-000000000001"), "first", 1L); + TopVoterPlayer second = new TopVoterPlayer( + UUID.fromString("00000000-0000-0000-0000-000000000002"), "second", 2L); + LinkedHashMap ranking = new LinkedHashMap<>(); + ranking.put(first, 20); + ranking.put(second, 10); + when(plugin.getSpecialRewardsConfig().isEnableDailyRewards()).thenReturn(true); + when(plugin.getSpecialRewardsConfig().getDailyPossibleRewardPlaces()).thenReturn(Set.of("1", "2")); + when(plugin.getTopVoter(TopVoter.Daily)).thenReturn(ranking); + + List targets = new TopVoterHandler(plugin) + .buildTopRewardSnapshot(TopVoter.Daily, transition); + + assertEquals(List.of( + new TimeChangeRewardTarget(first.getUuid().toString(), "first", 1, "1"), + new TimeChangeRewardTarget(second.getUuid().toString(), "second", 2, "2")), targets); + } } From ff1bab49d0185609e0093c72cc9a54ad9c7224bc Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:49:21 -0600 Subject: [PATCH 03/61] Make time-change recovery snapshots stable --- .../votingplugin/data/ServerData.java | 78 ++++++++++++++++++- .../topvoter/TopVoterHandler.java | 61 +++++++++++++-- .../votingplugin/user/VotingPluginUser.java | 41 ++++++---- .../service/VoteShopPurchaseService.java | 34 ++++++++ .../ServerDataTimeChangeRecoveryTest.java | 28 ++++++- .../TopVoterTimeChangeRecoveryTest.java | 21 ++++- .../service/VoteShopPurchaseServiceTest.java | 28 +++++++ 7 files changed, 261 insertions(+), 30 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index 5f6244ea62..57fccf6695 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -23,7 +23,17 @@ public class ServerData { public record TimeChangeUserProgress(String uuid, int streakTarget, boolean rewardRequired, boolean rewardComplete) { } - public record TimeChangeRewardTarget(String uuid, String playerName, int place, String reward) { } + public record TimeChangeRewardTarget(String uuid, String playerName, int place, String reward, int votes) { } + public record TimeChangeArchiveSection(String name, List lines) { + public TimeChangeArchiveSection { + lines = List.copyOf(lines); + } + } + public record TimeChangeArchiveSnapshot(List sections) { + public TimeChangeArchiveSnapshot { + sections = List.copyOf(sections); + } + } private static final String TIME_CHANGE_RECOVERY = "TimeChangeRecovery"; private static final List TIME_CHANGE_PHASES = List.of("START", "SNAPSHOT", "COPY_TOTALS", @@ -519,6 +529,7 @@ public synchronized List prepareTimeChangeRewardTargets( getData().set(targetPath + ".PlayerName", target.playerName()); getData().set(targetPath + ".Place", target.place()); getData().set(targetPath + ".Reward", target.reward()); + getData().set(targetPath + ".Votes", target.votes()); } getData().set(targetsPath + ".Prepared", true); try { @@ -549,7 +560,8 @@ public synchronized List getTimeChangeRewardTargets(Time getData().getString(targetPath + ".Uuid", ""), getData().getString(targetPath + ".PlayerName", ""), getData().getInt(targetPath + ".Place", 0), - getData().getString(targetPath + ".Reward", "")); + getData().getString(targetPath + ".Reward", ""), + getData().getInt(targetPath + ".Votes", -1)); validateRewardTarget(target); targets.add(target); } @@ -557,7 +569,7 @@ public synchronized List getTimeChangeRewardTargets(Time } private void validateRewardTarget(TimeChangeRewardTarget target) { - if (target == null || target.playerName() == null || target.place() <= 0 + if (target == null || target.playerName() == null || target.place() <= 0 || target.votes() < 0 || target.reward() == null || target.reward().isEmpty()) { throw new IllegalStateException("Invalid time change reward target"); } @@ -568,6 +580,66 @@ private void validateRewardTarget(TimeChangeRewardTarget target) { } } + /** Persists the complete top-voter archive contents selected at the period boundary. */ + public synchronized TimeChangeArchiveSnapshot prepareTimeChangeArchive(TimeChangeTransition transition, + TimeChangeArchiveSnapshot proposed) { + String path = timeChangeRecoveryPath(transition.getType()); + if (!transition.getId().equals(getData().getString(path + ".Id", ""))) { + throw new IllegalStateException("Time change recovery transition does not match"); + } + String archivePath = path + ".Archive"; + if (getData().getBoolean(archivePath + ".Prepared", false)) return getTimeChangeArchive(transition); + validateArchive(proposed); + getData().set(archivePath, null); + getData().set(archivePath + ".Count", proposed.sections().size()); + for (int index = 0; index < proposed.sections().size(); index++) { + TimeChangeArchiveSection section = proposed.sections().get(index); + String sectionPath = archivePath + ".Sections." + index; + getData().set(sectionPath + ".Name", section.name()); + getData().set(sectionPath + ".Lines", section.lines()); + } + getData().set(archivePath + ".Prepared", true); + try { + saveData(); + } catch (RuntimeException | Error failure) { + getData().set(archivePath, null); + throw failure; + } + return proposed; + } + + /** Returns the durable top-voter archive contents for this transition. */ + public synchronized TimeChangeArchiveSnapshot getTimeChangeArchive(TimeChangeTransition transition) { + String path = timeChangeRecoveryPath(transition.getType()); + String archivePath = path + ".Archive"; + if (!transition.getId().equals(getData().getString(path + ".Id", "")) + || !getData().getBoolean(archivePath + ".Prepared", false)) { + return new TimeChangeArchiveSnapshot(List.of()); + } + int count = getData().getInt(archivePath + ".Count", -1); + if (count < 0) throw new IllegalStateException("Invalid time change archive section count"); + List sections = new ArrayList<>(count); + for (int index = 0; index < count; index++) { + String sectionPath = archivePath + ".Sections." + index; + sections.add(new TimeChangeArchiveSection(getData().getString(sectionPath + ".Name", ""), + getData().getStringList(sectionPath + ".Lines"))); + } + TimeChangeArchiveSnapshot snapshot = new TimeChangeArchiveSnapshot(sections); + validateArchive(snapshot); + return snapshot; + } + + private void validateArchive(TimeChangeArchiveSnapshot snapshot) { + if (snapshot == null) throw new IllegalStateException("Invalid time change archive"); + Set names = new HashSet<>(); + for (TimeChangeArchiveSection section : snapshot.sections()) { + if (section == null || section.name() == null || section.name().isEmpty() || section.lines() == null + || !names.add(section.name()) || section.lines().stream().anyMatch(line -> line == null)) { + throw new IllegalStateException("Invalid time change archive section"); + } + } + } + /** Checks the durable receipt for one top-voter reward recipient. */ public synchronized boolean hasTimeChangeRewardReceipt(TimeChangeTransition transition, String uuid) { String path = timeChangeRecoveryPath(transition.getType()); 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 ee42f924d4..d450269b95 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -41,6 +41,8 @@ import com.bencodez.simpleapi.sql.Column; import com.bencodez.simpleapi.sql.DataType; import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.data.ServerData.TimeChangeArchiveSection; +import com.bencodez.votingplugin.data.ServerData.TimeChangeArchiveSnapshot; import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardTarget; import com.bencodez.votingplugin.data.ServerData.TimeChangeUserProgress; import com.bencodez.votingplugin.user.VotingPluginUser; @@ -598,7 +600,9 @@ private void runRecoverablePeriod(TopVoter top, TimeChangeTransition transition) || (top == TopVoter.Weekly && plugin.getConfigFile().isStoreTopVotersWeekly()) || top == TopVoter.Monthly) { plugin.getLogger().info("Saving TopVoters " + top); - storeTopVoters(top); + TimeChangeArchiveSnapshot archive = plugin.getServerData().prepareTimeChangeArchive(transition, + buildTopVoterArchiveSnapshot()); + storeTopVoters(top, transition, archive); } plugin.getServerData().completeTimeChangePhase(transition, SNAPSHOT); } @@ -626,7 +630,7 @@ private void runRecoverablePeriod(TopVoter top, TimeChangeTransition transition) for (String shopIdent : plugin.getShopFile().getShopIdentifiers()) { if (shouldResetVoteShop(top, shopIdent)) { resetVoteShopLimit(shopIdent, - VoteShopPurchaseService.currentLimitGenerationId(plugin, shopIdent)); + VoteShopPurchaseService.limitGenerationIdForTransition(plugin, shopIdent, transition)); } } plugin.getServerData().completeTimeChangePhase(transition, VOTE_SHOP); @@ -752,7 +756,7 @@ private void processRecoverableTopRewards(TopVoter top, TimeChangeTransition tra UUID.fromString(target.uuid()), target.playerName()); user.userDataFetechMode(UserDataFetchMode.NO_CACHE); if (!plugin.getConfigFile().isTopVoterIgnorePermission() || !user.isTopVoterIgnore()) { - giveTopVoterAward(top, user, target.place(), target.reward()); + giveTopVoterAward(top, user, target.place(), target.reward(), target.votes()); plugin.getServerData().completeTimeChangeReward(transition, target.uuid()); plugin.getLogger().info("Giving " + top + " top voter reward " + target.place() + " to " + target.playerName()); @@ -774,7 +778,7 @@ List buildTopRewardSnapshot(TopVoter top, TimeChangeTran if (places.containsKey(place)) { targets.add(new TimeChangeRewardTarget(entry.getKey().getUuid().toString(), entry.getKey().getPlayerName() == null ? "" : entry.getKey().getPlayerName(), - place, places.get(place))); + place, places.get(place), entry.getValue().intValue())); } lastTotal = entry.getValue().intValue(); } @@ -828,11 +832,11 @@ private Set getPossibleRewardPlaces(TopVoter top) { }; } - private void giveTopVoterAward(TopVoter top, VotingPluginUser user, int place, String reward) { + private void giveTopVoterAward(TopVoter top, VotingPluginUser user, int place, String reward, int votes) { switch (top) { - case Daily -> user.giveDailyTopVoterAward(place, reward); - case Weekly -> user.giveWeeklyTopVoterAward(place, reward); - case Monthly -> user.giveMonthlyTopVoterAward(place, reward); + case Daily -> user.giveDailyTopVoterAward(place, reward, votes); + case Weekly -> user.giveWeeklyTopVoterAward(place, reward, votes); + case Monthly -> user.giveMonthlyTopVoterAward(place, reward, votes); default -> { } } } @@ -996,6 +1000,47 @@ public void storeTopVoters(TopVoter top) { file.saveData(); } + TimeChangeArchiveSnapshot buildTopVoterArchiveSnapshot() { + List sections = new ArrayList<>(); + for (TopVoter current : TopVoter.values()) { + ArrayList lines = new ArrayList<>(); + int total = 0; + try { + for (Integer value : plugin.getUserManager().getNumbersInColumn(current.getColumnName())) { + total += value.intValue(); + } + lines.add("Combined total: " + total); + } catch (Exception failure) { + plugin.debug(failure); + } + if (plugin.getTopVoter().containsKey(current)) { + int place = 1; + for (Entry entry : plugin.getTopVoter(current).entrySet()) { + lines.add(place + ": " + entry.getKey().getPlayerName() + ": " + entry.getValue()); + place++; + } + sections.add(new TimeChangeArchiveSection(current.toString(), lines)); + } + } + return new TimeChangeArchiveSnapshot(sections); + } + + void storeTopVoters(TopVoter top, TimeChangeTransition transition, TimeChangeArchiveSnapshot snapshot) { + String fileName = timeChangeArchiveFileName(top, transition); + YMLFileHandler file = new YMLFileHandler(plugin, new File(plugin.getDataFolder(), fileName)); + file.setup(); + file.header("Saving top voters for " + top + ", file also contains other top voter info as backup"); + for (TimeChangeArchiveSection section : snapshot.sections()) { + file.getData().set(section.name(), section.lines()); + } + file.saveData(); + } + + String timeChangeArchiveFileName(TopVoter top, TimeChangeTransition transition) { + String stablePeriod = transition.getPeriodKey().replaceAll("[^A-Za-z0-9_-]", "_"); + return "TopVoter" + File.separator + top + File.separator + top + "_" + stablePeriod + ".yml"; + } + /** * Top voter all time * diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 48aff1f0fe..424ad67b60 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -1725,8 +1725,13 @@ public int getWeekVoteStreak() { * @param place the place of the top voter * @param path the path to the reward configuration */ - public void giveDailyTopVoterAward(int place, String path) { - SpecialRewardType type = SpecialRewardType.TOPVOTER; + public void giveDailyTopVoterAward(int place, String path) { + giveDailyTopVoterAward(place, path, getTotal(TopVoter.Daily)); + } + + /** Gives a daily top-voter award using the vote total captured at the period boundary. */ + public void giveDailyTopVoterAward(int place, String path, int votes) { + SpecialRewardType type = SpecialRewardType.TOPVOTER; type.setType("Daily"); type.setAmount(1); PlayerSpecialRewardEvent event = new PlayerSpecialRewardEvent(this, type, null); @@ -1735,9 +1740,9 @@ public void giveDailyTopVoterAward(int place, String path) { if (event.isCancelled()) { return; } - new RewardBuilder(plugin.getSpecialRewardsConfig().getData(), - plugin.getSpecialRewardsConfig().getDailyAwardRewardsPath(path)).withPlaceHolder("place", "" + place) - .withPlaceHolder("topvoter", "Daily").withPlaceHolder("votes", "" + getTotal(TopVoter.Daily)) + new RewardBuilder(plugin.getSpecialRewardsConfig().getData(), + plugin.getSpecialRewardsConfig().getDailyAwardRewardsPath(path)).withPlaceHolder("place", "" + place) + .withPlaceHolder("topvoter", "Daily").withPlaceHolder("votes", "" + votes) .setOnline(isOnline()).send(this); } @@ -1747,7 +1752,12 @@ public void giveDailyTopVoterAward(int place, String path) { * @param place the place of the top voter * @param path the path to the reward configuration */ - public void giveMonthlyTopVoterAward(int place, String path) { + public void giveMonthlyTopVoterAward(int place, String path) { + giveMonthlyTopVoterAward(place, path, getTotal(TopVoter.Monthly)); + } + + /** Gives a monthly top-voter award using the vote total captured at the period boundary. */ + public void giveMonthlyTopVoterAward(int place, String path, int votes) { SpecialRewardType type = SpecialRewardType.TOPVOTER; type.setType("Monthly"); type.setAmount(1); @@ -1757,9 +1767,9 @@ public void giveMonthlyTopVoterAward(int place, String path) { if (event.isCancelled()) { return; } - new RewardBuilder(plugin.getSpecialRewardsConfig().getData(), - plugin.getSpecialRewardsConfig().getMonthlyAwardRewardsPath(path)).withPlaceHolder("place", "" + place) - .withPlaceHolder("topvoter", "Monthly").withPlaceHolder("votes", "" + getTotal(TopVoter.Monthly)) + new RewardBuilder(plugin.getSpecialRewardsConfig().getData(), + plugin.getSpecialRewardsConfig().getMonthlyAwardRewardsPath(path)).withPlaceHolder("place", "" + place) + .withPlaceHolder("topvoter", "Monthly").withPlaceHolder("votes", "" + votes) .setOnline(isOnline()).send(this); } @@ -1769,7 +1779,12 @@ public void giveMonthlyTopVoterAward(int place, String path) { * @param place the place of the top voter * @param path the path to the reward configuration */ - public void giveWeeklyTopVoterAward(int place, String path) { + public void giveWeeklyTopVoterAward(int place, String path) { + giveWeeklyTopVoterAward(place, path, getTotal(TopVoter.Weekly)); + } + + /** Gives a weekly top-voter award using the vote total captured at the period boundary. */ + public void giveWeeklyTopVoterAward(int place, String path, int votes) { SpecialRewardType type = SpecialRewardType.TOPVOTER; type.setType("Weekly"); type.setAmount(1); @@ -1779,9 +1794,9 @@ public void giveWeeklyTopVoterAward(int place, String path) { if (event.isCancelled()) { return; } - new RewardBuilder(plugin.getSpecialRewardsConfig().getData(), - plugin.getSpecialRewardsConfig().getWeeklyAwardRewardsPath(path)).withPlaceHolder("place", "" + place) - .withPlaceHolder("topvoter", "Weekly").withPlaceHolder("votes", "" + getTotal(TopVoter.Weekly)) + new RewardBuilder(plugin.getSpecialRewardsConfig().getData(), + plugin.getSpecialRewardsConfig().getWeeklyAwardRewardsPath(path)).withPlaceHolder("place", "" + place) + .withPlaceHolder("topvoter", "Weekly").withPlaceHolder("votes", "" + votes) .setOnline(isOnline()).send(this); } 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 d0d34a56bb..c827795c73 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 @@ -6,7 +6,9 @@ import java.sql.SQLException; import java.time.Duration; import java.time.Instant; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.YearMonth; import java.time.ZoneId; import java.time.temporal.WeekFields; import java.util.HashMap; @@ -23,6 +25,7 @@ import com.bencodez.advancedcore.api.messages.PlaceholderUtils; import com.bencodez.advancedcore.api.rewards.RewardOptions; +import com.bencodez.advancedcore.api.time.TimeChangeTransition; import com.bencodez.advancedcore.api.user.UserDataFetchMode; import com.bencodez.advancedcore.api.user.UserStorage; import com.bencodez.advancedcore.api.user.usercache.UserDataCache; @@ -824,6 +827,37 @@ public static String currentLimitGenerationId(VotingPluginMain plugin, String id return limitGeneration(plugin, identifier, System.currentTimeMillis()).value(); } + /** Stable reset generation derived from the durable period transition. */ + public static String limitGenerationIdForTransition(VotingPluginMain plugin, String identifier, + TimeChangeTransition transition) { + boolean daily = plugin.getShopFile().getVoteShopResetDaily(identifier); + boolean weekly = plugin.getShopFile().getVoteShopResetWeekly(identifier); + boolean monthly = plugin.getShopFile().getVoteShopResetMonthly(identifier); + LocalDateTime boundary = transitionBoundary(transition, plugin.getOptions().getTimeWeekOffSet()); + return limitGeneration(boundary, System.currentTimeMillis(), daily, weekly, monthly, + plugin.getOptions().getTimeWeekOffSet(), configuredTimeZone(plugin), + plugin.getOptions().getTimeHourOffSet()).value(); + } + + static LocalDateTime transitionBoundary(TimeChangeTransition transition, int weekOffset) { + return switch (transition.getType()) { + case DAY -> LocalDate.parse(transition.getPeriodKey()).atStartOfDay(); + case MONTH -> YearMonth.parse(transition.getPeriodKey()).atDay(1).atStartOfDay(); + case WEEK -> { + String[] parts = transition.getPeriodKey().split("-W", -1); + if (parts.length != 2) throw new IllegalArgumentException("Invalid weekly transition period"); + int year = Integer.parseInt(parts[0]); + int week = Integer.parseInt(parts[1]); + WeekFields fields = WeekFields.of(Locale.getDefault()); + LocalDate adjustedBoundary = LocalDate.of(year, 1, 4) + .with(fields.weekBasedYear(), year) + .with(fields.weekOfWeekBasedYear(), week) + .with(fields.dayOfWeek(), 1); + yield adjustedBoundary.minusDays(weekOffset).atStartOfDay(); + } + }; + } + private static LimitGeneration limitGeneration(VotingPluginMain plugin, String identifier, long nowMillis) { boolean daily = plugin.getShopFile().getVoteShopResetDaily(identifier); boolean weekly = plugin.getShopFile().getVoteShopResetWeekly(identifier); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java index 1a134aefe0..4e0ee3e007 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java @@ -17,6 +17,8 @@ import com.bencodez.advancedcore.api.time.TimeType; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.data.ServerData; +import com.bencodez.votingplugin.data.ServerData.TimeChangeArchiveSection; +import com.bencodez.votingplugin.data.ServerData.TimeChangeArchiveSnapshot; import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardTarget; import com.bencodez.votingplugin.data.ServerData.TimeChangeUserProgress; @@ -100,16 +102,36 @@ void retryKeepsTheFirstDurableRankedRewardSelection() { TimeChangeTransition transition = transition("DAY:2026-09-21", "2026-09-21", TimeType.DAY); data.beginTimeChangeRecovery(transition); List original = List.of( - new TimeChangeRewardTarget("00000000-0000-0000-0000-000000000001", "first", 1, "1"), - new TimeChangeRewardTarget("00000000-0000-0000-0000-000000000002", "second", 2, "2")); + new TimeChangeRewardTarget("00000000-0000-0000-0000-000000000001", "first", 1, "1", 20), + new TimeChangeRewardTarget("00000000-0000-0000-0000-000000000002", "second", 2, "2", 10)); List changed = List.of( - new TimeChangeRewardTarget("00000000-0000-0000-0000-000000000003", "late", 1, "1")); + new TimeChangeRewardTarget("00000000-0000-0000-0000-000000000003", "late", 1, "1", 99)); assertEquals(original, data.prepareTimeChangeRewardTargets(transition, original)); assertEquals(original, data.prepareTimeChangeRewardTargets(transition, changed)); assertEquals(original, new ServerData(plugin).getTimeChangeRewardTargets(transition)); } + @Test + void retryKeepsTheFirstDurableArchiveSnapshot() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); + YamlConfiguration yaml = new YamlConfiguration(); + when(plugin.getServerDataFile()).thenReturn(coreData); + when(coreData.getData()).thenReturn(yaml); + ServerData data = new ServerData(plugin); + TimeChangeTransition transition = transition("DAY:2026-09-21", "2026-09-21", TimeType.DAY); + TimeChangeArchiveSnapshot original = new TimeChangeArchiveSnapshot(List.of( + new TimeChangeArchiveSection("Daily", List.of("Combined total: 20", "1: first: 20")))); + TimeChangeArchiveSnapshot changed = new TimeChangeArchiveSnapshot(List.of( + new TimeChangeArchiveSection("Daily", List.of("Combined total: 99", "1: late: 99")))); + data.beginTimeChangeRecovery(transition); + + assertEquals(original, data.prepareTimeChangeArchive(transition, original)); + assertEquals(original, data.prepareTimeChangeArchive(transition, changed)); + assertEquals(original, new ServerData(plugin).getTimeChangeArchive(transition)); + } + private TimeChangeTransition transition(String id, String period, TimeType type) { TimeChangeTransition transition = mock(TimeChangeTransition.class); when(transition.getId()).thenReturn(id); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java index 08dfaf5b22..08f990ed84 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java @@ -6,6 +6,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.io.File; import java.time.LocalDateTime; import java.util.LinkedHashMap; import java.util.List; @@ -57,7 +58,7 @@ void dailyRecoveryUsesTheDayBeforeTheStableTransitionDate() { } @Test - void rewardSnapshotFixesRecipientsAndPlacesAtThePeriodBoundary() { + void rewardSnapshotFixesRecipientsPlacesAndVoteTotalsAtThePeriodBoundary() { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); TimeChangeTransition transition = mock(TimeChangeTransition.class); TopVoterPlayer first = new TopVoterPlayer( @@ -75,7 +76,21 @@ void rewardSnapshotFixesRecipientsAndPlacesAtThePeriodBoundary() { .buildTopRewardSnapshot(TopVoter.Daily, transition); assertEquals(List.of( - new TimeChangeRewardTarget(first.getUuid().toString(), "first", 1, "1"), - new TimeChangeRewardTarget(second.getUuid().toString(), "second", 2, "2")), targets); + new TimeChangeRewardTarget(first.getUuid().toString(), "first", 1, "1", 20), + new TimeChangeRewardTarget(second.getUuid().toString(), "second", 2, "2", 10)), targets); + } + + @Test + void archiveRetryUsesOneStableTransitionFile() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + TimeChangeTransition transition = mock(TimeChangeTransition.class); + when(transition.getPeriodKey()).thenReturn("2026-W38"); + TopVoterHandler handler = new TopVoterHandler(plugin); + + String first = handler.timeChangeArchiveFileName(TopVoter.Weekly, transition); + String retry = handler.timeChangeArchiveFileName(TopVoter.Weekly, transition); + + assertEquals("TopVoter" + File.separator + "Weekly" + File.separator + "Weekly_2026-W38.yml", first); + assertEquals(first, retry); } } 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 index 58dcc53a0c..f3f2c37e07 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -52,6 +52,8 @@ 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.advancedcore.api.time.TimeChangeTransition; +import com.bencodez.advancedcore.api.time.TimeType; import com.bencodez.simpleapi.folialib.enums.EntityTaskResult; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.user.SharedMysqlCacheReconciler; @@ -172,6 +174,32 @@ void weeklyGenerationAndBoundaryIgnoreJvmDefaultLocale() { } } + @Test + void transitionBoundaryComesFromTheDurablePeriodInsteadOfRetryTime() { + TimeChangeTransition day = mock(TimeChangeTransition.class); + when(day.getType()).thenReturn(TimeType.DAY); + when(day.getPeriodKey()).thenReturn("2026-09-21"); + TimeChangeTransition month = mock(TimeChangeTransition.class); + when(month.getType()).thenReturn(TimeType.MONTH); + when(month.getPeriodKey()).thenReturn("2026-09"); + TimeChangeTransition week = mock(TimeChangeTransition.class); + when(week.getType()).thenReturn(TimeType.WEEK); + when(week.getPeriodKey()).thenReturn("2026-W38"); + + assertEquals(LocalDateTime.of(2026, 9, 21, 0, 0), + VoteShopPurchaseService.transitionBoundary(day, 0)); + assertEquals(LocalDateTime.of(2026, 9, 1, 0, 0), + VoteShopPurchaseService.transitionBoundary(month, 0)); + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(Locale.GERMANY); + assertEquals(LocalDateTime.of(2026, 9, 14, 0, 0), + VoteShopPurchaseService.transitionBoundary(week, 0)); + } finally { + Locale.setDefault(previous); + } + } + @Test void sharedMysqlResetUsesTheJournalEpochTransaction() throws Exception { MySQL table = mock(MySQL.class); From 647da925919a5205f9bf91f20d33f51c10360247 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:13:55 -0600 Subject: [PATCH 04/61] Make period reset retries idempotent --- .../votingplugin/data/ServerData.java | 46 +++++++++++ .../topvoter/TimeChangeTotalReset.java | 82 +++++++++++++++++++ .../topvoter/TopVoterHandler.java | 26 ++++-- .../service/VoteShopPurchaseService.java | 32 +------- .../ServerDataTimeChangeRecoveryTest.java | 22 +++++ .../TopVoterTimeChangeRecoveryTest.java | 21 +++++ .../service/VoteShopPurchaseServiceTest.java | 20 ++--- 7 files changed, 196 insertions(+), 53 deletions(-) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TimeChangeTotalReset.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index 57fccf6695..a9aa1d9a17 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -580,6 +580,52 @@ private void validateRewardTarget(TimeChangeRewardTarget target) { } } + /** Persists the reward and archive boundary snapshots in one checkpoint. */ + public synchronized void prepareTimeChangeSnapshot(TimeChangeTransition transition, + List targets, TimeChangeArchiveSnapshot archive) { + String path = timeChangeRecoveryPath(transition.getType()); + if (!transition.getId().equals(getData().getString(path + ".Id", ""))) { + throw new IllegalStateException("Time change recovery transition does not match"); + } + String snapshotPath = path + ".BoundarySnapshot"; + if (getData().getBoolean(snapshotPath + ".Prepared", false)) return; + List targetSnapshot = List.copyOf(targets); + for (TimeChangeRewardTarget target : targetSnapshot) validateRewardTarget(target); + validateArchive(archive); + String targetsPath = path + ".RewardTargets"; + String archivePath = path + ".Archive"; + getData().set(targetsPath, null); + getData().set(targetsPath + ".Count", targetSnapshot.size()); + for (int index = 0; index < targetSnapshot.size(); index++) { + TimeChangeRewardTarget target = targetSnapshot.get(index); + String targetPath = targetsPath + ".Entries." + index; + getData().set(targetPath + ".Uuid", target.uuid()); + getData().set(targetPath + ".PlayerName", target.playerName()); + getData().set(targetPath + ".Place", target.place()); + getData().set(targetPath + ".Reward", target.reward()); + getData().set(targetPath + ".Votes", target.votes()); + } + getData().set(targetsPath + ".Prepared", true); + getData().set(archivePath, null); + getData().set(archivePath + ".Count", archive.sections().size()); + for (int index = 0; index < archive.sections().size(); index++) { + TimeChangeArchiveSection section = archive.sections().get(index); + String sectionPath = archivePath + ".Sections." + index; + getData().set(sectionPath + ".Name", section.name()); + getData().set(sectionPath + ".Lines", section.lines()); + } + getData().set(archivePath + ".Prepared", true); + getData().set(snapshotPath + ".Prepared", true); + try { + saveData(); + } catch (RuntimeException | Error failure) { + getData().set(targetsPath, null); + getData().set(archivePath, null); + getData().set(snapshotPath, null); + throw failure; + } + } + /** Persists the complete top-voter archive contents selected at the period boundary. */ public synchronized TimeChangeArchiveSnapshot prepareTimeChangeArchive(TimeChangeTransition transition, TimeChangeArchiveSnapshot proposed) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TimeChangeTotalReset.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TimeChangeTotalReset.java new file mode 100644 index 0000000000..b7a098b190 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TimeChangeTotalReset.java @@ -0,0 +1,82 @@ +package com.bencodez.votingplugin.topvoter; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Statement; + +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.userstorage.sql.UserTable; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService; + +/** Applies a period-total reset once even when its outer checkpoint must retry. */ +final class TimeChangeTotalReset { + private static final String SQLITE_JOURNAL = "VotingPlugin_TimeTotalResets"; + + private TimeChangeTotalReset() { } + + static boolean reset(VotingPluginMain plugin, String column, String generation) { + if (UserStorage.MYSQL.equals(plugin.getStorageType())) { + return VoteShopPurchaseService.resetMysqlLimitWithPurchaseFence(plugin, column, generation); + } + if (!UserStorage.SQLITE.equals(plugin.getStorageType())) return false; + UserTable table = plugin.getSQLiteUserTable(); + try { + return resetSqlite(table.getSqLite().getSQLConnection(), table.getName(), column, generation); + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to atomically reset period totals: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + return false; + } + } + + static boolean resetSqlite(Connection connection, String table, String column, String generation) + throws SQLException { + if (!safeIdentifier(table) || !safeIdentifier(column) || generation == null || generation.isEmpty()) { + throw new SQLException("Invalid period-total reset input"); + } + synchronized (connection) { + boolean previousAutoCommit = connection.getAutoCommit(); + try { + if (!previousAutoCommit) throw new SQLException("SQLite user connection already has an active transaction"); + connection.setAutoCommit(false); + try (Statement schema = connection.createStatement()) { + schema.executeUpdate("CREATE TABLE IF NOT EXISTS " + quote(SQLITE_JOURNAL) + + " (generation TEXT PRIMARY KEY)"); + } + int inserted; + try (PreparedStatement marker = connection.prepareStatement( + "INSERT OR IGNORE INTO " + quote(SQLITE_JOURNAL) + " (generation) VALUES (?)")) { + marker.setString(1, generation); + inserted = marker.executeUpdate(); + } + if (inserted == 1) { + try (Statement wipe = connection.createStatement()) { + wipe.executeUpdate("UPDATE " + quote(table) + " SET " + quote(column) + " = 0"); + } + } + connection.commit(); + return true; + } catch (SQLException failure) { + try { + connection.rollback(); + } catch (SQLException rollbackFailure) { + failure.addSuppressed(rollbackFailure); + } + throw failure; + } finally { + connection.setAutoCommit(previousAutoCommit); + } + } + } + + private static boolean safeIdentifier(String value) { + return value != null && value.matches("[A-Za-z0-9_]+"); + } + + private static String quote(String identifier) { + return '`' + identifier + '`'; + } +} 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 d450269b95..7cd1ce3ff4 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -594,15 +594,16 @@ private void processRecoverableChange(TopVoter top, TimeChangeTransition transit private void runRecoverablePeriod(TopVoter top, TimeChangeTransition transition) { if (!plugin.getServerData().hasTimeChangePhase(transition, SNAPSHOT)) { ensureTransitionActive(transition); - plugin.getServerData().prepareTimeChangeRewardTargets(transition, - buildTopRewardSnapshot(top, transition)); - if ((top == TopVoter.Daily && plugin.getConfigFile().isStoreTopVotersDaily()) + boolean archiveRequired = (top == TopVoter.Daily && plugin.getConfigFile().isStoreTopVotersDaily()) || (top == TopVoter.Weekly && plugin.getConfigFile().isStoreTopVotersWeekly()) - || top == TopVoter.Monthly) { + || top == TopVoter.Monthly; + TimeChangeArchiveSnapshot proposedArchive = archiveRequired + ? buildTopVoterArchiveSnapshot() : new TimeChangeArchiveSnapshot(List.of()); + plugin.getServerData().prepareTimeChangeSnapshot(transition, + buildTopRewardSnapshot(top, transition), proposedArchive); + if (archiveRequired) { plugin.getLogger().info("Saving TopVoters " + top); - TimeChangeArchiveSnapshot archive = plugin.getServerData().prepareTimeChangeArchive(transition, - buildTopVoterArchiveSnapshot()); - storeTopVoters(top, transition, archive); + storeTopVoters(top, transition, plugin.getServerData().getTimeChangeArchive(transition)); } plugin.getServerData().completeTimeChangePhase(transition, SNAPSHOT); } @@ -630,7 +631,7 @@ private void runRecoverablePeriod(TopVoter top, TimeChangeTransition transition) for (String shopIdent : plugin.getShopFile().getShopIdentifiers()) { if (shouldResetVoteShop(top, shopIdent)) { resetVoteShopLimit(shopIdent, - VoteShopPurchaseService.limitGenerationIdForTransition(plugin, shopIdent, transition)); + VoteShopPurchaseService.limitGenerationIdForTransition(transition)); } } plugin.getServerData().completeTimeChangePhase(transition, VOTE_SHOP); @@ -643,7 +644,7 @@ private void runRecoverablePeriod(TopVoter top, TimeChangeTransition transition) if (!plugin.getServerData().hasTimeChangePhase(transition, TOTALS_RESET)) { ensureTransitionActive(transition); - if (!bungeeHandleResets()) resetTotals(top); + if (!bungeeHandleResets()) resetTotals(top, transition); plugin.getServerData().completeTimeChangePhase(transition, TOTALS_RESET); } @@ -914,6 +915,13 @@ public void resetTotals(TopVoter topVoter) { plugin.getUserManager().removeAllKeyValues(topVoter.getColumnName(), DataType.INTEGER); } + void resetTotals(TopVoter topVoter, TimeChangeTransition transition) { + String generation = "time-total:" + transition.getId(); + if (!TimeChangeTotalReset.reset(plugin, topVoter.getColumnName(), generation)) { + throw new IllegalStateException("Unable to durably reset " + topVoter + " totals"); + } + } + /** * Resets vote shop limits for a specific shop. * @param shopIdent the shop identifier 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 c827795c73..1162a950c0 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 @@ -6,9 +6,7 @@ import java.sql.SQLException; import java.time.Duration; import java.time.Instant; -import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.YearMonth; import java.time.ZoneId; import java.time.temporal.WeekFields; import java.util.HashMap; @@ -828,34 +826,8 @@ public static String currentLimitGenerationId(VotingPluginMain plugin, String id } /** Stable reset generation derived from the durable period transition. */ - public static String limitGenerationIdForTransition(VotingPluginMain plugin, String identifier, - TimeChangeTransition transition) { - boolean daily = plugin.getShopFile().getVoteShopResetDaily(identifier); - boolean weekly = plugin.getShopFile().getVoteShopResetWeekly(identifier); - boolean monthly = plugin.getShopFile().getVoteShopResetMonthly(identifier); - LocalDateTime boundary = transitionBoundary(transition, plugin.getOptions().getTimeWeekOffSet()); - return limitGeneration(boundary, System.currentTimeMillis(), daily, weekly, monthly, - plugin.getOptions().getTimeWeekOffSet(), configuredTimeZone(plugin), - plugin.getOptions().getTimeHourOffSet()).value(); - } - - static LocalDateTime transitionBoundary(TimeChangeTransition transition, int weekOffset) { - return switch (transition.getType()) { - case DAY -> LocalDate.parse(transition.getPeriodKey()).atStartOfDay(); - case MONTH -> YearMonth.parse(transition.getPeriodKey()).atDay(1).atStartOfDay(); - case WEEK -> { - String[] parts = transition.getPeriodKey().split("-W", -1); - if (parts.length != 2) throw new IllegalArgumentException("Invalid weekly transition period"); - int year = Integer.parseInt(parts[0]); - int week = Integer.parseInt(parts[1]); - WeekFields fields = WeekFields.of(Locale.getDefault()); - LocalDate adjustedBoundary = LocalDate.of(year, 1, 4) - .with(fields.weekBasedYear(), year) - .with(fields.weekOfWeekBasedYear(), week) - .with(fields.dayOfWeek(), 1); - yield adjustedBoundary.minusDays(weekOffset).atStartOfDay(); - } - }; + public static String limitGenerationIdForTransition(TimeChangeTransition transition) { + return "time-shop:" + transition.getType() + ':' + transition.getPeriodKey(); } private static LimitGeneration limitGeneration(VotingPluginMain plugin, String identifier, long nowMillis) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java index 4e0ee3e007..476b62d195 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java @@ -132,6 +132,28 @@ void retryKeepsTheFirstDurableArchiveSnapshot() { assertEquals(original, new ServerData(plugin).getTimeChangeArchive(transition)); } + @Test + void rewardAndArchiveBoundaryDataShareOneDurableCheckpoint() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); + YamlConfiguration yaml = new YamlConfiguration(); + when(plugin.getServerDataFile()).thenReturn(coreData); + when(coreData.getData()).thenReturn(yaml); + ServerData data = new ServerData(plugin); + TimeChangeTransition transition = transition("DAY:2026-09-21", "2026-09-21", TimeType.DAY); + List targets = List.of(new TimeChangeRewardTarget( + "00000000-0000-0000-0000-000000000001", "first", 1, "1", 20)); + TimeChangeArchiveSnapshot archive = new TimeChangeArchiveSnapshot(List.of( + new TimeChangeArchiveSection("Daily", List.of("Combined total: 20", "1: first: 20")))); + data.beginTimeChangeRecovery(transition); + + data.prepareTimeChangeSnapshot(transition, targets, archive); + data.prepareTimeChangeSnapshot(transition, List.of(), new TimeChangeArchiveSnapshot(List.of())); + + assertEquals(targets, data.getTimeChangeRewardTargets(transition)); + assertEquals(archive, data.getTimeChangeArchive(transition)); + } + private TimeChangeTransition transition(String id, String period, TimeType type) { TimeChangeTransition transition = mock(TimeChangeTransition.class); when(transition.getId()).thenReturn(id); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java index 08f990ed84..283dd96d03 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java @@ -7,6 +7,10 @@ import static org.mockito.Mockito.when; import java.io.File; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; import java.time.LocalDateTime; import java.util.LinkedHashMap; import java.util.List; @@ -93,4 +97,21 @@ void archiveRetryUsesOneStableTransitionFile() { assertEquals("TopVoter" + File.separator + "Weekly" + File.separator + "Weekly_2026-W38.yml", first); assertEquals(first, retry); } + + @Test + void sqliteTotalResetRetryPreservesVotesAcceptedAfterTheFirstReset() throws Exception { + try (Connection connection = DriverManager.getConnection("jdbc:sqlite::memory:"); + Statement statement = connection.createStatement()) { + statement.executeUpdate("CREATE TABLE users (uuid TEXT PRIMARY KEY, DailyTotal INTEGER)"); + statement.executeUpdate("INSERT INTO users VALUES ('player', 20)"); + + TimeChangeTotalReset.resetSqlite(connection, "users", "DailyTotal", "time-total:DAY:2026-09-21"); + statement.executeUpdate("UPDATE users SET DailyTotal = DailyTotal + 3 WHERE uuid = 'player'"); + TimeChangeTotalReset.resetSqlite(connection, "users", "DailyTotal", "time-total:DAY:2026-09-21"); + + try (ResultSet result = statement.executeQuery("SELECT DailyTotal FROM users WHERE uuid = 'player'")) { + assertEquals(3, result.getInt(1)); + } + } + } } 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 index f3f2c37e07..d048e21f94 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -175,26 +175,18 @@ void weeklyGenerationAndBoundaryIgnoreJvmDefaultLocale() { } @Test - void transitionBoundaryComesFromTheDurablePeriodInsteadOfRetryTime() { - TimeChangeTransition day = mock(TimeChangeTransition.class); - when(day.getType()).thenReturn(TimeType.DAY); - when(day.getPeriodKey()).thenReturn("2026-09-21"); - TimeChangeTransition month = mock(TimeChangeTransition.class); - when(month.getType()).thenReturn(TimeType.MONTH); - when(month.getPeriodKey()).thenReturn("2026-09"); + void transitionResetGenerationUsesThePeriodKeyWithoutLocaleInterpretation() { TimeChangeTransition week = mock(TimeChangeTransition.class); when(week.getType()).thenReturn(TimeType.WEEK); when(week.getPeriodKey()).thenReturn("2026-W38"); - - assertEquals(LocalDateTime.of(2026, 9, 21, 0, 0), - VoteShopPurchaseService.transitionBoundary(day, 0)); - assertEquals(LocalDateTime.of(2026, 9, 1, 0, 0), - VoteShopPurchaseService.transitionBoundary(month, 0)); Locale previous = Locale.getDefault(); try { + Locale.setDefault(Locale.US); + String usGeneration = VoteShopPurchaseService.limitGenerationIdForTransition(week); Locale.setDefault(Locale.GERMANY); - assertEquals(LocalDateTime.of(2026, 9, 14, 0, 0), - VoteShopPurchaseService.transitionBoundary(week, 0)); + String germanGeneration = VoteShopPurchaseService.limitGenerationIdForTransition(week); + assertEquals("time-shop:WEEK:2026-W38", usGeneration); + assertEquals(usGeneration, germanGeneration); } finally { Locale.setDefault(previous); } From 1019480081e8017394fbbacc3e9f961f3c26887a Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:58:15 -0600 Subject: [PATCH 05/61] Make period resets preserve concurrent votes --- .../votingplugin/data/ServerData.java | 23 ++++ .../specialrewards/voteparty/VoteParty.java | 42 +++++-- .../voteparty/VotePartyState.java | 5 + .../topvoter/TimeChangeTotalReset.java | 106 ++++++++++++++++-- .../topvoter/TopVoterHandler.java | 17 ++- .../user/PeriodTotalMutationFence.java | 31 +++++ .../votingplugin/user/VotingPluginUser.java | 18 +-- .../service/SharedMysqlPurchaseJournal.java | 79 +++++++++++++ .../service/VoteShopPurchaseService.java | 41 +++++++ .../votingplugin/tests/VotePartyTest.java | 5 +- .../ServerDataTimeChangeRecoveryTest.java | 20 ++++ .../TopVoterTimeChangeRecoveryTest.java | 32 +++++- .../SharedMysqlPurchaseJournalTest.java | 53 +++++++++ 13 files changed, 440 insertions(+), 32 deletions(-) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/user/PeriodTotalMutationFence.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index a9aa1d9a17..dda9fe3f56 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -720,6 +720,29 @@ public synchronized void completeTimeChangeEffect(TimeChangeTransition transitio saveData(); } + /** Resets YAML-backed VoteParty state and records its receipt in one save. */ + public synchronized void completeTimeChangeVotePartyReset(TimeChangeTransition transition, String effect) { + String path = timeChangeRecoveryPath(transition.getType()); + if (!transition.getId().equals(getData().getString(path + ".Id", ""))) { + throw new IllegalStateException("Time change recovery transition does not match"); + } + getData().set("VoteParty.Total", 0); + getData().set("VoteParty.Voted", new ArrayList<>()); + getData().set(path + ".Effects." + effect, true); + saveData(); + } + + /** Resets VoteParty's extra requirement and records its receipt in one save. */ + public synchronized void completeTimeChangeVotePartyExtraReset(TimeChangeTransition transition, String effect) { + String path = timeChangeRecoveryPath(transition.getType()); + if (!transition.getId().equals(getData().getString(path + ".Id", ""))) { + throw new IllegalStateException("Time change recovery transition does not match"); + } + getData().set("VotePartyExtraRequired", 0); + getData().set(path + ".Effects." + effect, true); + saveData(); + } + private String timeChangeRecoveryPath(TimeType type) { return TIME_CHANGE_RECOVERY + "." + type.name(); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java index 568e428654..46f32d2396 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java @@ -254,7 +254,7 @@ public void giveRewards(VotingPluginUser orgUser, boolean forceBungee) { @EventHandler public void onDayChange(DayChangeEvent event) { if (plugin.getSpecialRewardsConfig().isVotePartyResetEachDay()) { - runRecoverableReset(event.getTransition(), "VotePartyDayReset", () -> reset(true)); + runRecoverableVotePartyReset(event.getTransition(), "VotePartyDayReset"); } } @@ -266,12 +266,11 @@ public void onDayChange(DayChangeEvent event) { @EventHandler public void onMonthChange(MonthChangeEvent event) { if (plugin.getSpecialRewardsConfig().isVotePartyResetMonthly()) { - runRecoverableReset(event.getTransition(), "VotePartyMonthReset", () -> reset(true)); + runRecoverableVotePartyReset(event.getTransition(), "VotePartyMonthReset"); } if (plugin.getSpecialRewardsConfig().isVotePartyResetExtraVotesMonthly()) { - runRecoverableReset(event.getTransition(), "VotePartyMonthExtraVotes", - () -> plugin.getServerData().setVotePartyExtraRequired(0)); + runRecoverableExtraReset(event.getTransition(), "VotePartyMonthExtraVotes"); } } @@ -283,16 +282,42 @@ public void onMonthChange(MonthChangeEvent event) { @EventHandler public void onWeekChange(WeekChangeEvent event) { if (plugin.getSpecialRewardsConfig().isVotePartyResetWeekly()) { - runRecoverableReset(event.getTransition(), "VotePartyWeekReset", () -> reset(true)); + runRecoverableVotePartyReset(event.getTransition(), "VotePartyWeekReset"); } if (plugin.getSpecialRewardsConfig().isVotePartyResetExtraVotesWeekly()) { - runRecoverableReset(event.getTransition(), "VotePartyWeekExtraVotes", - () -> plugin.getServerData().setVotePartyExtraRequired(0)); + runRecoverableExtraReset(event.getTransition(), "VotePartyWeekExtraVotes"); } } - private void runRecoverableReset(TimeChangeTransition transition, String effect, Runnable reset) { + private void runRecoverableVotePartyReset(TimeChangeTransition transition, String effect) { + if (transition == null) { + reset(true); + return; + } + runRecoverableReset(transition, effect, () -> { + if (!resetRecoverableUserCounts("vote-party:" + transition.getId() + ':' + effect)) { + throw new IllegalStateException("Unable to durably reset VoteParty user counts"); + } + plugin.getServerData().completeTimeChangeVotePartyReset(transition, effect); + }); + } + + /** Storage boundary kept separate so lifecycle tests do not require a live database. */ + public boolean resetRecoverableUserCounts(String generation) { + return state.resetUserCounts(generation); + } + + private void runRecoverableExtraReset(TimeChangeTransition transition, String effect) { + if (transition == null) { + plugin.getServerData().setVotePartyExtraRequired(0); + return; + } + runRecoverableReset(transition, effect, + () -> plugin.getServerData().completeTimeChangeVotePartyExtraReset(transition, effect)); + } + + private synchronized void runRecoverableReset(TimeChangeTransition transition, String effect, Runnable reset) { if (transition == null) { reset.run(); return; @@ -305,7 +330,6 @@ private void runRecoverableReset(TimeChangeTransition transition, String effect, plugin.getServerData().beginTimeChangeRecovery(transition); if (!plugin.getServerData().hasTimeChangeEffect(transition, effect)) { reset.run(); - plugin.getServerData().completeTimeChangeEffect(transition, effect); } if (transition.isCancellationRequested()) { throw new java.util.concurrent.CancellationException("Time transition was cancelled"); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VotePartyState.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VotePartyState.java index 99d31ce860..ee14749e55 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VotePartyState.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VotePartyState.java @@ -5,6 +5,7 @@ import com.bencodez.simpleapi.sql.DataType; import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.topvoter.TimeChangeTotalReset; /** Owns persistent VoteParty totals, participant state, and per-user counters. */ public final class VotePartyState { @@ -59,4 +60,8 @@ public void reset(boolean resetTotal) { public void resetUserCounts() { plugin.getUserManager().removeAllKeyValues(USER_COUNT_KEY, DataType.INTEGER); } + + public boolean resetUserCounts(String generation) { + return TimeChangeTotalReset.resetToZero(plugin, USER_COUNT_KEY, generation); + } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TimeChangeTotalReset.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TimeChangeTotalReset.java index b7a098b190..b99856d4fd 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TimeChangeTotalReset.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TimeChangeTotalReset.java @@ -1,6 +1,7 @@ package com.bencodez.votingplugin.topvoter; import java.sql.Connection; +import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.SQLException; import java.sql.Statement; @@ -8,37 +9,120 @@ import com.bencodez.advancedcore.api.user.UserStorage; import com.bencodez.advancedcore.api.user.userstorage.sql.UserTable; import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.user.PeriodTotalMutationFence; import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService; /** Applies a period-total reset once even when its outer checkpoint must retry. */ -final class TimeChangeTotalReset { +public final class TimeChangeTotalReset { private static final String SQLITE_JOURNAL = "VotingPlugin_TimeTotalResets"; private TimeChangeTotalReset() { } - static boolean reset(VotingPluginMain plugin, String column, String generation) { + static boolean reset(VotingPluginMain plugin, String column, String previousColumn, String generation) { + boolean[] reset = { false }; + PeriodTotalMutationFence.withReset(() -> { + // Drain queued absolute totals while new total mutations are excluded. The + // reset then subtracts the boundary copy, preserving votes accepted while + // earlier recovery phases were running. + plugin.getUserManager().getDataManager().clearCache(); + if (UserStorage.MYSQL.equals(plugin.getStorageType())) { + reset[0] = VoteShopPurchaseService.resetMysqlPeriodTotal(plugin, column, previousColumn, generation); + return; + } + if (!UserStorage.SQLITE.equals(plugin.getStorageType())) return; + UserTable table = plugin.getSQLiteUserTable(); + try { + String url = table.getSqLite().getSQLConnection().getMetaData().getURL(); + try (Connection connection = DriverManager.getConnection(url)) { + reset[0] = resetSqlite(connection, table.getName(), column, previousColumn, generation); + } + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to atomically reset period totals: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + } + }); + return reset[0]; + } + + static boolean copyBoundary(VotingPluginMain plugin, String column, String previousColumn, String generation) { + boolean[] copied = { false }; + PeriodTotalMutationFence.withReset(() -> { + plugin.getUserManager().getDataManager().clearCache(); + if (UserStorage.MYSQL.equals(plugin.getStorageType())) { + copied[0] = VoteShopPurchaseService.copyMysqlPeriodBoundary(plugin, column, previousColumn, generation); + return; + } + if (!UserStorage.SQLITE.equals(plugin.getStorageType())) return; + UserTable table = plugin.getSQLiteUserTable(); + try { + String url = table.getSqLite().getSQLConnection().getMetaData().getURL(); + try (Connection connection = DriverManager.getConnection(url)) { + copied[0] = copyBoundarySqlite(connection, table.getName(), column, previousColumn, generation); + } + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to atomically copy period totals: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + } + }); + return copied[0]; + } + + /** Resets an auxiliary integer column once for a recoverable listener effect. */ + public static boolean resetToZero(VotingPluginMain plugin, String column, String generation) { + plugin.getUserManager().getDataManager().clearCache(); if (UserStorage.MYSQL.equals(plugin.getStorageType())) { return VoteShopPurchaseService.resetMysqlLimitWithPurchaseFence(plugin, column, generation); } if (!UserStorage.SQLITE.equals(plugin.getStorageType())) return false; UserTable table = plugin.getSQLiteUserTable(); try { - return resetSqlite(table.getSqLite().getSQLConnection(), table.getName(), column, generation); + String url = table.getSqLite().getSQLConnection().getMetaData().getURL(); + try (Connection connection = DriverManager.getConnection(url)) { + return resetSqliteToZero(connection, table.getName(), column, generation); + } } catch (SQLException failure) { - plugin.getLogger().severe("Unable to atomically reset period totals: " + plugin.getLogger().severe("Unable to atomically reset user counters: " + failure.getClass().getSimpleName()); plugin.debug(failure); return false; } } - static boolean resetSqlite(Connection connection, String table, String column, String generation) + static boolean resetSqliteToZero(Connection connection, String table, String column, String generation) throws SQLException { - if (!safeIdentifier(table) || !safeIdentifier(column) || generation == null || generation.isEmpty()) { + return resetSqliteExpression(connection, table, column, generation, "0"); + } + + static boolean copyBoundarySqlite(Connection connection, String table, String column, String previousColumn, + String generation) throws SQLException { + if (!safeIdentifier(column) || !safeIdentifier(previousColumn)) { + throw new SQLException("Invalid period boundary column"); + } + return resetSqliteExpression(connection, table, previousColumn, generation, + "COALESCE(" + quote(column) + ", 0)"); + } + + static boolean resetSqlite(Connection connection, String table, String column, String previousColumn, + String generation) + throws SQLException { + if (!safeIdentifier(table) || !safeIdentifier(column) || !safeIdentifier(previousColumn) + || generation == null || generation.isEmpty()) { throw new SQLException("Invalid period-total reset input"); } + return resetSqliteExpression(connection, table, column, generation, "MAX(0, COALESCE(" + quote(column) + + ", 0) - COALESCE(" + quote(previousColumn) + ", 0))"); + } + + private static boolean resetSqliteExpression(Connection connection, String table, String column, String generation, + String expression) throws SQLException { + if (!safeIdentifier(table) || !safeIdentifier(column) || generation == null || generation.isEmpty()) { + throw new SQLException("Invalid reset input"); + } synchronized (connection) { boolean previousAutoCommit = connection.getAutoCommit(); + SQLException primaryFailure = null; try { if (!previousAutoCommit) throw new SQLException("SQLite user connection already has an active transaction"); connection.setAutoCommit(false); @@ -54,12 +138,13 @@ static boolean resetSqlite(Connection connection, String table, String column, S } if (inserted == 1) { try (Statement wipe = connection.createStatement()) { - wipe.executeUpdate("UPDATE " + quote(table) + " SET " + quote(column) + " = 0"); + wipe.executeUpdate("UPDATE " + quote(table) + " SET " + quote(column) + " = " + expression); } } connection.commit(); return true; } catch (SQLException failure) { + primaryFailure = failure; try { connection.rollback(); } catch (SQLException rollbackFailure) { @@ -67,7 +152,12 @@ static boolean resetSqlite(Connection connection, String table, String column, S } throw failure; } finally { - connection.setAutoCommit(previousAutoCommit); + try { + connection.setAutoCommit(previousAutoCommit); + } catch (SQLException restoreFailure) { + if (primaryFailure != null) primaryFailure.addSuppressed(restoreFailure); + else throw restoreFailure; + } } } } 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 7cd1ce3ff4..dddf40e222 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -592,6 +592,11 @@ private void processRecoverableChange(TopVoter top, TimeChangeTransition transit } private void runRecoverablePeriod(TopVoter top, TimeChangeTransition transition) { + // Capture the boundary before any long-running phase. The database journal + // makes the later COPY_TOTALS retry a no-op, so votes accepted while reward + // or user recovery is pending remain above this boundary. + if (top != TopVoter.Monthly || !bungeeHandleResets()) copyTotalBoundary(top, transition); + if (!plugin.getServerData().hasTimeChangePhase(transition, SNAPSHOT)) { ensureTransitionActive(transition); boolean archiveRequired = (top == TopVoter.Daily && plugin.getConfigFile().isStoreTopVotersDaily()) @@ -611,7 +616,7 @@ private void runRecoverablePeriod(TopVoter top, TimeChangeTransition transition) if (!plugin.getServerData().hasTimeChangePhase(transition, COPY_TOTALS)) { ensureTransitionActive(transition); if (top != TopVoter.Monthly || !bungeeHandleResets()) { - plugin.getUserManager().copyColumnData(top.getColumnName(), top.getLastColumnName()); + copyTotalBoundary(top, transition); } plugin.getServerData().completeTimeChangePhase(transition, COPY_TOTALS); } @@ -655,6 +660,14 @@ private void runRecoverablePeriod(TopVoter top, TimeChangeTransition transition) } } + private void copyTotalBoundary(TopVoter top, TimeChangeTransition transition) { + ensureTransitionActive(transition); + if (!TimeChangeTotalReset.copyBoundary(plugin, top.getColumnName(), top.getLastColumnName(), + "time-copy:" + transition.getId())) { + throw new IllegalStateException("Unable to durably copy " + top + " boundary totals"); + } + } + private void processRecoverableUsers(TopVoter top, TimeChangeTransition transition) { if (!plugin.getConfigFile().isUseVoteStreaks() && !plugin.getConfigFile().isUseHighestTotals()) return; AtomicReference cursor = new AtomicReference<>(plugin.getServerData().getTimeChangeCursor(transition)); @@ -917,7 +930,7 @@ public void resetTotals(TopVoter topVoter) { void resetTotals(TopVoter topVoter, TimeChangeTransition transition) { String generation = "time-total:" + transition.getId(); - if (!TimeChangeTotalReset.reset(plugin, topVoter.getColumnName(), generation)) { + if (!TimeChangeTotalReset.reset(plugin, topVoter.getColumnName(), topVoter.getLastColumnName(), generation)) { throw new IllegalStateException("Unable to durably reset " + topVoter + " totals"); } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/PeriodTotalMutationFence.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/PeriodTotalMutationFence.java new file mode 100644 index 0000000000..f3b4222e8e --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/PeriodTotalMutationFence.java @@ -0,0 +1,31 @@ +package com.bencodez.votingplugin.user; + +import java.util.concurrent.locks.ReentrantReadWriteLock; + +/** Orders period-total writes against boundary snapshots and resets. */ +public final class PeriodTotalMutationFence { + private static final ReentrantReadWriteLock FENCE = new ReentrantReadWriteLock(true); + + private PeriodTotalMutationFence() { + } + + public static void withMutation(Runnable action) { + var lock = FENCE.readLock(); + lock.lock(); + try { + action.run(); + } finally { + lock.unlock(); + } + } + + public static void withReset(Runnable action) { + var lock = FENCE.writeLock(); + lock.lock(); + try { + action.run(); + } finally { + lock.unlock(); + } + } +} 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 424ad67b60..0a38b49cb7 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -165,8 +165,8 @@ public void addDayVoteStreak() { /** * Adds one to the monthly total votes. */ - public void addMonthTotal() { - setMonthTotal(getMonthTotal() + 1); + public void addMonthTotal() { + PeriodTotalMutationFence.withMutation(() -> setMonthTotal(getMonthTotal() + 1)); } /** @@ -877,15 +877,15 @@ public void addTotal() { /** * Adds one to the daily total votes. */ - public void addTotalDaily() { - setDailyTotal(getDailyTotal() + 1); + public void addTotalDaily() { + PeriodTotalMutationFence.withMutation(() -> setDailyTotal(getDailyTotal() + 1)); } /** * Adds one to the weekly total votes. */ - public void addTotalWeekly() { - setWeeklyTotal(getWeeklyTotal() + 1); + public void addTotalWeekly() { + PeriodTotalMutationFence.withMutation(() -> setWeeklyTotal(getWeeklyTotal() + 1)); } /** @@ -2402,7 +2402,11 @@ public void setTopVoterIgnore(boolean topVoterIgnore) { * @param top the top voter category * @param value the total votes to set */ - public void setTotal(TopVoter top, int value) { + public void setTotal(TopVoter top, int value) { + PeriodTotalMutationFence.withMutation(() -> setTotalWithinFence(top, value)); + } + + private void setTotalWithinFence(TopVoter top, int value) { switch (top) { case AllTime: getUserData().setInt("AllTimeTotal", value); 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 index c54a442955..2173c19598 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -223,6 +223,85 @@ void resetLimit(String limitColumn, String resetGeneration) throws SQLException } } + /** Subtracts the copied boundary total once, retaining votes accepted later. */ + void resetPeriodTotal(String totalColumn, String previousColumn, String resetGeneration) throws SQLException { + if (!isSafeColumn(totalColumn) || !isSafeColumn(previousColumn)) { + throw new SQLException("Unsafe period total column"); + } + if (resetGeneration == null || resetGeneration.isEmpty() || resetGeneration.length() > 128) { + throw new SQLException("Invalid period total reset generation"); + } + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try { + String markerKey = "period-reset:" + totalColumn; + EpochRow marker = lockLimitEpochRow(connection, markerKey); + if (resetGeneration.equals(marker.lastResetGeneration())) { + rollback(connection); + return; + } + long oldEpoch = marker.epoch(); + if (oldEpoch == Long.MAX_VALUE) throw new SQLException("Period total reset epoch overflow"); + String difference = "COALESCE(" + qi(totalColumn) + ", 0) - COALESCE(" + qi(previousColumn) + + ", 0)"; + String bounded = "GREATEST(0, " + difference + ')'; + try (PreparedStatement reset = connection.prepareStatement("UPDATE " + qi(table.getTableName()) + + " SET " + qi(totalColumn) + " = " + bounded); + PreparedStatement advance = connection.prepareStatement("UPDATE " + qiEpoch() + " SET " + + qi("epoch") + " = ?, " + qi("last_reset_generation") + " = ? WHERE " + + qi("limit_column") + " = ?")) { + reset.executeUpdate(); + advance.setLong(1, oldEpoch + 1L); + advance.setString(2, resetGeneration); + advance.setString(3, markerKey); + if (advance.executeUpdate() != 1) throw new SQLException("Period total epoch marker missing"); + } + connection.commit(); + } catch (SQLException failure) { + rollback(connection); + throw failure; + } + } + } + + /** Copies the period boundary once so a phase-receipt retry cannot move it. */ + void copyPeriodBoundary(String totalColumn, String previousColumn, String generation) throws SQLException { + if (!isSafeColumn(totalColumn) || !isSafeColumn(previousColumn)) { + throw new SQLException("Unsafe period boundary column"); + } + if (generation == null || generation.isEmpty() || generation.length() > 128) { + throw new SQLException("Invalid period boundary generation"); + } + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try { + String markerKey = "period-copy:" + totalColumn; + EpochRow marker = lockLimitEpochRow(connection, markerKey); + if (generation.equals(marker.lastResetGeneration())) { + rollback(connection); + return; + } + long oldEpoch = marker.epoch(); + if (oldEpoch == Long.MAX_VALUE) throw new SQLException("Period boundary epoch overflow"); + try (PreparedStatement copy = connection.prepareStatement("UPDATE " + qi(table.getTableName()) + + " SET " + qi(previousColumn) + " = COALESCE(" + qi(totalColumn) + ", 0)"); + PreparedStatement advance = connection.prepareStatement("UPDATE " + qiEpoch() + " SET " + + qi("epoch") + " = ?, " + qi("last_reset_generation") + " = ? WHERE " + + qi("limit_column") + " = ?")) { + copy.executeUpdate(); + advance.setLong(1, oldEpoch + 1L); + advance.setString(2, generation); + advance.setString(3, markerKey); + if (advance.executeUpdate() != 1) throw new SQLException("Period boundary epoch marker missing"); + } + connection.commit(); + } 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 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 1162a950c0..72cd453917 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 @@ -590,6 +590,47 @@ public static boolean resetMysqlLimitWithPurchaseFence(VotingPluginMain plugin, return reset.get(); } + /** Atomically removes the copied boundary total while retaining later votes. */ + public static boolean resetMysqlPeriodTotal(VotingPluginMain plugin, String totalColumn, String previousColumn, + String resetGeneration) { + if (!canRecoverSharedMysqlPurchases(plugin)) return false; + AtomicBoolean reset = new AtomicBoolean(); + try { + MySQL table = plugin.getMysql(); + table.checkColumn(totalColumn, DataType.INTEGER); + table.checkColumn(previousColumn, DataType.INTEGER); + SharedMysqlPurchaseJournal.forTable(table).resetPeriodTotal(totalColumn, previousColumn, resetGeneration); + reset.set(true); + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to atomically reset MySQL period total: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + } finally { + SharedMysqlCacheReconciler.invalidateAll(plugin, totalColumn); + } + return reset.get(); + } + + /** Atomically captures one period boundary for a recoverable transition. */ + public static boolean copyMysqlPeriodBoundary(VotingPluginMain plugin, String totalColumn, String previousColumn, + String generation) { + if (!canRecoverSharedMysqlPurchases(plugin)) return false; + try { + MySQL table = plugin.getMysql(); + table.checkColumn(totalColumn, DataType.INTEGER); + table.checkColumn(previousColumn, DataType.INTEGER); + SharedMysqlPurchaseJournal.forTable(table).copyPeriodBoundary(totalColumn, previousColumn, generation); + return true; + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to atomically copy MySQL period boundary: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + return false; + } finally { + SharedMysqlCacheReconciler.invalidateAll(plugin, previousColumn); + } + } + static void withSharedMysqlCacheResetFence(Runnable action) { SharedMysqlCacheReconciler.withResetFence(action); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java index edb316bd36..64b61f7735 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java @@ -8,6 +8,7 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.doReturn; import java.util.ArrayList; import java.util.Collection; @@ -122,11 +123,13 @@ public void dayResetRecordsTransitionEffectBeforeCompletingLease() { when(transition.getType()).thenReturn(com.bencodez.advancedcore.api.time.TimeType.DAY); when(transition.getId()).thenReturn("DAY:2026-09-21"); when(plugin.getSpecialRewardsConfig().isVotePartyResetEachDay()).thenReturn(true); + doReturn(true).when(voteParty).resetRecoverableUserCounts("vote-party:DAY:2026-09-21:VotePartyDayReset"); voteParty.onDayChange(new DayChangeEvent(transition)); verify(plugin.getServerData()).beginTimeChangeRecovery(transition); - verify(plugin.getServerData()).completeTimeChangeEffect(transition, "VotePartyDayReset"); + verify(plugin.getServerData()).completeTimeChangeVotePartyReset(transition, "VotePartyDayReset"); + verify(plugin.getServerData(), never()).completeTimeChangeEffect(transition, "VotePartyDayReset"); verify(lease).complete(); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java index 476b62d195..1c7a28a916 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java @@ -154,6 +154,26 @@ void rewardAndArchiveBoundaryDataShareOneDurableCheckpoint() { assertEquals(archive, data.getTimeChangeArchive(transition)); } + @Test + void votePartyStateAndEffectReceiptShareOneCheckpoint() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); + YamlConfiguration yaml = new YamlConfiguration(); + when(plugin.getServerDataFile()).thenReturn(coreData); + when(coreData.getData()).thenReturn(yaml); + ServerData data = new ServerData(plugin); + TimeChangeTransition transition = transition("WEEK:2026-W38", "2026-W38", TimeType.WEEK); + data.beginTimeChangeRecovery(transition); + data.getData().set("VoteParty.Total", 12); + data.getData().set("VoteParty.Voted", List.of("player")); + + data.completeTimeChangeVotePartyReset(transition, "VotePartyWeekReset"); + + assertEquals(0, data.getData().getInt("VoteParty.Total")); + assertTrue(data.getData().getStringList("VoteParty.Voted").isEmpty()); + assertTrue(data.hasTimeChangeEffect(transition, "VotePartyWeekReset")); + } + private TimeChangeTransition transition(String id, String period, TimeType type) { TimeChangeTransition transition = mock(TimeChangeTransition.class); when(transition.getId()).thenReturn(id); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java index 283dd96d03..fd9a62b0b7 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java @@ -102,15 +102,37 @@ void archiveRetryUsesOneStableTransitionFile() { void sqliteTotalResetRetryPreservesVotesAcceptedAfterTheFirstReset() throws Exception { try (Connection connection = DriverManager.getConnection("jdbc:sqlite::memory:"); Statement statement = connection.createStatement()) { - statement.executeUpdate("CREATE TABLE users (uuid TEXT PRIMARY KEY, DailyTotal INTEGER)"); - statement.executeUpdate("INSERT INTO users VALUES ('player', 20)"); + statement.executeUpdate("CREATE TABLE users (uuid TEXT PRIMARY KEY, DailyTotal INTEGER, LastDailyTotal INTEGER)"); + statement.executeUpdate("INSERT INTO users VALUES ('player', 20, 0)"); - TimeChangeTotalReset.resetSqlite(connection, "users", "DailyTotal", "time-total:DAY:2026-09-21"); + TimeChangeTotalReset.copyBoundarySqlite(connection, "users", "DailyTotal", "LastDailyTotal", + "time-copy:DAY:2026-09-21"); statement.executeUpdate("UPDATE users SET DailyTotal = DailyTotal + 3 WHERE uuid = 'player'"); - TimeChangeTotalReset.resetSqlite(connection, "users", "DailyTotal", "time-total:DAY:2026-09-21"); + TimeChangeTotalReset.copyBoundarySqlite(connection, "users", "DailyTotal", "LastDailyTotal", + "time-copy:DAY:2026-09-21"); + TimeChangeTotalReset.resetSqlite(connection, "users", "DailyTotal", "LastDailyTotal", + "time-total:DAY:2026-09-21"); + statement.executeUpdate("UPDATE users SET DailyTotal = DailyTotal + 2 WHERE uuid = 'player'"); + TimeChangeTotalReset.resetSqlite(connection, "users", "DailyTotal", "LastDailyTotal", + "time-total:DAY:2026-09-21"); try (ResultSet result = statement.executeQuery("SELECT DailyTotal FROM users WHERE uuid = 'player'")) { - assertEquals(3, result.getInt(1)); + assertEquals(5, result.getInt(1)); + } + } + } + + @Test + void sqliteAuxiliaryResetDoesNotRepeatAfterNewVotes() throws Exception { + try (Connection connection = DriverManager.getConnection("jdbc:sqlite::memory:"); + Statement statement = connection.createStatement()) { + statement.executeUpdate("CREATE TABLE users (uuid TEXT PRIMARY KEY, VotePartyVotes INTEGER)"); + statement.executeUpdate("INSERT INTO users VALUES ('player', 4)"); + TimeChangeTotalReset.resetSqliteToZero(connection, "users", "VotePartyVotes", "vote-party:DAY"); + statement.executeUpdate("UPDATE users SET VotePartyVotes = 2 WHERE uuid = 'player'"); + TimeChangeTotalReset.resetSqliteToZero(connection, "users", "VotePartyVotes", "vote-party:DAY"); + try (ResultSet result = statement.executeQuery("SELECT VotePartyVotes FROM users WHERE uuid = 'player'")) { + assertEquals(2, result.getInt(1)); } } } 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 index 7f49f61e8d..39f4dc957e 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -264,6 +264,59 @@ void repeatedResetGenerationDoesNotWipeNewPeriodPurchases() throws Exception { verify(fixture.work, org.mockito.Mockito.never()).commit(); } + @Test + void periodResetSubtractsTheBoundaryCopyAndAdvancesItsGeneration() throws Exception { + Fixture fixture = fixture(); + PreparedStatement markerInsert = mock(PreparedStatement.class); + PreparedStatement markerSelect = mock(PreparedStatement.class); + PreparedStatement reset = mock(PreparedStatement.class); + PreparedStatement advance = mock(PreparedStatement.class); + ResultSet epoch = mock(ResultSet.class); + when(epoch.next()).thenReturn(true); + when(epoch.getLong(1)).thenReturn(4L); + when(markerSelect.executeQuery()).thenReturn(epoch); + when(advance.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, reset, advance); + + new SharedMysqlPurchaseJournal(fixture.table, false).resetPeriodTotal( + "DailyTotal", "LastDailyTotal", "time-total:DAY:2026-09-21"); + + 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( + "`DailyTotal` = GREATEST(0, COALESCE(`DailyTotal`, 0) - COALESCE(`LastDailyTotal`, 0))")); + verify(advance).setLong(1, 5L); + verify(advance).setString(2, "time-total:DAY:2026-09-21"); + verify(advance).setString(3, "period-reset:DailyTotal"); + verify(fixture.work).commit(); + } + + @Test + void periodBoundaryCopyAndGenerationAdvanceShareOneTransaction() throws Exception { + Fixture fixture = fixture(); + PreparedStatement markerInsert = mock(PreparedStatement.class); + PreparedStatement markerSelect = mock(PreparedStatement.class); + PreparedStatement copy = mock(PreparedStatement.class); + PreparedStatement advance = mock(PreparedStatement.class); + ResultSet epoch = mock(ResultSet.class); + when(epoch.next()).thenReturn(true); + when(epoch.getLong(1)).thenReturn(8L); + when(markerSelect.executeQuery()).thenReturn(epoch); + when(advance.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, copy, advance); + + new SharedMysqlPurchaseJournal(fixture.table, false).copyPeriodBoundary( + "DailyTotal", "LastDailyTotal", "time-copy:DAY:2026-09-21"); + + 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( + "`LastDailyTotal` = COALESCE(`DailyTotal`, 0)")); + verify(advance).setLong(1, 9L); + verify(advance).setString(3, "period-copy:DailyTotal"); + verify(fixture.work).commit(); + } + @Test void ambiguousResetCommitIsConfirmedAfterTheConnectionIsReleased() throws Exception { Fixture fixture = fixture(); From 8c63793f81b141b77d43bb1ba204a3d97a323302 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:17:09 -0600 Subject: [PATCH 06/61] Use boundary totals for weekly recovery --- .../votingplugin/data/ServerData.java | 34 +++++++++++++++---- .../topvoter/TopVoterHandler.java | 7 ++-- .../user/PeriodTotalMutationFence.java | 2 +- .../votingplugin/user/VotingPluginUser.java | 21 ++++++++---- .../votingplugin/tests/VotePartyTest.java | 7 ++-- .../ServerDataTimeChangeRecoveryTest.java | 30 ++++++++++++++++ .../TopVoterTimeChangeRecoveryTest.java | 24 +++++++++++++ 7 files changed, 106 insertions(+), 19 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index dda9fe3f56..dd064ce634 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -726,10 +726,21 @@ public synchronized void completeTimeChangeVotePartyReset(TimeChangeTransition t if (!transition.getId().equals(getData().getString(path + ".Id", ""))) { throw new IllegalStateException("Time change recovery transition does not match"); } - getData().set("VoteParty.Total", 0); - getData().set("VoteParty.Voted", new ArrayList<>()); - getData().set(path + ".Effects." + effect, true); - saveData(); + int previousTotal = getData().getInt("VoteParty.Total"); + List previousVoters = new ArrayList<>(getData().getStringList("VoteParty.Voted")); + String effectPath = path + ".Effects." + effect; + Object previousEffect = getData().get(effectPath); + try { + getData().set("VoteParty.Total", 0); + getData().set("VoteParty.Voted", new ArrayList<>()); + getData().set(effectPath, true); + saveData(); + } catch (RuntimeException | Error failure) { + getData().set("VoteParty.Total", previousTotal); + getData().set("VoteParty.Voted", previousVoters); + getData().set(effectPath, previousEffect); + throw failure; + } } /** Resets VoteParty's extra requirement and records its receipt in one save. */ @@ -738,9 +749,18 @@ public synchronized void completeTimeChangeVotePartyExtraReset(TimeChangeTransit if (!transition.getId().equals(getData().getString(path + ".Id", ""))) { throw new IllegalStateException("Time change recovery transition does not match"); } - getData().set("VotePartyExtraRequired", 0); - getData().set(path + ".Effects." + effect, true); - saveData(); + int previousExtra = getData().getInt("VotePartyExtraRequired"); + String effectPath = path + ".Effects." + effect; + Object previousEffect = getData().get(effectPath); + try { + getData().set("VotePartyExtraRequired", 0); + getData().set(effectPath, true); + saveData(); + } catch (RuntimeException | Error failure) { + getData().set("VotePartyExtraRequired", previousExtra); + getData().set(effectPath, previousEffect); + throw failure; + } } private String timeChangeRecoveryPath(TimeType type) { 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 dddf40e222..4c04db1b71 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -702,13 +702,14 @@ private void processDailyUser(VotingPluginUser user, TimeChangeTransition transi } } - private void processWeeklyUser(VotingPluginUser user, TimeChangeTransition transition, String uuid) { + void processWeeklyUser(VotingPluginUser user, TimeChangeTransition transition, String uuid) { + int boundaryTotal = user.getLastWeeklyTotal(); if (plugin.getConfigFile().isUseVoteStreaks()) { - if (user.getTotal(TopVoter.Weekly) == 0 && user.getWeekVoteStreak() != 0) { + if (boundaryTotal == 0 && user.getWeekVoteStreak() != 0) { applyRecoverableStreak(user, transition, uuid, TopVoter.Weekly, 0, false); } else if (!plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage() || user.hasPercentageTotal(TopVoter.Weekly, - plugin.getSpecialRewardsConfig().getVoteStreakRequirementWeek(), null)) { + plugin.getSpecialRewardsConfig().getVoteStreakRequirementWeek(), null, boundaryTotal)) { applyRecoverableStreak(user, transition, uuid, TopVoter.Weekly, user.getWeekVoteStreak() + 1, true); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/PeriodTotalMutationFence.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/PeriodTotalMutationFence.java index f3b4222e8e..ce554cc45e 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/PeriodTotalMutationFence.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/PeriodTotalMutationFence.java @@ -10,7 +10,7 @@ private PeriodTotalMutationFence() { } public static void withMutation(Runnable action) { - var lock = FENCE.readLock(); + var lock = FENCE.writeLock(); lock.lock(); try { action.run(); 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 0a38b49cb7..622125b5ae 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -1319,9 +1319,14 @@ public int getHighestWeeklyTotal() { * * @return the total votes for the last month */ - public int getLastMonthTotal() { - return getData().getInt("LastMonthTotal"); - } + public int getLastMonthTotal() { + return getData().getInt("LastMonthTotal"); + } + + /** Returns the weekly total captured at the time-change boundary. */ + public int getLastWeeklyTotal() { + return getData().getInt("LastWeeklyTotal"); + } /** * Gets the last votes for each vote site. @@ -1845,9 +1850,13 @@ public long getUniqueVoteSitesToday() { * @return true if the user has the percentage of the total votes, false * otherwise */ - public boolean hasPercentageTotal(TopVoter top, double percentage, LocalDateTime time) { - int total = getTotal(top, time); - switch (top) { + public boolean hasPercentageTotal(TopVoter top, double percentage, LocalDateTime time) { + return hasPercentageTotal(top, percentage, time, getTotal(top, time)); + } + + /** Checks a percentage requirement against an explicitly captured total. */ + public boolean hasPercentageTotal(TopVoter top, double percentage, LocalDateTime time, int total) { + switch (top) { case Daily: return (double) total / (double) plugin.getVoteSiteManager().getVoteSitesEnabled().size() * 100 > percentage; diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java index 64b61f7735..e40586211f 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; import org.mockito.Mockito; +import org.mockito.InOrder; import com.bencodez.advancedcore.api.time.TimeChangeTransition; import com.bencodez.advancedcore.api.time.events.DayChangeEvent; @@ -128,9 +129,11 @@ public void dayResetRecordsTransitionEffectBeforeCompletingLease() { voteParty.onDayChange(new DayChangeEvent(transition)); verify(plugin.getServerData()).beginTimeChangeRecovery(transition); - verify(plugin.getServerData()).completeTimeChangeVotePartyReset(transition, "VotePartyDayReset"); + InOrder completionOrder = Mockito.inOrder(plugin.getServerData(), lease); + completionOrder.verify(plugin.getServerData()) + .completeTimeChangeVotePartyReset(transition, "VotePartyDayReset"); + completionOrder.verify(lease).complete(); verify(plugin.getServerData(), never()).completeTimeChangeEffect(transition, "VotePartyDayReset"); - verify(lease).complete(); } @Test diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java index 1c7a28a916..f51a5fd3c4 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java @@ -3,6 +3,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -174,6 +176,34 @@ void votePartyStateAndEffectReceiptShareOneCheckpoint() { assertTrue(data.hasTimeChangeEffect(transition, "VotePartyWeekReset")); } + @Test + void failedVotePartyCheckpointKeepsTheEffectPendingInMemory() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); + YamlConfiguration yaml = new YamlConfiguration(); + when(plugin.getServerDataFile()).thenReturn(coreData); + when(coreData.getData()).thenReturn(yaml); + ServerData data = new ServerData(plugin); + TimeChangeTransition transition = transition("WEEK:2026-W38", "2026-W38", TimeType.WEEK); + data.beginTimeChangeRecovery(transition); + data.getData().set("VoteParty.Total", 12); + data.getData().set("VoteParty.Voted", List.of("player")); + doThrow(new IllegalStateException("disk unavailable")).when(coreData).saveData(); + + assertThrows(IllegalStateException.class, + () -> data.completeTimeChangeVotePartyReset(transition, "VotePartyWeekReset")); + + assertEquals(12, data.getData().getInt("VoteParty.Total")); + assertEquals(List.of("player"), data.getData().getStringList("VoteParty.Voted")); + assertFalse(data.hasTimeChangeEffect(transition, "VotePartyWeekReset")); + + data.getData().set("VotePartyExtraRequired", 7); + assertThrows(IllegalStateException.class, + () -> data.completeTimeChangeVotePartyExtraReset(transition, "VotePartyWeekExtraVotes")); + assertEquals(7, data.getData().getInt("VotePartyExtraRequired")); + assertFalse(data.hasTimeChangeEffect(transition, "VotePartyWeekExtraVotes")); + } + private TimeChangeTransition transition(String id, String period, TimeType type) { TimeChangeTransition transition = mock(TimeChangeTransition.class); when(transition.getId()).thenReturn(id); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java index fd9a62b0b7..f53d5816da 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java @@ -51,6 +51,30 @@ void retryUsesPersistedAbsoluteTargetAndDoesNotDuplicateCompletedStreakReward() verify(serverData, never()).completeTimeChangeUserStreakReward(transition, uuid); } + @Test + void weeklyStreakUsesBoundaryTotalInsteadOfANewWeekVote() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + ServerData serverData = mock(ServerData.class); + VotingPluginUser user = mock(VotingPluginUser.class); + TimeChangeTransition transition = mock(TimeChangeTransition.class); + String uuid = "00000000-0000-0000-0000-000000000001"; + when(plugin.getServerData()).thenReturn(serverData); + when(plugin.getConfigFile().isUseVoteStreaks()).thenReturn(true); + when(user.getLastWeeklyTotal()).thenReturn(0); + when(user.getTotal(TopVoter.Weekly)).thenReturn(1); + when(user.getWeekVoteStreak()).thenReturn(4); + when(serverData.prepareTimeChangeUserStreak(transition, uuid, 0, false)) + .thenReturn(new TimeChangeUserProgress(uuid, 0, false, false)); + + new TopVoterHandler(plugin).processWeeklyUser(user, transition, uuid); + + verify(serverData).prepareTimeChangeUserStreak(transition, uuid, 0, false); + verify(user).setWeekVoteStreak(0); + verify(user, never()).hasPercentageTotal(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.anyInt()); + } + @Test void dailyRecoveryUsesTheDayBeforeTheStableTransitionDate() { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); From dd204ec7a13fe14938d6665527a586c86df2447a Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 04:47:19 -0600 Subject: [PATCH 07/61] Keep recovered period boundaries stable --- .../topvoter/TopVoterHandler.java | 74 +++++++---- .../votingplugin/user/VotingPluginUser.java | 5 + .../service/VoteShopLimitMutationFence.java | 28 ++++ .../service/VoteShopPurchaseService.java | 26 ++-- .../TopVoterTimeChangeRecoveryTest.java | 123 ++++++++++++++++++ 5 files changed, 217 insertions(+), 39 deletions(-) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopLimitMutationFence.java 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 4c04db1b71..7624172d9c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -46,6 +46,7 @@ import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardTarget; import com.bencodez.votingplugin.data.ServerData.TimeChangeUserProgress; import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.voteshop.service.VoteShopLimitMutationFence; import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService; /** @@ -631,16 +632,7 @@ private void runRecoverablePeriod(TopVoter top, TimeChangeTransition transition) plugin.getServerData().completeTimeChangePhase(transition, TOP_REWARDS); } - if (!plugin.getServerData().hasTimeChangePhase(transition, VOTE_SHOP)) { - ensureTransitionActive(transition); - for (String shopIdent : plugin.getShopFile().getShopIdentifiers()) { - if (shouldResetVoteShop(top, shopIdent)) { - resetVoteShopLimit(shopIdent, - VoteShopPurchaseService.limitGenerationIdForTransition(transition)); - } - } - plugin.getServerData().completeTimeChangePhase(transition, VOTE_SHOP); - } + processRecoverableVoteShop(top, transition); if (!plugin.getServerData().hasTimeChangePhase(transition, BUNGEE_WAIT)) { waitForBungee(top, transition); @@ -691,14 +683,15 @@ private void processRecoverableUsers(TopVoter top, TimeChangeTransition transiti }, count -> { }); } - private void processDailyUser(VotingPluginUser user, TimeChangeTransition transition, String uuid) { + void processDailyUser(VotingPluginUser user, TimeChangeTransition transition, String uuid) { + int boundaryTotal = user.getLastDailyTotal(); if (plugin.getConfigFile().isUseVoteStreaks() && !user.voteStreakUpdatedToday(previousDayTime(transition)) && user.getDayVoteStreak() != 0) { applyRecoverableStreak(user, transition, uuid, TopVoter.Daily, 0, false); } if (plugin.getConfigFile().isUseHighestTotals() - && user.getHighestDailyTotal() < user.getTotal(TopVoter.Daily)) { - user.setHighestDailyTotal(user.getTotal(TopVoter.Daily)); + && user.getHighestDailyTotal() < boundaryTotal) { + user.setHighestDailyTotal(boundaryTotal); } } @@ -715,27 +708,42 @@ void processWeeklyUser(VotingPluginUser user, TimeChangeTransition transition, S } } if (plugin.getConfigFile().isUseHighestTotals() - && user.getHighestWeeklyTotal() < user.getTotal(TopVoter.Weekly)) { - user.setHighestWeeklyTotal(user.getTotal(TopVoter.Weekly)); + && user.getHighestWeeklyTotal() < boundaryTotal) { + user.setHighestWeeklyTotal(boundaryTotal); } } - private void processMonthlyUser(VotingPluginUser user, LocalDateTime lastMonthTime, + void processMonthlyUser(VotingPluginUser user, LocalDateTime lastMonthTime, TimeChangeTransition transition, String uuid) { + int boundaryTotal = plugin.getConfigFile().isUseMonthDateTotalsAsPrimaryTotal() + ? user.getTotal(TopVoter.Monthly, lastMonthTime) : user.getLastMonthTotal(); if (plugin.getConfigFile().isUseVoteStreaks()) { - if (user.getTotal(TopVoter.Monthly, lastMonthTime) == 0 && user.getMonthVoteStreak() != 0) { + if (boundaryTotal == 0 && user.getMonthVoteStreak() != 0) { applyRecoverableStreak(user, transition, uuid, TopVoter.Monthly, 0, false); } else if (!plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage() || user.hasPercentageTotal(TopVoter.Monthly, - plugin.getSpecialRewardsConfig().getVoteStreakRequirementMonth(), lastMonthTime)) { + plugin.getSpecialRewardsConfig().getVoteStreakRequirementMonth(), lastMonthTime, + boundaryTotal)) { applyRecoverableStreak(user, transition, uuid, TopVoter.Monthly, user.getMonthVoteStreak() + 1, true); } } if (plugin.getConfigFile().isUseHighestTotals() - && user.getHighestMonthlyTotal() < user.getTotal(TopVoter.Monthly, lastMonthTime)) { - user.setHighestMonthlyTotal(user.getTotal(TopVoter.Monthly, lastMonthTime)); + && user.getHighestMonthlyTotal() < boundaryTotal) { + user.setHighestMonthlyTotal(boundaryTotal); + } + } + + void processRecoverableVoteShop(TopVoter top, TimeChangeTransition transition) { + if (plugin.getServerData().hasTimeChangePhase(transition, VOTE_SHOP)) return; + ensureTransitionActive(transition); + String generation = VoteShopPurchaseService.limitGenerationIdForTransition(transition); + for (String shopIdent : plugin.getShopFile().getShopIdentifiers()) { + if (shouldResetVoteShop(top, shopIdent) && !resetVoteShopLimit(shopIdent, generation)) { + throw new IllegalStateException("Unable to durably reset VoteShop limit " + shopIdent); + } } + plugin.getServerData().completeTimeChangePhase(transition, VOTE_SHOP); } void applyRecoverableStreak(VotingPluginUser user, TimeChangeTransition transition, String uuid, @@ -944,23 +952,33 @@ public void resetVoteShopLimit(String shopIdent) { resetVoteShopLimit(shopIdent, null); } - private void resetVoteShopLimit(String shopIdent, String resetGeneration) { + private boolean resetVoteShopLimit(String shopIdent, String resetGeneration) { String limitColumn = "VoteShopLimit" + shopIdent; + if (resetGeneration != null) { + if (UserStorage.MYSQL.equals(plugin.getStorageType())) { + return VoteShopPurchaseService.resetMysqlLimitWithPurchaseFence(plugin, limitColumn, resetGeneration); + } + if (UserStorage.SQLITE.equals(plugin.getStorageType())) { + return VoteShopLimitMutationFence.withLock(() -> TimeChangeTotalReset.resetToZero(plugin, limitColumn, + resetGeneration + ':' + limitColumn)); + } + return false; + } if (UserStorage.MYSQL.equals(plugin.getStorageType()) && !plugin.getBungeeSettings().isPerServerPoints()) { - if (resetGeneration == null) VoteShopPurchaseService.resetSharedMysqlLimit(plugin, limitColumn); - else VoteShopPurchaseService.resetSharedMysqlLimit(plugin, limitColumn, resetGeneration); - return; + VoteShopPurchaseService.resetSharedMysqlLimit(plugin, limitColumn); + return true; } 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; + return VoteShopPurchaseService.resetMysqlLimitWithPurchaseFence(plugin, limitColumn, + UUID.randomUUID().toString()); } - plugin.getUserManager().removeAllKeyValues(limitColumn, DataType.INTEGER); + VoteShopLimitMutationFence.withLock( + () -> plugin.getUserManager().removeAllKeyValues(limitColumn, DataType.INTEGER)); + return true; } /** 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 622125b5ae..4bd7926d2d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -1323,6 +1323,11 @@ public int getLastMonthTotal() { return getData().getInt("LastMonthTotal"); } + /** Returns the daily total captured at the time-change boundary. */ + public int getLastDailyTotal() { + return getData().getInt("LastDailyTotal"); + } + /** Returns the weekly total captured at the time-change boundary. */ public int getLastWeeklyTotal() { return getData().getInt("LastWeeklyTotal"); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopLimitMutationFence.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopLimitMutationFence.java new file mode 100644 index 0000000000..4b30535222 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopLimitMutationFence.java @@ -0,0 +1,28 @@ +package com.bencodez.votingplugin.voteshop.service; + +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Supplier; + +/** Orders local VoteShop limit mutations against period resets. */ +public final class VoteShopLimitMutationFence { + private static final ReentrantLock FENCE = new ReentrantLock(true); + + private VoteShopLimitMutationFence() { + } + + public static T withLock(Supplier action) { + FENCE.lock(); + try { + return action.get(); + } finally { + FENCE.unlock(); + } + } + + public static void withLock(Runnable action) { + withLock(() -> { + action.run(); + return null; + }); + } +} 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 72cd453917..d6479338bb 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 @@ -492,20 +492,24 @@ VoteShopPurchaseResult debitForPurchase(VotingPluginUser user, VoteShopItem item 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 VoteShopLimitMutationFence.withLock(() -> debitLocal(user, item)); } } + private VoteShopPurchaseResult debitLocal(VotingPluginUser user, VoteShopItem 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; + } + private boolean usesSharedMysqlPoints() { return usesSharedMysqlPoints(plugin); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java index f53d5816da..e31b4b7f11 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java @@ -1,7 +1,9 @@ package com.bencodez.votingplugin.topvoter; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -18,14 +20,18 @@ import java.util.UUID; import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; import com.bencodez.advancedcore.api.time.TimeChangeTransition; +import com.bencodez.advancedcore.api.time.TimeType; +import com.bencodez.advancedcore.api.user.UserStorage; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.data.ServerData; import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardTarget; import com.bencodez.votingplugin.data.ServerData.TimeChangeUserProgress; import com.bencodez.votingplugin.specialrewards.SpecialRewards; import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService; class TopVoterTimeChangeRecoveryTest { @Test @@ -75,6 +81,75 @@ void weeklyStreakUsesBoundaryTotalInsteadOfANewWeekVote() { org.mockito.ArgumentMatchers.anyInt()); } + @Test + void recoveredHighestTotalsUseCopiedDailyAndWeeklyBoundaries() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + VotingPluginUser user = mock(VotingPluginUser.class); + TimeChangeTransition transition = mock(TimeChangeTransition.class); + when(plugin.getConfigFile().isUseHighestTotals()).thenReturn(true); + when(user.getLastDailyTotal()).thenReturn(5); + when(user.getTotal(TopVoter.Daily)).thenReturn(8); + when(user.getHighestDailyTotal()).thenReturn(2); + when(user.getLastWeeklyTotal()).thenReturn(6); + when(user.getTotal(TopVoter.Weekly)).thenReturn(9); + when(user.getHighestWeeklyTotal()).thenReturn(3); + + TopVoterHandler handler = new TopVoterHandler(plugin); + handler.processDailyUser(user, transition, "player"); + handler.processWeeklyUser(user, transition, "player"); + + verify(user).setHighestDailyTotal(5); + verify(user).setHighestWeeklyTotal(6); + verify(user, never()).setHighestDailyTotal(8); + verify(user, never()).setHighestWeeklyTotal(9); + } + + @Test + void monthlyRecoveryUsesCopiedBoundaryWhenUndatedTotalsArePrimary() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + ServerData serverData = mock(ServerData.class); + VotingPluginUser user = mock(VotingPluginUser.class); + TimeChangeTransition transition = mock(TimeChangeTransition.class); + LocalDateTime previousMonth = LocalDateTime.of(2026, 8, 15, 0, 0); + String uuid = "00000000-0000-0000-0000-000000000001"; + when(plugin.getServerData()).thenReturn(serverData); + when(plugin.getConfigFile().isUseVoteStreaks()).thenReturn(true); + when(plugin.getConfigFile().isUseHighestTotals()).thenReturn(true); + when(plugin.getConfigFile().isUseMonthDateTotalsAsPrimaryTotal()).thenReturn(false); + when(user.getLastMonthTotal()).thenReturn(0); + when(user.getTotal(TopVoter.Monthly, previousMonth)).thenReturn(7); + when(user.getMonthVoteStreak()).thenReturn(4); + when(serverData.prepareTimeChangeUserStreak(transition, uuid, 0, false)) + .thenReturn(new TimeChangeUserProgress(uuid, 0, false, false)); + + new TopVoterHandler(plugin).processMonthlyUser(user, previousMonth, transition, uuid); + + verify(serverData).prepareTimeChangeUserStreak(transition, uuid, 0, false); + verify(user).setMonthVoteStreak(0); + verify(user, never()).setHighestMonthlyTotal(7); + verify(user, never()).hasPercentageTotal(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.anyInt()); + } + + @Test + void monthlyRecoveryUsesDatedBoundaryWhenDateTotalsArePrimary() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + VotingPluginUser user = mock(VotingPluginUser.class); + TimeChangeTransition transition = mock(TimeChangeTransition.class); + LocalDateTime previousMonth = LocalDateTime.of(2026, 8, 15, 0, 0); + when(plugin.getConfigFile().isUseHighestTotals()).thenReturn(true); + when(plugin.getConfigFile().isUseMonthDateTotalsAsPrimaryTotal()).thenReturn(true); + when(user.getTotal(TopVoter.Monthly, previousMonth)).thenReturn(5); + when(user.getLastMonthTotal()).thenReturn(9); + when(user.getHighestMonthlyTotal()).thenReturn(2); + + new TopVoterHandler(plugin).processMonthlyUser(user, previousMonth, transition, "player"); + + verify(user).setHighestMonthlyTotal(5); + verify(user, never()).setHighestMonthlyTotal(9); + } + @Test void dailyRecoveryUsesTheDayBeforeTheStableTransitionDate() { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); @@ -160,4 +235,52 @@ void sqliteAuxiliaryResetDoesNotRepeatAfterNewVotes() throws Exception { } } } + + @Test + void sqliteVoteShopResetsRemainIndependentAndDoNotRepeat() throws Exception { + try (Connection connection = DriverManager.getConnection("jdbc:sqlite::memory:"); + Statement statement = connection.createStatement()) { + statement.executeUpdate("CREATE TABLE users (uuid TEXT PRIMARY KEY, VoteShopLimitdaily INTEGER, " + + "VoteShopLimitweekly INTEGER)"); + statement.executeUpdate("INSERT INTO users VALUES ('player', 4, 6)"); + TimeChangeTotalReset.resetSqliteToZero(connection, "users", "VoteShopLimitdaily", + "time-shop:DAY:2026-09-21:VoteShopLimitdaily"); + TimeChangeTotalReset.resetSqliteToZero(connection, "users", "VoteShopLimitweekly", + "time-shop:DAY:2026-09-21:VoteShopLimitweekly"); + statement.executeUpdate("UPDATE users SET VoteShopLimitdaily = 1, VoteShopLimitweekly = 2"); + TimeChangeTotalReset.resetSqliteToZero(connection, "users", "VoteShopLimitdaily", + "time-shop:DAY:2026-09-21:VoteShopLimitdaily"); + TimeChangeTotalReset.resetSqliteToZero(connection, "users", "VoteShopLimitweekly", + "time-shop:DAY:2026-09-21:VoteShopLimitweekly"); + try (ResultSet result = statement.executeQuery( + "SELECT VoteShopLimitdaily, VoteShopLimitweekly FROM users WHERE uuid = 'player'")) { + assertEquals(1, result.getInt(1)); + assertEquals(2, result.getInt(2)); + } + } + } + + @Test + void failedMysqlVoteShopResetLeavesPhasePending() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + ServerData serverData = mock(ServerData.class); + TimeChangeTransition transition = mock(TimeChangeTransition.class); + when(plugin.getServerData()).thenReturn(serverData); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getShopFile().getShopIdentifiers()).thenReturn(Set.of("daily")); + when(plugin.getShopFile().getVoteShopResetDaily("daily")).thenReturn(true); + when(transition.getType()).thenReturn(TimeType.DAY); + when(transition.getPeriodKey()).thenReturn("2026-09-21"); + try (MockedStatic purchaseService = mockStatic(VoteShopPurchaseService.class)) { + purchaseService.when(() -> VoteShopPurchaseService.limitGenerationIdForTransition(transition)) + .thenReturn("time-shop:DAY:2026-09-21"); + purchaseService.when(() -> VoteShopPurchaseService.resetMysqlLimitWithPurchaseFence(plugin, + "VoteShopLimitdaily", "time-shop:DAY:2026-09-21")).thenReturn(false); + + assertThrows(IllegalStateException.class, + () -> new TopVoterHandler(plugin).processRecoverableVoteShop(TopVoter.Daily, transition)); + } + + verify(serverData, never()).completeTimeChangePhase(transition, "VOTE_SHOP"); + } } From fc6e8862ed5f76b5928ad20998bd5df017df24b3 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 05:14:02 -0600 Subject: [PATCH 08/61] Fix period boundary recovery ordering --- .../votingplugin/data/ServerData.java | 31 ++++++++++++++----- .../votingplugin/proxy/VotingPluginProxy.java | 3 -- .../specialrewards/voteparty/VoteParty.java | 12 ++++++- .../voteparty/VotePartyState.java | 7 ++++- .../topvoter/TimeChangeTotalReset.java | 4 +-- .../topvoter/TopVoterHandler.java | 6 ++-- .../votingplugin/user/UserManager.java | 1 + .../votingplugin/tests/VotePartyTest.java | 13 ++++++-- .../tests/VotingPluginProxyTest.java | 9 ++++++ .../ServerDataTimeChangeRecoveryTest.java | 30 +++++++++++++++--- .../TopVoterTimeChangeRecoveryTest.java | 20 +++++++----- 11 files changed, 104 insertions(+), 32 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index dd064ce634..49e59f0259 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -720,24 +720,41 @@ public synchronized void completeTimeChangeEffect(TimeChangeTransition transitio saveData(); } - /** Resets YAML-backed VoteParty state and records its receipt in one save. */ + /** + * Clears YAML-backed VoteParty state before the recoverable database reset. + * The in-memory marker deliberately remains applied when saving fails. A later + * vote will therefore persist the cleared boundary and its own new state + * together, while a restart can safely retry the clear before votes resume. + */ + public synchronized void prepareTimeChangeVotePartyReset(TimeChangeTransition transition, String effect) { + String path = timeChangeRecoveryPath(transition.getType()); + if (!transition.getId().equals(getData().getString(path + ".Id", ""))) { + throw new IllegalStateException("Time change recovery transition does not match"); + } + String resetPath = path + ".VotePartyResets." + effect + ".StateReset"; + if (getData().getBoolean(resetPath, false)) return; + getData().set("VoteParty.Total", 0); + getData().set("VoteParty.Voted", new ArrayList<>()); + getData().set(resetPath, true); + saveData(); + } + + /** Records the VoteParty reset receipt after its database boundary is removed. */ public synchronized void completeTimeChangeVotePartyReset(TimeChangeTransition transition, String effect) { String path = timeChangeRecoveryPath(transition.getType()); if (!transition.getId().equals(getData().getString(path + ".Id", ""))) { throw new IllegalStateException("Time change recovery transition does not match"); } - int previousTotal = getData().getInt("VoteParty.Total"); - List previousVoters = new ArrayList<>(getData().getStringList("VoteParty.Voted")); + String resetPath = path + ".VotePartyResets." + effect + ".StateReset"; + if (!getData().getBoolean(resetPath, false)) { + throw new IllegalStateException("VoteParty state reset is not prepared"); + } String effectPath = path + ".Effects." + effect; Object previousEffect = getData().get(effectPath); try { - getData().set("VoteParty.Total", 0); - getData().set("VoteParty.Voted", new ArrayList<>()); getData().set(effectPath, true); saveData(); } catch (RuntimeException | Error failure) { - getData().set("VoteParty.Total", previousTotal); - getData().set("VoteParty.Voted", previousVoters); getData().set(effectPath, previousEffect); throw failure; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java index be7053e6b5..9ba456734a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java @@ -470,9 +470,6 @@ public void onTimeChangedFailed(String srv, TimeType type) { } public void onTimeChangedFinished(TimeType type) { - if (type.equals(TimeType.MONTH)) { - getProxyMySQL().copyColumnData(TopVoter.Monthly.getColumnName(), "LastMonthTotal"); - } getProxyMySQL().wipeColumnData(TopVoter.of(type).getColumnName(), DataType.INTEGER); if (!getConfig().getGlobalDataEnabled()) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java index 46f32d2396..31b7dac92a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java @@ -296,13 +296,23 @@ private void runRecoverableVotePartyReset(TimeChangeTransition transition, Strin return; } runRecoverableReset(transition, effect, () -> { - if (!resetRecoverableUserCounts("vote-party:" + transition.getId() + ':' + effect)) { + String generation = transition.getId() + ':' + effect; + if (!copyRecoverableUserCountBoundary("vote-party-copy:" + generation)) { + throw new IllegalStateException("Unable to durably capture VoteParty user counts"); + } + plugin.getServerData().prepareTimeChangeVotePartyReset(transition, effect); + if (!resetRecoverableUserCounts("vote-party-reset:" + generation)) { throw new IllegalStateException("Unable to durably reset VoteParty user counts"); } plugin.getServerData().completeTimeChangeVotePartyReset(transition, effect); }); } + /** Storage boundary kept separate so lifecycle tests do not require a live database. */ + public boolean copyRecoverableUserCountBoundary(String generation) { + return state.copyUserCountBoundary(generation); + } + /** Storage boundary kept separate so lifecycle tests do not require a live database. */ public boolean resetRecoverableUserCounts(String generation) { return state.resetUserCounts(generation); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VotePartyState.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VotePartyState.java index ee14749e55..82ee7f2d94 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VotePartyState.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VotePartyState.java @@ -13,6 +13,7 @@ public final class VotePartyState { private static final String TOTAL_PATH = "VoteParty.Total"; private static final String VOTED_PATH = "VoteParty.Voted"; private static final String USER_COUNT_KEY = "VotePartyVotes"; + private static final String LAST_USER_COUNT_KEY = "LastVotePartyVotes"; private final VotingPluginMain plugin; @@ -61,7 +62,11 @@ public void resetUserCounts() { plugin.getUserManager().removeAllKeyValues(USER_COUNT_KEY, DataType.INTEGER); } + public boolean copyUserCountBoundary(String generation) { + return TimeChangeTotalReset.copyBoundary(plugin, USER_COUNT_KEY, LAST_USER_COUNT_KEY, generation); + } + public boolean resetUserCounts(String generation) { - return TimeChangeTotalReset.resetToZero(plugin, USER_COUNT_KEY, generation); + return TimeChangeTotalReset.reset(plugin, USER_COUNT_KEY, LAST_USER_COUNT_KEY, generation); } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TimeChangeTotalReset.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TimeChangeTotalReset.java index b99856d4fd..de9be87956 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TimeChangeTotalReset.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TimeChangeTotalReset.java @@ -18,7 +18,7 @@ public final class TimeChangeTotalReset { private TimeChangeTotalReset() { } - static boolean reset(VotingPluginMain plugin, String column, String previousColumn, String generation) { + public static boolean reset(VotingPluginMain plugin, String column, String previousColumn, String generation) { boolean[] reset = { false }; PeriodTotalMutationFence.withReset(() -> { // Drain queued absolute totals while new total mutations are excluded. The @@ -45,7 +45,7 @@ static boolean reset(VotingPluginMain plugin, String column, String previousColu return reset[0]; } - static boolean copyBoundary(VotingPluginMain plugin, String column, String previousColumn, String generation) { + public static boolean copyBoundary(VotingPluginMain plugin, String column, String previousColumn, String generation) { boolean[] copied = { false }; PeriodTotalMutationFence.withReset(() -> { plugin.getUserManager().getDataManager().clearCache(); 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 7624172d9c..a382c462af 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -596,7 +596,7 @@ private void runRecoverablePeriod(TopVoter top, TimeChangeTransition transition) // Capture the boundary before any long-running phase. The database journal // makes the later COPY_TOTALS retry a no-op, so votes accepted while reward // or user recovery is pending remain above this boundary. - if (top != TopVoter.Monthly || !bungeeHandleResets()) copyTotalBoundary(top, transition); + copyTotalBoundary(top, transition); if (!plugin.getServerData().hasTimeChangePhase(transition, SNAPSHOT)) { ensureTransitionActive(transition); @@ -616,9 +616,7 @@ private void runRecoverablePeriod(TopVoter top, TimeChangeTransition transition) if (!plugin.getServerData().hasTimeChangePhase(transition, COPY_TOTALS)) { ensureTransitionActive(transition); - if (top != TopVoter.Monthly || !bungeeHandleResets()) { - copyTotalBoundary(top, transition); - } + copyTotalBoundary(top, transition); plugin.getServerData().completeTimeChangePhase(transition, COPY_TOTALS); } 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 8c6b2c1564..69c7ee7b4f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/UserManager.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/UserManager.java @@ -53,6 +53,7 @@ public void addCachingKeys() { UserDataManager manager = plugin.getUserManager().getDataManager(); manager.addKey(new UserDataKeyBoolean("TopVoterIgnore")); manager.addKey(new UserDataKeyInt("VotePartyVotes")); + manager.addKey(new UserDataKeyInt("LastVotePartyVotes")); manager.addKey(new UserDataKeyString("LastVotes")); manager.addKey(new UserDataKeyBoolean(getCoolDownCheckPath())); manager.addKey(new UserDataKeyString("OfflineVotes").setColumnType("MEDIUMTEXT")); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java index e40586211f..467e114e3e 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java @@ -124,12 +124,21 @@ public void dayResetRecordsTransitionEffectBeforeCompletingLease() { when(transition.getType()).thenReturn(com.bencodez.advancedcore.api.time.TimeType.DAY); when(transition.getId()).thenReturn("DAY:2026-09-21"); when(plugin.getSpecialRewardsConfig().isVotePartyResetEachDay()).thenReturn(true); - doReturn(true).when(voteParty).resetRecoverableUserCounts("vote-party:DAY:2026-09-21:VotePartyDayReset"); + doReturn(true).when(voteParty) + .copyRecoverableUserCountBoundary("vote-party-copy:DAY:2026-09-21:VotePartyDayReset"); + doReturn(true).when(voteParty) + .resetRecoverableUserCounts("vote-party-reset:DAY:2026-09-21:VotePartyDayReset"); voteParty.onDayChange(new DayChangeEvent(transition)); verify(plugin.getServerData()).beginTimeChangeRecovery(transition); - InOrder completionOrder = Mockito.inOrder(plugin.getServerData(), lease); + InOrder completionOrder = Mockito.inOrder(voteParty, plugin.getServerData(), lease); + completionOrder.verify(voteParty) + .copyRecoverableUserCountBoundary("vote-party-copy:DAY:2026-09-21:VotePartyDayReset"); + completionOrder.verify(plugin.getServerData()) + .prepareTimeChangeVotePartyReset(transition, "VotePartyDayReset"); + completionOrder.verify(voteParty) + .resetRecoverableUserCounts("vote-party-reset:DAY:2026-09-21:VotePartyDayReset"); completionOrder.verify(plugin.getServerData()) .completeTimeChangeVotePartyReset(transition, "VotePartyDayReset"); completionOrder.verify(lease).complete(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java index 3b77956d2f..17555b7413 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java @@ -17,6 +17,7 @@ import org.mockito.Mockito; import org.mockito.MockitoAnnotations; +import com.bencodez.advancedcore.api.time.TimeType; import com.bencodez.advancedcore.bungeeapi.globaldata.GlobalDataHandlerProxy; import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; import com.bencodez.simpleapi.servercomm.http.HttpProxyTransportServer; @@ -60,6 +61,14 @@ void setUp() { } + @Test + void monthlyCompletionOnlyClearsTheBoundaryAlreadyCapturedByTheBackend() { + votingPluginProxy.onTimeChangedFinished(TimeType.MONTH); + + verify(proxyMySQL).wipeColumnData("MonthTotal", com.bencodez.simpleapi.sql.DataType.INTEGER); + verify(proxyMySQL, never()).copyColumnData(Mockito.anyString(), Mockito.anyString()); + } + @Test void httpMethodPreparationRejectsAnOccupiedPortBeforeConfigurationPublication() throws Exception { try (java.net.ServerSocket occupied = new java.net.ServerSocket(0, 1, diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java index f51a5fd3c4..aa78a3d20f 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; @@ -157,7 +158,7 @@ void rewardAndArchiveBoundaryDataShareOneDurableCheckpoint() { } @Test - void votePartyStateAndEffectReceiptShareOneCheckpoint() { + void votePartyStateResetIsPreparedBeforeItsEffectReceipt() { VotingPluginMain plugin = mock(VotingPluginMain.class); com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); YamlConfiguration yaml = new YamlConfiguration(); @@ -169,6 +170,7 @@ void votePartyStateAndEffectReceiptShareOneCheckpoint() { data.getData().set("VoteParty.Total", 12); data.getData().set("VoteParty.Voted", List.of("player")); + data.prepareTimeChangeVotePartyReset(transition, "VotePartyWeekReset"); data.completeTimeChangeVotePartyReset(transition, "VotePartyWeekReset"); assertEquals(0, data.getData().getInt("VoteParty.Total")); @@ -177,7 +179,7 @@ void votePartyStateAndEffectReceiptShareOneCheckpoint() { } @Test - void failedVotePartyCheckpointKeepsTheEffectPendingInMemory() { + void failedVotePartyStateSaveKeepsTheLogicalResetForLaterVotes() { VotingPluginMain plugin = mock(VotingPluginMain.class); com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); YamlConfiguration yaml = new YamlConfiguration(); @@ -191,13 +193,31 @@ void failedVotePartyCheckpointKeepsTheEffectPendingInMemory() { doThrow(new IllegalStateException("disk unavailable")).when(coreData).saveData(); assertThrows(IllegalStateException.class, - () -> data.completeTimeChangeVotePartyReset(transition, "VotePartyWeekReset")); + () -> data.prepareTimeChangeVotePartyReset(transition, "VotePartyWeekReset")); + + assertEquals(0, data.getData().getInt("VoteParty.Total")); + assertTrue(data.getData().getStringList("VoteParty.Voted").isEmpty()); + assertFalse(data.hasTimeChangeEffect(transition, "VotePartyWeekReset")); - assertEquals(12, data.getData().getInt("VoteParty.Total")); - assertEquals(List.of("player"), data.getData().getStringList("VoteParty.Voted")); + data.getData().set("VoteParty.Total", 2); + data.getData().set("VoteParty.Voted", List.of("new-player")); + doNothing().when(coreData).saveData(); + data.prepareTimeChangeVotePartyReset(transition, "VotePartyWeekReset"); + doThrow(new IllegalStateException("disk unavailable")).when(coreData).saveData(); + assertThrows(IllegalStateException.class, + () -> data.completeTimeChangeVotePartyReset(transition, "VotePartyWeekReset")); + assertEquals(2, data.getData().getInt("VoteParty.Total")); + assertEquals(List.of("new-player"), data.getData().getStringList("VoteParty.Voted")); assertFalse(data.hasTimeChangeEffect(transition, "VotePartyWeekReset")); + doNothing().when(coreData).saveData(); + data.completeTimeChangeVotePartyReset(transition, "VotePartyWeekReset"); + assertEquals(2, data.getData().getInt("VoteParty.Total")); + assertEquals(List.of("new-player"), data.getData().getStringList("VoteParty.Voted")); + assertTrue(data.hasTimeChangeEffect(transition, "VotePartyWeekReset")); + data.getData().set("VotePartyExtraRequired", 7); + doThrow(new IllegalStateException("disk unavailable")).when(coreData).saveData(); assertThrows(IllegalStateException.class, () -> data.completeTimeChangeVotePartyExtraReset(transition, "VotePartyWeekExtraVotes")); assertEquals(7, data.getData().getInt("VotePartyExtraRequired")); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java index e31b4b7f11..7ba4bd245a 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java @@ -222,16 +222,22 @@ void sqliteTotalResetRetryPreservesVotesAcceptedAfterTheFirstReset() throws Exce } @Test - void sqliteAuxiliaryResetDoesNotRepeatAfterNewVotes() throws Exception { + void sqliteVotePartyBoundaryResetPreservesVotesAcceptedAfterTheBoundary() throws Exception { try (Connection connection = DriverManager.getConnection("jdbc:sqlite::memory:"); Statement statement = connection.createStatement()) { - statement.executeUpdate("CREATE TABLE users (uuid TEXT PRIMARY KEY, VotePartyVotes INTEGER)"); - statement.executeUpdate("INSERT INTO users VALUES ('player', 4)"); - TimeChangeTotalReset.resetSqliteToZero(connection, "users", "VotePartyVotes", "vote-party:DAY"); - statement.executeUpdate("UPDATE users SET VotePartyVotes = 2 WHERE uuid = 'player'"); - TimeChangeTotalReset.resetSqliteToZero(connection, "users", "VotePartyVotes", "vote-party:DAY"); + statement.executeUpdate("CREATE TABLE users (uuid TEXT PRIMARY KEY, VotePartyVotes INTEGER, " + + "LastVotePartyVotes INTEGER)"); + statement.executeUpdate("INSERT INTO users VALUES ('player', 4, 0)"); + TimeChangeTotalReset.copyBoundarySqlite(connection, "users", "VotePartyVotes", "LastVotePartyVotes", + "vote-party-copy:DAY"); + statement.executeUpdate("UPDATE users SET VotePartyVotes = VotePartyVotes + 3 WHERE uuid = 'player'"); + TimeChangeTotalReset.resetSqlite(connection, "users", "VotePartyVotes", "LastVotePartyVotes", + "vote-party-reset:DAY"); + statement.executeUpdate("UPDATE users SET VotePartyVotes = VotePartyVotes + 2 WHERE uuid = 'player'"); + TimeChangeTotalReset.resetSqlite(connection, "users", "VotePartyVotes", "LastVotePartyVotes", + "vote-party-reset:DAY"); try (ResultSet result = statement.executeQuery("SELECT VotePartyVotes FROM users WHERE uuid = 'player'")) { - assertEquals(2, result.getInt(1)); + assertEquals(5, result.getInt(1)); } } } From 4dc22c3a4d90ce997bf1f4cc185c105408975f25 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 05:34:00 -0600 Subject: [PATCH 09/61] Snapshot daily streak recovery state --- .../topvoter/TimeChangeTotalReset.java | 34 +++++++++ .../topvoter/TopVoterHandler.java | 27 +++++-- .../votingplugin/user/UserManager.java | 2 + .../votingplugin/user/VotingPluginUser.java | 56 ++++++++++----- .../service/SharedMysqlPurchaseJournal.java | 20 +++++- .../service/VoteShopPurchaseService.java | 24 +++++++ .../TopVoterTimeChangeRecoveryTest.java | 71 +++++++++++++++++++ .../SharedMysqlPurchaseJournalTest.java | 26 +++++++ 8 files changed, 233 insertions(+), 27 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TimeChangeTotalReset.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TimeChangeTotalReset.java index de9be87956..00483612b9 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TimeChangeTotalReset.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TimeChangeTotalReset.java @@ -69,6 +69,32 @@ public static boolean copyBoundary(VotingPluginMain plugin, String column, Strin return copied[0]; } + /** Captures the daily-streak value and timestamp as one recoverable boundary. */ + public static boolean copyDailyStreakBoundary(VotingPluginMain plugin, String generation) { + boolean[] copied = { false }; + PeriodTotalMutationFence.withReset(() -> { + plugin.getUserManager().getDataManager().clearCache(); + if (UserStorage.MYSQL.equals(plugin.getStorageType())) { + copied[0] = VoteShopPurchaseService.copyMysqlDailyStreakBoundary(plugin, "DayVoteStreak", + "LastDayVoteStreak", "DayVoteStreakLastUpdate", "LastDayVoteStreakLastUpdate", generation); + return; + } + if (!UserStorage.SQLITE.equals(plugin.getStorageType())) return; + UserTable table = plugin.getSQLiteUserTable(); + try { + String url = table.getSqLite().getSQLConnection().getMetaData().getURL(); + try (Connection connection = DriverManager.getConnection(url)) { + copied[0] = copyDailyStreakBoundarySqlite(connection, table.getName(), generation); + } + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to atomically copy daily streak boundary: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + } + }); + return copied[0]; + } + /** Resets an auxiliary integer column once for a recoverable listener effect. */ public static boolean resetToZero(VotingPluginMain plugin, String column, String generation) { plugin.getUserManager().getDataManager().clearCache(); @@ -104,6 +130,14 @@ static boolean copyBoundarySqlite(Connection connection, String table, String co "COALESCE(" + quote(column) + ", 0)"); } + static boolean copyDailyStreakBoundarySqlite(Connection connection, String table, String generation) + throws SQLException { + if (!safeIdentifier(table)) throw new SQLException("Invalid daily streak boundary table"); + return resetSqliteExpression(connection, table, "LastDayVoteStreak", generation, + "COALESCE(" + quote("DayVoteStreak") + ", 0), " + quote("LastDayVoteStreakLastUpdate") + + " = COALESCE(" + quote("DayVoteStreakLastUpdate") + ", '')"); + } + static boolean resetSqlite(Connection connection, String table, String column, String previousColumn, String generation) throws SQLException { 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 a382c462af..a4e731e7a7 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -45,6 +45,7 @@ import com.bencodez.votingplugin.data.ServerData.TimeChangeArchiveSnapshot; import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardTarget; import com.bencodez.votingplugin.data.ServerData.TimeChangeUserProgress; +import com.bencodez.votingplugin.user.PeriodTotalMutationFence; import com.bencodez.votingplugin.user.VotingPluginUser; import com.bencodez.votingplugin.voteshop.service.VoteShopLimitMutationFence; import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService; @@ -652,9 +653,17 @@ private void runRecoverablePeriod(TopVoter top, TimeChangeTransition transition) private void copyTotalBoundary(TopVoter top, TimeChangeTransition transition) { ensureTransitionActive(transition); - if (!TimeChangeTotalReset.copyBoundary(plugin, top.getColumnName(), top.getLastColumnName(), - "time-copy:" + transition.getId())) { - throw new IllegalStateException("Unable to durably copy " + top + " boundary totals"); + boolean[] copied = { false }; + PeriodTotalMutationFence.withReset(() -> { + copied[0] = TimeChangeTotalReset.copyBoundary(plugin, top.getColumnName(), top.getLastColumnName(), + "time-copy:" + transition.getId()); + if (copied[0] && top == TopVoter.Daily && plugin.getConfigFile().isUseVoteStreaks()) { + copied[0] = TimeChangeTotalReset.copyDailyStreakBoundary(plugin, + "time-streak-copy:" + transition.getId()); + } + }); + if (!copied[0]) { + throw new IllegalStateException("Unable to durably copy " + top + " boundary state"); } } @@ -683,9 +692,15 @@ private void processRecoverableUsers(TopVoter top, TimeChangeTransition transiti void processDailyUser(VotingPluginUser user, TimeChangeTransition transition, String uuid) { int boundaryTotal = user.getLastDailyTotal(); - if (plugin.getConfigFile().isUseVoteStreaks() - && !user.voteStreakUpdatedToday(previousDayTime(transition)) && user.getDayVoteStreak() != 0) { - applyRecoverableStreak(user, transition, uuid, TopVoter.Daily, 0, false); + if (plugin.getConfigFile().isUseVoteStreaks()) { + int boundaryStreak = user.getLastDayVoteStreak(); + long boundaryUpdate = user.getLastDayVoteStreakLastUpdate(); + if (!user.voteStreakUpdatedAt(boundaryUpdate, previousDayTime(transition)) && boundaryStreak != 0) { + // A vote accepted after the boundary has already started the new day's + // streak. Preserve that contribution while removing the stale streak. + int target = user.getDayVoteStreakLastUpdate() == boundaryUpdate ? 0 : 1; + applyRecoverableStreak(user, transition, uuid, TopVoter.Daily, target, false); + } } if (plugin.getConfigFile().isUseHighestTotals() && user.getHighestDailyTotal() < boundaryTotal) { 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 69c7ee7b4f..3c89372851 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/UserManager.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/UserManager.java @@ -72,8 +72,10 @@ public void addCachingKeys() { manager.addKey(new UserDataKeyInt("WeeklyTotal")); manager.addKey(new UserDataKeyInt("Points")); manager.addKey(new UserDataKeyInt("DayVoteStreak")); + manager.addKey(new UserDataKeyInt("LastDayVoteStreak")); manager.addKey(new UserDataKeyInt("BestDayVoteStreak")); manager.addKey(new UserDataKeyString("DayVoteStreakLastUpdate").setColumnType("MEDIUMTEXT")); + manager.addKey(new UserDataKeyString("LastDayVoteStreakLastUpdate").setColumnType("MEDIUMTEXT")); manager.addKey(new UserDataKeyString(VoteMilestonesManager.getLIMITS_STORAGE_KEY()).setColumnType("LONGTEXT")); manager.addKey(new UserDataKeyBoolean("Reminded")); manager.addKey(new UserDataKeyBoolean("DisableBroadcast")); 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 4bd7926d2d..62bb694419 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -1061,20 +1061,22 @@ public boolean checkAlmostAllVotes() { * * @param forceBungee whether to force bungee */ - @Deprecated - public void checkDayVoteStreak(boolean forceBungee) { - if (!voteStreakUpdatedToday(LocalDateTime.now())) { - if (!plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage() || hasPercentageTotal( - TopVoter.Daily, plugin.getSpecialRewardsConfig().getVoteStreakRequirementDay(), null)) { - plugin.extraDebug("Adding day vote streak to " + getUUID() + " " - + plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage() + " " - + hasPercentageTotal(TopVoter.Daily, - plugin.getSpecialRewardsConfig().getVoteStreakRequirementDay(), null)); - addDayVoteStreak(); - plugin.getSpecialRewards().checkVoteStreak(null, this, "Day", forceBungee); - setDayVoteStreakLastUpdate(System.currentTimeMillis()); - } - } + @Deprecated + public void checkDayVoteStreak(boolean forceBungee) { + PeriodTotalMutationFence.withMutation(() -> { + if (!voteStreakUpdatedToday(LocalDateTime.now())) { + if (!plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage() || hasPercentageTotal( + TopVoter.Daily, plugin.getSpecialRewardsConfig().getVoteStreakRequirementDay(), null)) { + plugin.extraDebug("Adding day vote streak to " + getUUID() + " " + + plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage() + " " + + hasPercentageTotal(TopVoter.Daily, + plugin.getSpecialRewardsConfig().getVoteStreakRequirementDay(), null)); + addDayVoteStreak(); + plugin.getSpecialRewards().checkVoteStreak(null, this, "Day", forceBungee); + setDayVoteStreakLastUpdate(System.currentTimeMillis()); + } + } + }); } /** @@ -1230,8 +1232,20 @@ public int getDayVoteStreak() { * @return the last update time for the day vote streak */ @Deprecated - public long getDayVoteStreakLastUpdate() { - String str = getData().getString("DayVoteStreakLastUpdate"); + public long getDayVoteStreakLastUpdate() { + return getVoteStreakUpdate("DayVoteStreakLastUpdate"); + } + + public int getLastDayVoteStreak() { + return getData().getInt("LastDayVoteStreak"); + } + + public long getLastDayVoteStreakLastUpdate() { + return getVoteStreakUpdate("LastDayVoteStreakLastUpdate"); + } + + private long getVoteStreakUpdate(String path) { + String str = getData().getString(path); if (str == null || str.isEmpty() || str.equals("null")) { return 0; } @@ -2765,9 +2779,13 @@ public long voteNextDurationTime(VoteSite voteSite, long time) { * @return true if the vote streak was updated today, false otherwise */ @Deprecated - public boolean voteStreakUpdatedToday(LocalDateTime time) { - return MiscUtils.getInstance().getTime(getDayVoteStreakLastUpdate()).getDayOfYear() == time.getDayOfYear(); - } + public boolean voteStreakUpdatedToday(LocalDateTime time) { + return voteStreakUpdatedAt(getDayVoteStreakLastUpdate(), time); + } + + public boolean voteStreakUpdatedAt(long update, LocalDateTime time) { + return MiscUtils.getInstance().getTime(update).getDayOfYear() == time.getDayOfYear(); + } public String getVoteStreakState(String columnName) { return getData().getString(columnName); 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 index 2173c19598..484f0113c4 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -269,13 +269,29 @@ void copyPeriodBoundary(String totalColumn, String previousColumn, String genera if (!isSafeColumn(totalColumn) || !isSafeColumn(previousColumn)) { throw new SQLException("Unsafe period boundary column"); } + copyBoundary("period-copy:" + totalColumn, generation, + qi(previousColumn) + " = COALESCE(" + qi(totalColumn) + ", 0)"); + } + + /** Copies the daily-streak value and timestamp in one recoverable transaction. */ + void copyDailyStreakBoundary(String streakColumn, String previousStreakColumn, String updateColumn, + String previousUpdateColumn, String generation) throws SQLException { + if (!isSafeColumn(streakColumn) || !isSafeColumn(previousStreakColumn) || !isSafeColumn(updateColumn) + || !isSafeColumn(previousUpdateColumn)) { + throw new SQLException("Unsafe daily streak boundary column"); + } + copyBoundary("streak-copy:" + streakColumn, generation, + qi(previousStreakColumn) + " = COALESCE(" + qi(streakColumn) + ", 0), " + + qi(previousUpdateColumn) + " = COALESCE(" + qi(updateColumn) + ", '')"); + } + + private void copyBoundary(String markerKey, String generation, String assignments) throws SQLException { if (generation == null || generation.isEmpty() || generation.length() > 128) { throw new SQLException("Invalid period boundary generation"); } try (Connection connection = connection()) { connection.setAutoCommit(false); try { - String markerKey = "period-copy:" + totalColumn; EpochRow marker = lockLimitEpochRow(connection, markerKey); if (generation.equals(marker.lastResetGeneration())) { rollback(connection); @@ -284,7 +300,7 @@ void copyPeriodBoundary(String totalColumn, String previousColumn, String genera long oldEpoch = marker.epoch(); if (oldEpoch == Long.MAX_VALUE) throw new SQLException("Period boundary epoch overflow"); try (PreparedStatement copy = connection.prepareStatement("UPDATE " + qi(table.getTableName()) - + " SET " + qi(previousColumn) + " = COALESCE(" + qi(totalColumn) + ", 0)"); + + " SET " + assignments); PreparedStatement advance = connection.prepareStatement("UPDATE " + qiEpoch() + " SET " + qi("epoch") + " = ?, " + qi("last_reset_generation") + " = ? WHERE " + qi("limit_column") + " = ?")) { 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 d6479338bb..4add50c1e6 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 @@ -635,6 +635,30 @@ public static boolean copyMysqlPeriodBoundary(VotingPluginMain plugin, String to } } + /** Atomically captures the daily-streak value and timestamp boundary. */ + public static boolean copyMysqlDailyStreakBoundary(VotingPluginMain plugin, String streakColumn, + String previousStreakColumn, String updateColumn, String previousUpdateColumn, String generation) { + if (!canRecoverSharedMysqlPurchases(plugin)) return false; + try { + MySQL table = plugin.getMysql(); + table.checkColumn(streakColumn, DataType.INTEGER); + table.checkColumn(previousStreakColumn, DataType.INTEGER); + table.checkColumn(updateColumn, DataType.STRING); + table.checkColumn(previousUpdateColumn, DataType.STRING); + SharedMysqlPurchaseJournal.forTable(table).copyDailyStreakBoundary(streakColumn, previousStreakColumn, + updateColumn, previousUpdateColumn, generation); + return true; + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to atomically copy MySQL daily streak boundary: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + return false; + } finally { + SharedMysqlCacheReconciler.invalidateAll(plugin, previousStreakColumn); + SharedMysqlCacheReconciler.invalidateAll(plugin, previousUpdateColumn); + } + } + static void withSharedMysqlCacheResetFence(Runnable action) { SharedMysqlCacheReconciler.withResetFence(action); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java index 7ba4bd245a..21304db089 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java @@ -14,6 +14,7 @@ import java.sql.ResultSet; import java.sql.Statement; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.LinkedHashMap; import java.util.List; import java.util.Set; @@ -81,6 +82,55 @@ void weeklyStreakUsesBoundaryTotalInsteadOfANewWeekVote() { org.mockito.ArgumentMatchers.anyInt()); } + @Test + void delayedDailyRecoveryPreservesAStreakStartedAfterTheBoundary() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + ServerData serverData = mock(ServerData.class); + VotingPluginUser user = mock(VotingPluginUser.class); + TimeChangeTransition transition = mock(TimeChangeTransition.class); + String uuid = "00000000-0000-0000-0000-000000000001"; + long oldUpdate = LocalDateTime.of(2026, 9, 19, 12, 0).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + long newUpdate = LocalDateTime.of(2026, 9, 21, 12, 0).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + when(plugin.getServerData()).thenReturn(serverData); + when(plugin.getConfigFile().isUseVoteStreaks()).thenReturn(true); + when(transition.getPeriodKey()).thenReturn("2026-09-21"); + when(user.getLastDayVoteStreak()).thenReturn(4); + when(user.getLastDayVoteStreakLastUpdate()).thenReturn(oldUpdate); + when(user.getDayVoteStreakLastUpdate()).thenReturn(newUpdate); + when(user.getDayVoteStreak()).thenReturn(5); + when(serverData.prepareTimeChangeUserStreak(transition, uuid, 1, false)) + .thenReturn(new TimeChangeUserProgress(uuid, 1, false, false)); + + new TopVoterHandler(plugin).processDailyUser(user, transition, uuid); + + verify(serverData).prepareTimeChangeUserStreak(transition, uuid, 1, false); + verify(user).setDayVoteStreak(1); + } + + @Test + void dailyRecoveryClearsAnIneligibleBoundaryStreakWithoutANewVote() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + ServerData serverData = mock(ServerData.class); + VotingPluginUser user = mock(VotingPluginUser.class); + TimeChangeTransition transition = mock(TimeChangeTransition.class); + String uuid = "00000000-0000-0000-0000-000000000001"; + long oldUpdate = LocalDateTime.of(2026, 9, 19, 12, 0).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + when(plugin.getServerData()).thenReturn(serverData); + when(plugin.getConfigFile().isUseVoteStreaks()).thenReturn(true); + when(transition.getPeriodKey()).thenReturn("2026-09-21"); + when(user.getLastDayVoteStreak()).thenReturn(4); + when(user.getLastDayVoteStreakLastUpdate()).thenReturn(oldUpdate); + when(user.getDayVoteStreakLastUpdate()).thenReturn(oldUpdate); + when(user.getDayVoteStreak()).thenReturn(4); + when(serverData.prepareTimeChangeUserStreak(transition, uuid, 0, false)) + .thenReturn(new TimeChangeUserProgress(uuid, 0, false, false)); + + new TopVoterHandler(plugin).processDailyUser(user, transition, uuid); + + verify(serverData).prepareTimeChangeUserStreak(transition, uuid, 0, false); + verify(user).setDayVoteStreak(0); + } + @Test void recoveredHighestTotalsUseCopiedDailyAndWeeklyBoundaries() { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); @@ -242,6 +292,27 @@ void sqliteVotePartyBoundaryResetPreservesVotesAcceptedAfterTheBoundary() throws } } + @Test + void sqliteDailyStreakBoundaryKeepsValueAndTimestampTogether() throws Exception { + try (Connection connection = DriverManager.getConnection("jdbc:sqlite::memory:"); + Statement statement = connection.createStatement()) { + statement.executeUpdate("CREATE TABLE users (uuid TEXT PRIMARY KEY, DayVoteStreak INTEGER, " + + "LastDayVoteStreak INTEGER, DayVoteStreakLastUpdate TEXT, " + + "LastDayVoteStreakLastUpdate TEXT)"); + statement.executeUpdate("INSERT INTO users VALUES ('player', 4, 0, '100', '')"); + + TimeChangeTotalReset.copyDailyStreakBoundarySqlite(connection, "users", "time-streak-copy:DAY"); + statement.executeUpdate("UPDATE users SET DayVoteStreak = 5, DayVoteStreakLastUpdate = '200'"); + TimeChangeTotalReset.copyDailyStreakBoundarySqlite(connection, "users", "time-streak-copy:DAY"); + + try (ResultSet result = statement.executeQuery( + "SELECT LastDayVoteStreak, LastDayVoteStreakLastUpdate FROM users WHERE uuid = 'player'")) { + assertEquals(4, result.getInt(1)); + assertEquals("100", result.getString(2)); + } + } + } + @Test void sqliteVoteShopResetsRemainIndependentAndDoNotRepeat() throws Exception { try (Connection connection = DriverManager.getConnection("jdbc:sqlite::memory:"); 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 index 39f4dc957e..7fb1210bf7 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -317,6 +317,32 @@ void periodBoundaryCopyAndGenerationAdvanceShareOneTransaction() throws Exceptio verify(fixture.work).commit(); } + @Test + void dailyStreakValueAndTimestampCopyShareOneTransaction() throws Exception { + Fixture fixture = fixture(); + PreparedStatement markerInsert = mock(PreparedStatement.class); + PreparedStatement markerSelect = mock(PreparedStatement.class); + PreparedStatement copy = 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(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, copy, advance); + + new SharedMysqlPurchaseJournal(fixture.table, false).copyDailyStreakBoundary("DayVoteStreak", + "LastDayVoteStreak", "DayVoteStreakLastUpdate", "LastDayVoteStreakLastUpdate", + "time-streak-copy:DAY:2026-09-21"); + + 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("`LastDayVoteStreak` = COALESCE(`DayVoteStreak`, 0), " + + "`LastDayVoteStreakLastUpdate` = COALESCE(`DayVoteStreakLastUpdate`, '')")); + verify(advance).setString(3, "streak-copy:DayVoteStreak"); + verify(fixture.work).commit(); + } + @Test void ambiguousResetCommitIsConfirmedAfterTheConnectionIsReleased() throws Exception { Fixture fixture = fixture(); From 96f9359952e5cd6e46c730114b24f547f99ca8f0 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 05:56:11 -0600 Subject: [PATCH 10/61] Harden period reward recovery boundaries --- .../votingplugin/data/ServerData.java | 82 +++++++++++++++++-- .../topvoter/TopVoterHandler.java | 78 ++++++++++++------ .../votingplugin/topvoter/TopVoterLoader.java | 42 ++++++++++ .../ServerDataTimeChangeRecoveryTest.java | 58 +++++++++++++ .../topvoter/TopVoterLoaderTest.java | 40 +++++++++ .../TopVoterTimeChangeRecoveryTest.java | 52 +++++++++++- 6 files changed, 317 insertions(+), 35 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index 49e59f0259..abb5f40e17 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -21,6 +21,10 @@ import com.bencodez.votingplugin.topvoter.TopVoter; public class ServerData { + public enum TimeChangeRewardState { + UNCLAIMED, CLAIMED, COMPLETE + } + public record TimeChangeUserProgress(String uuid, int streakTarget, boolean rewardRequired, boolean rewardComplete) { } public record TimeChangeRewardTarget(String uuid, String playerName, int place, String reward, int votes) { } @@ -482,6 +486,7 @@ public synchronized TimeChangeUserProgress prepareTimeChangeUserStreak(TimeChang getData().set(userPath + ".Uuid", uuid); getData().set(userPath + ".StreakTarget", streakTarget); getData().set(userPath + ".RewardRequired", rewardRequired); + getData().set(userPath + ".RewardClaimed", false); getData().set(userPath + ".RewardComplete", false); saveData(); } @@ -490,6 +495,29 @@ public synchronized TimeChangeUserProgress prepareTimeChangeUserStreak(TimeChang getData().getBoolean(userPath + ".RewardComplete", false)); } + public synchronized TimeChangeRewardState getTimeChangeUserStreakRewardState( + TimeChangeTransition transition, String uuid) { + String path = timeChangeRecoveryPath(transition.getType()); + String userPath = path + ".CurrentUser"; + if (!transition.getId().equals(getData().getString(path + ".Id", "")) + || !uuid.equals(getData().getString(userPath + ".Uuid", ""))) { + throw new IllegalStateException("Time change recovery user does not match"); + } + if (getData().getBoolean(userPath + ".RewardComplete", false)) return TimeChangeRewardState.COMPLETE; + return getData().getBoolean(userPath + ".RewardClaimed", false) + ? TimeChangeRewardState.CLAIMED : TimeChangeRewardState.UNCLAIMED; + } + + /** Durably claims the in-flight user's streak reward before invoking it. */ + public synchronized void claimTimeChangeUserStreakReward(TimeChangeTransition transition, String uuid) { + if (getTimeChangeUserStreakRewardState(transition, uuid) != TimeChangeRewardState.UNCLAIMED) { + throw new IllegalStateException("Time change streak reward is already claimed"); + } + String userPath = timeChangeRecoveryPath(transition.getType()) + ".CurrentUser"; + getData().set(userPath + ".RewardClaimed", true); + saveData(); + } + /** Marks the in-flight user's streak reward call as returned successfully. */ public synchronized void completeTimeChangeUserStreakReward(TimeChangeTransition transition, String uuid) { String path = timeChangeRecoveryPath(transition.getType()); @@ -499,7 +527,12 @@ public synchronized void completeTimeChangeUserStreakReward(TimeChangeTransition throw new IllegalStateException("Time change recovery user does not match"); } getData().set(userPath + ".RewardComplete", true); - saveData(); + try { + saveData(); + } catch (RuntimeException failure) { + getData().set(userPath + ".RewardComplete", false); + throw failure; + } } /** @@ -686,11 +719,39 @@ private void validateArchive(TimeChangeArchiveSnapshot snapshot) { } } + /** Returns the durable delivery state for one top-voter reward recipient. */ + public synchronized TimeChangeRewardState getTimeChangeRewardState(TimeChangeTransition transition, + String uuid) { + String path = timeChangeRecoveryPath(transition.getType()); + if (!transition.getId().equals(getData().getString(path + ".Id", ""))) { + return TimeChangeRewardState.UNCLAIMED; + } + if (getData().getBoolean(path + ".Rewards." + uuid, false)) return TimeChangeRewardState.COMPLETE; + String stored = getData().getString(path + ".RewardStates." + uuid, ""); + try { + return stored.isEmpty() ? TimeChangeRewardState.UNCLAIMED : TimeChangeRewardState.valueOf(stored); + } catch (IllegalArgumentException invalid) { + throw new IllegalStateException("Invalid time change reward state for " + uuid, invalid); + } + } + /** Checks the durable receipt for one top-voter reward recipient. */ public synchronized boolean hasTimeChangeRewardReceipt(TimeChangeTransition transition, String uuid) { + return getTimeChangeRewardState(transition, uuid) == TimeChangeRewardState.COMPLETE; + } + + /** Durably claims a top-voter reward before invoking its existing reward API. */ + public synchronized void claimTimeChangeReward(TimeChangeTransition transition, String uuid) { String path = timeChangeRecoveryPath(transition.getType()); - return transition.getId().equals(getData().getString(path + ".Id", "")) - && getData().getBoolean(path + ".Rewards." + uuid, false); + if (!transition.getId().equals(getData().getString(path + ".Id", ""))) { + throw new IllegalStateException("Time change recovery transition does not match"); + } + if (getTimeChangeRewardState(transition, uuid) != TimeChangeRewardState.UNCLAIMED) { + throw new IllegalStateException("Time change reward is already claimed for " + uuid); + } + String statePath = path + ".RewardStates." + uuid; + getData().set(statePath, TimeChangeRewardState.CLAIMED.name()); + saveData(); } /** Persists a recipient receipt only after the existing reward API returns. */ @@ -699,8 +760,19 @@ public synchronized void completeTimeChangeReward(TimeChangeTransition transitio if (!transition.getId().equals(getData().getString(path + ".Id", ""))) { throw new IllegalStateException("Time change recovery transition does not match"); } - getData().set(path + ".Rewards." + uuid, true); - saveData(); + String receiptPath = path + ".Rewards." + uuid; + String statePath = path + ".RewardStates." + uuid; + Object previousReceipt = getData().get(receiptPath); + String previousState = getData().getString(statePath, ""); + getData().set(receiptPath, true); + getData().set(statePath, TimeChangeRewardState.COMPLETE.name()); + try { + saveData(); + } catch (RuntimeException failure) { + getData().set(receiptPath, previousReceipt); + getData().set(statePath, previousState.isEmpty() ? null : previousState); + throw failure; + } } /** Records idempotent non-reward listener effects such as VoteParty resets. */ 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 a4e731e7a7..e76395bd16 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -44,6 +44,7 @@ import com.bencodez.votingplugin.data.ServerData.TimeChangeArchiveSection; import com.bencodez.votingplugin.data.ServerData.TimeChangeArchiveSnapshot; import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardTarget; +import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardState; import com.bencodez.votingplugin.data.ServerData.TimeChangeUserProgress; import com.bencodez.votingplugin.user.PeriodTotalMutationFence; import com.bencodez.votingplugin.user.VotingPluginUser; @@ -605,7 +606,7 @@ private void runRecoverablePeriod(TopVoter top, TimeChangeTransition transition) || (top == TopVoter.Weekly && plugin.getConfigFile().isStoreTopVotersWeekly()) || top == TopVoter.Monthly; TimeChangeArchiveSnapshot proposedArchive = archiveRequired - ? buildTopVoterArchiveSnapshot() : new TimeChangeArchiveSnapshot(List.of()); + ? buildTopVoterArchiveSnapshot(top, transition) : new TimeChangeArchiveSnapshot(List.of()); plugin.getServerData().prepareTimeChangeSnapshot(transition, buildTopRewardSnapshot(top, transition), proposedArchive); if (archiveRequired) { @@ -693,14 +694,16 @@ private void processRecoverableUsers(TopVoter top, TimeChangeTransition transiti void processDailyUser(VotingPluginUser user, TimeChangeTransition transition, String uuid) { int boundaryTotal = user.getLastDailyTotal(); if (plugin.getConfigFile().isUseVoteStreaks()) { - int boundaryStreak = user.getLastDayVoteStreak(); - long boundaryUpdate = user.getLastDayVoteStreakLastUpdate(); - if (!user.voteStreakUpdatedAt(boundaryUpdate, previousDayTime(transition)) && boundaryStreak != 0) { - // A vote accepted after the boundary has already started the new day's - // streak. Preserve that contribution while removing the stale streak. - int target = user.getDayVoteStreakLastUpdate() == boundaryUpdate ? 0 : 1; - applyRecoverableStreak(user, transition, uuid, TopVoter.Daily, target, false); - } + PeriodTotalMutationFence.withReset(() -> { + int boundaryStreak = user.getLastDayVoteStreak(); + long boundaryUpdate = user.getLastDayVoteStreakLastUpdate(); + if (!user.voteStreakUpdatedAt(boundaryUpdate, previousDayTime(transition)) && boundaryStreak != 0) { + // A vote accepted after the boundary has already started the new day's + // streak. Preserve that contribution while removing the stale streak. + int target = user.getDayVoteStreakLastUpdate() == boundaryUpdate ? 0 : 1; + applyRecoverableStreak(user, transition, uuid, TopVoter.Daily, target, false); + } + }); } if (plugin.getConfigFile().isUseHighestTotals() && user.getHighestDailyTotal() < boundaryTotal) { @@ -778,20 +781,35 @@ void applyRecoverableStreak(VotingPluginUser user, TimeChangeTransition transiti } } if (progress.rewardRequired() && !progress.rewardComplete()) { + TimeChangeRewardState rewardState = plugin.getServerData() + .getTimeChangeUserStreakRewardState(transition, uuid); + if (rewardState == TimeChangeRewardState.CLAIMED) { + throw new IllegalStateException("Streak reward for " + uuid + + " may already have run and requires manual reconciliation"); + } + if (rewardState == TimeChangeRewardState.COMPLETE) return; + plugin.getServerData().claimTimeChangeUserStreakReward(transition, uuid); plugin.getSpecialRewards().checkVoteStreak(null, user, top == TopVoter.Weekly ? "Week" : "Month", plugin.getBungeeSettings().isUseBungeecoord()); plugin.getServerData().completeTimeChangeUserStreakReward(transition, uuid); } } - private void processRecoverableTopRewards(TopVoter top, TimeChangeTransition transition) { + void processRecoverableTopRewards(TopVoter top, TimeChangeTransition transition) { for (TimeChangeRewardTarget target : plugin.getServerData().getTimeChangeRewardTargets(transition)) { ensureTransitionActive(transition); - if (plugin.getServerData().hasTimeChangeRewardReceipt(transition, target.uuid())) continue; + TimeChangeRewardState rewardState = plugin.getServerData() + .getTimeChangeRewardState(transition, target.uuid()); + if (rewardState == TimeChangeRewardState.COMPLETE) continue; + if (rewardState == TimeChangeRewardState.CLAIMED) { + throw new IllegalStateException("Top voter reward for " + target.uuid() + + " may already have run and requires manual reconciliation"); + } VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser( UUID.fromString(target.uuid()), target.playerName()); user.userDataFetechMode(UserDataFetchMode.NO_CACHE); if (!plugin.getConfigFile().isTopVoterIgnorePermission() || !user.isTopVoterIgnore()) { + plugin.getServerData().claimTimeChangeReward(transition, target.uuid()); giveTopVoterAward(top, user, target.place(), target.reward(), target.votes()); plugin.getServerData().completeTimeChangeReward(transition, target.uuid()); plugin.getLogger().info("Giving " + top + " top voter reward " + target.place() + " to " @@ -806,7 +824,7 @@ List buildTopRewardSnapshot(TopVoter top, TimeChangeTran List targets = new ArrayList<>(); int place = 0; int lastTotal = -1; - for (Entry entry : topVotersFor(top, transition)) { + for (Entry entry : boundaryTopVotersFor(top, transition).entrySet()) { ensureTransitionActive(transition); if (plugin.getConfigFile().isTopVoterAwardsTies()) { if (entry.getValue().intValue() != lastTotal) place++; @@ -821,15 +839,12 @@ List buildTopRewardSnapshot(TopVoter top, TimeChangeTran return List.copyOf(targets); } - private Iterable> topVotersFor(TopVoter top, + LinkedHashMap boundaryTopVotersFor(TopVoter top, TimeChangeTransition transition) { if (top == TopVoter.Monthly && plugin.getConfigFile().isUseMonthDateTotalsAsPrimaryTotal()) { - return getMonthlyTopVotersAtTime(previousMonthTime(transition)).entrySet(); + return loader.getBoundaryMonthlyTopVotersAtTime(previousMonthTime(transition)); } - @SuppressWarnings("unchecked") - LinkedHashMap clone = (LinkedHashMap) plugin - .getTopVoter(top).clone(); - return clone.entrySet(); + return loader.getBoundaryTopVoters(top); } private LocalDateTime previousMonthTime(TimeChangeTransition transition) { @@ -1053,22 +1068,31 @@ public void storeTopVoters(TopVoter top) { file.saveData(); } - TimeChangeArchiveSnapshot buildTopVoterArchiveSnapshot() { + TimeChangeArchiveSnapshot buildTopVoterArchiveSnapshot(TopVoter boundaryTop, + TimeChangeTransition transition) { List sections = new ArrayList<>(); + LinkedHashMap boundaryRanking = boundaryTopVotersFor(boundaryTop, transition); for (TopVoter current : TopVoter.values()) { ArrayList lines = new ArrayList<>(); int total = 0; - try { - for (Integer value : plugin.getUserManager().getNumbersInColumn(current.getColumnName())) { - total += value.intValue(); + if (current == boundaryTop) { + for (Integer value : boundaryRanking.values()) total += value.intValue(); + } else { + try { + for (Integer value : plugin.getUserManager().getNumbersInColumn(current.getColumnName())) { + total += value.intValue(); + } + } catch (Exception failure) { + plugin.debug(failure); + throw new IllegalStateException("Unable to build " + current + " archive total", failure); } - lines.add("Combined total: " + total); - } catch (Exception failure) { - plugin.debug(failure); } - if (plugin.getTopVoter().containsKey(current)) { + lines.add("Combined total: " + total); + if (current == boundaryTop || plugin.getTopVoter().containsKey(current)) { int place = 1; - for (Entry entry : plugin.getTopVoter(current).entrySet()) { + Iterable> ranking = current == boundaryTop + ? boundaryRanking.entrySet() : plugin.getTopVoter(current).entrySet(); + for (Entry entry : ranking) { lines.add(place + ": " + entry.getKey().getPlayerName() + ": " + entry.getValue()); place++; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterLoader.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterLoader.java index c6c6485d0b..50f683c223 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterLoader.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterLoader.java @@ -50,6 +50,48 @@ public LinkedHashMap getMonthlyTopVotersAtTime(LocalDat return TopVoterRanking.sortByValues(topVoters, false); } + /** Loads a ranking from the immutable last-period column copied at transition admission. */ + public LinkedHashMap getBoundaryTopVoters(TopVoter top) { + return getBoundaryTopVoters(top, null); + } + + /** Loads an immutable dated-month ranking without stopping when shutdown starts. */ + public LinkedHashMap getBoundaryMonthlyTopVotersAtTime(LocalDateTime atTime) { + return getBoundaryTopVoters(TopVoter.Monthly, atTime); + } + + private LinkedHashMap getBoundaryTopVoters(TopVoter top, + LocalDateTime monthlyTime) { + LinkedHashMap topVoters = new LinkedHashMap<>(); + CountDownLatch latch = new CountDownLatch(1); + plugin.getUserManager().forEachUserKeys((uuid, columns) -> { + if (uuid == null) return; + VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, false); + user.userDataFetechMode(UserDataFetchMode.TEMP_ONLY); + user.updateTempCacheWithColumns(columns); + try { + int total = monthlyTime == null ? switch (top) { + case Daily -> user.getLastDailyTotal(); + case Weekly -> user.getLastWeeklyTotal(); + case Monthly -> user.getLastMonthTotal(); + default -> 0; + } : user.getTotal(TopVoter.Monthly, monthlyTime); + if (total > 0) topVoters.put(user.getTopVoterPlayer(), total); + } finally { + user.clearTempCache(); + } + }, count -> latch.countDown()); + try { + if (!latch.await(10, TimeUnit.MINUTES)) { + throw new IllegalStateException("Timed out loading boundary top voters"); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while loading boundary top voters", interrupted); + } + return TopVoterRanking.sortByValues(topVoters, false); + } + public LinkedHashMap getTopVotersOfMonth(YearMonth month, HashMap> columnsByPlayer) { LinkedHashMap totals = new LinkedHashMap<>(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java index aa78a3d20f..8de088ced5 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java @@ -23,6 +23,7 @@ import com.bencodez.votingplugin.data.ServerData.TimeChangeArchiveSection; import com.bencodez.votingplugin.data.ServerData.TimeChangeArchiveSnapshot; import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardTarget; +import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardState; import com.bencodez.votingplugin.data.ServerData.TimeChangeUserProgress; class ServerDataTimeChangeRecoveryTest { @@ -94,6 +95,63 @@ void interruptedUserKeepsItsAbsoluteStreakTargetUntilCursorAdvances() { assertEquals(uuid, data.getTimeChangeCursor(transition)); } + @Test + void durableRewardClaimsPreventAmbiguousAutomaticReplay() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); + YamlConfiguration yaml = new YamlConfiguration(); + when(plugin.getServerDataFile()).thenReturn(coreData); + when(coreData.getData()).thenReturn(yaml); + ServerData data = new ServerData(plugin); + TimeChangeTransition transition = transition("WEEK:2026-W38", "2026-W38", TimeType.WEEK); + String uuid = "00000000-0000-0000-0000-000000000001"; + data.beginTimeChangeRecovery(transition); + + assertEquals(TimeChangeRewardState.UNCLAIMED, data.getTimeChangeRewardState(transition, uuid)); + data.claimTimeChangeReward(transition, uuid); + assertEquals(TimeChangeRewardState.CLAIMED, data.getTimeChangeRewardState(transition, uuid)); + assertThrows(IllegalStateException.class, () -> data.claimTimeChangeReward(transition, uuid)); + data.completeTimeChangeReward(transition, uuid); + assertEquals(TimeChangeRewardState.COMPLETE, data.getTimeChangeRewardState(transition, uuid)); + assertTrue(data.hasTimeChangeRewardReceipt(transition, uuid)); + } + + @Test + void failedRewardClaimSaveRemainsClaimedBecausePersistenceOutcomeIsAmbiguous() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); + YamlConfiguration yaml = new YamlConfiguration(); + when(plugin.getServerDataFile()).thenReturn(coreData); + when(coreData.getData()).thenReturn(yaml); + ServerData data = new ServerData(plugin); + TimeChangeTransition transition = transition("DAY:2026-09-21", "2026-09-21", TimeType.DAY); + String uuid = "00000000-0000-0000-0000-000000000001"; + data.beginTimeChangeRecovery(transition); + doThrow(new IllegalStateException("disk unavailable")).when(coreData).saveData(); + + assertThrows(IllegalStateException.class, () -> data.claimTimeChangeReward(transition, uuid)); + assertEquals(TimeChangeRewardState.CLAIMED, data.getTimeChangeRewardState(transition, uuid)); + } + + @Test + void failedRewardCompletionRemainsClaimed() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); + YamlConfiguration yaml = new YamlConfiguration(); + when(plugin.getServerDataFile()).thenReturn(coreData); + when(coreData.getData()).thenReturn(yaml); + ServerData data = new ServerData(plugin); + TimeChangeTransition transition = transition("DAY:2026-09-21", "2026-09-21", TimeType.DAY); + String uuid = "00000000-0000-0000-0000-000000000001"; + data.beginTimeChangeRecovery(transition); + data.claimTimeChangeReward(transition, uuid); + doThrow(new IllegalStateException("disk unavailable")).when(coreData).saveData(); + + assertThrows(IllegalStateException.class, () -> data.completeTimeChangeReward(transition, uuid)); + assertEquals(TimeChangeRewardState.CLAIMED, data.getTimeChangeRewardState(transition, uuid)); + assertFalse(data.hasTimeChangeRewardReceipt(transition, uuid)); + } + @Test void retryKeepsTheFirstDurableRankedRewardSelection() { VotingPluginMain plugin = mock(VotingPluginMain.class); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterLoaderTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterLoaderTest.java index 2fd872f13f..84fa442e05 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterLoaderTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterLoaderTest.java @@ -2,11 +2,23 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import java.time.YearMonth; +import java.util.ArrayList; +import java.util.UUID; +import java.util.function.BiConsumer; +import java.util.function.Consumer; import org.junit.jupiter.api.Test; +import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.simpleapi.sql.Column; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.user.VotingPluginUser; + public class TopVoterLoaderTest { @Test @@ -22,4 +34,32 @@ public void rejectsMalformedMonthColumns() { assertNull(TopVoterLoader.parseMonthColumn("Other-JANUARY-2025")); assertNull(TopVoterLoader.parseMonthColumn(null)); } + + @Test + @SuppressWarnings("unchecked") + void boundaryRankingUsesCopiedTotalInsteadOfCurrentPeriodTotal() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + UserManager userManager = mock(UserManager.class); + com.bencodez.votingplugin.user.UserManager votingUserManager = + mock(com.bencodez.votingplugin.user.UserManager.class); + VotingPluginUser user = mock(VotingPluginUser.class); + UUID uuid = UUID.fromString("00000000-0000-0000-0000-000000000001"); + TopVoterPlayer player = new TopVoterPlayer(uuid, "first", 1L); + when(plugin.getUserManager()).thenReturn(userManager); + when(plugin.getVotingPluginUserManager()).thenReturn(votingUserManager); + when(votingUserManager.getVotingPluginUser(uuid, false)).thenReturn(user); + when(user.getLastDailyTotal()).thenReturn(20); + when(user.getTotal(TopVoter.Daily)).thenReturn(99); + when(user.getTopVoterPlayer()).thenReturn(player); + doAnswer(invocation -> { + BiConsumer> perUser = invocation.getArgument(0); + Consumer finished = invocation.getArgument(1); + perUser.accept(uuid, new ArrayList<>()); + finished.accept(1); + return null; + }).when(userManager).forEachUserKeys(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + + assertEquals(20, new TopVoterLoader(plugin).getBoundaryTopVoters(TopVoter.Daily).get(player)); + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java index 21304db089..da1d95ca27 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java @@ -5,6 +5,8 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -29,6 +31,7 @@ import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.data.ServerData; import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardTarget; +import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardState; import com.bencodez.votingplugin.data.ServerData.TimeChangeUserProgress; import com.bencodez.votingplugin.specialrewards.SpecialRewards; import com.bencodez.votingplugin.user.VotingPluginUser; @@ -58,6 +61,31 @@ void retryUsesPersistedAbsoluteTargetAndDoesNotDuplicateCompletedStreakReward() verify(serverData, never()).completeTimeChangeUserStreakReward(transition, uuid); } + @Test + void ambiguousStreakRewardClaimStopsAutomaticReplay() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + ServerData serverData = mock(ServerData.class); + SpecialRewards specialRewards = mock(SpecialRewards.class); + VotingPluginUser user = mock(VotingPluginUser.class); + TimeChangeTransition transition = mock(TimeChangeTransition.class); + String uuid = "00000000-0000-0000-0000-000000000001"; + when(plugin.getServerData()).thenReturn(serverData); + when(plugin.getSpecialRewards()).thenReturn(specialRewards); + when(serverData.prepareTimeChangeUserStreak(transition, uuid, 9, true)) + .thenReturn(new TimeChangeUserProgress(uuid, 8, true, false)); + when(serverData.getTimeChangeUserStreakRewardState(transition, uuid)) + .thenReturn(TimeChangeRewardState.CLAIMED); + when(user.getWeekVoteStreak()).thenReturn(8); + + assertThrows(IllegalStateException.class, + () -> new TopVoterHandler(plugin).applyRecoverableStreak( + user, transition, uuid, TopVoter.Weekly, 9, true)); + + verify(specialRewards, never()).checkVoteStreak(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.eq(user), org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyBoolean()); + } + @Test void weeklyStreakUsesBoundaryTotalInsteadOfANewWeekVote() { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); @@ -223,16 +251,34 @@ void rewardSnapshotFixesRecipientsPlacesAndVoteTotalsAtThePeriodBoundary() { ranking.put(second, 10); when(plugin.getSpecialRewardsConfig().isEnableDailyRewards()).thenReturn(true); when(plugin.getSpecialRewardsConfig().getDailyPossibleRewardPlaces()).thenReturn(Set.of("1", "2")); - when(plugin.getTopVoter(TopVoter.Daily)).thenReturn(ranking); + TopVoterHandler handler = spy(new TopVoterHandler(plugin)); + doReturn(ranking).when(handler).boundaryTopVotersFor(TopVoter.Daily, transition); - List targets = new TopVoterHandler(plugin) - .buildTopRewardSnapshot(TopVoter.Daily, transition); + List targets = handler.buildTopRewardSnapshot(TopVoter.Daily, transition); assertEquals(List.of( new TimeChangeRewardTarget(first.getUuid().toString(), "first", 1, "1", 20), new TimeChangeRewardTarget(second.getUuid().toString(), "second", 2, "2", 10)), targets); } + @Test + void ambiguousTopRewardClaimStopsAutomaticReplay() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + ServerData serverData = mock(ServerData.class); + TimeChangeTransition transition = mock(TimeChangeTransition.class); + String uuid = "00000000-0000-0000-0000-000000000001"; + TimeChangeRewardTarget target = new TimeChangeRewardTarget(uuid, "first", 1, "1", 20); + when(plugin.getServerData()).thenReturn(serverData); + when(serverData.getTimeChangeRewardTargets(transition)).thenReturn(List.of(target)); + when(serverData.getTimeChangeRewardState(transition, uuid)).thenReturn(TimeChangeRewardState.CLAIMED); + + assertThrows(IllegalStateException.class, + () -> new TopVoterHandler(plugin).processRecoverableTopRewards(TopVoter.Daily, transition)); + + verify(plugin.getVotingPluginUserManager(), never()).getVotingPluginUser( + org.mockito.ArgumentMatchers.any(UUID.class), org.mockito.ArgumentMatchers.anyString()); + } + @Test void archiveRetryUsesOneStableTransitionFile() { VotingPluginMain plugin = mock(VotingPluginMain.class); From f75b499c5a5682611841863b7449504246fff193 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 06:21:24 -0600 Subject: [PATCH 11/61] Coordinate durable period recovery --- .../votingplugin/data/ServerData.java | 14 +++- .../listeners/PlayerVoteListener.java | 7 +- .../topvoter/TopVoterHandler.java | 45 +++++++---- .../votingplugin/user/VotingPluginUser.java | 50 +++++++++++-- .../service/SharedMysqlPurchaseJournal.java | 60 +++++++++++++++ .../service/VoteShopPurchaseService.java | 22 ++++++ .../ServerDataTimeChangeRecoveryTest.java | 4 +- .../TopVoterTimeChangeRecoveryTest.java | 74 ++++++++++++++++++- .../VotingPluginUserVoteShopLimitTest.java | 23 ++++++ .../SharedMysqlPurchaseJournalTest.java | 39 ++++++++++ 10 files changed, 314 insertions(+), 24 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index abb5f40e17..7fbf583d5b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -515,7 +515,12 @@ public synchronized void claimTimeChangeUserStreakReward(TimeChangeTransition tr } String userPath = timeChangeRecoveryPath(transition.getType()) + ".CurrentUser"; getData().set(userPath + ".RewardClaimed", true); - saveData(); + try { + saveData(); + } catch (RuntimeException failure) { + getData().set(userPath + ".RewardClaimed", false); + throw failure; + } } /** Marks the in-flight user's streak reward call as returned successfully. */ @@ -751,7 +756,12 @@ public synchronized void claimTimeChangeReward(TimeChangeTransition transition, } String statePath = path + ".RewardStates." + uuid; getData().set(statePath, TimeChangeRewardState.CLAIMED.name()); - saveData(); + try { + saveData(); + } catch (RuntimeException failure) { + getData().set(statePath, null); + throw failure; + } } /** Persists a recipient receipt only after the existing reward API returns. */ diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java index 1752560bdb..bf18a41828 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java @@ -11,6 +11,7 @@ import org.bukkit.event.Listener; import com.bencodez.advancedcore.api.bedrock.BedrockNameResolver; +import com.bencodez.advancedcore.api.user.UserStorage; import com.bencodez.advancedcore.api.user.validation.UserValidationResult; import com.bencodez.simpleapi.array.ArrayUtils; import com.bencodez.votingplugin.VotingPluginMain; @@ -163,7 +164,11 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { @Override public int userMonthTotal(VotingPluginUser user) { return user.getTotal(TopVoter.Monthly); } @Override public int currentDayOfMonth() { return plugin.getTimeChecker().getTime().getDayOfMonth(); } @Override public int enabledSiteCount() { return plugin.getVoteSiteManager().getVoteSitesEnabled().size(); } - @Override public void setMonthTotal(VotingPluginUser user, int total) { user.setTotal(TopVoter.Monthly, total); } + @Override public void setMonthTotal(VotingPluginUser user, int total) { + // Shared MySQL increments apply the same cap inside the boundary-serialized + // transaction, so an absolute cache write here could only reintroduce a stale value. + if (!UserStorage.MYSQL.equals(plugin.getStorageType())) user.setTotal(TopVoter.Monthly, total); + } @Override public void milestones(VotingPluginUser user, UUID voteId, boolean forceProxyRouting) { plugin.getVoteMilestonesManager().handleVote(user, event.getBungeeTextTotals(), forceProxyRouting, voteId, new HashMap()); 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 e76395bd16..47d18a5e5b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -668,27 +668,46 @@ private void copyTotalBoundary(TopVoter top, TimeChangeTransition transition) { } } - private void processRecoverableUsers(TopVoter top, TimeChangeTransition transition) { + void processRecoverableUsers(TopVoter top, TimeChangeTransition transition) { if (!plugin.getConfigFile().isUseVoteStreaks() && !plugin.getConfigFile().isUseHighestTotals()) return; AtomicReference cursor = new AtomicReference<>(plugin.getServerData().getTimeChangeCursor(transition)); + AtomicReference failure = new AtomicReference<>(); + CountDownLatch finished = new CountDownLatch(1); LocalDateTime lastMonthTime = top == TopVoter.Monthly ? previousMonthTime(transition) : null; plugin.getUserManager().forEachUserKeys((uuid, columns) -> { + if (failure.get() != null) return; String value = uuid.toString(); if (value.compareTo(cursor.get()) <= 0) return; - ensureTransitionActive(transition); - VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, false); - user.userDataFetechMode(UserDataFetchMode.TEMP_ONLY); - user.updateTempCacheWithColumns(columns); try { - if (top == TopVoter.Daily) processDailyUser(user, transition, value); - else if (top == TopVoter.Weekly) processWeeklyUser(user, transition, value); - else processMonthlyUser(user, lastMonthTime, transition, value); - } finally { - user.clearTempCache(); + ensureTransitionActive(transition); + VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, false); + user.userDataFetechMode(UserDataFetchMode.TEMP_ONLY); + user.updateTempCacheWithColumns(columns); + try { + if (top == TopVoter.Daily) processDailyUser(user, transition, value); + else if (top == TopVoter.Weekly) processWeeklyUser(user, transition, value); + else processMonthlyUser(user, lastMonthTime, transition, value); + if (user.getCache() != null) user.getCache().clearChanges(); + } finally { + user.clearTempCache(); + } + plugin.getServerData().completeTimeChangeUser(transition, value); + cursor.set(value); + } catch (Throwable userFailure) { + failure.compareAndSet(null, userFailure); } - plugin.getServerData().completeTimeChangeUser(transition, value); - cursor.set(value); - }, count -> { }); + }, count -> finished.countDown()); + try { + if (!finished.await(10, TimeUnit.MINUTES)) { + throw new IllegalStateException("Timed out processing time-change users"); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new CancellationException("Interrupted while processing time-change users"); + } + if (failure.get() != null) { + throw new IllegalStateException("Unable to durably process a time-change user", failure.get()); + } } void processDailyUser(VotingPluginUser user, TimeChangeTransition transition, String uuid) { 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 62bb694419..5bc6af489f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -48,7 +48,8 @@ import com.bencodez.votingplugin.topvoter.TopVoter; import com.bencodez.votingplugin.topvoter.TopVoterPlayer; import com.bencodez.votingplugin.util.BukkitCompletionScheduler; -import com.bencodez.votingplugin.votesites.NextSite; +import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService; +import com.bencodez.votingplugin.votesites.NextSite; import com.bencodez.votingplugin.votesites.VoteSite; /** @@ -166,7 +167,10 @@ public void addDayVoteStreak() { * Adds one to the monthly total votes. */ public void addMonthTotal() { - PeriodTotalMutationFence.withMutation(() -> setMonthTotal(getMonthTotal() + 1)); + PeriodTotalMutationFence.withMutation(() -> { + if (incrementSharedMysqlMonthTotal()) return; + setMonthTotal(getMonthTotal() + 1); + }); } /** @@ -878,15 +882,51 @@ public void addTotal() { * Adds one to the daily total votes. */ public void addTotalDaily() { - PeriodTotalMutationFence.withMutation(() -> setDailyTotal(getDailyTotal() + 1)); + PeriodTotalMutationFence.withMutation(() -> { + if (incrementSharedMysqlPeriodTotal(TopVoter.Daily, "DailyTotal")) return; + setDailyTotal(getDailyTotal() + 1); + }); } /** * Adds one to the weekly total votes. */ public void addTotalWeekly() { - PeriodTotalMutationFence.withMutation(() -> setWeeklyTotal(getWeeklyTotal() + 1)); - } + PeriodTotalMutationFence.withMutation(() -> { + if (incrementSharedMysqlPeriodTotal(TopVoter.Weekly, "WeeklyTotal")) return; + setWeeklyTotal(getWeeklyTotal() + 1); + }); + } + + private boolean incrementSharedMysqlMonthTotal() { + if (plugin == null || !UserStorage.MYSQL.equals(plugin.getStorageType())) return false; + if (getCache() != null) getCache().clearChanges(); + ArrayList columns = new ArrayList<>(); + columns.add("MonthTotal"); + if (plugin.getConfigFile().isStoreMonthTotalsWithDate()) { + columns.add(plugin.getVotingPluginUserManager().getMonthTotalsWithDatePath()); + } + Integer maximum = null; + if (plugin.getConfigFile().isLimitMonthlyVotes()) { + maximum = Integer.valueOf(plugin.getTimeChecker().getTime().getDayOfMonth() + * plugin.getVoteSiteManager().getVoteSitesEnabled().size()); + } + if (!VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, getUUID(), "MonthTotal", "LastMonthTotal", + columns, maximum)) { + throw new IllegalStateException("Unable to persist shared MySQL monthly vote total"); + } + return true; + } + + private boolean incrementSharedMysqlPeriodTotal(TopVoter top, String column) { + if (plugin == null || !UserStorage.MYSQL.equals(plugin.getStorageType())) return false; + if (getCache() != null) getCache().clearChanges(); + if (!VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, getUUID(), column, top.getLastColumnName(), + List.of(column), null)) { + throw new IllegalStateException("Unable to persist shared MySQL " + top + " vote total"); + } + return true; + } /** * Adds one to the weekly vote streak. 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 index 484f0113c4..d85bc16ac5 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -264,6 +264,66 @@ void resetPeriodTotal(String totalColumn, String previousColumn, String resetGen } } + /** + * Serializes accepted-vote increments with the shared boundary marker. Every + * backend therefore agrees whether an increment precedes or follows the copy. + */ + void incrementPeriodTotals(String uuid, String boundaryColumn, String previousColumn, List columns, + Integer maximum) + throws SQLException { + if (uuid == null || uuid.isEmpty() || !isSafeColumn(boundaryColumn) || columns == null + || columns.isEmpty() || columns.stream().anyMatch(column -> !isSafeColumn(column)) + || maximum != null && (maximum.intValue() < 0 || !isSafeColumn(previousColumn))) { + throw new SQLException("Invalid period total increment"); + } + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try { + EpochRow copyMarker = lockLimitEpochRow(connection, "period-copy:" + boundaryColumn); + boolean resetPending = maximum != null && resetPending(connection, boundaryColumn, copyMarker); + StringBuilder sql = new StringBuilder("UPDATE ").append(qi(table.getTableName())).append(" SET "); + for (int index = 0; index < columns.size(); index++) { + if (index > 0) sql.append(", "); + String quoted = qi(columns.get(index)); + String increment = "COALESCE(" + quoted + ", 0) + 1"; + sql.append(quoted).append(" = "); + if (maximum != null && resetPending && columns.get(index).equals(boundaryColumn)) { + sql.append("LEAST(COALESCE(").append(qi(previousColumn)).append(", 0) + ?, ") + .append(increment).append(')'); + } else if (maximum != null) sql.append("LEAST(?, ").append(increment).append(')'); + else sql.append(increment); + } + sql.append(" WHERE ").append(qi("uuid")).append(uuidCast()); + try (PreparedStatement update = connection.prepareStatement(sql.toString())) { + int parameter = 1; + if (maximum != null) { + for (int ignored = 0; ignored < columns.size(); ignored++) { + update.setInt(parameter++, maximum.intValue()); + } + } + update.setString(parameter, uuid); + if (update.executeUpdate() != 1) throw new SQLException("Period total user row is missing"); + } + connection.commit(); + } catch (SQLException failure) { + rollback(connection); + throw failure; + } + } + } + + private boolean resetPending(Connection connection, String boundaryColumn, EpochRow copyMarker) + throws SQLException { + EpochRow resetMarker = lockLimitEpochRow(connection, "period-reset:" + boundaryColumn); + String copyTransition = generationTransition(copyMarker.lastResetGeneration(), "time-copy:"); + String resetTransition = generationTransition(resetMarker.lastResetGeneration(), "time-total:"); + return copyTransition != null && !copyTransition.equals(resetTransition); + } + + private static String generationTransition(String generation, String prefix) { + return generation != null && generation.startsWith(prefix) ? generation.substring(prefix.length()) : null; + } + /** Copies the period boundary once so a phase-receipt retry cannot move it. */ void copyPeriodBoundary(String totalColumn, String previousColumn, String generation) throws SQLException { if (!isSafeColumn(totalColumn) || !isSafeColumn(previousColumn)) { 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 4add50c1e6..85c26e4f0c 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 @@ -10,6 +10,7 @@ import java.time.ZoneId; import java.time.temporal.WeekFields; import java.util.HashMap; +import java.util.List; import java.util.Locale; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -615,6 +616,27 @@ public static boolean resetMysqlPeriodTotal(VotingPluginMain plugin, String tota return reset.get(); } + /** Atomically increments period totals under the cross-backend boundary lock. */ + public static boolean incrementMysqlPeriodTotals(VotingPluginMain plugin, String uuid, String boundaryColumn, + String previousColumn, List columns, Integer maximum) { + if (!canRecoverSharedMysqlPurchases(plugin) || columns == null || columns.isEmpty()) return false; + try { + MySQL table = plugin.getMysql(); + for (String column : columns) table.checkColumn(column, DataType.INTEGER); + if (maximum != null) table.checkColumn(previousColumn, DataType.INTEGER); + SharedMysqlPurchaseJournal.forTable(table).incrementPeriodTotals(uuid, boundaryColumn, previousColumn, + columns, maximum); + return true; + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to atomically increment MySQL period total: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + return false; + } finally { + for (String column : columns) SharedMysqlCacheReconciler.invalidate(plugin, uuid, column); + } + } + /** Atomically captures one period boundary for a recoverable transition. */ public static boolean copyMysqlPeriodBoundary(VotingPluginMain plugin, String totalColumn, String previousColumn, String generation) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java index 8de088ced5..2f62f6b490 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java @@ -117,7 +117,7 @@ void durableRewardClaimsPreventAmbiguousAutomaticReplay() { } @Test - void failedRewardClaimSaveRemainsClaimedBecausePersistenceOutcomeIsAmbiguous() { + void failedRewardClaimSaveRollsBackToUnclaimed() { VotingPluginMain plugin = mock(VotingPluginMain.class); com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); YamlConfiguration yaml = new YamlConfiguration(); @@ -130,7 +130,7 @@ void failedRewardClaimSaveRemainsClaimedBecausePersistenceOutcomeIsAmbiguous() { doThrow(new IllegalStateException("disk unavailable")).when(coreData).saveData(); assertThrows(IllegalStateException.class, () -> data.claimTimeChangeReward(transition, uuid)); - assertEquals(TimeChangeRewardState.CLAIMED, data.getTimeChangeRewardState(transition, uuid)); + assertEquals(TimeChangeRewardState.UNCLAIMED, data.getTimeChangeRewardState(transition, uuid)); } @Test diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java index da1d95ca27..b7d2169e7e 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java @@ -1,11 +1,15 @@ package com.bencodez.votingplugin.topvoter; 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.assertThrows; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; -import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -21,6 +25,11 @@ import java.util.List; import java.util.Set; import java.util.UUID; +import java.util.ArrayList; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; @@ -28,6 +37,8 @@ import com.bencodez.advancedcore.api.time.TimeChangeTransition; import com.bencodez.advancedcore.api.time.TimeType; import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.simpleapi.sql.Column; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.data.ServerData; import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardTarget; @@ -86,6 +97,67 @@ void ambiguousStreakRewardClaimStopsAutomaticReplay() { org.mockito.ArgumentMatchers.anyBoolean()); } + @Test + void userRecoveryWaitsForTheStorageIterationCompletionCallback() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + com.bencodez.advancedcore.api.user.UserManager userManager = + mock(com.bencodez.advancedcore.api.user.UserManager.class); + ServerData serverData = mock(ServerData.class); + TimeChangeTransition transition = mock(TimeChangeTransition.class); + AtomicReference> completion = new AtomicReference<>(); + when(plugin.getUserManager()).thenReturn(userManager); + when(plugin.getServerData()).thenReturn(serverData); + when(plugin.getConfigFile().isUseHighestTotals()).thenReturn(true); + doAnswer(invocation -> { + completion.set(invocation.getArgument(1)); + return null; + }).when(userManager).forEachUserKeys(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + + CompletableFuture recovery = CompletableFuture.runAsync( + () -> new TopVoterHandler(plugin).processRecoverableUsers(TopVoter.Daily, transition)); + for (int attempt = 0; attempt < 100 && completion.get() == null; attempt++) Thread.sleep(5L); + assertNotNull(completion.get()); + assertFalse(recovery.isDone()); + completion.get().accept(0); + recovery.get(2, TimeUnit.SECONDS); + } + + @Test + void userRecoveryFlushesUserChangesBeforeAdvancingTheCursor() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + com.bencodez.advancedcore.api.user.UserManager userManager = + mock(com.bencodez.advancedcore.api.user.UserManager.class); + com.bencodez.votingplugin.user.UserManager votingUsers = + mock(com.bencodez.votingplugin.user.UserManager.class); + ServerData serverData = mock(ServerData.class); + VotingPluginUser user = mock(VotingPluginUser.class); + UserDataCache cache = mock(UserDataCache.class); + TimeChangeTransition transition = mock(TimeChangeTransition.class); + UUID uuid = UUID.fromString("00000000-0000-0000-0000-000000000001"); + when(plugin.getUserManager()).thenReturn(userManager); + when(plugin.getVotingPluginUserManager()).thenReturn(votingUsers); + when(plugin.getServerData()).thenReturn(serverData); + when(plugin.getConfigFile().isUseHighestTotals()).thenReturn(true); + when(serverData.getTimeChangeCursor(transition)).thenReturn(""); + when(votingUsers.getVotingPluginUser(uuid, false)).thenReturn(user); + when(user.getCache()).thenReturn(cache); + doAnswer(invocation -> { + java.util.function.BiConsumer> perUser = invocation.getArgument(0); + Consumer finished = invocation.getArgument(1); + perUser.accept(uuid, new ArrayList<>()); + finished.accept(1); + return null; + }).when(userManager).forEachUserKeys(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + + new TopVoterHandler(plugin).processRecoverableUsers(TopVoter.Daily, transition); + + org.mockito.InOrder order = inOrder(cache, serverData); + order.verify(cache).clearChanges(); + order.verify(serverData).completeTimeChangeUser(transition, uuid.toString()); + } + @Test void weeklyStreakUsesBoundaryTotalInsteadOfANewWeekVote() { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java index ec43050a0a..42640f9ec3 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java @@ -9,14 +9,37 @@ import static org.mockito.Mockito.when; import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; 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; +import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService; class VotingPluginUserVoteShopLimitTest { + @Test + void sharedMysqlDailyVotesUseTheCrossBackendBoundaryMutation() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + AdvancedCoreUser base = mock(AdvancedCoreUser.class); + when(base.getUserData()).thenReturn(mock(UserData.class)); + when(base.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + VotingPluginUser user = spy(new VotingPluginUser(plugin, base)); + doReturn(null).when(user).getCache(); + try (MockedStatic service = org.mockito.Mockito + .mockStatic(VoteShopPurchaseService.class)) { + service.when(() -> VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, base.getUUID(), + "DailyTotal", "LastDailyTotal", java.util.List.of("DailyTotal"), null)).thenReturn(true); + + user.addTotalDaily(); + + service.verify(() -> VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, base.getUUID(), + "DailyTotal", "LastDailyTotal", java.util.List.of("DailyTotal"), null)); + } + } + @Test void sharedMysqlLimitsUseNonBlockingUserCacheWhenAvailable() { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); 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 index 7fb1210bf7..b9a21ce7f3 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -317,6 +317,45 @@ void periodBoundaryCopyAndGenerationAdvanceShareOneTransaction() throws Exceptio verify(fixture.work).commit(); } + @Test + void periodIncrementLocksTheSameBoundaryRowBeforeUpdatingTheUser() throws Exception { + Fixture fixture = fixture(); + PreparedStatement markerInsert = mock(PreparedStatement.class); + PreparedStatement markerSelect = mock(PreparedStatement.class); + PreparedStatement resetMarkerInsert = mock(PreparedStatement.class); + PreparedStatement resetMarkerSelect = mock(PreparedStatement.class); + PreparedStatement increment = mock(PreparedStatement.class); + ResultSet epoch = mock(ResultSet.class); + ResultSet resetEpoch = mock(ResultSet.class); + when(epoch.next()).thenReturn(true); + when(epoch.getLong(1)).thenReturn(9L); + when(epoch.getString(2)).thenReturn("time-copy:MONTH:2026-09"); + when(resetEpoch.next()).thenReturn(true); + when(resetEpoch.getString(2)).thenReturn("time-total:MONTH:2026-08"); + when(markerSelect.executeQuery()).thenReturn(epoch); + when(resetMarkerSelect.executeQuery()).thenReturn(resetEpoch); + when(increment.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, + resetMarkerInsert, resetMarkerSelect, increment); + + new SharedMysqlPurchaseJournal(fixture.table, false).incrementPeriodTotals( + "00000000-0000-0000-0000-000000000001", "MonthTotal", "LastMonthTotal", + java.util.List.of("MonthTotal", "MonthTotal-SEPTEMBER-2026"), Integer.valueOf(42)); + + org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class); + verify(fixture.work, org.mockito.Mockito.times(5)).prepareStatement(sql.capture()); + assertTrue(sql.getAllValues().get(1).contains("FOR UPDATE")); + assertTrue(sql.getAllValues().get(4).contains( + "`MonthTotal` = LEAST(COALESCE(`LastMonthTotal`, 0) + ?, COALESCE(`MonthTotal`, 0) + 1)")); + assertTrue(sql.getAllValues().get(4).contains( + "`MonthTotal-SEPTEMBER-2026` = LEAST(?, COALESCE(`MonthTotal-SEPTEMBER-2026`, 0) + 1)")); + verify(markerSelect).setString(1, "period-copy:MonthTotal"); + verify(increment).setInt(1, 42); + verify(increment).setInt(2, 42); + verify(increment).setString(3, "00000000-0000-0000-0000-000000000001"); + verify(fixture.work).commit(); + } + @Test void dailyStreakValueAndTimestampCopyShareOneTransaction() throws Exception { Fixture fixture = fixture(); From 65a9d55cfd0586ba130e8fc337130e7556206f8d Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 06:36:59 -0600 Subject: [PATCH 12/61] Fence shared period mutations --- .../specialrewards/voteparty/VoteParty.java | 2 +- .../topvoter/TopVoterHandler.java | 38 +++++++++---------- .../votingplugin/user/VotingPluginUser.java | 33 +++++++++++++--- .../service/SharedMysqlPurchaseJournal.java | 22 +++++++++++ .../service/VoteShopPurchaseService.java | 19 ++++++++++ .../TopVoterTimeChangeRecoveryTest.java | 15 +++++--- .../VotingPluginUserVoteShopLimitTest.java | 22 +++++++++++ .../SharedMysqlPurchaseJournalTest.java | 21 ++++++++++ 8 files changed, 142 insertions(+), 30 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java index 31b7dac92a..d562a37cd9 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java @@ -48,7 +48,7 @@ public VoteParty(VotingPluginMain plugin) { */ public void addTotal(VotingPluginUser user) { setTotalVotes(getTotalVotes() + 1); - user.setVotePartyVotes(user.getVotePartyVotes() + 1); + user.addVotePartyVote(); plugin.getPlaceholders().onVotePartyUpdate(); } 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 47d18a5e5b..11d6f09750 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -671,18 +671,29 @@ private void copyTotalBoundary(TopVoter top, TimeChangeTransition transition) { void processRecoverableUsers(TopVoter top, TimeChangeTransition transition) { if (!plugin.getConfigFile().isUseVoteStreaks() && !plugin.getConfigFile().isUseHighestTotals()) return; AtomicReference cursor = new AtomicReference<>(plugin.getServerData().getTimeChangeCursor(transition)); - AtomicReference failure = new AtomicReference<>(); CountDownLatch finished = new CountDownLatch(1); + record BoundaryUser(UUID uuid, ArrayList columns) { } + List users = Collections.synchronizedList(new ArrayList<>()); LocalDateTime lastMonthTime = top == TopVoter.Monthly ? previousMonthTime(transition) : null; - plugin.getUserManager().forEachUserKeys((uuid, columns) -> { - if (failure.get() != null) return; - String value = uuid.toString(); - if (value.compareTo(cursor.get()) <= 0) return; + plugin.getUserManager().forEachUserKeys((uuid, columns) -> users.add(new BoundaryUser(uuid, columns)), + count -> finished.countDown()); + try { + if (!finished.await(10, TimeUnit.MINUTES)) { + throw new IllegalStateException("Timed out loading time-change users"); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new CancellationException("Interrupted while loading time-change users"); + } + users.sort(Comparator.comparing(boundary -> boundary.uuid().toString())); + for (BoundaryUser boundary : users) { + String value = boundary.uuid().toString(); + if (value.compareTo(cursor.get()) <= 0) continue; try { ensureTransitionActive(transition); - VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, false); + VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(boundary.uuid(), false); user.userDataFetechMode(UserDataFetchMode.TEMP_ONLY); - user.updateTempCacheWithColumns(columns); + user.updateTempCacheWithColumns(boundary.columns()); try { if (top == TopVoter.Daily) processDailyUser(user, transition, value); else if (top == TopVoter.Weekly) processWeeklyUser(user, transition, value); @@ -694,19 +705,8 @@ void processRecoverableUsers(TopVoter top, TimeChangeTransition transition) { plugin.getServerData().completeTimeChangeUser(transition, value); cursor.set(value); } catch (Throwable userFailure) { - failure.compareAndSet(null, userFailure); + throw new IllegalStateException("Unable to durably process time-change user " + value, userFailure); } - }, count -> finished.countDown()); - try { - if (!finished.await(10, TimeUnit.MINUTES)) { - throw new IllegalStateException("Timed out processing time-change users"); - } - } catch (InterruptedException interrupted) { - Thread.currentThread().interrupt(); - throw new CancellationException("Interrupted while processing time-change users"); - } - if (failure.get() != null) { - throw new IllegalStateException("Unable to durably process a time-change user", failure.get()); } } 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 5bc6af489f..687bf03ac7 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -1111,9 +1111,19 @@ public void checkDayVoteStreak(boolean forceBungee) { + plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage() + " " + hasPercentageTotal(TopVoter.Daily, plugin.getSpecialRewardsConfig().getVoteStreakRequirementDay(), null)); - addDayVoteStreak(); + int streak = getDayVoteStreak() + 1; + long updatedAt = System.currentTimeMillis(); + if (UserStorage.MYSQL.equals(plugin.getStorageType())) { + if (getCache() != null) getCache().clearChanges(); + if (!VoteShopPurchaseService.updateMysqlDailyStreak(plugin, getUUID(), streak, updatedAt)) { + throw new IllegalStateException("Unable to persist shared MySQL daily streak"); + } + if (getBestDayVoteStreak() < streak) setBestDayVoteStreak(streak); + } else { + setDayVoteStreak(streak); + } plugin.getSpecialRewards().checkVoteStreak(null, this, "Day", forceBungee); - setDayVoteStreakLastUpdate(System.currentTimeMillis()); + if (!UserStorage.MYSQL.equals(plugin.getStorageType())) setDayVoteStreakLastUpdate(updatedAt); } } }); @@ -2508,9 +2518,22 @@ private void setTotalWithinFence(TopVoter top, int value) { * * @param value the number of votes to set */ - public void setVotePartyVotes(int value) { - getUserData().setInt("VotePartyVotes", value); - } + public void setVotePartyVotes(int value) { + getUserData().setInt("VotePartyVotes", value); + } + + /** Adds one VoteParty count using the shared period boundary when MySQL is shared. */ + public void addVotePartyVote() { + if (plugin != null && UserStorage.MYSQL.equals(plugin.getStorageType())) { + if (getCache() != null) getCache().clearChanges(); + if (!VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, getUUID(), "VotePartyVotes", + "LastVotePartyVotes", List.of("VotePartyVotes"), null)) { + throw new IllegalStateException("Unable to persist shared MySQL VoteParty count"); + } + return; + } + setVotePartyVotes(getVotePartyVotes() + 1); + } /** * Sets the vote shop identifier limit. 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 index d85bc16ac5..ecce382f9a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -324,6 +324,28 @@ private static String generationTransition(String generation, String prefix) { return generation != null && generation.startsWith(prefix) ? generation.substring(prefix.length()) : null; } + /** Serializes the accepted daily-streak value and timestamp with its shared boundary. */ + void updateDailyStreak(String uuid, int streak, long updatedAt) throws SQLException { + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try { + lockLimitEpochRow(connection, "streak-copy:DayVoteStreak"); + String sql = "UPDATE " + qi(table.getTableName()) + " SET " + qi("DayVoteStreak") + " = ?, " + + qi("DayVoteStreakLastUpdate") + " = ? WHERE " + qi("uuid") + uuidCast(); + try (PreparedStatement update = connection.prepareStatement(sql)) { + update.setInt(1, streak); + update.setString(2, Long.toString(updatedAt)); + update.setString(3, uuid); + if (update.executeUpdate() != 1) throw new SQLException("Daily streak user row is missing"); + } + connection.commit(); + } catch (SQLException failure) { + rollback(connection); + throw failure; + } + } + } + /** Copies the period boundary once so a phase-receipt retry cannot move it. */ void copyPeriodBoundary(String totalColumn, String previousColumn, String generation) throws SQLException { if (!isSafeColumn(totalColumn) || !isSafeColumn(previousColumn)) { 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 85c26e4f0c..f426ff5109 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 @@ -637,6 +637,25 @@ public static boolean incrementMysqlPeriodTotals(VotingPluginMain plugin, String } } + /** Atomically publishes an accepted daily streak under the shared boundary lock. */ + public static boolean updateMysqlDailyStreak(VotingPluginMain plugin, String uuid, int streak, long updatedAt) { + if (!canRecoverSharedMysqlPurchases(plugin)) return false; + try { + MySQL table = plugin.getMysql(); + table.checkColumn("DayVoteStreak", DataType.INTEGER); + table.checkColumn("DayVoteStreakLastUpdate", DataType.STRING); + SharedMysqlPurchaseJournal.forTable(table).updateDailyStreak(uuid, streak, updatedAt); + return true; + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to atomically update MySQL daily streak: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + return false; + } finally { + SharedMysqlCacheReconciler.invalidate(plugin, uuid, "DayVoteStreak", "DayVoteStreakLastUpdate"); + } + } + /** Atomically captures one period boundary for a recoverable transition. */ public static boolean copyMysqlPeriodBoundary(VotingPluginMain plugin, String totalColumn, String previousColumn, String generation) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java index b7d2169e7e..592d740324 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java @@ -134,19 +134,22 @@ void userRecoveryFlushesUserChangesBeforeAdvancingTheCursor() { VotingPluginUser user = mock(VotingPluginUser.class); UserDataCache cache = mock(UserDataCache.class); TimeChangeTransition transition = mock(TimeChangeTransition.class); - UUID uuid = UUID.fromString("00000000-0000-0000-0000-000000000001"); + UUID firstUuid = UUID.fromString("00000000-0000-0000-0000-000000000001"); + UUID secondUuid = UUID.fromString("00000000-0000-0000-0000-000000000002"); when(plugin.getUserManager()).thenReturn(userManager); when(plugin.getVotingPluginUserManager()).thenReturn(votingUsers); when(plugin.getServerData()).thenReturn(serverData); when(plugin.getConfigFile().isUseHighestTotals()).thenReturn(true); when(serverData.getTimeChangeCursor(transition)).thenReturn(""); - when(votingUsers.getVotingPluginUser(uuid, false)).thenReturn(user); + when(votingUsers.getVotingPluginUser(org.mockito.ArgumentMatchers.any(UUID.class), + org.mockito.ArgumentMatchers.eq(false))).thenReturn(user); when(user.getCache()).thenReturn(cache); doAnswer(invocation -> { java.util.function.BiConsumer> perUser = invocation.getArgument(0); Consumer finished = invocation.getArgument(1); - perUser.accept(uuid, new ArrayList<>()); - finished.accept(1); + perUser.accept(secondUuid, new ArrayList<>()); + perUser.accept(firstUuid, new ArrayList<>()); + finished.accept(2); return null; }).when(userManager).forEachUserKeys(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); @@ -155,7 +158,9 @@ void userRecoveryFlushesUserChangesBeforeAdvancingTheCursor() { org.mockito.InOrder order = inOrder(cache, serverData); order.verify(cache).clearChanges(); - order.verify(serverData).completeTimeChangeUser(transition, uuid.toString()); + order.verify(serverData).completeTimeChangeUser(transition, firstUuid.toString()); + order.verify(cache).clearChanges(); + order.verify(serverData).completeTimeChangeUser(transition, secondUuid.toString()); } @Test diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java index 42640f9ec3..62ff0a8b62 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java @@ -40,6 +40,28 @@ void sharedMysqlDailyVotesUseTheCrossBackendBoundaryMutation() { } } + @Test + void sharedMysqlVotePartyCountsUseTheCrossBackendBoundaryMutation() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + AdvancedCoreUser base = mock(AdvancedCoreUser.class); + when(base.getUserData()).thenReturn(mock(UserData.class)); + when(base.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + VotingPluginUser user = spy(new VotingPluginUser(plugin, base)); + doReturn(null).when(user).getCache(); + try (MockedStatic service = org.mockito.Mockito + .mockStatic(VoteShopPurchaseService.class)) { + service.when(() -> VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, base.getUUID(), + "VotePartyVotes", "LastVotePartyVotes", java.util.List.of("VotePartyVotes"), null)) + .thenReturn(true); + + user.addVotePartyVote(); + + service.verify(() -> VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, base.getUUID(), + "VotePartyVotes", "LastVotePartyVotes", java.util.List.of("VotePartyVotes"), null)); + } + } + @Test void sharedMysqlLimitsUseNonBlockingUserCacheWhenAvailable() { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); 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 index b9a21ce7f3..1bba98d247 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -382,6 +382,27 @@ void dailyStreakValueAndTimestampCopyShareOneTransaction() throws Exception { verify(fixture.work).commit(); } + @Test + void dailyStreakUpdateLocksTheSameBoundaryRow() throws Exception { + Fixture fixture = fixture(); + PreparedStatement markerInsert = mock(PreparedStatement.class); + PreparedStatement markerSelect = mock(PreparedStatement.class); + PreparedStatement update = mock(PreparedStatement.class); + ResultSet epoch = mock(ResultSet.class); + when(epoch.next()).thenReturn(true); + when(markerSelect.executeQuery()).thenReturn(epoch); + when(update.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, update); + + new SharedMysqlPurchaseJournal(fixture.table, false).updateDailyStreak( + "00000000-0000-0000-0000-000000000001", 7, 1234L); + + verify(markerSelect).setString(1, "streak-copy:DayVoteStreak"); + verify(update).setInt(1, 7); + verify(update).setString(2, "1234"); + verify(fixture.work).commit(); + } + @Test void ambiguousResetCommitIsConfirmedAfterTheConnectionIsReleased() throws Exception { Fixture fixture = fixture(); From 54db523037a7737ff5dbf28982044a3e64381a97 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:07:01 -0600 Subject: [PATCH 13/61] Make shared vote accounting retry safe --- .../core/vote/SharedVoteProcessor.java | 26 ++-- .../listeners/PlayerVoteListener.java | 14 +-- .../specialrewards/voteparty/VoteParty.java | 13 +- .../votingplugin/user/VotingPluginUser.java | 94 +++++++++----- .../service/SharedMysqlPurchaseJournal.java | 116 +++++++++++++++++- .../service/VoteShopPurchaseService.java | 12 +- .../core/vote/SharedVoteProcessorTest.java | 14 +-- .../VotingPluginUserVoteShopLimitTest.java | 69 ++++++++++- .../SharedMysqlPurchaseJournalTest.java | 93 ++++++++++++-- 9 files changed, 368 insertions(+), 83 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java index a2f0444efe..1569359b52 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java @@ -61,7 +61,7 @@ public interface Operations { UUID proxyVoteId(); void cache(U user); void updateName(U user); - void voteParty(U user, boolean realVote, boolean forceProxyRouting); + void voteParty(U user, boolean realVote, boolean forceProxyRouting, UUID voteId); long incomingTime(); void setTime(U user, S site, long time); void setTimeNow(U user, S site); @@ -76,11 +76,11 @@ public interface Operations { int offlineVotesLimitAmount(); void addOfflineVote(U user, String siteKey); SharedVotePolicy countingPolicy(); - void addTotal(U user); - void addTotalDaily(U user); - void addTotalWeekly(U user); + void addTotal(U user, UUID voteId); + void addTotalDaily(U user, UUID voteId); + void addTotalWeekly(U user, UUID voteId); void addPoints(U user); - void checkDayVoteStreak(U user, boolean forceProxyRouting); + void checkDayVoteStreak(U user, boolean forceProxyRouting, UUID voteId); boolean limitMonthlyVotes(); int proxyMonthTotal(); int userMonthTotal(U user); @@ -161,15 +161,16 @@ public static void process(Operations ops) { ops.debug("Allowing queued proxy vote for " + ops.userName(user) + " on " + ops.siteKey(site) + "; proxy vote time already matches LastVotes: " + ops.incomingTime()); } - UUID voteId = UUID.randomUUID(); + UUID candidateVoteId = UUID.randomUUID(); if (ops.proxyVote() && ops.hasProxyTextTotals()) { - voteId = ops.proxyVoteId(); - if (voteId == null) voteId = UUID.randomUUID(); + candidateVoteId = ops.proxyVoteId(); + if (candidateVoteId == null) candidateVoteId = UUID.randomUUID(); } + final UUID voteId = candidateVoteId; String userId = ops.userId(user); ops.cache(user); ops.updateName(user); - ops.voteParty(user, ops.realVote(), ops.forceProxyRouting()); + ops.voteParty(user, ops.realVote(), ops.forceProxyRouting(), voteId); if (ops.broadcastEnabled() && ops.hasBroadcastHandler()) { boolean currentOnline = ops.userOnline(user); boolean online = currentOnline; @@ -207,9 +208,10 @@ public static void process(Operations ops) { SharedVotePolicy policy = ops.countingPolicy(); SharedVoteInput input = new SharedVoteInput(voteId, playerName, ops.serviceSite(), voteTime, ops.realVote(), ops.addTotals(), ops.proxyVote(), ops.forceProxyRouting(), ops.wasOnline()); - SharedVoteAccounting.apply(input, policy, () -> ops.userOnline(user), () -> ops.addTotal(user), - () -> ops.addTotalDaily(user), () -> ops.addTotalWeekly(user), () -> ops.addPoints(user)); - ops.checkDayVoteStreak(user, ops.forceProxyRouting()); + SharedVoteAccounting.apply(input, policy, () -> ops.userOnline(user), () -> ops.addTotal(user, voteId), + () -> ops.addTotalDaily(user, voteId), () -> ops.addTotalWeekly(user, voteId), + () -> ops.addPoints(user)); + ops.checkDayVoteStreak(user, ops.forceProxyRouting(), voteId); if (ops.limitMonthlyVotes() && (!ops.proxyVote() || ops.hasProxyTextTotals())) { int value = ops.proxyVote() ? ops.proxyMonthTotal() : ops.userMonthTotal(user); int days = ops.currentDayOfMonth(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java index bf18a41828..69feeb57ff 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java @@ -130,8 +130,8 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { @Override public UUID proxyVoteId() { return event.getBungeeTextTotals().getVoteUUID(); } @Override public void cache(VotingPluginUser user) { user.cache(); } @Override public void updateName(VotingPluginUser user) { user.updateName(true); } - @Override public void voteParty(VotingPluginUser user, boolean realVote, boolean forceProxyRouting) { - plugin.getVoteParty().vote(user, realVote, forceProxyRouting); + @Override public void voteParty(VotingPluginUser user, boolean realVote, boolean forceProxyRouting, UUID voteId) { + plugin.getVoteParty().vote(user, realVote, forceProxyRouting, voteId); } @Override public long incomingTime() { return event.getTime(); } @Override public void setTime(VotingPluginUser user, VoteSite site, long time) { user.setTime(site, time); } @@ -152,12 +152,12 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { return new SharedVotePolicy(plugin.getConfigFile().isCountFakeVotes(), plugin.getConfigFile().isAddTotals(), plugin.getConfigFile().isAddTotalsOffline(), false, false); } - @Override public void addTotal(VotingPluginUser user) { user.addTotal(); } - @Override public void addTotalDaily(VotingPluginUser user) { user.addTotalDaily(); } - @Override public void addTotalWeekly(VotingPluginUser user) { user.addTotalWeekly(); } + @Override public void addTotal(VotingPluginUser user, UUID voteId) { user.addTotal(voteId); } + @Override public void addTotalDaily(VotingPluginUser user, UUID voteId) { user.addTotalDaily(voteId); } + @Override public void addTotalWeekly(VotingPluginUser user, UUID voteId) { user.addTotalWeekly(voteId); } @Override public void addPoints(VotingPluginUser user) { user.addPoints(); } - @Override public void checkDayVoteStreak(VotingPluginUser user, boolean forceProxyRouting) { - user.checkDayVoteStreak(forceProxyRouting); + @Override public void checkDayVoteStreak(VotingPluginUser user, boolean forceProxyRouting, UUID voteId) { + user.checkDayVoteStreak(forceProxyRouting, voteId); } @Override public boolean limitMonthlyVotes() { return plugin.getConfigFile().isLimitMonthlyVotes(); } @Override public int proxyMonthTotal() { return event.getBungeeTextTotals().getMonthTotal(); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java index d562a37cd9..c872f6516b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java @@ -4,6 +4,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.UUID; import org.bukkit.Bukkit; import org.bukkit.command.CommandSender; @@ -47,8 +48,12 @@ public VoteParty(VotingPluginMain plugin) { * @param user the voting plugin user */ public void addTotal(VotingPluginUser user) { + addTotal(user, null); + } + + public void addTotal(VotingPluginUser user, UUID voteId) { setTotalVotes(getTotalVotes() + 1); - user.addVotePartyVote(); + user.addVotePartyVote(voteId); plugin.getPlaceholders().onVotePartyUpdate(); } @@ -406,10 +411,14 @@ public void setVotedUsers(List value) { * @param forceBungee whether to force Bungee processing */ public synchronized void vote(VotingPluginUser user, boolean realVote, boolean forceBungee) { + vote(user, realVote, forceBungee, null); + } + + public synchronized void vote(VotingPluginUser user, boolean realVote, boolean forceBungee, UUID voteId) { if (plugin.getSpecialRewardsConfig().isVotePartyEnabled()) { if (plugin.getSpecialRewardsConfig().isVotePartyCountFakeVotes() || realVote) { if (plugin.getSpecialRewardsConfig().isVotePartyCountOfflineVotes() || user.isOnline()) { - addTotal(user); + addTotal(user, voteId); addVotePlayer(user); check(user, forceBungee); checkVoteReminder(user); 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 687bf03ac7..5fd6aaae2d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -167,9 +167,21 @@ public void addDayVoteStreak() { * Adds one to the monthly total votes. */ public void addMonthTotal() { + addMonthTotal(null); + } + + public void addMonthTotal(UUID voteId) { PeriodTotalMutationFence.withMutation(() -> { - if (incrementSharedMysqlMonthTotal()) return; - setMonthTotal(getMonthTotal() + 1); + int fallbackTotal = getMonthTotal() + 1; + if (plugin != null && UserStorage.MYSQL.equals(plugin.getStorageType()) + && plugin.getConfigFile().isLimitMonthlyVotes()) { + int maximum = plugin.getTimeChecker().getTime().getDayOfMonth() + * plugin.getVoteSiteManager().getVoteSitesEnabled().size(); + fallbackTotal = Math.min(maximum, fallbackTotal); + } + int queuedFallbackTotal = fallbackTotal; + if (incrementSharedMysqlMonthTotal(voteId)) return; + setMonthTotal(queuedFallbackTotal); }); } @@ -873,18 +885,27 @@ public synchronized void addPointsStorageAware(int value, BiConsumer { - if (incrementSharedMysqlPeriodTotal(TopVoter.Daily, "DailyTotal")) return; - setDailyTotal(getDailyTotal() + 1); + int fallbackTotal = getDailyTotal() + 1; + if (incrementSharedMysqlPeriodTotal(voteId, TopVoter.Daily, "DailyTotal")) return; + setDailyTotal(fallbackTotal); }); } @@ -892,13 +913,18 @@ public void addTotalDaily() { * Adds one to the weekly total votes. */ public void addTotalWeekly() { + addTotalWeekly(null); + } + + public void addTotalWeekly(UUID voteId) { PeriodTotalMutationFence.withMutation(() -> { - if (incrementSharedMysqlPeriodTotal(TopVoter.Weekly, "WeeklyTotal")) return; - setWeeklyTotal(getWeeklyTotal() + 1); + int fallbackTotal = getWeeklyTotal() + 1; + if (incrementSharedMysqlPeriodTotal(voteId, TopVoter.Weekly, "WeeklyTotal")) return; + setWeeklyTotal(fallbackTotal); }); } - private boolean incrementSharedMysqlMonthTotal() { + private boolean incrementSharedMysqlMonthTotal(UUID voteId) { if (plugin == null || !UserStorage.MYSQL.equals(plugin.getStorageType())) return false; if (getCache() != null) getCache().clearChanges(); ArrayList columns = new ArrayList<>(); @@ -911,21 +937,15 @@ private boolean incrementSharedMysqlMonthTotal() { maximum = Integer.valueOf(plugin.getTimeChecker().getTime().getDayOfMonth() * plugin.getVoteSiteManager().getVoteSitesEnabled().size()); } - if (!VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, getUUID(), "MonthTotal", "LastMonthTotal", - columns, maximum)) { - throw new IllegalStateException("Unable to persist shared MySQL monthly vote total"); - } - return true; + return VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, getUUID(), "MonthTotal", + "LastMonthTotal", columns, maximum); } - private boolean incrementSharedMysqlPeriodTotal(TopVoter top, String column) { + private boolean incrementSharedMysqlPeriodTotal(UUID voteId, TopVoter top, String column) { if (plugin == null || !UserStorage.MYSQL.equals(plugin.getStorageType())) return false; if (getCache() != null) getCache().clearChanges(); - if (!VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, getUUID(), column, top.getLastColumnName(), - List.of(column), null)) { - throw new IllegalStateException("Unable to persist shared MySQL " + top + " vote total"); - } - return true; + return VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, getUUID(), column, + top.getLastColumnName(), List.of(column), null); } /** @@ -1103,6 +1123,10 @@ public boolean checkAlmostAllVotes() { */ @Deprecated public void checkDayVoteStreak(boolean forceBungee) { + checkDayVoteStreak(forceBungee, null); + } + + public void checkDayVoteStreak(boolean forceBungee, UUID voteId) { PeriodTotalMutationFence.withMutation(() -> { if (!voteStreakUpdatedToday(LocalDateTime.now())) { if (!plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage() || hasPercentageTotal( @@ -1113,17 +1137,21 @@ public void checkDayVoteStreak(boolean forceBungee) { plugin.getSpecialRewardsConfig().getVoteStreakRequirementDay(), null)); int streak = getDayVoteStreak() + 1; long updatedAt = System.currentTimeMillis(); + boolean sharedStreakPersisted = false; if (UserStorage.MYSQL.equals(plugin.getStorageType())) { if (getCache() != null) getCache().clearChanges(); - if (!VoteShopPurchaseService.updateMysqlDailyStreak(plugin, getUUID(), streak, updatedAt)) { - throw new IllegalStateException("Unable to persist shared MySQL daily streak"); + sharedStreakPersisted = VoteShopPurchaseService.updateMysqlDailyStreak( + plugin, voteId, getUUID(), streak, updatedAt); + if (sharedStreakPersisted) { + if (getBestDayVoteStreak() < streak) setBestDayVoteStreak(streak); + } else { + setDayVoteStreak(streak); } - if (getBestDayVoteStreak() < streak) setBestDayVoteStreak(streak); } else { setDayVoteStreak(streak); } plugin.getSpecialRewards().checkVoteStreak(null, this, "Day", forceBungee); - if (!UserStorage.MYSQL.equals(plugin.getStorageType())) setDayVoteStreakLastUpdate(updatedAt); + if (!sharedStreakPersisted) setDayVoteStreakLastUpdate(updatedAt); } } }); @@ -2524,15 +2552,17 @@ public void setVotePartyVotes(int value) { /** Adds one VoteParty count using the shared period boundary when MySQL is shared. */ public void addVotePartyVote() { + addVotePartyVote(null); + } + + public void addVotePartyVote(UUID voteId) { + int fallbackTotal = getVotePartyVotes() + 1; if (plugin != null && UserStorage.MYSQL.equals(plugin.getStorageType())) { if (getCache() != null) getCache().clearChanges(); - if (!VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, getUUID(), "VotePartyVotes", - "LastVotePartyVotes", List.of("VotePartyVotes"), null)) { - throw new IllegalStateException("Unable to persist shared MySQL VoteParty count"); - } - return; + if (VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, getUUID(), "VotePartyVotes", + "LastVotePartyVotes", List.of("VotePartyVotes"), null)) return; } - setVotePartyVotes(getVotePartyVotes() + 1); + setVotePartyVotes(fallbackTotal); } /** 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 index ecce382f9a..42bdf23bfc 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -14,6 +14,7 @@ import java.util.Iterator; import java.util.List; import java.util.Set; +import java.util.UUID; import java.util.concurrent.TimeUnit; import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; @@ -43,9 +44,16 @@ final class SharedMysqlPurchaseJournal { 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 ACCOUNTING_SUFFIX = "_VoteAccounting"; private static final String HASHED_TABLE_PREFIX = "vp_vsp_"; private static final String HASHED_EPOCH_TABLE_PREFIX = "vp_vse_"; + private static final String HASHED_ACCOUNTING_TABLE_PREFIX = "vp_vsa_"; private static final int HASHED_TABLE_HEX_LENGTH = 32; + private static final int DAILY_TOTAL = 1; + private static final int WEEKLY_TOTAL = 2; + private static final int MONTH_TOTAL = 4; + private static final int VOTE_PARTY_TOTAL = 8; + private static final int DAILY_STREAK = 16; private static final ReferenceQueue INITIALIZED_QUEUE = new ReferenceQueue<>(); private static final Set INITIALIZED = new HashSet<>(); @@ -53,11 +61,13 @@ final class SharedMysqlPurchaseJournal { private final MySQL table; private final String journalTable; private final String epochTable; + private final String accountingTable; SharedMysqlPurchaseJournal(MySQL table, boolean initializeSchema) throws SQLException { this.table = table; journalTable = journalTableName(table.getTableName()); epochTable = epochTableName(table.getTableName()); + accountingTable = accountingTableName(table.getTableName()); if (initializeSchema) ensureSchema(); } @@ -74,6 +84,10 @@ static String epochTableName(String sourceTable) { return auxiliaryTableName(sourceTable, EPOCH_SUFFIX, HASHED_EPOCH_TABLE_PREFIX); } + static String accountingTableName(String sourceTable) { + return auxiliaryTableName(sourceTable, ACCOUNTING_SUFFIX, HASHED_ACCOUNTING_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; @@ -268,10 +282,10 @@ void resetPeriodTotal(String totalColumn, String previousColumn, String resetGen * Serializes accepted-vote increments with the shared boundary marker. Every * backend therefore agrees whether an increment precedes or follows the copy. */ - void incrementPeriodTotals(String uuid, String boundaryColumn, String previousColumn, List columns, + void incrementPeriodTotals(UUID voteId, String uuid, String boundaryColumn, String previousColumn, List columns, Integer maximum) throws SQLException { - if (uuid == null || uuid.isEmpty() || !isSafeColumn(boundaryColumn) || columns == null + if (voteId == null || uuid == null || uuid.isEmpty() || !isSafeColumn(boundaryColumn) || columns == null || columns.isEmpty() || columns.stream().anyMatch(column -> !isSafeColumn(column)) || maximum != null && (maximum.intValue() < 0 || !isSafeColumn(previousColumn))) { throw new SQLException("Invalid period total increment"); @@ -280,6 +294,12 @@ void incrementPeriodTotals(String uuid, String boundaryColumn, String previousCo connection.setAutoCommit(false); try { EpochRow copyMarker = lockLimitEpochRow(connection, "period-copy:" + boundaryColumn); + int operation = accountingOperation(boundaryColumn); + int completed = lockAccountingVote(connection, voteId); + if ((completed & operation) != 0) { + rollback(connection); + return; + } boolean resetPending = maximum != null && resetPending(connection, boundaryColumn, copyMarker); StringBuilder sql = new StringBuilder("UPDATE ").append(qi(table.getTableName())).append(" SET "); for (int index = 0; index < columns.size(); index++) { @@ -304,7 +324,8 @@ void incrementPeriodTotals(String uuid, String boundaryColumn, String previousCo update.setString(parameter, uuid); if (update.executeUpdate() != 1) throw new SQLException("Period total user row is missing"); } - connection.commit(); + markAccountingComplete(connection, voteId, completed | operation); + commitAndConfirmAccounting(connection, voteId, operation); } catch (SQLException failure) { rollback(connection); throw failure; @@ -312,6 +333,74 @@ void incrementPeriodTotals(String uuid, String boundaryColumn, String previousCo } } + private static int accountingOperation(String boundaryColumn) throws SQLException { + return switch (boundaryColumn) { + case "DailyTotal" -> DAILY_TOTAL; + case "WeeklyTotal" -> WEEKLY_TOTAL; + case "MonthTotal" -> MONTH_TOTAL; + case "VotePartyVotes" -> VOTE_PARTY_TOTAL; + default -> throw new SQLException("Unsupported period total boundary"); + }; + } + + private int lockAccountingVote(Connection connection, UUID voteId) throws SQLException { + String insert = table.getDbType() == DbType.POSTGRESQL + ? "INSERT INTO " + qiAccounting() + " (" + qi("vote_id") + ", " + qi("completed") + + ", " + qi("created_at") + ") VALUES (?, 0, ?) ON CONFLICT DO NOTHING" + : "INSERT IGNORE INTO " + qiAccounting() + " (" + qi("vote_id") + ", " + qi("completed") + + ", " + qi("created_at") + ") VALUES (?, 0, ?)"; + try (PreparedStatement statement = connection.prepareStatement(insert)) { + statement.setString(1, voteId.toString()); + statement.setLong(2, System.currentTimeMillis()); + statement.executeUpdate(); + } + String select = "SELECT " + qi("completed") + " FROM " + qiAccounting() + " WHERE " + + qi("vote_id") + " = ? FOR UPDATE"; + try (PreparedStatement statement = connection.prepareStatement(select)) { + statement.setString(1, voteId.toString()); + try (ResultSet result = statement.executeQuery()) { + if (!result.next()) throw new SQLException("Vote accounting marker is missing"); + return result.getInt(1); + } + } + } + + private void markAccountingComplete(Connection connection, UUID voteId, int completed) throws SQLException { + String update = "UPDATE " + qiAccounting() + " SET " + qi("completed") + " = ? WHERE " + + qi("vote_id") + " = ?"; + try (PreparedStatement statement = connection.prepareStatement(update)) { + statement.setInt(1, completed); + statement.setString(2, voteId.toString()); + if (statement.executeUpdate() != 1) throw new SQLException("Vote accounting marker is missing"); + } + } + + private void commitAndConfirmAccounting(Connection connection, UUID voteId, int operation) throws SQLException { + try { + connection.commit(); + } catch (SQLException ambiguousCommit) { + closeQuietly(connection); + try { + Integer completed = findAccountingVote(voteId); + if (completed != null && (completed.intValue() & operation) != 0) return; + } catch (SQLException confirmationFailure) { + ambiguousCommit.addSuppressed(confirmationFailure); + } + throw ambiguousCommit; + } + } + + private Integer findAccountingVote(UUID voteId) throws SQLException { + String select = "SELECT " + qi("completed") + " FROM " + qiAccounting() + " WHERE " + + qi("vote_id") + " = ?"; + try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(select)) { + statement.setString(1, voteId.toString()); + try (ResultSet result = statement.executeQuery()) { + return result.next() ? Integer.valueOf(result.getInt(1)) : null; + } + } + } + private boolean resetPending(Connection connection, String boundaryColumn, EpochRow copyMarker) throws SQLException { EpochRow resetMarker = lockLimitEpochRow(connection, "period-reset:" + boundaryColumn); @@ -325,11 +414,16 @@ private static String generationTransition(String generation, String prefix) { } /** Serializes the accepted daily-streak value and timestamp with its shared boundary. */ - void updateDailyStreak(String uuid, int streak, long updatedAt) throws SQLException { + void updateDailyStreak(UUID voteId, String uuid, int streak, long updatedAt) throws SQLException { try (Connection connection = connection()) { connection.setAutoCommit(false); try { lockLimitEpochRow(connection, "streak-copy:DayVoteStreak"); + int completed = lockAccountingVote(connection, voteId); + if ((completed & DAILY_STREAK) != 0) { + rollback(connection); + return; + } String sql = "UPDATE " + qi(table.getTableName()) + " SET " + qi("DayVoteStreak") + " = ?, " + qi("DayVoteStreakLastUpdate") + " = ? WHERE " + qi("uuid") + uuidCast(); try (PreparedStatement update = connection.prepareStatement(sql)) { @@ -338,7 +432,8 @@ void updateDailyStreak(String uuid, int streak, long updatedAt) throws SQLExcept update.setString(3, uuid); if (update.executeUpdate() != 1) throw new SQLException("Daily streak user row is missing"); } - connection.commit(); + markAccountingComplete(connection, voteId, completed | DAILY_STREAK); + commitAndConfirmAccounting(connection, voteId, DAILY_STREAK); } catch (SQLException failure) { rollback(connection); throw failure; @@ -731,6 +826,7 @@ private void ensureSchema() throws SQLException { ensureColumn(connection, "limit_generation_expires_at", "BIGINT NULL"); ensureColumn(connection, "limit_epoch", "BIGINT NULL"); ensureEpochSchema(connection); + ensureAccountingSchema(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") + ");"; @@ -742,6 +838,15 @@ private void ensureSchema() throws SQLException { } } + private void ensureAccountingSchema(Connection connection) throws SQLException { + String create = "CREATE TABLE IF NOT EXISTS " + qiAccounting() + " (" + qi("vote_id") + + " VARCHAR(36) NOT NULL, " + qi("completed") + " INT NOT NULL, " + qi("created_at") + + " BIGINT NOT NULL, PRIMARY KEY (" + qi("vote_id") + "));"; + try (PreparedStatement statement = connection.prepareStatement(create)) { + statement.executeUpdate(); + } + } + 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, " @@ -779,6 +884,7 @@ private Connection connection() throws SQLException { private String qiJournal() { return table.qi(journalTable); } private String qiEpoch() { return table.qi(epochTable); } + private String qiAccounting() { return table.qi(accountingTable); } private String qi(String identifier) { return table.qi(identifier); } private String uuidCast() { return table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"; } 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 f426ff5109..af187e13b5 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 @@ -617,15 +617,15 @@ public static boolean resetMysqlPeriodTotal(VotingPluginMain plugin, String tota } /** Atomically increments period totals under the cross-backend boundary lock. */ - public static boolean incrementMysqlPeriodTotals(VotingPluginMain plugin, String uuid, String boundaryColumn, + public static boolean incrementMysqlPeriodTotals(VotingPluginMain plugin, UUID voteId, String uuid, String boundaryColumn, String previousColumn, List columns, Integer maximum) { if (!canRecoverSharedMysqlPurchases(plugin) || columns == null || columns.isEmpty()) return false; try { MySQL table = plugin.getMysql(); for (String column : columns) table.checkColumn(column, DataType.INTEGER); if (maximum != null) table.checkColumn(previousColumn, DataType.INTEGER); - SharedMysqlPurchaseJournal.forTable(table).incrementPeriodTotals(uuid, boundaryColumn, previousColumn, - columns, maximum); + SharedMysqlPurchaseJournal.forTable(table).incrementPeriodTotals( + voteId == null ? UUID.randomUUID() : voteId, uuid, boundaryColumn, previousColumn, columns, maximum); return true; } catch (SQLException failure) { plugin.getLogger().severe("Unable to atomically increment MySQL period total: " @@ -638,13 +638,15 @@ public static boolean incrementMysqlPeriodTotals(VotingPluginMain plugin, String } /** Atomically publishes an accepted daily streak under the shared boundary lock. */ - public static boolean updateMysqlDailyStreak(VotingPluginMain plugin, String uuid, int streak, long updatedAt) { + public static boolean updateMysqlDailyStreak(VotingPluginMain plugin, UUID voteId, String uuid, int streak, + long updatedAt) { if (!canRecoverSharedMysqlPurchases(plugin)) return false; try { MySQL table = plugin.getMysql(); table.checkColumn("DayVoteStreak", DataType.INTEGER); table.checkColumn("DayVoteStreakLastUpdate", DataType.STRING); - SharedMysqlPurchaseJournal.forTable(table).updateDailyStreak(uuid, streak, updatedAt); + SharedMysqlPurchaseJournal.forTable(table).updateDailyStreak( + voteId == null ? UUID.randomUUID() : voteId, uuid, streak, updatedAt); return true; } catch (SQLException failure) { plugin.getLogger().severe("Unable to atomically update MySQL daily streak: " diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java index 200cc8a48e..4a95eaab08 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java @@ -62,17 +62,17 @@ void onlineVoteKeepsProductionPhaseOrderAndCounts() { order.verify(ops).lastVoteTime(user, site); order.verify(ops).cache(user); order.verify(ops).updateName(user); - order.verify(ops).voteParty(user, true, false); + order.verify(ops).voteParty(eq(user), eq(true), eq(false), any(UUID.class)); order.verify(ops).broadcast(any(UUID.class), eq("Ben"), any(), eq(true)); order.verify(ops).setTime(user, site, 123L); order.verify(ops).playerVote(user, site, true, false); order.verify(ops).sendVoteEffects(user, true); order.verify(ops).closeInventory(user); - order.verify(ops).addTotal(user); - order.verify(ops).addTotalDaily(user); - order.verify(ops).addTotalWeekly(user); + order.verify(ops).addTotal(eq(user), any(UUID.class)); + order.verify(ops).addTotalDaily(eq(user), any(UUID.class)); + order.verify(ops).addTotalWeekly(eq(user), any(UUID.class)); order.verify(ops).addPoints(user); - order.verify(ops).checkDayVoteStreak(user, false); + order.verify(ops).checkDayVoteStreak(eq(user), eq(false), any(UUID.class)); order.verify(ops).setMonthTotal(user, 2); order.verify(ops).milestones(eq(user), any(UUID.class), eq(false)); order.verify(ops).cooldown(user, site); @@ -102,7 +102,7 @@ void proxyVoteUsesHistoricalOnlineForDeliveryButCurrentOnlineForTotals() { verify(ops).broadcast(any(UUID.class), eq("Ben"), any(), eq(true)); verify(ops).playerVote(user, site, true, false); verify(ops, never()).addOfflineVote(any(), any()); - verify(ops, never()).addTotal(user); + verify(ops, never()).addTotal(eq(user), any(UUID.class)); verify(ops).addPoints(user); ArgumentCaptor id = ArgumentCaptor.forClass(UUID.class); verify(ops).postVote(eq(site), eq(user), eq("Ben"), eq(321L), id.capture(), eq(false)); @@ -165,7 +165,7 @@ void offlineVoteQueuesBeforeAccountingAndMarksPostEventCached() { order.verify(ops).addPoints(user); order.verify(ops).postVote(eq(site), eq(user), eq("Ben"), eq(456L), any(UUID.class), eq(true)); verify(ops, never()).playerVote(any(), any(), eq(false), eq(false)); - verify(ops, never()).addTotal(user); + verify(ops, never()).addTotal(eq(user), any(UUID.class)); verify(ops).clearCache(user); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java index 62ff0a8b62..d91d014e25 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; @@ -11,6 +12,8 @@ import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; +import java.util.UUID; + import com.bencodez.advancedcore.api.user.AdvancedCoreUser; import com.bencodez.advancedcore.api.user.UserData; import com.bencodez.advancedcore.api.user.UserDataFetchMode; @@ -28,14 +31,16 @@ void sharedMysqlDailyVotesUseTheCrossBackendBoundaryMutation() { when(base.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); VotingPluginUser user = spy(new VotingPluginUser(plugin, base)); doReturn(null).when(user).getCache(); + doReturn(0).when(user).getDailyTotal(); + UUID voteId = UUID.randomUUID(); try (MockedStatic service = org.mockito.Mockito .mockStatic(VoteShopPurchaseService.class)) { - service.when(() -> VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, base.getUUID(), + service.when(() -> VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, base.getUUID(), "DailyTotal", "LastDailyTotal", java.util.List.of("DailyTotal"), null)).thenReturn(true); - user.addTotalDaily(); + user.addTotalDaily(voteId); - service.verify(() -> VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, base.getUUID(), + service.verify(() -> VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, base.getUUID(), "DailyTotal", "LastDailyTotal", java.util.List.of("DailyTotal"), null)); } } @@ -49,19 +54,71 @@ void sharedMysqlVotePartyCountsUseTheCrossBackendBoundaryMutation() { when(base.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); VotingPluginUser user = spy(new VotingPluginUser(plugin, base)); doReturn(null).when(user).getCache(); + doReturn(0).when(user).getVotePartyVotes(); + UUID voteId = UUID.randomUUID(); try (MockedStatic service = org.mockito.Mockito .mockStatic(VoteShopPurchaseService.class)) { - service.when(() -> VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, base.getUUID(), + service.when(() -> VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, base.getUUID(), "VotePartyVotes", "LastVotePartyVotes", java.util.List.of("VotePartyVotes"), null)) .thenReturn(true); - user.addVotePartyVote(); + user.addVotePartyVote(voteId); - service.verify(() -> VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, base.getUUID(), + service.verify(() -> VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, base.getUUID(), "VotePartyVotes", "LastVotePartyVotes", java.util.List.of("VotePartyVotes"), null)); } } + @Test + void failedSharedMysqlIncrementFallsBackToTheQueuedUserMutation() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + AdvancedCoreUser base = mock(AdvancedCoreUser.class); + when(base.getUserData()).thenReturn(mock(UserData.class)); + when(base.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + VotingPluginUser user = spy(new VotingPluginUser(plugin, base)); + doReturn(null).when(user).getCache(); + doReturn(4).when(user).getDailyTotal(); + doNothing().when(user).setDailyTotal(org.mockito.ArgumentMatchers.anyInt()); + UUID voteId = UUID.randomUUID(); + try (MockedStatic service = org.mockito.Mockito + .mockStatic(VoteShopPurchaseService.class)) { + service.when(() -> VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, base.getUUID(), + "DailyTotal", "LastDailyTotal", java.util.List.of("DailyTotal"), null)).thenReturn(false); + + user.addTotalDaily(voteId); + + verify(user).setDailyTotal(5); + } + } + + @Test + void failedSharedMysqlMonthlyIncrementKeepsTheConfiguredCap() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getConfigFile().isLimitMonthlyVotes()).thenReturn(true); + when(plugin.getTimeChecker().getTime().getDayOfMonth()).thenReturn(2); + when(plugin.getVoteSiteManager().getVoteSitesEnabled().size()).thenReturn(3); + AdvancedCoreUser base = mock(AdvancedCoreUser.class); + when(base.getUserData()).thenReturn(mock(UserData.class)); + when(base.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + VotingPluginUser user = spy(new VotingPluginUser(plugin, base)); + doReturn(null).when(user).getCache(); + doReturn(6).when(user).getMonthTotal(); + doNothing().when(user).setMonthTotal(org.mockito.ArgumentMatchers.anyInt()); + UUID voteId = UUID.randomUUID(); + try (MockedStatic service = org.mockito.Mockito + .mockStatic(VoteShopPurchaseService.class)) { + service.when(() -> VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, base.getUUID(), + "MonthTotal", "LastMonthTotal", java.util.List.of("MonthTotal"), Integer.valueOf(6))) + .thenReturn(false); + + user.addMonthTotal(voteId); + + verify(user).setMonthTotal(6); + } + } + @Test void sharedMysqlLimitsUseNonBlockingUserCacheWhenAvailable() { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); 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 index 1bba98d247..827da51ea3 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -322,10 +322,14 @@ void periodIncrementLocksTheSameBoundaryRowBeforeUpdatingTheUser() throws Except Fixture fixture = fixture(); PreparedStatement markerInsert = mock(PreparedStatement.class); PreparedStatement markerSelect = mock(PreparedStatement.class); + PreparedStatement accountingInsert = mock(PreparedStatement.class); + PreparedStatement accountingSelect = mock(PreparedStatement.class); PreparedStatement resetMarkerInsert = mock(PreparedStatement.class); PreparedStatement resetMarkerSelect = mock(PreparedStatement.class); PreparedStatement increment = mock(PreparedStatement.class); + PreparedStatement accountingUpdate = mock(PreparedStatement.class); ResultSet epoch = mock(ResultSet.class); + ResultSet accounting = mock(ResultSet.class); ResultSet resetEpoch = mock(ResultSet.class); when(epoch.next()).thenReturn(true); when(epoch.getLong(1)).thenReturn(9L); @@ -333,21 +337,25 @@ void periodIncrementLocksTheSameBoundaryRowBeforeUpdatingTheUser() throws Except when(resetEpoch.next()).thenReturn(true); when(resetEpoch.getString(2)).thenReturn("time-total:MONTH:2026-08"); when(markerSelect.executeQuery()).thenReturn(epoch); + when(accounting.next()).thenReturn(true); + when(accounting.getInt(1)).thenReturn(0); + when(accountingSelect.executeQuery()).thenReturn(accounting); when(resetMarkerSelect.executeQuery()).thenReturn(resetEpoch); when(increment.executeUpdate()).thenReturn(1); + when(accountingUpdate.executeUpdate()).thenReturn(1); when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, - resetMarkerInsert, resetMarkerSelect, increment); + accountingInsert, accountingSelect, resetMarkerInsert, resetMarkerSelect, increment, accountingUpdate); - new SharedMysqlPurchaseJournal(fixture.table, false).incrementPeriodTotals( + new SharedMysqlPurchaseJournal(fixture.table, false).incrementPeriodTotals(java.util.UUID.randomUUID(), "00000000-0000-0000-0000-000000000001", "MonthTotal", "LastMonthTotal", java.util.List.of("MonthTotal", "MonthTotal-SEPTEMBER-2026"), Integer.valueOf(42)); org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class); - verify(fixture.work, org.mockito.Mockito.times(5)).prepareStatement(sql.capture()); + verify(fixture.work, org.mockito.Mockito.times(8)).prepareStatement(sql.capture()); assertTrue(sql.getAllValues().get(1).contains("FOR UPDATE")); - assertTrue(sql.getAllValues().get(4).contains( + assertTrue(sql.getAllValues().get(6).contains( "`MonthTotal` = LEAST(COALESCE(`LastMonthTotal`, 0) + ?, COALESCE(`MonthTotal`, 0) + 1)")); - assertTrue(sql.getAllValues().get(4).contains( + assertTrue(sql.getAllValues().get(6).contains( "`MonthTotal-SEPTEMBER-2026` = LEAST(?, COALESCE(`MonthTotal-SEPTEMBER-2026`, 0) + 1)")); verify(markerSelect).setString(1, "period-copy:MonthTotal"); verify(increment).setInt(1, 42); @@ -356,6 +364,68 @@ void periodIncrementLocksTheSameBoundaryRowBeforeUpdatingTheUser() throws Except verify(fixture.work).commit(); } + @Test + void repeatedVoteOperationDoesNotIncrementThePeriodTwice() throws Exception { + Fixture fixture = fixture(); + PreparedStatement markerInsert = mock(PreparedStatement.class); + PreparedStatement markerSelect = mock(PreparedStatement.class); + PreparedStatement accountingInsert = mock(PreparedStatement.class); + PreparedStatement accountingSelect = mock(PreparedStatement.class); + ResultSet epoch = mock(ResultSet.class); + ResultSet accounting = mock(ResultSet.class); + when(epoch.next()).thenReturn(true); + when(markerSelect.executeQuery()).thenReturn(epoch); + when(accounting.next()).thenReturn(true); + when(accounting.getInt(1)).thenReturn(1); + when(accountingSelect.executeQuery()).thenReturn(accounting); + when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, + accountingInsert, accountingSelect); + + new SharedMysqlPurchaseJournal(fixture.table, false).incrementPeriodTotals(java.util.UUID.randomUUID(), + "player", "DailyTotal", "LastDailyTotal", java.util.List.of("DailyTotal"), null); + + verify(fixture.work).rollback(); + verify(fixture.work, org.mockito.Mockito.never()).commit(); + verify(fixture.work, org.mockito.Mockito.times(4)).prepareStatement(anyString()); + } + + @Test + void ambiguousPeriodIncrementCommitIsConfirmedByTheVoteMarker() throws Exception { + Fixture fixture = fixture(); + Connection confirmation = mock(Connection.class); + PreparedStatement markerInsert = mock(PreparedStatement.class); + PreparedStatement markerSelect = mock(PreparedStatement.class); + PreparedStatement accountingInsert = mock(PreparedStatement.class); + PreparedStatement accountingSelect = mock(PreparedStatement.class); + PreparedStatement increment = mock(PreparedStatement.class); + PreparedStatement accountingUpdate = mock(PreparedStatement.class); + PreparedStatement confirm = mock(PreparedStatement.class); + ResultSet epoch = mock(ResultSet.class); + ResultSet accounting = mock(ResultSet.class); + ResultSet confirmed = mock(ResultSet.class); + when(epoch.next()).thenReturn(true); + when(markerSelect.executeQuery()).thenReturn(epoch); + when(accounting.next()).thenReturn(true); + when(accounting.getInt(1)).thenReturn(0); + when(accountingSelect.executeQuery()).thenReturn(accounting); + when(increment.executeUpdate()).thenReturn(1); + when(accountingUpdate.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, + accountingInsert, accountingSelect, increment, accountingUpdate); + doThrow(new java.sql.SQLException("commit acknowledgement lost")).when(fixture.work).commit(); + when(confirmed.next()).thenReturn(true); + when(confirmed.getInt(1)).thenReturn(1); + when(confirm.executeQuery()).thenReturn(confirmed); + when(confirmation.prepareStatement(anyString())).thenReturn(confirm); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.work, confirmation); + + new SharedMysqlPurchaseJournal(fixture.table, false).incrementPeriodTotals(java.util.UUID.randomUUID(), + "player", "DailyTotal", "LastDailyTotal", java.util.List.of("DailyTotal"), null); + + verify(increment).executeUpdate(); + verify(confirm).executeQuery(); + } + @Test void dailyStreakValueAndTimestampCopyShareOneTransaction() throws Exception { Fixture fixture = fixture(); @@ -387,14 +457,23 @@ void dailyStreakUpdateLocksTheSameBoundaryRow() throws Exception { Fixture fixture = fixture(); PreparedStatement markerInsert = mock(PreparedStatement.class); PreparedStatement markerSelect = mock(PreparedStatement.class); + PreparedStatement accountingInsert = mock(PreparedStatement.class); + PreparedStatement accountingSelect = mock(PreparedStatement.class); PreparedStatement update = mock(PreparedStatement.class); + PreparedStatement accountingUpdate = mock(PreparedStatement.class); ResultSet epoch = mock(ResultSet.class); + ResultSet accounting = mock(ResultSet.class); when(epoch.next()).thenReturn(true); when(markerSelect.executeQuery()).thenReturn(epoch); + when(accounting.next()).thenReturn(true); + when(accounting.getInt(1)).thenReturn(0); + when(accountingSelect.executeQuery()).thenReturn(accounting); when(update.executeUpdate()).thenReturn(1); - when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, update); + when(accountingUpdate.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, + accountingInsert, accountingSelect, update, accountingUpdate); - new SharedMysqlPurchaseJournal(fixture.table, false).updateDailyStreak( + new SharedMysqlPurchaseJournal(fixture.table, false).updateDailyStreak(java.util.UUID.randomUUID(), "00000000-0000-0000-0000-000000000001", 7, 1234L); verify(markerSelect).setString(1, "streak-copy:DayVoteStreak"); From c06bf3ffff19f1e2fdf92a0662c6550b4ba8cec0 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:31:14 -0600 Subject: [PATCH 14/61] Recover deferred shared vote accounting --- .../topvoter/TopVoterHandler.java | 7 +- .../votingplugin/user/VotingPluginUser.java | 25 +- .../service/SharedMysqlPurchaseJournal.java | 259 ++++++++++++++++-- .../service/VoteShopPurchaseService.java | 26 +- .../VotingPluginUserVoteShopLimitTest.java | 13 +- .../SharedMysqlPurchaseJournalTest.java | 163 ++++++++--- 6 files changed, 411 insertions(+), 82 deletions(-) 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 11d6f09750..dc88eda70c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -791,7 +791,12 @@ void applyRecoverableStreak(VotingPluginUser user, TimeChangeTransition transiti case Monthly -> user.getMonthVoteStreak(); default -> proposedTarget; }; - if (current != progress.streakTarget()) { + if (top == TopVoter.Daily && UserStorage.MYSQL.equals(plugin.getStorageType())) { + if (!VoteShopPurchaseService.resetMysqlDailyStreakAtBoundary(plugin, uuid, + user.getLastDayVoteStreakLastUpdate())) { + throw new IllegalStateException("Unable to serialize shared MySQL daily streak reset"); + } + } else if (current != progress.streakTarget()) { switch (top) { case Daily -> user.setDayVoteStreak(progress.streakTarget()); case Weekly -> user.setWeekVoteStreak(progress.streakTarget()); 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 5fd6aaae2d..9683784559 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -937,15 +937,21 @@ private boolean incrementSharedMysqlMonthTotal(UUID voteId) { maximum = Integer.valueOf(plugin.getTimeChecker().getTime().getDayOfMonth() * plugin.getVoteSiteManager().getVoteSitesEnabled().size()); } - return VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, getUUID(), "MonthTotal", - "LastMonthTotal", columns, maximum); + if (!VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, getUUID(), "MonthTotal", + "LastMonthTotal", columns, maximum)) { + throw new IllegalStateException("Unable to retain shared MySQL monthly vote total"); + } + return true; } private boolean incrementSharedMysqlPeriodTotal(UUID voteId, TopVoter top, String column) { if (plugin == null || !UserStorage.MYSQL.equals(plugin.getStorageType())) return false; if (getCache() != null) getCache().clearChanges(); - return VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, getUUID(), column, - top.getLastColumnName(), List.of(column), null); + if (!VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, getUUID(), column, + top.getLastColumnName(), List.of(column), null)) { + throw new IllegalStateException("Unable to retain shared MySQL " + top + " vote total"); + } + return true; } /** @@ -1144,9 +1150,7 @@ public void checkDayVoteStreak(boolean forceBungee, UUID voteId) { plugin, voteId, getUUID(), streak, updatedAt); if (sharedStreakPersisted) { if (getBestDayVoteStreak() < streak) setBestDayVoteStreak(streak); - } else { - setDayVoteStreak(streak); - } + } else throw new IllegalStateException("Unable to retain shared MySQL daily streak"); } else { setDayVoteStreak(streak); } @@ -2559,8 +2563,11 @@ public void addVotePartyVote(UUID voteId) { int fallbackTotal = getVotePartyVotes() + 1; if (plugin != null && UserStorage.MYSQL.equals(plugin.getStorageType())) { if (getCache() != null) getCache().clearChanges(); - if (VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, getUUID(), "VotePartyVotes", - "LastVotePartyVotes", List.of("VotePartyVotes"), null)) return; + if (!VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, getUUID(), "VotePartyVotes", + "LastVotePartyVotes", List.of("VotePartyVotes"), null)) { + throw new IllegalStateException("Unable to retain shared MySQL VoteParty count"); + } + return; } setVotePartyVotes(fallbackTotal); } 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 index 42bdf23bfc..7c8ce06318 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -38,6 +38,7 @@ final class SharedMysqlPurchaseJournal { 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); + static final long ACCOUNTING_RETENTION_MILLIS = TimeUnit.DAYS.toMillis(30); 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. */ @@ -278,11 +279,26 @@ void resetPeriodTotal(String totalColumn, String previousColumn, String resetGen } } + private void commitAndConfirmRequested(Connection connection, UUID voteId, int operation) throws SQLException { + try { + connection.commit(); + } catch (SQLException ambiguousCommit) { + closeQuietly(connection); + try { + AccountingRow row = findAccountingVote(voteId); + if (row != null && (row.requested() & operation) != 0) return; + } catch (SQLException confirmationFailure) { + ambiguousCommit.addSuppressed(confirmationFailure); + } + throw ambiguousCommit; + } + } + /** * Serializes accepted-vote increments with the shared boundary marker. Every * backend therefore agrees whether an increment precedes or follows the copy. */ - void incrementPeriodTotals(UUID voteId, String uuid, String boundaryColumn, String previousColumn, List columns, + boolean incrementPeriodTotals(UUID voteId, String uuid, String boundaryColumn, String previousColumn, List columns, Integer maximum) throws SQLException { if (voteId == null || uuid == null || uuid.isEmpty() || !isSafeColumn(boundaryColumn) || columns == null @@ -290,12 +306,26 @@ void incrementPeriodTotals(UUID voteId, String uuid, String boundaryColumn, Stri || maximum != null && (maximum.intValue() < 0 || !isSafeColumn(previousColumn))) { throw new SQLException("Invalid period total increment"); } + int operation = accountingOperation(boundaryColumn); + requestAccounting(voteId, uuid, operation, operation == MONTH_TOTAL && columns.size() > 1 ? columns.get(1) : null, + maximum, null, null); + try { + applyPeriodTotals(voteId, uuid, boundaryColumn, previousColumn, columns, maximum, operation); + return true; + } catch (SQLException deferred) { + return false; + } + } + + private void applyPeriodTotals(UUID voteId, String uuid, String boundaryColumn, String previousColumn, + List columns, Integer maximum, int operation) throws SQLException { try (Connection connection = connection()) { connection.setAutoCommit(false); try { EpochRow copyMarker = lockLimitEpochRow(connection, "period-copy:" + boundaryColumn); - int operation = accountingOperation(boundaryColumn); - int completed = lockAccountingVote(connection, voteId); + AccountingRow accounting = findAndLockAccountingVote(connection, voteId); + if (accounting == null) throw new SQLException("Vote accounting marker is missing"); + int completed = accounting.completed(); if ((completed & operation) != 0) { rollback(connection); return; @@ -343,24 +373,66 @@ private static int accountingOperation(String boundaryColumn) throws SQLExceptio }; } - private int lockAccountingVote(Connection connection, UUID voteId) throws SQLException { + private void requestAccounting(UUID voteId, String uuid, int operation, String monthColumn, Integer monthMaximum, + Integer streakValue, Long streakUpdatedAt) throws SQLException { + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try { + ensureAccountingVote(connection, voteId, uuid); + AccountingRow row = findAndLockAccountingVote(connection, voteId); + if (row == null || row.uuid() != null && !row.uuid().equals(uuid)) { + throw new SQLException("Vote accounting identity does not match"); + } + String update = "UPDATE " + qiAccounting() + " SET " + qi("player_uuid") + " = ?, " + + qi("requested") + " = ?, " + qi("month_column") + " = COALESCE(?, " + + qi("month_column") + "), " + qi("month_maximum") + " = COALESCE(?, " + + qi("month_maximum") + "), " + qi("streak_value") + " = COALESCE(?, " + + qi("streak_value") + "), " + qi("streak_updated_at") + " = COALESCE(?, " + + qi("streak_updated_at") + ") WHERE " + qi("vote_id") + " = ?"; + try (PreparedStatement statement = connection.prepareStatement(update)) { + statement.setString(1, uuid); + statement.setInt(2, row.requested() | operation); + statement.setString(3, monthColumn); + if (monthMaximum == null) statement.setNull(4, java.sql.Types.INTEGER); + else statement.setInt(4, monthMaximum.intValue()); + if (streakValue == null) statement.setNull(5, java.sql.Types.INTEGER); + else statement.setInt(5, streakValue.intValue()); + if (streakUpdatedAt == null) statement.setNull(6, java.sql.Types.BIGINT); + else statement.setLong(6, streakUpdatedAt.longValue()); + statement.setString(7, voteId.toString()); + if (statement.executeUpdate() != 1) throw new SQLException("Vote accounting marker is missing"); + } + commitAndConfirmRequested(connection, voteId, operation); + } catch (SQLException failure) { + rollback(connection); + throw failure; + } + } + } + + private void ensureAccountingVote(Connection connection, UUID voteId, String uuid) throws SQLException { String insert = table.getDbType() == DbType.POSTGRESQL - ? "INSERT INTO " + qiAccounting() + " (" + qi("vote_id") + ", " + qi("completed") - + ", " + qi("created_at") + ") VALUES (?, 0, ?) ON CONFLICT DO NOTHING" - : "INSERT IGNORE INTO " + qiAccounting() + " (" + qi("vote_id") + ", " + qi("completed") - + ", " + qi("created_at") + ") VALUES (?, 0, ?)"; + ? "INSERT INTO " + qiAccounting() + " (" + qi("vote_id") + ", " + qi("player_uuid") + ", " + + qi("requested") + ", " + qi("completed") + ", " + qi("created_at") + + ") VALUES (?, ?, 0, 0, ?) ON CONFLICT DO NOTHING" + : "INSERT IGNORE INTO " + qiAccounting() + " (" + qi("vote_id") + ", " + qi("player_uuid") + ", " + + qi("requested") + ", " + qi("completed") + ", " + qi("created_at") + + ") VALUES (?, ?, 0, 0, ?)"; try (PreparedStatement statement = connection.prepareStatement(insert)) { statement.setString(1, voteId.toString()); - statement.setLong(2, System.currentTimeMillis()); + statement.setString(2, uuid); + statement.setLong(3, System.currentTimeMillis()); statement.executeUpdate(); } - String select = "SELECT " + qi("completed") + " FROM " + qiAccounting() + " WHERE " + } + + private AccountingRow findAndLockAccountingVote(Connection connection, UUID voteId) throws SQLException { + String select = "SELECT " + accountingColumns() + " FROM " + qiAccounting() + " WHERE " + qi("vote_id") + " = ? FOR UPDATE"; try (PreparedStatement statement = connection.prepareStatement(select)) { statement.setString(1, voteId.toString()); try (ResultSet result = statement.executeQuery()) { - if (!result.next()) throw new SQLException("Vote accounting marker is missing"); - return result.getInt(1); + return result.next() ? accountingRow(result) : null; } } } @@ -381,8 +453,8 @@ private void commitAndConfirmAccounting(Connection connection, UUID voteId, int } catch (SQLException ambiguousCommit) { closeQuietly(connection); try { - Integer completed = findAccountingVote(voteId); - if (completed != null && (completed.intValue() & operation) != 0) return; + AccountingRow row = findAccountingVote(voteId); + if (row != null && (row.completed() & operation) != 0) return; } catch (SQLException confirmationFailure) { ambiguousCommit.addSuppressed(confirmationFailure); } @@ -390,17 +462,28 @@ private void commitAndConfirmAccounting(Connection connection, UUID voteId, int } } - private Integer findAccountingVote(UUID voteId) throws SQLException { - String select = "SELECT " + qi("completed") + " FROM " + qiAccounting() + " WHERE " + private AccountingRow findAccountingVote(UUID voteId) throws SQLException { + String select = "SELECT " + accountingColumns() + " FROM " + qiAccounting() + " WHERE " + qi("vote_id") + " = ?"; try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(select)) { statement.setString(1, voteId.toString()); try (ResultSet result = statement.executeQuery()) { - return result.next() ? Integer.valueOf(result.getInt(1)) : null; + return result.next() ? accountingRow(result) : null; } } } + private String accountingColumns() { + return qi("player_uuid") + ", " + qi("requested") + ", " + qi("completed") + ", " + + qi("month_column") + ", " + qi("month_maximum") + ", " + qi("streak_value") + ", " + + qi("streak_updated_at"); + } + + private static AccountingRow accountingRow(ResultSet result) throws SQLException { + return new AccountingRow(result.getString(1), result.getInt(2), result.getInt(3), result.getString(4), + nullableInteger(result, 5), nullableInteger(result, 6), nullableLong(result, 7)); + } + private boolean resetPending(Connection connection, String boundaryColumn, EpochRow copyMarker) throws SQLException { EpochRow resetMarker = lockLimitEpochRow(connection, "period-reset:" + boundaryColumn); @@ -414,22 +497,34 @@ private static String generationTransition(String generation, String prefix) { } /** Serializes the accepted daily-streak value and timestamp with its shared boundary. */ - void updateDailyStreak(UUID voteId, String uuid, int streak, long updatedAt) throws SQLException { + boolean updateDailyStreak(UUID voteId, String uuid, int streak, long updatedAt) throws SQLException { + requestAccounting(voteId, uuid, DAILY_STREAK, null, null, Integer.valueOf(streak), Long.valueOf(updatedAt)); + try { + applyDailyStreak(voteId, uuid, streak, updatedAt); + return true; + } catch (SQLException deferred) { + return false; + } + } + + private void applyDailyStreak(UUID voteId, String uuid, int streak, long updatedAt) throws SQLException { try (Connection connection = connection()) { connection.setAutoCommit(false); try { lockLimitEpochRow(connection, "streak-copy:DayVoteStreak"); - int completed = lockAccountingVote(connection, voteId); + AccountingRow accounting = findAndLockAccountingVote(connection, voteId); + if (accounting == null) throw new SQLException("Vote accounting marker is missing"); + int completed = accounting.completed(); if ((completed & DAILY_STREAK) != 0) { rollback(connection); return; } - String sql = "UPDATE " + qi(table.getTableName()) + " SET " + qi("DayVoteStreak") + " = ?, " - + qi("DayVoteStreakLastUpdate") + " = ? WHERE " + qi("uuid") + uuidCast(); + String sql = "UPDATE " + qi(table.getTableName()) + " SET " + qi("DayVoteStreak") + " = COALESCE(" + + qi("DayVoteStreak") + ", 0) + 1, " + qi("DayVoteStreakLastUpdate") + " = ? WHERE " + + qi("uuid") + uuidCast(); try (PreparedStatement update = connection.prepareStatement(sql)) { - update.setInt(1, streak); - update.setString(2, Long.toString(updatedAt)); - update.setString(3, uuid); + update.setString(1, Long.toString(updatedAt)); + update.setString(2, uuid); if (update.executeUpdate() != 1) throw new SQLException("Daily streak user row is missing"); } markAccountingComplete(connection, voteId, completed | DAILY_STREAK); @@ -441,6 +536,29 @@ void updateDailyStreak(UUID voteId, String uuid, int streak, long updatedAt) thr } } + void resetDailyStreakAtBoundary(String uuid, long boundaryUpdatedAt) throws SQLException { + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try { + lockLimitEpochRow(connection, "streak-copy:DayVoteStreak"); + String streak = qi("DayVoteStreak"); + String updated = qi("DayVoteStreakLastUpdate"); + String sql = "UPDATE " + qi(table.getTableName()) + " SET " + streak + " = CASE WHEN COALESCE(" + + updated + ", '') = ? THEN 0 ELSE GREATEST(COALESCE(" + streak + ", 0), 1) END WHERE " + + qi("uuid") + uuidCast(); + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, Long.toString(boundaryUpdatedAt)); + statement.setString(2, uuid); + if (statement.executeUpdate() != 1) throw new SQLException("Daily streak user row is missing"); + } + connection.commit(); + } catch (SQLException failure) { + rollback(connection); + throw failure; + } + } + } + /** Copies the period boundary once so a phase-receipt retry cannot move it. */ void copyPeriodBoundary(String totalColumn, String previousColumn, String generation) throws SQLException { if (!isSafeColumn(totalColumn) || !isSafeColumn(previousColumn)) { @@ -772,6 +890,62 @@ List recoverAndCleanup(long now) throws SQLException { return List.copyOf(refunded); } + void recoverAccounting(long now) throws SQLException { + while (true) { + List pending = findPendingAccounting(RECOVERY_BATCH_SIZE); + if (pending.isEmpty()) break; + for (AccountingRecovery recovery : pending) { + AccountingRow row = recovery.row(); + int missing = row.requested() & ~row.completed(); + if ((missing & DAILY_TOTAL) != 0) applyPeriodTotals(recovery.voteId(), row.uuid(), "DailyTotal", + "LastDailyTotal", List.of("DailyTotal"), null, DAILY_TOTAL); + if ((missing & WEEKLY_TOTAL) != 0) applyPeriodTotals(recovery.voteId(), row.uuid(), "WeeklyTotal", + "LastWeeklyTotal", List.of("WeeklyTotal"), null, WEEKLY_TOTAL); + if ((missing & MONTH_TOTAL) != 0) { + List columns = row.monthColumn() == null ? List.of("MonthTotal") + : List.of("MonthTotal", row.monthColumn()); + applyPeriodTotals(recovery.voteId(), row.uuid(), "MonthTotal", "LastMonthTotal", columns, + row.monthMaximum(), MONTH_TOTAL); + } + if ((missing & VOTE_PARTY_TOTAL) != 0) applyPeriodTotals(recovery.voteId(), row.uuid(), + "VotePartyVotes", "LastVotePartyVotes", List.of("VotePartyVotes"), null, VOTE_PARTY_TOTAL); + if ((missing & DAILY_STREAK) != 0) { + if (row.streakValue() == null || row.streakUpdatedAt() == null) { + throw new SQLException("Pending daily streak accounting payload is incomplete"); + } + applyDailyStreak(recovery.voteId(), row.uuid(), row.streakValue().intValue(), + row.streakUpdatedAt().longValue()); + } + } + } + cleanupAccounting(now - ACCOUNTING_RETENTION_MILLIS); + } + + private List findPendingAccounting(int limit) throws SQLException { + String select = "SELECT " + qi("vote_id") + ", " + accountingColumns() + " FROM " + qiAccounting() + + " WHERE " + qi("requested") + " <> " + qi("completed") + " ORDER BY " + qi("created_at") + + " ASC LIMIT ?"; + List pending = new ArrayList<>(); + try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(select)) { + statement.setInt(1, limit); + try (ResultSet result = statement.executeQuery()) { + while (result.next()) pending.add(new AccountingRecovery(UUID.fromString(result.getString(1)), + new AccountingRow(result.getString(2), result.getInt(3), result.getInt(4), result.getString(5), + nullableInteger(result, 6), nullableInteger(result, 7), nullableLong(result, 8)))); + } + } + return pending; + } + + private void cleanupAccounting(long cutoff) throws SQLException { + String delete = "DELETE FROM " + qiAccounting() + " WHERE " + qi("requested") + " = " + + qi("completed") + " AND " + qi("created_at") + " < ?"; + try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(delete)) { + statement.setLong(1, cutoff); + statement.executeUpdate(); + } + } + 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 ?"; @@ -840,11 +1014,34 @@ private void ensureSchema() throws SQLException { private void ensureAccountingSchema(Connection connection) throws SQLException { String create = "CREATE TABLE IF NOT EXISTS " + qiAccounting() + " (" + qi("vote_id") - + " VARCHAR(36) NOT NULL, " + qi("completed") + " INT NOT NULL, " + qi("created_at") + + " VARCHAR(36) NOT NULL, " + qi("player_uuid") + " VARCHAR(37) NULL, " + + qi("requested") + " INT NOT NULL DEFAULT 0, " + qi("completed") + " INT NOT NULL, " + + qi("month_column") + " VARCHAR(128) NULL, " + qi("month_maximum") + " INT NULL, " + + qi("streak_value") + " INT NULL, " + qi("streak_updated_at") + " BIGINT NULL, " + qi("created_at") + " BIGINT NOT NULL, PRIMARY KEY (" + qi("vote_id") + "));"; try (PreparedStatement statement = connection.prepareStatement(create)) { statement.executeUpdate(); } + ensureAccountingColumn(connection, "player_uuid", "VARCHAR(37) NULL"); + ensureAccountingColumn(connection, "requested", "INT NOT NULL DEFAULT 0"); + ensureAccountingColumn(connection, "month_column", "VARCHAR(128) NULL"); + ensureAccountingColumn(connection, "month_maximum", "INT NULL"); + ensureAccountingColumn(connection, "streak_value", "INT NULL"); + ensureAccountingColumn(connection, "streak_updated_at", "BIGINT NULL"); + try (PreparedStatement migrate = connection.prepareStatement("UPDATE " + qiAccounting() + " SET " + + qi("requested") + " = " + qi("completed") + " WHERE " + qi("requested") + " = 0 AND " + + qi("completed") + " <> 0")) { + migrate.executeUpdate(); + } + } + + private void ensureAccountingColumn(Connection connection, String column, String definition) throws SQLException { + String alter = "ALTER TABLE " + qiAccounting() + " 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 ensureEpochSchema(Connection connection) throws SQLException { @@ -940,9 +1137,21 @@ private static Long nullableLong(ResultSet result, int index) throws SQLExceptio return value instanceof Number number ? number.longValue() : null; } + private static Integer nullableInteger(ResultSet result, int index) throws SQLException { + Object value = result.getObject(index); + return value instanceof Number number ? number.intValue() : null; + } + private record EpochRow(long epoch, String lastResetGeneration) { } + private record AccountingRow(String uuid, int requested, int completed, String monthColumn, + Integer monthMaximum, Integer streakValue, Long streakUpdatedAt) { + } + + private record AccountingRecovery(UUID voteId, AccountingRow row) { + } + private static boolean isSafeColumn(String column) { // Columns are passed through AbstractSqlTable.qi(), which escapes the // database-specific identifier delimiter. Preserve configured shop keys 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 af187e13b5..eb2c9ba433 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 @@ -624,8 +624,10 @@ public static boolean incrementMysqlPeriodTotals(VotingPluginMain plugin, UUID v MySQL table = plugin.getMysql(); for (String column : columns) table.checkColumn(column, DataType.INTEGER); if (maximum != null) table.checkColumn(previousColumn, DataType.INTEGER); - SharedMysqlPurchaseJournal.forTable(table).incrementPeriodTotals( + boolean applied = SharedMysqlPurchaseJournal.forTable(table).incrementPeriodTotals( voteId == null ? UUID.randomUUID() : voteId, uuid, boundaryColumn, previousColumn, columns, maximum); + if (!applied) plugin.getLogger().warning( + "Shared MySQL period total was retained for retry after a persistence failure"); return true; } catch (SQLException failure) { plugin.getLogger().severe("Unable to atomically increment MySQL period total: " @@ -645,8 +647,10 @@ public static boolean updateMysqlDailyStreak(VotingPluginMain plugin, UUID voteI MySQL table = plugin.getMysql(); table.checkColumn("DayVoteStreak", DataType.INTEGER); table.checkColumn("DayVoteStreakLastUpdate", DataType.STRING); - SharedMysqlPurchaseJournal.forTable(table).updateDailyStreak( + boolean applied = SharedMysqlPurchaseJournal.forTable(table).updateDailyStreak( voteId == null ? UUID.randomUUID() : voteId, uuid, streak, updatedAt); + if (!applied) plugin.getLogger().warning( + "Shared MySQL daily streak was retained for retry after a persistence failure"); return true; } catch (SQLException failure) { plugin.getLogger().severe("Unable to atomically update MySQL daily streak: " @@ -658,6 +662,23 @@ public static boolean updateMysqlDailyStreak(VotingPluginMain plugin, UUID voteI } } + /** Resets the copied daily streak without overwriting a new-day vote from another backend. */ + public static boolean resetMysqlDailyStreakAtBoundary(VotingPluginMain plugin, String uuid, + long boundaryUpdatedAt) { + if (!canRecoverSharedMysqlPurchases(plugin)) return false; + try { + SharedMysqlPurchaseJournal.forTable(plugin.getMysql()).resetDailyStreakAtBoundary(uuid, boundaryUpdatedAt); + return true; + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to atomically reset MySQL daily streak: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + return false; + } finally { + SharedMysqlCacheReconciler.invalidate(plugin, uuid, "DayVoteStreak"); + } + } + /** Atomically captures one period boundary for a recoverable transition. */ public static boolean copyMysqlPeriodBoundary(VotingPluginMain plugin, String totalColumn, String previousColumn, String generation) { @@ -725,6 +746,7 @@ public static void recoverSharedMysqlPurchases(VotingPluginMain plugin) { static void recoverSharedMysqlPurchases(VotingPluginMain plugin, SharedMysqlPurchaseJournal journal) throws SQLException { retryPendingCompensationMarkers(plugin, journal); + journal.recoverAccounting(System.currentTimeMillis()); for (SharedMysqlPurchaseJournal.RefundedPurchase refund : journal.recoverAndCleanup(System.currentTimeMillis())) { SharedMysqlCacheReconciler.invalidateAndRefresh(plugin, refund.uuid(), refund.pointsColumn(), refund.limitColumn()); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java index d91d014e25..61d6003e8d 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java @@ -1,6 +1,7 @@ package com.bencodez.votingplugin.user; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; @@ -70,7 +71,7 @@ void sharedMysqlVotePartyCountsUseTheCrossBackendBoundaryMutation() { } @Test - void failedSharedMysqlIncrementFallsBackToTheQueuedUserMutation() { + void unretainedSharedMysqlIncrementDoesNotWriteAnUnfencedAbsoluteValue() { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); AdvancedCoreUser base = mock(AdvancedCoreUser.class); @@ -86,14 +87,14 @@ void failedSharedMysqlIncrementFallsBackToTheQueuedUserMutation() { service.when(() -> VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, base.getUUID(), "DailyTotal", "LastDailyTotal", java.util.List.of("DailyTotal"), null)).thenReturn(false); - user.addTotalDaily(voteId); + assertThrows(IllegalStateException.class, () -> user.addTotalDaily(voteId)); - verify(user).setDailyTotal(5); + verify(user, never()).setDailyTotal(5); } } @Test - void failedSharedMysqlMonthlyIncrementKeepsTheConfiguredCap() { + void unretainedSharedMysqlMonthlyIncrementDoesNotWriteAnUnfencedCappedValue() { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getConfigFile().isLimitMonthlyVotes()).thenReturn(true); @@ -113,9 +114,9 @@ void failedSharedMysqlMonthlyIncrementKeepsTheConfiguredCap() { "MonthTotal", "LastMonthTotal", java.util.List.of("MonthTotal"), Integer.valueOf(6))) .thenReturn(false); - user.addMonthTotal(voteId); + assertThrows(IllegalStateException.class, () -> user.addMonthTotal(voteId)); - verify(user).setMonthTotal(6); + verify(user, never()).setMonthTotal(6); } } 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 index 827da51ea3..0bbd08dba7 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -320,16 +320,19 @@ void periodBoundaryCopyAndGenerationAdvanceShareOneTransaction() throws Exceptio @Test void periodIncrementLocksTheSameBoundaryRowBeforeUpdatingTheUser() throws Exception { Fixture fixture = fixture(); + PreparedStatement requestInsert = mock(PreparedStatement.class); + PreparedStatement requestSelect = mock(PreparedStatement.class); + PreparedStatement requestUpdate = mock(PreparedStatement.class); PreparedStatement markerInsert = mock(PreparedStatement.class); PreparedStatement markerSelect = mock(PreparedStatement.class); - PreparedStatement accountingInsert = mock(PreparedStatement.class); PreparedStatement accountingSelect = mock(PreparedStatement.class); PreparedStatement resetMarkerInsert = mock(PreparedStatement.class); PreparedStatement resetMarkerSelect = mock(PreparedStatement.class); PreparedStatement increment = mock(PreparedStatement.class); PreparedStatement accountingUpdate = mock(PreparedStatement.class); ResultSet epoch = mock(ResultSet.class); - ResultSet accounting = mock(ResultSet.class); + ResultSet requested = accountingRow("00000000-0000-0000-0000-000000000001", 0, 0); + ResultSet accounting = accountingRow("00000000-0000-0000-0000-000000000001", 4, 0); ResultSet resetEpoch = mock(ResultSet.class); when(epoch.next()).thenReturn(true); when(epoch.getLong(1)).thenReturn(9L); @@ -337,87 +340,95 @@ void periodIncrementLocksTheSameBoundaryRowBeforeUpdatingTheUser() throws Except when(resetEpoch.next()).thenReturn(true); when(resetEpoch.getString(2)).thenReturn("time-total:MONTH:2026-08"); when(markerSelect.executeQuery()).thenReturn(epoch); - when(accounting.next()).thenReturn(true); - when(accounting.getInt(1)).thenReturn(0); + when(requestSelect.executeQuery()).thenReturn(requested); when(accountingSelect.executeQuery()).thenReturn(accounting); when(resetMarkerSelect.executeQuery()).thenReturn(resetEpoch); when(increment.executeUpdate()).thenReturn(1); when(accountingUpdate.executeUpdate()).thenReturn(1); - when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, - accountingInsert, accountingSelect, resetMarkerInsert, resetMarkerSelect, increment, accountingUpdate); + when(requestUpdate.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(requestInsert, requestSelect, requestUpdate, + markerInsert, markerSelect, + accountingSelect, resetMarkerInsert, resetMarkerSelect, increment, accountingUpdate); new SharedMysqlPurchaseJournal(fixture.table, false).incrementPeriodTotals(java.util.UUID.randomUUID(), "00000000-0000-0000-0000-000000000001", "MonthTotal", "LastMonthTotal", java.util.List.of("MonthTotal", "MonthTotal-SEPTEMBER-2026"), Integer.valueOf(42)); org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class); - verify(fixture.work, org.mockito.Mockito.times(8)).prepareStatement(sql.capture()); - assertTrue(sql.getAllValues().get(1).contains("FOR UPDATE")); - assertTrue(sql.getAllValues().get(6).contains( + verify(fixture.work, org.mockito.Mockito.times(10)).prepareStatement(sql.capture()); + assertTrue(sql.getAllValues().get(4).contains("FOR UPDATE")); + assertTrue(sql.getAllValues().get(8).contains( "`MonthTotal` = LEAST(COALESCE(`LastMonthTotal`, 0) + ?, COALESCE(`MonthTotal`, 0) + 1)")); - assertTrue(sql.getAllValues().get(6).contains( + assertTrue(sql.getAllValues().get(8).contains( "`MonthTotal-SEPTEMBER-2026` = LEAST(?, COALESCE(`MonthTotal-SEPTEMBER-2026`, 0) + 1)")); verify(markerSelect).setString(1, "period-copy:MonthTotal"); verify(increment).setInt(1, 42); verify(increment).setInt(2, 42); verify(increment).setString(3, "00000000-0000-0000-0000-000000000001"); - verify(fixture.work).commit(); + verify(fixture.work, org.mockito.Mockito.times(2)).commit(); } @Test void repeatedVoteOperationDoesNotIncrementThePeriodTwice() throws Exception { Fixture fixture = fixture(); + PreparedStatement requestInsert = mock(PreparedStatement.class); + PreparedStatement requestSelect = mock(PreparedStatement.class); + PreparedStatement requestUpdate = mock(PreparedStatement.class); PreparedStatement markerInsert = mock(PreparedStatement.class); PreparedStatement markerSelect = mock(PreparedStatement.class); - PreparedStatement accountingInsert = mock(PreparedStatement.class); PreparedStatement accountingSelect = mock(PreparedStatement.class); ResultSet epoch = mock(ResultSet.class); - ResultSet accounting = mock(ResultSet.class); + ResultSet requested = accountingRow("player", 1, 1); + ResultSet accounting = accountingRow("player", 1, 1); when(epoch.next()).thenReturn(true); when(markerSelect.executeQuery()).thenReturn(epoch); - when(accounting.next()).thenReturn(true); - when(accounting.getInt(1)).thenReturn(1); + when(requestSelect.executeQuery()).thenReturn(requested); when(accountingSelect.executeQuery()).thenReturn(accounting); - when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, - accountingInsert, accountingSelect); + when(requestUpdate.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(requestInsert, requestSelect, requestUpdate, + markerInsert, markerSelect, + accountingSelect); new SharedMysqlPurchaseJournal(fixture.table, false).incrementPeriodTotals(java.util.UUID.randomUUID(), "player", "DailyTotal", "LastDailyTotal", java.util.List.of("DailyTotal"), null); verify(fixture.work).rollback(); - verify(fixture.work, org.mockito.Mockito.never()).commit(); - verify(fixture.work, org.mockito.Mockito.times(4)).prepareStatement(anyString()); + verify(fixture.work).commit(); + verify(fixture.work, org.mockito.Mockito.times(6)).prepareStatement(anyString()); } @Test void ambiguousPeriodIncrementCommitIsConfirmedByTheVoteMarker() throws Exception { Fixture fixture = fixture(); Connection confirmation = mock(Connection.class); + PreparedStatement requestInsert = mock(PreparedStatement.class); + PreparedStatement requestSelect = mock(PreparedStatement.class); + PreparedStatement requestUpdate = mock(PreparedStatement.class); PreparedStatement markerInsert = mock(PreparedStatement.class); PreparedStatement markerSelect = mock(PreparedStatement.class); - PreparedStatement accountingInsert = mock(PreparedStatement.class); PreparedStatement accountingSelect = mock(PreparedStatement.class); PreparedStatement increment = mock(PreparedStatement.class); PreparedStatement accountingUpdate = mock(PreparedStatement.class); PreparedStatement confirm = mock(PreparedStatement.class); ResultSet epoch = mock(ResultSet.class); - ResultSet accounting = mock(ResultSet.class); - ResultSet confirmed = mock(ResultSet.class); + ResultSet requested = accountingRow("player", 0, 0); + ResultSet accounting = accountingRow("player", 1, 0); + ResultSet confirmed = accountingRow("player", 1, 1); when(epoch.next()).thenReturn(true); when(markerSelect.executeQuery()).thenReturn(epoch); - when(accounting.next()).thenReturn(true); - when(accounting.getInt(1)).thenReturn(0); + when(requestSelect.executeQuery()).thenReturn(requested); when(accountingSelect.executeQuery()).thenReturn(accounting); when(increment.executeUpdate()).thenReturn(1); when(accountingUpdate.executeUpdate()).thenReturn(1); - when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, - accountingInsert, accountingSelect, increment, accountingUpdate); - doThrow(new java.sql.SQLException("commit acknowledgement lost")).when(fixture.work).commit(); - when(confirmed.next()).thenReturn(true); - when(confirmed.getInt(1)).thenReturn(1); + when(requestUpdate.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(requestInsert, requestSelect, requestUpdate, + markerInsert, markerSelect, + accountingSelect, increment, accountingUpdate); + org.mockito.Mockito.doNothing().doThrow(new java.sql.SQLException("commit acknowledgement lost")) + .when(fixture.work).commit(); when(confirm.executeQuery()).thenReturn(confirmed); when(confirmation.prepareStatement(anyString())).thenReturn(confirm); - when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.work, confirmation); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.work, fixture.work, confirmation); new SharedMysqlPurchaseJournal(fixture.table, false).incrementPeriodTotals(java.util.UUID.randomUUID(), "player", "DailyTotal", "LastDailyTotal", java.util.List.of("DailyTotal"), null); @@ -455,31 +466,96 @@ void dailyStreakValueAndTimestampCopyShareOneTransaction() throws Exception { @Test void dailyStreakUpdateLocksTheSameBoundaryRow() throws Exception { Fixture fixture = fixture(); + PreparedStatement requestInsert = mock(PreparedStatement.class); + PreparedStatement requestSelect = mock(PreparedStatement.class); + PreparedStatement requestUpdate = mock(PreparedStatement.class); PreparedStatement markerInsert = mock(PreparedStatement.class); PreparedStatement markerSelect = mock(PreparedStatement.class); - PreparedStatement accountingInsert = mock(PreparedStatement.class); PreparedStatement accountingSelect = mock(PreparedStatement.class); PreparedStatement update = mock(PreparedStatement.class); PreparedStatement accountingUpdate = mock(PreparedStatement.class); ResultSet epoch = mock(ResultSet.class); - ResultSet accounting = mock(ResultSet.class); + ResultSet requested = accountingRow("00000000-0000-0000-0000-000000000001", 0, 0); + ResultSet accounting = accountingRow("00000000-0000-0000-0000-000000000001", 16, 0); when(epoch.next()).thenReturn(true); when(markerSelect.executeQuery()).thenReturn(epoch); - when(accounting.next()).thenReturn(true); - when(accounting.getInt(1)).thenReturn(0); + when(requestSelect.executeQuery()).thenReturn(requested); when(accountingSelect.executeQuery()).thenReturn(accounting); when(update.executeUpdate()).thenReturn(1); when(accountingUpdate.executeUpdate()).thenReturn(1); - when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, - accountingInsert, accountingSelect, update, accountingUpdate); + when(requestUpdate.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(requestInsert, requestSelect, requestUpdate, + markerInsert, markerSelect, + accountingSelect, update, accountingUpdate); new SharedMysqlPurchaseJournal(fixture.table, false).updateDailyStreak(java.util.UUID.randomUUID(), "00000000-0000-0000-0000-000000000001", 7, 1234L); verify(markerSelect).setString(1, "streak-copy:DayVoteStreak"); - verify(update).setInt(1, 7); - verify(update).setString(2, "1234"); - verify(fixture.work).commit(); + verify(update).setString(1, "1234"); + verify(update).setString(2, "00000000-0000-0000-0000-000000000001"); + verify(fixture.work, org.mockito.Mockito.times(2)).commit(); + } + + @Test + void dailyStreakBoundaryResetUsesTheSharedDatabaseFence() throws Exception { + Fixture fixture = fixture(); + PreparedStatement markerInsert = mock(PreparedStatement.class); + PreparedStatement markerSelect = mock(PreparedStatement.class); + PreparedStatement reset = mock(PreparedStatement.class); + ResultSet epoch = mock(ResultSet.class); + when(epoch.next()).thenReturn(true); + when(markerSelect.executeQuery()).thenReturn(epoch); + when(reset.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, reset); + + new SharedMysqlPurchaseJournal(fixture.table, false).resetDailyStreakAtBoundary("player", 1234L); + + 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(2).contains("CASE WHEN COALESCE(`DayVoteStreakLastUpdate`, '') = ?")); + verify(markerSelect).setString(1, "streak-copy:DayVoteStreak"); + verify(reset).setString(1, "1234"); + verify(reset).setString(2, "player"); + } + + @Test + void failedApplyLeavesTheCommittedAccountingRequestForRecovery() throws Exception { + Fixture fixture = fixture(); + Connection request = mock(Connection.class); + Connection apply = mock(Connection.class); + PreparedStatement insert = mock(PreparedStatement.class); + PreparedStatement select = mock(PreparedStatement.class); + PreparedStatement update = mock(PreparedStatement.class); + ResultSet row = accountingRow("player", 0, 0); + when(select.executeQuery()).thenReturn(row); + when(update.executeUpdate()).thenReturn(1); + when(request.prepareStatement(anyString())).thenReturn(insert, select, update); + when(apply.prepareStatement(anyString())).thenThrow(new java.sql.SQLException("database unavailable")); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(request, apply); + + assertFalse(new SharedMysqlPurchaseJournal(fixture.table, false).incrementPeriodTotals( + java.util.UUID.randomUUID(), "player", "DailyTotal", "LastDailyTotal", + java.util.List.of("DailyTotal"), null)); + + verify(request).commit(); + verify(request, org.mockito.Mockito.never()).rollback(); + } + + @Test + void completedAccountingRowsAreDeletedAfterTheReplayWindow() throws Exception { + Fixture fixture = fixture(); + PreparedStatement pending = mock(PreparedStatement.class); + PreparedStatement cleanup = mock(PreparedStatement.class); + ResultSet none = ids(); + when(pending.executeQuery()).thenReturn(none); + when(fixture.work.prepareStatement(anyString())).thenReturn(pending, cleanup); + + new SharedMysqlPurchaseJournal(fixture.table, false) + .recoverAccounting(SharedMysqlPurchaseJournal.ACCOUNTING_RETENTION_MILLIS + 5L); + + verify(cleanup).setLong(1, 5L); + verify(cleanup).executeUpdate(); } @Test @@ -788,6 +864,15 @@ private static ResultSet pendingRow() throws Exception { return pendingRow("PENDING"); } + private static ResultSet accountingRow(String uuid, int requested, int completed) throws Exception { + ResultSet row = mock(ResultSet.class); + when(row.next()).thenReturn(true); + when(row.getString(1)).thenReturn(uuid); + when(row.getInt(2)).thenReturn(requested); + when(row.getInt(3)).thenReturn(completed); + return row; + } + private static ResultSet pendingRow(String state) throws Exception { ResultSet row = mock(ResultSet.class); when(row.next()).thenReturn(true); From baca6dac6e837dfb0ef82bc4d6519bd8dbe4bb19 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 08:07:39 -0600 Subject: [PATCH 15/61] Fence vote accounting across period transitions --- .../votingplugin/VotingPluginMain.java | 3 +- .../cache/ProcessedVoteCache.java | 5 + .../messaging/BackendProxyMessageRouter.java | 11 +- .../vote/SharedVoteAdmissionException.java | 10 + .../core/vote/SharedVoteProcessor.java | 135 +++++++------- .../votingplugin/events/PlayerVoteEvent.java | 19 +- .../listeners/PlayerVoteListener.java | 25 ++- .../votingplugin/listeners/VotiferEvent.java | 24 ++- .../listeners/VotifierVoteOverflowQueue.java | 40 +++- .../topvoter/TopVoterHandler.java | 6 + .../votingplugin/user/VotingPluginUser.java | 29 ++- .../service/SharedMysqlPurchaseJournal.java | 172 ++++++++++++++++-- .../service/VoteShopPurchaseService.java | 61 ++++++- .../BackendProxyMessageRouterTest.java | 25 ++- .../core/vote/SharedVoteProcessorTest.java | 4 +- .../VotiferEventDisabledVoteSiteTest.java | 6 +- .../SharedMysqlPurchaseJournalTest.java | 47 +++-- 17 files changed, 499 insertions(+), 123 deletions(-) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteAdmissionException.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 05f3eb66fb..dc4c5034b3 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -1882,7 +1882,8 @@ private void registerEvents() { pm.registerEvents(new PlayerJoinEvent(this), this); if (isVotifierLoaded()) { VotiferEvent votifierEvent = new VotiferEvent(this); - votifierVoteOverflowQueue = new VotifierVoteOverflowQueue(this, votifierEvent::processVote); + votifierVoteOverflowQueue = new VotifierVoteOverflowQueue(this, + (serviceSite, username, voteId) -> votifierEvent.processVote(serviceSite, username, voteId)); pm.registerEvents(votifierEvent, this); } pm.registerEvents(new PlayerVoteListener(this), this); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/cache/ProcessedVoteCache.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/cache/ProcessedVoteCache.java index 5029b7e4d4..95fee7775c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/cache/ProcessedVoteCache.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/cache/ProcessedVoteCache.java @@ -66,6 +66,11 @@ public boolean reserve(UUID voteId) { } } + /** Releases an admission that failed before any vote side effects ran. */ + public void release(UUID voteId) { + if (voteId != null) processedVotes.remove(voteId); + } + /** Deduplicates one Redis envelope across overlapping subscribers during a validated handoff. */ public synchronized boolean reserveRedisDelivery(String deliveryId) { if (deliveryId == null || !deliveryId.matches("[0-9a-fA-F-]{36}")) return true; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java index a68238669e..66af49f7b6 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java @@ -17,6 +17,7 @@ import com.bencodez.votingplugin.backendproxy.global.BackendGlobalDataSync; import com.bencodez.votingplugin.backendproxy.presence.BackendPresenceManager; import com.bencodez.votingplugin.backendproxy.voteparty.BackendVotePartySync; +import com.bencodez.votingplugin.core.vote.SharedVoteAdmissionException; import com.bencodez.votingplugin.proxy.BungeeMethod; import com.bencodez.votingplugin.proxy.VoteTotalsSnapshot; import com.bencodez.votingplugin.proxy.VotingPluginWire; @@ -126,6 +127,9 @@ public void handleOrderedVote(JsonEnvelope msg, Consumer com if (VotingPluginWire.SUB_VOTE.equals(subChannel) || VotingPluginWire.SUB_VOTE_ONLINE.equals(subChannel)) { try { handleWireVote(msg); + } catch (SharedVoteAdmissionException retryableAdmission) { + completion.accept(OrderedVoteOutcome.RETRY); + return; } catch (RuntimeException | Error failure) { completion.accept(OrderedVoteOutcome.QUARANTINE); throw failure; @@ -382,8 +386,11 @@ private void handleWireVote(JsonEnvelope msg) { VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(javaUuid, vote.player); votePartySync.replace(totals.getVotePartyCurrent(), totals.getVotePartyRequired()); user.cache(); - user.bungeeVotePluginMessaging(vote.service, vote.time, totals, !vote.manageTotals, - vote.wasOnline, vote.broadcast, vote.num); + if (!user.bungeeVotePluginMessagingAccepted(vote.service, vote.time, totals, !vote.manageTotals, + vote.wasOnline, vote.broadcast, vote.num, voteId)) { + processedVoteCache.release(voteId); + throw new SharedVoteAdmissionException("Proxy vote accounting admission failed"); + } if (plugin.getBungeeSettings().isPerServerPoints()) { user.addPoints(plugin.getConfigFile().getPointsOnVote()); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteAdmissionException.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteAdmissionException.java new file mode 100644 index 0000000000..6a0d3cd0df --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteAdmissionException.java @@ -0,0 +1,10 @@ +package com.bencodez.votingplugin.core.vote; + +/** Signals that durable accounting admission failed before vote side effects began. */ +public final class SharedVoteAdmissionException extends RuntimeException { + private static final long serialVersionUID = 1L; + + public SharedVoteAdmissionException(String message) { + super(message); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java index 1569359b52..b22969d628 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java @@ -58,7 +58,9 @@ public interface Operations { boolean hasBroadcastHandler(); void broadcast(UUID uuid, String name, String siteDisplayName, boolean online); boolean hasProxyTextTotals(); - UUID proxyVoteId(); + UUID incomingVoteId(); + void prepareAccounting(U user, UUID voteId, boolean countTotals); + void finishAccounting(UUID voteId); void cache(U user); void updateName(U user); void voteParty(U user, boolean realVote, boolean forceProxyRouting, UUID voteId); @@ -161,74 +163,79 @@ public static void process(Operations ops) { ops.debug("Allowing queued proxy vote for " + ops.userName(user) + " on " + ops.siteKey(site) + "; proxy vote time already matches LastVotes: " + ops.incomingTime()); } - UUID candidateVoteId = UUID.randomUUID(); - if (ops.proxyVote() && ops.hasProxyTextTotals()) { - candidateVoteId = ops.proxyVoteId(); - if (candidateVoteId == null) candidateVoteId = UUID.randomUUID(); - } + UUID candidateVoteId = ops.incomingVoteId(); + if (candidateVoteId == null) candidateVoteId = UUID.randomUUID(); final UUID voteId = candidateVoteId; String userId = ops.userId(user); - ops.cache(user); - ops.updateName(user); - ops.voteParty(user, ops.realVote(), ops.forceProxyRouting(), voteId); - if (ops.broadcastEnabled() && ops.hasBroadcastHandler()) { - boolean currentOnline = ops.userOnline(user); - boolean online = currentOnline; - if (ops.proxyVote()) online = ops.wasOnline(); - if (!ops.userVanished(user)) { - SharedVoteIdentity identity = new SharedVoteIdentity(ops.userUuid(user), playerName, currentOnline); - ops.broadcast(identity.uuid(), identity.playerName(), ops.siteDisplayName(site), online); + SharedVotePolicy policy = ops.countingPolicy(); + SharedVoteInput accountingInput = new SharedVoteInput(voteId, playerName, ops.serviceSite(), + ops.incomingTime(), ops.realVote(), ops.addTotals(), ops.proxyVote(), + ops.forceProxyRouting(), ops.wasOnline()); + ops.prepareAccounting(user, voteId, policy.shouldCountTotals(accountingInput, () -> ops.userOnline(user))); + try { + ops.cache(user); + ops.updateName(user); + ops.voteParty(user, ops.realVote(), ops.forceProxyRouting(), voteId); + if (ops.broadcastEnabled() && ops.hasBroadcastHandler()) { + boolean currentOnline = ops.userOnline(user); + boolean online = currentOnline; + if (ops.proxyVote()) online = ops.wasOnline(); + if (!ops.userVanished(user)) { + SharedVoteIdentity identity = new SharedVoteIdentity(ops.userUuid(user), playerName, currentOnline); + ops.broadcast(identity.uuid(), identity.playerName(), ops.siteDisplayName(site), online); + } else { + ops.debug("Not broadcasting vote for vanished user: " + ops.userName(user)); + } + } + long voteTime; + if (ops.incomingTime() != 0) { + ops.setTime(user, site, ops.incomingTime()); + voteTime = ops.incomingTime(); } else { - ops.debug("Not broadcasting vote for vanished user: " + ops.userName(user)); + ops.setTimeNow(user, site); + voteTime = LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); } - } - long voteTime; - if (ops.incomingTime() != 0) { - ops.setTime(user, site, ops.incomingTime()); - voteTime = ops.incomingTime(); - } else { - ops.setTimeNow(user, site); - voteTime = LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); - } - boolean cached = false; - if (SharedVoteDelivery.shouldDeliverNow(ops::proxyVote, () -> ops.userOnline(user), - () -> ops.giveOfflineRewards(site), ops::processRewards)) { - boolean online = true; - if (ops.proxyVote()) online = ops.wasOnline(); - ops.playerVote(user, site, online, ops.forceProxyRouting()); - if (ops.voteNumber() == 1) ops.sendVoteEffects(user, online); - if (ops.closeInventoryOnVote()) ops.closeInventory(user); - } else if (!ops.offlineVotesLimitEnabled() || ops.offlineVotes(user, site) <= ops.offlineVotesLimitAmount()) { - ops.addOfflineVote(user, ops.siteKey(site)); - cached = true; - ops.debug("Offline vote set for " + playerName + " (" + ops.userId(user) + ") on " + ops.siteKey(site)); - } else { - ops.debug("Not setting offline vote, offline vote limit reached"); - } - SharedVotePolicy policy = ops.countingPolicy(); - SharedVoteInput input = new SharedVoteInput(voteId, playerName, ops.serviceSite(), voteTime, - ops.realVote(), ops.addTotals(), ops.proxyVote(), ops.forceProxyRouting(), ops.wasOnline()); - SharedVoteAccounting.apply(input, policy, () -> ops.userOnline(user), () -> ops.addTotal(user, voteId), - () -> ops.addTotalDaily(user, voteId), () -> ops.addTotalWeekly(user, voteId), - () -> ops.addPoints(user)); - ops.checkDayVoteStreak(user, ops.forceProxyRouting(), voteId); - if (ops.limitMonthlyVotes() && (!ops.proxyVote() || ops.hasProxyTextTotals())) { - int value = ops.proxyVote() ? ops.proxyMonthTotal() : ops.userMonthTotal(user); - int days = ops.currentDayOfMonth(); - ops.extraDebug("Current day of month: " + days + " Current total: " + value); - if (value >= days * ops.enabledSiteCount()) { - ops.debug("Detected higher month total, changing. Current Total: " + value + " Days: " + days - + " New Total: " + days * ops.enabledSiteCount()); - ops.setMonthTotal(user, days * ops.enabledSiteCount()); + boolean cached = false; + if (SharedVoteDelivery.shouldDeliverNow(ops::proxyVote, () -> ops.userOnline(user), + () -> ops.giveOfflineRewards(site), ops::processRewards)) { + boolean online = true; + if (ops.proxyVote()) online = ops.wasOnline(); + ops.playerVote(user, site, online, ops.forceProxyRouting()); + if (ops.voteNumber() == 1) ops.sendVoteEffects(user, online); + if (ops.closeInventoryOnVote()) ops.closeInventory(user); + } else if (!ops.offlineVotesLimitEnabled() || ops.offlineVotes(user, site) <= ops.offlineVotesLimitAmount()) { + ops.addOfflineVote(user, ops.siteKey(site)); + cached = true; + ops.debug("Offline vote set for " + playerName + " (" + ops.userId(user) + ") on " + ops.siteKey(site)); + } else { + ops.debug("Not setting offline vote, offline vote limit reached"); + } + SharedVoteInput input = new SharedVoteInput(voteId, playerName, ops.serviceSite(), voteTime, + ops.realVote(), ops.addTotals(), ops.proxyVote(), ops.forceProxyRouting(), ops.wasOnline()); + SharedVoteAccounting.apply(input, policy, () -> ops.userOnline(user), () -> ops.addTotal(user, voteId), + () -> ops.addTotalDaily(user, voteId), () -> ops.addTotalWeekly(user, voteId), + () -> ops.addPoints(user)); + ops.checkDayVoteStreak(user, ops.forceProxyRouting(), voteId); + if (ops.limitMonthlyVotes() && (!ops.proxyVote() || ops.hasProxyTextTotals())) { + int value = ops.proxyVote() ? ops.proxyMonthTotal() : ops.userMonthTotal(user); + int days = ops.currentDayOfMonth(); + ops.extraDebug("Current day of month: " + days + " Current total: " + value); + if (value >= days * ops.enabledSiteCount()) { + ops.debug("Detected higher month total, changing. Current Total: " + value + " Days: " + days + + " New Total: " + days * ops.enabledSiteCount()); + ops.setMonthTotal(user, days * ops.enabledSiteCount()); + } } + ops.milestones(user, voteId, ops.forceProxyRouting()); + ops.cooldown(user, site); + ops.voteStreak(user, voteTime, voteId); + ops.postVote(site, user, playerName, voteTime, voteId, cached); + if (ops.userOnline(user) || ops.placeholderCacheAlways()) ops.updatePlaceholders(user); + if (!ops.userOnline(user)) ops.clearCache(user); + ops.setUpdate(); + ops.extraDebug("Finished vote processing: " + playerName + "/" + userId); + } finally { + ops.finishAccounting(voteId); } - ops.milestones(user, voteId, ops.forceProxyRouting()); - ops.cooldown(user, site); - ops.voteStreak(user, voteTime, voteId); - ops.postVote(site, user, playerName, voteTime, voteId, cached); - if (ops.userOnline(user) || ops.placeholderCacheAlways()) ops.updatePlaceholders(user); - if (!ops.userOnline(user)) ops.clearCache(user); - ops.setUpdate(); - ops.extraDebug("Finished vote processing: " + playerName + "/" + userId); } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java index d75f7bd1a6..39351426a6 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java @@ -1,4 +1,6 @@ -package com.bencodez.votingplugin.events; +package com.bencodez.votingplugin.events; + +import java.util.UUID; import org.bukkit.event.Event; import org.bukkit.event.HandlerList; @@ -76,9 +78,18 @@ public static HandlerList getHandlerList() { @Setter private boolean broadcast = true; - @Getter - @Setter - private int voteNumber = 1; + @Getter + @Setter + private int voteNumber = 1; + + /** Stable delivery identity supplied by proxy transports and durable retries. */ + @Getter + @Setter + private UUID voteId; + + @Getter + @Setter + private boolean accountingAdmissionFailed; /** * Constructs a new PlayerVoteEvent. diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java index 69feeb57ff..005ac3b3f5 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java @@ -15,6 +15,7 @@ import com.bencodez.advancedcore.api.user.validation.UserValidationResult; import com.bencodez.simpleapi.array.ArrayUtils; import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.core.vote.SharedVoteAdmissionException; import com.bencodez.votingplugin.core.vote.SharedVotePolicy; import com.bencodez.votingplugin.core.vote.SharedVoteProcessor; import com.bencodez.votingplugin.events.PlayerPostVoteEvent; @@ -22,6 +23,7 @@ import com.bencodez.votingplugin.topvoter.TopVoter; import com.bencodez.votingplugin.user.VotingPluginUser; import com.bencodez.votingplugin.votesites.VoteSite; +import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService; /** Bukkit event entry point and platform operations for the shared vote sequence. */ public class PlayerVoteListener implements Listener { @@ -33,7 +35,11 @@ public PlayerVoteListener(VotingPluginMain plugin) { @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onplayerVote(PlayerVoteEvent event) { - SharedVoteProcessor.process(new BukkitOperations(plugin, event)); + try { + SharedVoteProcessor.process(new BukkitOperations(plugin, event)); + } catch (SharedVoteAdmissionException admissionFailure) { + event.setAccountingAdmissionFailed(true); + } } private static final class BukkitOperations implements SharedVoteProcessor.Operations { @@ -127,7 +133,22 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { plugin.getBroadcastHandler().broadcastVote(uuid, name, siteDisplayName, online); } @Override public boolean hasProxyTextTotals() { return event.getBungeeTextTotals() != null; } - @Override public UUID proxyVoteId() { return event.getBungeeTextTotals().getVoteUUID(); } + @Override public UUID incomingVoteId() { + if (event.getVoteId() != null) return event.getVoteId(); + return event.getBungeeTextTotals() == null ? null : event.getBungeeTextTotals().getVoteUUID(); + } + @Override public void prepareAccounting(VotingPluginUser user, UUID voteId, boolean countTotals) { + boolean countVoteParty = plugin.getSpecialRewardsConfig().isVotePartyEnabled() + && (plugin.getSpecialRewardsConfig().isVotePartyCountFakeVotes() || event.isRealVote()) + && (plugin.getSpecialRewardsConfig().isVotePartyCountOfflineVotes() || user.isOnline()); + if (!VoteShopPurchaseService.prepareMysqlVoteAccounting(plugin, voteId, user.getUUID(), countTotals, + countVoteParty)) { + throw new SharedVoteAdmissionException("Unable to admit shared MySQL vote accounting before processing"); + } + } + @Override public void finishAccounting(UUID voteId) { + VoteShopPurchaseService.finishMysqlVoteAccounting(voteId); + } @Override public void cache(VotingPluginUser user) { user.cache(); } @Override public void updateName(VotingPluginUser user) { user.updateName(true); } @Override public void voteParty(VotingPluginUser user, boolean realVote, boolean forceProxyRouting, UUID voteId) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java index 5e7188a5f9..6b4de4f26e 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java @@ -1,5 +1,6 @@ package com.bencodez.votingplugin.listeners; +import java.util.UUID; import java.util.concurrent.RejectedExecutionException; import org.bukkit.event.EventHandler; @@ -36,6 +37,10 @@ public VotiferEvent(VotingPluginMain plugin) { * @param voteUsername the validated player name */ public void processVote(String voteSite, String voteUsername) { + processVote(voteSite, voteUsername, UUID.randomUUID()); + } + + public void processVote(String voteSite, String voteUsername, UUID voteId) { try { plugin.getServerData().addServiceSite(voteSite); if (plugin.getBungeeSettings().isUseBungeecoord() && !plugin.getBungeeSettings().isVotifierBypass() @@ -85,7 +90,12 @@ public void processVote(String voteSite, String voteUsername) { PlayerVoteEvent voteEvent = new PlayerVoteEvent( plugin.getVoteSiteManager().getVoteSite(voteSiteName, true), voteUsername, voteSite, true); + voteEvent.setVoteId(voteId); plugin.getServer().getPluginManager().callEvent(voteEvent); + if (voteEvent.isAccountingAdmissionFailed()) { + retainForAccountingRetry(voteSite, voteUsername, voteId); + return; + } if (voteEvent.isCancelled()) { plugin.debug("Vote cancelled"); @@ -96,6 +106,15 @@ public void processVote(String voteSite, String voteUsername) { } } + private void retainForAccountingRetry(String voteSite, String voteUsername, UUID voteId) { + VotifierVoteOverflowQueue overflow = plugin.getVotifierVoteOverflowQueue(); + if (overflow == null || !overflow.enqueue(voteUsername, voteSite, voteId)) { + plugin.getLogger().severe("Unable to retain vote after shared MySQL accounting admission failed"); + } else { + plugin.getLogger().warning("Shared MySQL accounting is unavailable; retained Votifier vote for retry"); + } + } + /** * On votifer event. * @@ -135,11 +154,12 @@ public void onVotiferEvent(VotifierEvent event) { plugin.debug("VoteSite: " + voteSite); plugin.debug("IP: " + IP); + UUID voteId = UUID.randomUUID(); try { - plugin.getVoteTimer().submit(() -> processVote(voteSite, voteUsername)); + plugin.getVoteTimer().submit(() -> processVote(voteSite, voteUsername, voteId)); } catch (RejectedExecutionException rejected) { VotifierVoteOverflowQueue overflow = plugin.getVotifierVoteOverflowQueue(); - if (overflow == null || !overflow.enqueue(voteUsername, voteSite)) { + if (overflow == null || !overflow.enqueue(voteUsername, voteSite, voteId)) { plugin.getLogger().severe("Votifier vote queue is full; vote was not admitted for " + MinecraftUsernameValidator.sanitizeForLog(voteUsername)); } else { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java index 7ec1cb4c82..0a62ba677d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java @@ -17,6 +17,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.UUID; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; @@ -42,7 +43,7 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable { private static final String QUEUE_FILE = "VotifierVoteQueue.yml"; private final VotingPluginMain plugin; - private final BiConsumer processor; + private final VoteProcessor processor; private final Path file; private final ScheduledThreadPoolExecutor worker; private final Object lock = new Object(); @@ -62,7 +63,7 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable { * @param plugin the owning plugin * @param processor callback receiving service site and player name */ - public VotifierVoteOverflowQueue(VotingPluginMain plugin, BiConsumer processor) { + public VotifierVoteOverflowQueue(VotingPluginMain plugin, VoteProcessor processor) { this.plugin = plugin; this.processor = processor; this.file = new File(plugin.getDataFolder(), QUEUE_FILE).toPath(); @@ -75,6 +76,10 @@ public VotifierVoteOverflowQueue(VotingPluginMain plugin, BiConsumer processor) { + this(plugin, (serviceSite, username, voteId) -> processor.accept(serviceSite, username)); + } + /** * Starts draining persisted and newly queued votes. Call this only after the * Votifier and vote-processing listeners have been registered so a recovered @@ -97,10 +102,14 @@ public void start() { * @return false when the bounded overflow is full or shutting down */ public boolean enqueue(String username, String serviceSite) { - if (username == null || serviceSite == null) return false; + return enqueue(username, serviceSite, UUID.randomUUID()); + } + + public boolean enqueue(String username, String serviceSite, UUID voteId) { + if (username == null || serviceSite == null || voteId == null) return false; synchronized (lock) { if (closed || entries.size() >= MAX_ENTRIES) return false; - entries.addLast(new PendingVote(username, serviceSite, System.currentTimeMillis())); + entries.addLast(new PendingVote(username, serviceSite, System.currentTimeMillis(), voteId)); stateVersion++; requestPersistenceLocked(); scheduleDrainLocked(); @@ -153,7 +162,7 @@ private void drain() { // above cannot change in the gap before submit accepts this vote. plugin.getVoteTimer().submit(() -> { try { - processor.accept(pending.serviceSite, pending.username); + processor.accept(pending.serviceSite, pending.username, pending.voteId); } finally { acknowledge(pending); } @@ -258,6 +267,7 @@ private void load() { Object username = map.get("Username"); Object serviceSite = map.get("ServiceSite"); Object time = map.get("Time"); + Object rawVoteId = map.get("VoteId"); if (!(username instanceof String name) || !(serviceSite instanceof String site) || !(time instanceof Number timestamp) || !MinecraftUsernameValidator.isValid(name, plugin.getOptions().getBedrockPlayerPrefix()) @@ -265,7 +275,15 @@ private void load() { skipped = true; continue; } - entries.addLast(new PendingVote(name, site, timestamp.longValue())); + UUID voteId; + try { + voteId = rawVoteId instanceof String id ? UUID.fromString(id) : UUID.randomUUID(); + if (!(rawVoteId instanceof String)) skipped = true; + } catch (IllegalArgumentException invalidVoteId) { + skipped = true; + continue; + } + entries.addLast(new PendingVote(name, site, timestamp.longValue(), voteId)); } if (skipped) { synchronized (lock) { @@ -305,6 +323,7 @@ private void writeSnapshotLocked(List snapshot) throws IOException value.put("Username", pending.username); value.put("ServiceSite", pending.serviceSite); value.put("Time", pending.time); + value.put("VoteId", pending.voteId.toString()); values.add(value); } yaml.set("Votes", values); @@ -359,12 +378,19 @@ private static final class PendingVote { private final String username; private final String serviceSite; private final long time; + private final UUID voteId; private boolean submitted; - private PendingVote(String username, String serviceSite, long time) { + private PendingVote(String username, String serviceSite, long time, UUID voteId) { this.username = username; this.serviceSite = serviceSite; this.time = time; + this.voteId = voteId; } } + + @FunctionalInterface + public interface VoteProcessor { + void accept(String serviceSite, String username, UUID voteId); + } } 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 dc88eda70c..f78ce29d58 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -626,6 +626,12 @@ private void runRecoverablePeriod(TopVoter top, TimeChangeTransition transition) processRecoverableUsers(top, transition); plugin.getServerData().completeTimeChangePhase(transition, USER_UPDATES); } + if (top == TopVoter.Daily && plugin.getConfigFile().isUseVoteStreaks() + && plugin.getStorageType().equals(UserStorage.MYSQL) + && !VoteShopPurchaseService.completeMysqlDailyStreakReset(plugin, + "time-streak-reset:" + transition.getId())) { + throw new IllegalStateException("Unable to publish the completed daily streak reset"); + } if (!plugin.getServerData().hasTimeChangePhase(transition, TOP_REWARDS)) { processRecoverableTopRewards(top, transition); 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 9683784559..be27f61f87 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -973,9 +973,21 @@ public void addWeekVoteStreak() { * @param broadcast whether to broadcast the vote * @param num the vote number */ - public void bungeeVotePluginMessaging(String service, long time, VoteTotalsSnapshot text, boolean setTotals, - boolean wasOnline, boolean broadcast, int num) { - if (plugin.getBungeeSettings().isUseBungeecoord()) { + public void bungeeVotePluginMessaging(String service, long time, VoteTotalsSnapshot text, boolean setTotals, + boolean wasOnline, boolean broadcast, int num) { + @SuppressWarnings("deprecation") + UUID legacyVoteId = text == null ? null : text.getVoteUUID(); + bungeeVotePluginMessaging(service, time, text, setTotals, wasOnline, broadcast, num, legacyVoteId); + } + + public void bungeeVotePluginMessaging(String service, long time, VoteTotalsSnapshot text, boolean setTotals, + boolean wasOnline, boolean broadcast, int num, UUID voteId) { + bungeeVotePluginMessagingAccepted(service, time, text, setTotals, wasOnline, broadcast, num, voteId); + } + + public boolean bungeeVotePluginMessagingAccepted(String service, long time, VoteTotalsSnapshot text, boolean setTotals, + boolean wasOnline, boolean broadcast, int num, UUID voteId) { + if (plugin.getBungeeSettings().isUseBungeecoord()) { plugin.debug("Pluginmessaging vote for " + getPlayerName() + " on " + service); PlayerVoteEvent voteEvent = new PlayerVoteEvent(plugin.getVoteSiteManager().getVoteSite(service, true), @@ -988,10 +1000,13 @@ public void bungeeVotePluginMessaging(String service, long time, VoteTotalsSnaps voteEvent.setBungeeTextTotals(text); voteEvent.setWasOnline(wasOnline); voteEvent.setBroadcast(broadcast); - voteEvent.setVoteNumber(num); - plugin.getServer().getPluginManager().callEvent(voteEvent); - } - } + voteEvent.setVoteNumber(num); + voteEvent.setVoteId(voteId); + plugin.getServer().getPluginManager().callEvent(voteEvent); + return !voteEvent.isAccountingAdmissionFailed(); + } + return false; + } /** * Checks if the user can vote on all sites. 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 index 7c8ce06318..85d37ae4f6 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -286,7 +286,7 @@ private void commitAndConfirmRequested(Connection connection, UUID voteId, int o closeQuietly(connection); try { AccountingRow row = findAccountingVote(voteId); - if (row != null && (row.requested() & operation) != 0) return; + if (row != null && (row.requested() & operation) == operation) return; } catch (SQLException confirmationFailure) { ambiguousCommit.addSuppressed(confirmationFailure); } @@ -299,7 +299,7 @@ private void commitAndConfirmRequested(Connection connection, UUID voteId, int o * backend therefore agrees whether an increment precedes or follows the copy. */ boolean incrementPeriodTotals(UUID voteId, String uuid, String boundaryColumn, String previousColumn, List columns, - Integer maximum) + Integer maximum, boolean alreadyRequested) throws SQLException { if (voteId == null || uuid == null || uuid.isEmpty() || !isSafeColumn(boundaryColumn) || columns == null || columns.isEmpty() || columns.stream().anyMatch(column -> !isSafeColumn(column)) @@ -307,8 +307,10 @@ boolean incrementPeriodTotals(UUID voteId, String uuid, String boundaryColumn, S throw new SQLException("Invalid period total increment"); } int operation = accountingOperation(boundaryColumn); - requestAccounting(voteId, uuid, operation, operation == MONTH_TOTAL && columns.size() > 1 ? columns.get(1) : null, - maximum, null, null); + if (!alreadyRequested) { + requestAccounting(voteId, uuid, operation, null, + operation == MONTH_TOTAL && columns.size() > 1 ? columns.get(1) : null, maximum, null, null); + } try { applyPeriodTotals(voteId, uuid, boundaryColumn, previousColumn, columns, maximum, operation); return true; @@ -317,6 +319,50 @@ boolean incrementPeriodTotals(UUID voteId, String uuid, String boundaryColumn, S } } + boolean incrementPeriodTotals(UUID voteId, String uuid, String boundaryColumn, String previousColumn, + List columns, Integer maximum) throws SQLException { + return incrementPeriodTotals(voteId, uuid, boundaryColumn, previousColumn, columns, maximum, false); + } + + int prepareVoteAccounting(UUID voteId, String uuid, boolean countTotals, boolean countVoteParty, + String monthColumn, Integer monthMaximum) throws SQLException { + int requested = (countTotals ? DAILY_TOTAL | WEEKLY_TOTAL | MONTH_TOTAL : 0) + | (countVoteParty ? VOTE_PARTY_TOTAL : 0); + if (requested == 0) return 0; + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try { + if ((requested & DAILY_TOTAL) != 0) lockLimitEpochRow(connection, "period-copy:DailyTotal"); + if ((requested & MONTH_TOTAL) != 0) lockLimitEpochRow(connection, "period-copy:MonthTotal"); + if ((requested & VOTE_PARTY_TOTAL) != 0) lockLimitEpochRow(connection, "period-copy:VotePartyVotes"); + if ((requested & WEEKLY_TOTAL) != 0) lockLimitEpochRow(connection, "period-copy:WeeklyTotal"); + ensureAccountingVote(connection, voteId, uuid); + AccountingRow row = findAndLockAccountingVote(connection, voteId); + if (row == null || row.uuid() != null && !row.uuid().equals(uuid)) { + throw new SQLException("Vote accounting identity does not match"); + } + String update = "UPDATE " + qiAccounting() + " SET " + qi("player_uuid") + " = ?, " + + qi("requested") + " = ?, " + qi("month_column") + " = COALESCE(?, " + + qi("month_column") + "), " + qi("month_maximum") + " = COALESCE(?, " + + qi("month_maximum") + ") WHERE " + qi("vote_id") + " = ?"; + try (PreparedStatement statement = connection.prepareStatement(update)) { + statement.setString(1, uuid); + statement.setInt(2, row.requested() | requested); + statement.setString(3, monthColumn); + if (monthMaximum == null) statement.setNull(4, java.sql.Types.INTEGER); + else statement.setInt(4, monthMaximum.intValue()); + statement.setString(5, voteId.toString()); + if (statement.executeUpdate() != 1) throw new SQLException("Vote accounting marker is missing"); + } + commitAndConfirmRequested(connection, voteId, requested); + return requested; + } catch (SQLException failure) { + rollback(connection); + throw failure; + } + } + } + private void applyPeriodTotals(UUID voteId, String uuid, String boundaryColumn, String previousColumn, List columns, Integer maximum, int operation) throws SQLException { try (Connection connection = connection()) { @@ -373,11 +419,12 @@ private static int accountingOperation(String boundaryColumn) throws SQLExceptio }; } - private void requestAccounting(UUID voteId, String uuid, int operation, String monthColumn, Integer monthMaximum, + private void requestAccounting(UUID voteId, String uuid, int operation, String boundaryMarker, String monthColumn, Integer monthMaximum, Integer streakValue, Long streakUpdatedAt) throws SQLException { try (Connection connection = connection()) { connection.setAutoCommit(false); try { + if (boundaryMarker != null) lockLimitEpochRow(connection, boundaryMarker); ensureAccountingVote(connection, voteId, uuid); AccountingRow row = findAndLockAccountingVote(connection, voteId); if (row == null || row.uuid() != null && !row.uuid().equals(uuid)) { @@ -438,11 +485,13 @@ private AccountingRow findAndLockAccountingVote(Connection connection, UUID vote } private void markAccountingComplete(Connection connection, UUID voteId, int completed) throws SQLException { - String update = "UPDATE " + qiAccounting() + " SET " + qi("completed") + " = ? WHERE " + String update = "UPDATE " + qiAccounting() + " SET " + qi("completed") + " = ?, " + + qi("created_at") + " = ? WHERE " + qi("vote_id") + " = ?"; try (PreparedStatement statement = connection.prepareStatement(update)) { statement.setInt(1, completed); - statement.setString(2, voteId.toString()); + statement.setLong(2, System.currentTimeMillis()); + statement.setString(3, voteId.toString()); if (statement.executeUpdate() != 1) throw new SQLException("Vote accounting marker is missing"); } } @@ -498,7 +547,8 @@ private static String generationTransition(String generation, String prefix) { /** Serializes the accepted daily-streak value and timestamp with its shared boundary. */ boolean updateDailyStreak(UUID voteId, String uuid, int streak, long updatedAt) throws SQLException { - requestAccounting(voteId, uuid, DAILY_STREAK, null, null, Integer.valueOf(streak), Long.valueOf(updatedAt)); + requestAccounting(voteId, uuid, DAILY_STREAK, "streak-copy:DayVoteStreak", null, null, + Integer.valueOf(streak), Long.valueOf(updatedAt)); try { applyDailyStreak(voteId, uuid, streak, updatedAt); return true; @@ -511,7 +561,13 @@ private void applyDailyStreak(UUID voteId, String uuid, int streak, long updated try (Connection connection = connection()) { connection.setAutoCommit(false); try { - lockLimitEpochRow(connection, "streak-copy:DayVoteStreak"); + EpochRow copyMarker = lockLimitEpochRow(connection, "streak-copy:DayVoteStreak"); + EpochRow resetMarker = lockLimitEpochRow(connection, "streak-reset:DayVoteStreak"); + String copyTransition = generationTransition(copyMarker.lastResetGeneration(), "time-streak-copy:"); + String resetTransition = generationTransition(resetMarker.lastResetGeneration(), "time-streak-reset:"); + if (copyTransition != null && !copyTransition.equals(resetTransition)) { + throw new SQLException("Daily streak boundary reset is still active"); + } AccountingRow accounting = findAndLockAccountingVote(connection, voteId); if (accounting == null) throw new SQLException("Vote accounting marker is missing"); int completed = accounting.completed(); @@ -559,12 +615,42 @@ void resetDailyStreakAtBoundary(String uuid, long boundaryUpdatedAt) throws SQLE } } + /** Publishes that every copied daily streak has been reset for this transition. */ + void completeDailyStreakReset(String generation) throws SQLException { + if (generation == null || !generation.startsWith("time-streak-reset:") || generation.length() > 128) { + throw new SQLException("Invalid daily streak reset generation"); + } + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try { + lockLimitEpochRow(connection, "streak-copy:DayVoteStreak"); + EpochRow resetMarker = lockLimitEpochRow(connection, "streak-reset:DayVoteStreak"); + if (generation.equals(resetMarker.lastResetGeneration())) { + rollback(connection); + return; + } + String update = "UPDATE " + qiEpoch() + " SET " + qi("epoch") + " = ?, " + + qi("last_reset_generation") + " = ? WHERE " + qi("limit_column") + " = ?"; + try (PreparedStatement statement = connection.prepareStatement(update)) { + statement.setLong(1, resetMarker.epoch() + 1L); + statement.setString(2, generation); + statement.setString(3, "streak-reset:DayVoteStreak"); + if (statement.executeUpdate() != 1) throw new SQLException("Daily streak reset marker is missing"); + } + connection.commit(); + } catch (SQLException failure) { + rollback(connection); + throw failure; + } + } + } + /** Copies the period boundary once so a phase-receipt retry cannot move it. */ void copyPeriodBoundary(String totalColumn, String previousColumn, String generation) throws SQLException { if (!isSafeColumn(totalColumn) || !isSafeColumn(previousColumn)) { throw new SQLException("Unsafe period boundary column"); } - copyBoundary("period-copy:" + totalColumn, generation, + copyBoundary("period-copy:" + totalColumn, generation, accountingOperation(totalColumn), qi(previousColumn) + " = COALESCE(" + qi(totalColumn) + ", 0)"); } @@ -575,12 +661,12 @@ void copyDailyStreakBoundary(String streakColumn, String previousStreakColumn, S || !isSafeColumn(previousUpdateColumn)) { throw new SQLException("Unsafe daily streak boundary column"); } - copyBoundary("streak-copy:" + streakColumn, generation, + copyBoundary("streak-copy:" + streakColumn, generation, DAILY_STREAK, qi(previousStreakColumn) + " = COALESCE(" + qi(streakColumn) + ", 0), " + qi(previousUpdateColumn) + " = COALESCE(" + qi(updateColumn) + ", '')"); } - private void copyBoundary(String markerKey, String generation, String assignments) throws SQLException { + private void copyBoundary(String markerKey, String generation, int accountingOperation, String assignments) throws SQLException { if (generation == null || generation.isEmpty() || generation.length() > 128) { throw new SQLException("Invalid period boundary generation"); } @@ -594,6 +680,7 @@ private void copyBoundary(String markerKey, String generation, String assignment } long oldEpoch = marker.epoch(); if (oldEpoch == Long.MAX_VALUE) throw new SQLException("Period boundary epoch overflow"); + drainPendingAccounting(connection, accountingOperation); try (PreparedStatement copy = connection.prepareStatement("UPDATE " + qi(table.getTableName()) + " SET " + assignments); PreparedStatement advance = connection.prepareStatement("UPDATE " + qiEpoch() + " SET " @@ -613,6 +700,67 @@ private void copyBoundary(String markerKey, String generation, String assignment } } + private void drainPendingAccounting(Connection connection, int operation) throws SQLException { + String select = "SELECT " + qi("vote_id") + ", " + accountingColumns() + " FROM " + qiAccounting() + + " WHERE (" + qi("requested") + " & ?) <> 0 AND (" + qi("completed") + + " & ?) = 0 FOR UPDATE"; + try (PreparedStatement statement = connection.prepareStatement(select)) { + statement.setInt(1, operation); + statement.setInt(2, operation); + try (ResultSet result = statement.executeQuery()) { + while (result.next()) { + UUID voteId = UUID.fromString(result.getString(1)); + AccountingRow row = new AccountingRow(result.getString(2), result.getInt(3), result.getInt(4), + result.getString(5), nullableInteger(result, 6), nullableInteger(result, 7), nullableLong(result, 8)); + applyPendingAccounting(connection, voteId, row, operation); + } + } + } + } + + private void applyPendingAccounting(Connection connection, UUID voteId, AccountingRow row, int operation) + throws SQLException { + if (operation == DAILY_STREAK) { + if (row.streakUpdatedAt() == null) throw new SQLException("Pending daily streak accounting payload is incomplete"); + String sql = "UPDATE " + qi(table.getTableName()) + " SET " + qi("DayVoteStreak") + " = COALESCE(" + + qi("DayVoteStreak") + ", 0) + 1, " + qi("DayVoteStreakLastUpdate") + " = ? WHERE " + + qi("uuid") + uuidCast(); + try (PreparedStatement update = connection.prepareStatement(sql)) { + update.setString(1, Long.toString(row.streakUpdatedAt().longValue())); + update.setString(2, row.uuid()); + if (update.executeUpdate() != 1) throw new SQLException("Daily streak user row is missing"); + } + markAccountingComplete(connection, voteId, row.completed() | operation); + return; + } + String boundary = operation == DAILY_TOTAL ? "DailyTotal" + : operation == WEEKLY_TOTAL ? "WeeklyTotal" + : operation == MONTH_TOTAL ? "MonthTotal" : "VotePartyVotes"; + List columns = operation == MONTH_TOTAL && row.monthColumn() != null + ? List.of(boundary, row.monthColumn()) : List.of(boundary); + StringBuilder sql = new StringBuilder("UPDATE ").append(qi(table.getTableName())).append(" SET "); + for (int index = 0; index < columns.size(); index++) { + if (index > 0) sql.append(", "); + String quoted = qi(columns.get(index)); + sql.append(quoted).append(" = "); + if (row.monthMaximum() != null) sql.append("LEAST(?, "); + sql.append("COALESCE(").append(quoted).append(", 0) + 1"); + if (row.monthMaximum() != null) sql.append(')'); + } + sql.append(" WHERE ").append(qi("uuid")).append(uuidCast()); + try (PreparedStatement update = connection.prepareStatement(sql.toString())) { + int parameter = 1; + if (row.monthMaximum() != null) { + for (int ignored = 0; ignored < columns.size(); ignored++) { + update.setInt(parameter++, row.monthMaximum().intValue()); + } + } + update.setString(parameter, row.uuid()); + if (update.executeUpdate() != 1) throw new SQLException("Period total user row is missing"); + } + markAccountingComplete(connection, voteId, row.completed() | operation); + } + /** * A JDBC commit error does not prove that the database discarded the * transaction. Close the possibly-broken handle before looking up the same 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 eb2c9ba433..daa38e980f 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 @@ -14,6 +14,8 @@ import java.util.Locale; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; @@ -49,6 +51,7 @@ @Getter @Setter public class VoteShopPurchaseService { + private static final ConcurrentMap ADMITTED_ACCOUNTING = new ConcurrentHashMap<>(); private static final int PURCHASE_LOCK_STRIPES = 256; private static final Object[] PURCHASE_LOCKS = createPurchaseLocks(); private static final int COMPLETION_PENDING = 0; @@ -624,8 +627,12 @@ public static boolean incrementMysqlPeriodTotals(VotingPluginMain plugin, UUID v MySQL table = plugin.getMysql(); for (String column : columns) table.checkColumn(column, DataType.INTEGER); if (maximum != null) table.checkColumn(previousColumn, DataType.INTEGER); - boolean applied = SharedMysqlPurchaseJournal.forTable(table).incrementPeriodTotals( - voteId == null ? UUID.randomUUID() : voteId, uuid, boundaryColumn, previousColumn, columns, maximum); + UUID accountingId = voteId == null ? UUID.randomUUID() : voteId; + int operation = "DailyTotal".equals(boundaryColumn) ? 1 : "WeeklyTotal".equals(boundaryColumn) ? 2 + : "MonthTotal".equals(boundaryColumn) ? 4 : 8; + int admitted = ADMITTED_ACCOUNTING.getOrDefault(accountingId, Integer.valueOf(0)).intValue(); + boolean applied = SharedMysqlPurchaseJournal.forTable(table).incrementPeriodTotals(accountingId, uuid, + boundaryColumn, previousColumn, columns, maximum, (admitted & operation) != 0); if (!applied) plugin.getLogger().warning( "Shared MySQL period total was retained for retry after a persistence failure"); return true; @@ -639,6 +646,42 @@ public static boolean incrementMysqlPeriodTotals(VotingPluginMain plugin, UUID v } } + /** Durably admits shared total mutations before vote rewards or broadcasts run. */ + public static boolean prepareMysqlVoteAccounting(VotingPluginMain plugin, UUID voteId, String uuid, + boolean countTotals, boolean countVoteParty) { + if (!canRecoverSharedMysqlPurchases(plugin) || voteId == null) return true; + if (!countTotals && !countVoteParty) return true; + try { + MySQL table = plugin.getMysql(); + String monthColumn = plugin.getConfigFile().isStoreMonthTotalsWithDate() + ? plugin.getVotingPluginUserManager().getMonthTotalsWithDatePath() : null; + Integer maximum = plugin.getConfigFile().isLimitMonthlyVotes() + ? Integer.valueOf(plugin.getTimeChecker().getTime().getDayOfMonth() + * plugin.getVoteSiteManager().getVoteSitesEnabled().size()) : null; + if (countTotals) { + table.checkColumn("DailyTotal", DataType.INTEGER); + table.checkColumn("WeeklyTotal", DataType.INTEGER); + table.checkColumn("MonthTotal", DataType.INTEGER); + if (monthColumn != null) table.checkColumn(monthColumn, DataType.INTEGER); + if (maximum != null) table.checkColumn("LastMonthTotal", DataType.INTEGER); + } + if (countVoteParty) table.checkColumn("VotePartyVotes", DataType.INTEGER); + int bits = SharedMysqlPurchaseJournal.forTable(table).prepareVoteAccounting(voteId, uuid, countTotals, + countVoteParty, monthColumn, maximum); + ADMITTED_ACCOUNTING.put(voteId, Integer.valueOf(bits)); + return true; + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to admit shared MySQL vote accounting: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + return false; + } + } + + public static void finishMysqlVoteAccounting(UUID voteId) { + if (voteId != null) ADMITTED_ACCOUNTING.remove(voteId); + } + /** Atomically publishes an accepted daily streak under the shared boundary lock. */ public static boolean updateMysqlDailyStreak(VotingPluginMain plugin, UUID voteId, String uuid, int streak, long updatedAt) { @@ -679,6 +722,20 @@ public static boolean resetMysqlDailyStreakAtBoundary(VotingPluginMain plugin, S } } + /** Releases accepted daily streak increments after every copied user is reset. */ + public static boolean completeMysqlDailyStreakReset(VotingPluginMain plugin, String generation) { + if (!canRecoverSharedMysqlPurchases(plugin)) return false; + try { + SharedMysqlPurchaseJournal.forTable(plugin.getMysql()).completeDailyStreakReset(generation); + return true; + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to complete the shared MySQL daily streak reset: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + return false; + } + } + /** Atomically captures one period boundary for a recoverable transition. */ public static boolean copyMysqlPeriodBoundary(VotingPluginMain plugin, String totalColumn, String previousColumn, String generation) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java index 4bce68c6de..9aaa4f86fc 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java @@ -180,8 +180,8 @@ void voteRewardFailureRequestsQuarantineAfterVoteIdReservation() { when(plugin.getBungeeSettings()).thenReturn(mock(BungeeSettings.class)); when(plugin.getVotingPluginUserManager().getVotingPluginUser(PLAYER_UUID, "Player")) .thenReturn(user); - doThrow(new IllegalStateException("partial reward")).when(user).bungeeVotePluginMessaging( - any(), anyLong(), any(), anyBoolean(), anyBoolean(), anyBoolean(), anyInt()); + when(user.bungeeVotePluginMessagingAccepted(any(), anyLong(), any(), anyBoolean(), anyBoolean(), + anyBoolean(), anyInt(), any())).thenThrow(new IllegalStateException("partial reward")); BackendProxyMessageRouter voteRouter = new BackendProxyMessageRouter(plugin, mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class), mock(BackendVotePartySync.class), cache); @@ -192,4 +192,25 @@ void voteRewardFailureRequestsQuarantineAfterVoteIdReservation() { assertEquals(OrderedVoteOutcome.QUARANTINE, outcome.get()); } + @Test + void accountingAdmissionFailureReleasesVoteIdAndRetriesWithoutQuarantine() { + UUID voteId = UUID.randomUUID(); + ProcessedVoteCache cache = mock(ProcessedVoteCache.class); + when(cache.reserve(voteId)).thenReturn(true); + when(plugin.getBungeeSettings()).thenReturn(mock(BungeeSettings.class)); + when(plugin.getVotingPluginUserManager().getVotingPluginUser(PLAYER_UUID, "Player")).thenReturn(user); + when(user.bungeeVotePluginMessagingAccepted(any(), anyLong(), any(), anyBoolean(), anyBoolean(), + anyBoolean(), anyInt(), any())).thenReturn(false); + BackendProxyMessageRouter voteRouter = new BackendProxyMessageRouter(plugin, + mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class), + mock(BackendVotePartySync.class), cache); + AtomicReference outcome = new AtomicReference<>(); + + voteRouter.handleOrderedVote(VotingPluginWire.vote("Player", PLAYER_UUID.toString(), "known.example", + LAST_VOTE_TIME, true, true, "", voteId, false, false, 1, 1), outcome::set); + + assertEquals(OrderedVoteOutcome.RETRY, outcome.get()); + verify(cache).release(voteId); + } + } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java index 4a95eaab08..795606462e 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java @@ -60,6 +60,7 @@ void onlineVoteKeepsProductionPhaseOrderAndCounts() { InOrder order = inOrder(ops); order.verify(ops).lastVoteTime(user, site); + order.verify(ops).prepareAccounting(eq(user), any(UUID.class), eq(true)); order.verify(ops).cache(user); order.verify(ops).updateName(user); order.verify(ops).voteParty(eq(user), eq(true), eq(false), any(UUID.class)); @@ -80,6 +81,7 @@ void onlineVoteKeepsProductionPhaseOrderAndCounts() { order.verify(ops).postVote(eq(site), eq(user), eq("Ben"), eq(123L), any(UUID.class), eq(false)); order.verify(ops).updatePlaceholders(user); order.verify(ops).setUpdate(); + order.verify(ops).finishAccounting(any(UUID.class)); } @Test @@ -88,7 +90,7 @@ void proxyVoteUsesHistoricalOnlineForDeliveryButCurrentOnlineForTotals() { UUID proxyId = UUID.randomUUID(); when(ops.proxyVote()).thenReturn(true); when(ops.hasProxyTextTotals()).thenReturn(true); - when(ops.proxyVoteId()).thenReturn(proxyId); + when(ops.incomingVoteId()).thenReturn(proxyId); when(ops.wasOnline()).thenReturn(true); when(ops.incomingTime()).thenReturn(321L); when(ops.lastVoteTime(user, site)).thenReturn(321L); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java index b667f52987..c7cf973665 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java @@ -148,11 +148,13 @@ public void testUnknownSiteStillAttemptsGenerationByVotifierPath() { public void testVoteIsQueuedWhenBoundedExecutorRejectsIt() { doThrow(new RejectedExecutionException("capacity exhausted")) .when(voteTimer).submit(any(Runnable.class)); - when(overflowQueue.enqueue("Steve", SERVICE_SITE)).thenReturn(true); + when(overflowQueue.enqueue(org.mockito.ArgumentMatchers.eq("Steve"), + org.mockito.ArgumentMatchers.eq(SERVICE_SITE), any())).thenReturn(true); listener.onVotiferEvent(createVoteEvent(SERVICE_SITE)); - verify(overflowQueue).enqueue("Steve", SERVICE_SITE); + verify(overflowQueue).enqueue(org.mockito.ArgumentMatchers.eq("Steve"), + org.mockito.ArgumentMatchers.eq(SERVICE_SITE), any()); verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class)); } } 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 index 0bbd08dba7..8b05fe7a88 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -296,21 +296,24 @@ void periodBoundaryCopyAndGenerationAdvanceShareOneTransaction() throws Exceptio Fixture fixture = fixture(); PreparedStatement markerInsert = mock(PreparedStatement.class); PreparedStatement markerSelect = mock(PreparedStatement.class); + PreparedStatement pending = mock(PreparedStatement.class); PreparedStatement copy = mock(PreparedStatement.class); PreparedStatement advance = mock(PreparedStatement.class); ResultSet epoch = mock(ResultSet.class); + ResultSet noPending = ids(); when(epoch.next()).thenReturn(true); when(epoch.getLong(1)).thenReturn(8L); when(markerSelect.executeQuery()).thenReturn(epoch); + when(pending.executeQuery()).thenReturn(noPending); when(advance.executeUpdate()).thenReturn(1); - when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, copy, advance); + when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, pending, copy, advance); new SharedMysqlPurchaseJournal(fixture.table, false).copyPeriodBoundary( "DailyTotal", "LastDailyTotal", "time-copy:DAY:2026-09-21"); 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( + verify(fixture.work, org.mockito.Mockito.times(5)).prepareStatement(sql.capture()); + assertTrue(sql.getAllValues().get(3).contains( "`LastDailyTotal` = COALESCE(`DailyTotal`, 0)")); verify(advance).setLong(1, 9L); verify(advance).setString(3, "period-copy:DailyTotal"); @@ -442,22 +445,25 @@ void dailyStreakValueAndTimestampCopyShareOneTransaction() throws Exception { Fixture fixture = fixture(); PreparedStatement markerInsert = mock(PreparedStatement.class); PreparedStatement markerSelect = mock(PreparedStatement.class); + PreparedStatement pending = mock(PreparedStatement.class); PreparedStatement copy = mock(PreparedStatement.class); PreparedStatement advance = mock(PreparedStatement.class); ResultSet epoch = mock(ResultSet.class); + ResultSet noPending = ids(); when(epoch.next()).thenReturn(true); when(epoch.getLong(1)).thenReturn(2L); when(markerSelect.executeQuery()).thenReturn(epoch); + when(pending.executeQuery()).thenReturn(noPending); when(advance.executeUpdate()).thenReturn(1); - when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, copy, advance); + when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, pending, copy, advance); new SharedMysqlPurchaseJournal(fixture.table, false).copyDailyStreakBoundary("DayVoteStreak", "LastDayVoteStreak", "DayVoteStreakLastUpdate", "LastDayVoteStreakLastUpdate", "time-streak-copy:DAY:2026-09-21"); 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("`LastDayVoteStreak` = COALESCE(`DayVoteStreak`, 0), " + verify(fixture.work, org.mockito.Mockito.times(5)).prepareStatement(sql.capture()); + assertTrue(sql.getAllValues().get(3).contains("`LastDayVoteStreak` = COALESCE(`DayVoteStreak`, 0), " + "`LastDayVoteStreakLastUpdate` = COALESCE(`DayVoteStreakLastUpdate`, '')")); verify(advance).setString(3, "streak-copy:DayVoteStreak"); verify(fixture.work).commit(); @@ -466,32 +472,43 @@ void dailyStreakValueAndTimestampCopyShareOneTransaction() throws Exception { @Test void dailyStreakUpdateLocksTheSameBoundaryRow() throws Exception { Fixture fixture = fixture(); + PreparedStatement requestMarkerInsert = mock(PreparedStatement.class); + PreparedStatement requestMarkerSelect = mock(PreparedStatement.class); PreparedStatement requestInsert = mock(PreparedStatement.class); PreparedStatement requestSelect = mock(PreparedStatement.class); PreparedStatement requestUpdate = mock(PreparedStatement.class); - PreparedStatement markerInsert = mock(PreparedStatement.class); - PreparedStatement markerSelect = mock(PreparedStatement.class); + PreparedStatement copyMarkerInsert = mock(PreparedStatement.class); + PreparedStatement copyMarkerSelect = mock(PreparedStatement.class); + PreparedStatement resetMarkerInsert = mock(PreparedStatement.class); + PreparedStatement resetMarkerSelect = mock(PreparedStatement.class); PreparedStatement accountingSelect = mock(PreparedStatement.class); PreparedStatement update = mock(PreparedStatement.class); PreparedStatement accountingUpdate = mock(PreparedStatement.class); - ResultSet epoch = mock(ResultSet.class); + ResultSet requestEpoch = mock(ResultSet.class); + ResultSet copyEpoch = mock(ResultSet.class); + ResultSet resetEpoch = mock(ResultSet.class); ResultSet requested = accountingRow("00000000-0000-0000-0000-000000000001", 0, 0); ResultSet accounting = accountingRow("00000000-0000-0000-0000-000000000001", 16, 0); - when(epoch.next()).thenReturn(true); - when(markerSelect.executeQuery()).thenReturn(epoch); + when(requestEpoch.next()).thenReturn(true); + when(copyEpoch.next()).thenReturn(true); + when(resetEpoch.next()).thenReturn(true); + when(requestMarkerSelect.executeQuery()).thenReturn(requestEpoch); + when(copyMarkerSelect.executeQuery()).thenReturn(copyEpoch); + when(resetMarkerSelect.executeQuery()).thenReturn(resetEpoch); when(requestSelect.executeQuery()).thenReturn(requested); when(accountingSelect.executeQuery()).thenReturn(accounting); when(update.executeUpdate()).thenReturn(1); when(accountingUpdate.executeUpdate()).thenReturn(1); when(requestUpdate.executeUpdate()).thenReturn(1); - when(fixture.work.prepareStatement(anyString())).thenReturn(requestInsert, requestSelect, requestUpdate, - markerInsert, markerSelect, - accountingSelect, update, accountingUpdate); + when(fixture.work.prepareStatement(anyString())).thenReturn(requestMarkerInsert, requestMarkerSelect, + requestInsert, requestSelect, requestUpdate, copyMarkerInsert, copyMarkerSelect, + resetMarkerInsert, resetMarkerSelect, accountingSelect, update, accountingUpdate); new SharedMysqlPurchaseJournal(fixture.table, false).updateDailyStreak(java.util.UUID.randomUUID(), "00000000-0000-0000-0000-000000000001", 7, 1234L); - verify(markerSelect).setString(1, "streak-copy:DayVoteStreak"); + verify(requestMarkerSelect).setString(1, "streak-copy:DayVoteStreak"); + verify(copyMarkerSelect).setString(1, "streak-copy:DayVoteStreak"); verify(update).setString(1, "1234"); verify(update).setString(2, "00000000-0000-0000-0000-000000000001"); verify(fixture.work, org.mockito.Mockito.times(2)).commit(); From 4933c262e03cb4b2aaf458d055321f77516c34fd Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 08:28:44 -0600 Subject: [PATCH 16/61] Retain votes across accounting retries --- .../votingplugin/VotingPluginMain.java | 2 +- .../core/vote/SharedVoteProcessor.java | 5 +- .../votingplugin/listeners/VotiferEvent.java | 18 +++++-- .../listeners/VotifierVoteOverflowQueue.java | 27 +++++++--- .../timequeue/TimeQueueHandler.java | 8 ++- .../votingplugin/user/VotingPluginUser.java | 20 +++++--- .../service/SharedMysqlPurchaseJournal.java | 49 ++++++++++++++++--- .../service/VoteShopPurchaseService.java | 20 ++++++-- .../core/vote/SharedVoteProcessorTest.java | 19 +++++-- .../VotifierVoteOverflowQueueTest.java | 22 +++++++++ .../TimeQueueHandlerRejectionTest.java | 22 +++++++++ .../SharedMysqlPurchaseJournalTest.java | 17 ++++++- 12 files changed, 193 insertions(+), 36 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index dc4c5034b3..5a3e84c02c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -1883,7 +1883,7 @@ private void registerEvents() { if (isVotifierLoaded()) { VotiferEvent votifierEvent = new VotiferEvent(this); votifierVoteOverflowQueue = new VotifierVoteOverflowQueue(this, - (serviceSite, username, voteId) -> votifierEvent.processVote(serviceSite, username, voteId)); + (serviceSite, username, voteId) -> votifierEvent.processQueuedVote(serviceSite, username, voteId)); pm.registerEvents(votifierEvent, this); } pm.registerEvents(new PlayerVoteListener(this), this); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java index b22969d628..8c340499ab 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java @@ -171,7 +171,8 @@ public static void process(Operations ops) { SharedVoteInput accountingInput = new SharedVoteInput(voteId, playerName, ops.serviceSite(), ops.incomingTime(), ops.realVote(), ops.addTotals(), ops.proxyVote(), ops.forceProxyRouting(), ops.wasOnline()); - ops.prepareAccounting(user, voteId, policy.shouldCountTotals(accountingInput, () -> ops.userOnline(user))); + boolean countTotals = policy.shouldCountTotals(accountingInput, () -> ops.userOnline(user)); + ops.prepareAccounting(user, voteId, countTotals); try { ops.cache(user); ops.updateName(user); @@ -212,7 +213,7 @@ public static void process(Operations ops) { } SharedVoteInput input = new SharedVoteInput(voteId, playerName, ops.serviceSite(), voteTime, ops.realVote(), ops.addTotals(), ops.proxyVote(), ops.forceProxyRouting(), ops.wasOnline()); - SharedVoteAccounting.apply(input, policy, () -> ops.userOnline(user), () -> ops.addTotal(user, voteId), + SharedVoteAccounting.apply(input, policy, () -> countTotals, () -> ops.addTotal(user, voteId), () -> ops.addTotalDaily(user, voteId), () -> ops.addTotalWeekly(user, voteId), () -> ops.addPoints(user)); ops.checkDayVoteStreak(user, ops.forceProxyRouting(), voteId); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java index 6b4de4f26e..b015217c55 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java @@ -41,6 +41,16 @@ public void processVote(String voteSite, String voteUsername) { } public void processVote(String voteSite, String voteUsername, UUID voteId) { + if (!processVoteAttempt(voteSite, voteUsername, voteId)) { + retainForAccountingRetry(voteSite, voteUsername, voteId); + } + } + + public boolean processQueuedVote(String voteSite, String voteUsername, UUID voteId) { + return processVoteAttempt(voteSite, voteUsername, voteId); + } + + private boolean processVoteAttempt(String voteSite, String voteUsername, UUID voteId) { try { plugin.getServerData().addServiceSite(voteSite); if (plugin.getBungeeSettings().isUseBungeecoord() && !plugin.getBungeeSettings().isVotifierBypass() @@ -51,7 +61,7 @@ public void processVote(String voteSite, String voteUsername, UUID voteId) { || plugin.getBackendProxyHandler().getMethod().equals(BungeeMethod.REDIS))) { plugin.getLogger().severe( "Ignoring vote from votifier since a proxy vote transport is enabled; receive votes on the proxy or enable VotifierBypass, then check: https://github.com/BenCodez/VotingPlugin/wiki/Bungeecord-Setups"); - return; + return true; } String matchSite = ""; @@ -83,7 +93,7 @@ public void processVote(String voteSite, String voteUsername, UUID voteId) { && plugin.getConfigFile().isQueueVotesDuringTimeChange()) { plugin.debug("Adding vote to time queue " + voteUsername + "/" + voteSite); plugin.getTimeQueueHandler().addVote(voteUsername, voteSite); - return; + return true; } String voteSiteName = plugin.getVoteSiteManager().getVoteSiteName(true, serviceSite, matchSite); @@ -93,8 +103,7 @@ public void processVote(String voteSite, String voteUsername, UUID voteId) { voteEvent.setVoteId(voteId); plugin.getServer().getPluginManager().callEvent(voteEvent); if (voteEvent.isAccountingAdmissionFailed()) { - retainForAccountingRetry(voteSite, voteUsername, voteId); - return; + return false; } if (voteEvent.isCancelled()) { @@ -104,6 +113,7 @@ public void processVote(String voteSite, String voteUsername, UUID voteId) { plugin.getLogger().severe("Error occured during vote processing"); e.printStackTrace(); } + return true; } private void retainForAccountingRetry(String voteSite, String voteUsername, UUID voteId) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java index 0a62ba677d..5665a35dd0 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java @@ -77,7 +77,10 @@ public VotifierVoteOverflowQueue(VotingPluginMain plugin, VoteProcessor processo } public VotifierVoteOverflowQueue(VotingPluginMain plugin, BiConsumer processor) { - this(plugin, (serviceSite, username, voteId) -> processor.accept(serviceSite, username)); + this(plugin, (serviceSite, username, voteId) -> { + processor.accept(serviceSite, username); + return true; + }); } /** @@ -161,11 +164,8 @@ private void drain() { // Serialize admission with enqueue so the version proven durable // above cannot change in the gap before submit accepts this vote. plugin.getVoteTimer().submit(() -> { - try { - processor.accept(pending.serviceSite, pending.username, pending.voteId); - } finally { - acknowledge(pending); - } + if (processor.accept(pending.serviceSite, pending.username, pending.voteId)) acknowledge(pending); + else retry(pending); }); } catch (RejectedExecutionException rejected) { pending.submitted = false; @@ -182,6 +182,19 @@ private void drain() { } } + private void retry(PendingVote pending) { + synchronized (lock) { + if (closed || !entries.contains(pending)) return; + pending.submitted = false; + drainScheduled = false; + try { + worker.schedule(this::scheduleDrain, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS); + } catch (RejectedExecutionException ignored) { + // The durable entry remains for restart. + } + } + } + private PendingVote nextUnsubmittedLocked() { for (PendingVote pending : entries) { if (!pending.submitted) return pending; @@ -391,6 +404,6 @@ private PendingVote(String username, String serviceSite, long time, UUID voteId) @FunctionalInterface public interface VoteProcessor { - void accept(String serviceSite, String username, UUID voteId); + boolean accept(String serviceSite, String username, UUID voteId); } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java index 22efdd7432..aca6579d6c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java @@ -143,8 +143,14 @@ public void processQueue() { PlayerVoteEvent voteEvent = new PlayerVoteEvent( plugin.getVoteSiteManager().getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, vote.getService()), true), vote.getName(), vote.getService(), true); - voteEvent.setTime(voteEvent.getTime()); + voteEvent.setTime(vote.getTime()); + voteEvent.setVoteId(vote.getVoteId() == null ? vote.legacyTimedVoteId() : vote.getVoteId()); plugin.getServer().getPluginManager().callEvent(voteEvent); + if (voteEvent.isAccountingAdmissionFailed()) { + timeChangeQueue.add(vote); + scheduleRetry(); + return; + } if (voteEvent.isCancelled()) { plugin.debug("Vote cancelled"); 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 be27f61f87..faa3062cf9 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -1159,17 +1159,25 @@ public void checkDayVoteStreak(boolean forceBungee, UUID voteId) { int streak = getDayVoteStreak() + 1; long updatedAt = System.currentTimeMillis(); boolean sharedStreakPersisted = false; + boolean sharedStreakAlreadyUpdated = false; if (UserStorage.MYSQL.equals(plugin.getStorageType())) { if (getCache() != null) getCache().clearChanges(); - sharedStreakPersisted = VoteShopPurchaseService.updateMysqlDailyStreak( - plugin, voteId, getUUID(), streak, updatedAt); - if (sharedStreakPersisted) { - if (getBestDayVoteStreak() < streak) setBestDayVoteStreak(streak); - } else throw new IllegalStateException("Unable to retain shared MySQL daily streak"); + VoteShopPurchaseService.MysqlDailyStreakResult result = VoteShopPurchaseService + .updateMysqlDailyStreakResult(plugin, voteId, getUUID(), streak, updatedAt); + sharedStreakPersisted = result != VoteShopPurchaseService.MysqlDailyStreakResult.FAILED; + sharedStreakAlreadyUpdated = result == VoteShopPurchaseService.MysqlDailyStreakResult.ALREADY_UPDATED; + if (!sharedStreakPersisted) { + throw new IllegalStateException("Unable to retain shared MySQL daily streak"); + } + if (!sharedStreakAlreadyUpdated && getBestDayVoteStreak() < streak) { + setBestDayVoteStreak(streak); + } } else { setDayVoteStreak(streak); } - plugin.getSpecialRewards().checkVoteStreak(null, this, "Day", forceBungee); + if (!sharedStreakAlreadyUpdated) { + plugin.getSpecialRewards().checkVoteStreak(null, this, "Day", forceBungee); + } if (!sharedStreakPersisted) setDayVoteStreakLastUpdate(updatedAt); } } 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 index 85d37ae4f6..c4ec067369 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -9,6 +9,8 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.time.Instant; +import java.time.ZoneId; import java.util.ArrayList; import java.util.HashSet; import java.util.Iterator; @@ -30,6 +32,7 @@ * arbitrary, non-idempotent side effects.

*/ final class SharedMysqlPurchaseJournal { + enum DailyStreakOutcome { APPLIED, ALREADY_UPDATED, DEFERRED } private static final String PENDING = "PENDING"; private static final String HOOK_STARTED = "HOOK_STARTED"; private static final String COMPENSATING = "COMPENSATING"; @@ -546,18 +549,17 @@ private static String generationTransition(String generation, String prefix) { } /** Serializes the accepted daily-streak value and timestamp with its shared boundary. */ - boolean updateDailyStreak(UUID voteId, String uuid, int streak, long updatedAt) throws SQLException { + DailyStreakOutcome updateDailyStreak(UUID voteId, String uuid, int streak, long updatedAt) throws SQLException { requestAccounting(voteId, uuid, DAILY_STREAK, "streak-copy:DayVoteStreak", null, null, Integer.valueOf(streak), Long.valueOf(updatedAt)); try { - applyDailyStreak(voteId, uuid, streak, updatedAt); - return true; + return applyDailyStreak(voteId, uuid, streak, updatedAt); } catch (SQLException deferred) { - return false; + return DailyStreakOutcome.DEFERRED; } } - private void applyDailyStreak(UUID voteId, String uuid, int streak, long updatedAt) throws SQLException { + private DailyStreakOutcome applyDailyStreak(UUID voteId, String uuid, int streak, long updatedAt) throws SQLException { try (Connection connection = connection()) { connection.setAutoCommit(false); try { @@ -573,7 +575,13 @@ private void applyDailyStreak(UUID voteId, String uuid, int streak, long updated int completed = accounting.completed(); if ((completed & DAILY_STREAK) != 0) { rollback(connection); - return; + return DailyStreakOutcome.ALREADY_UPDATED; + } + String persistedUpdate = findDailyStreakUpdate(connection, uuid); + if (sameLocalDay(persistedUpdate, updatedAt)) { + markAccountingComplete(connection, voteId, completed | DAILY_STREAK); + commitAndConfirmAccounting(connection, voteId, DAILY_STREAK); + return DailyStreakOutcome.ALREADY_UPDATED; } String sql = "UPDATE " + qi(table.getTableName()) + " SET " + qi("DayVoteStreak") + " = COALESCE(" + qi("DayVoteStreak") + ", 0) + 1, " + qi("DayVoteStreakLastUpdate") + " = ? WHERE " @@ -585,6 +593,7 @@ private void applyDailyStreak(UUID voteId, String uuid, int streak, long updated } markAccountingComplete(connection, voteId, completed | DAILY_STREAK); commitAndConfirmAccounting(connection, voteId, DAILY_STREAK); + return DailyStreakOutcome.APPLIED; } catch (SQLException failure) { rollback(connection); throw failure; @@ -592,6 +601,30 @@ private void applyDailyStreak(UUID voteId, String uuid, int streak, long updated } } + private String findDailyStreakUpdate(Connection connection, String uuid) throws SQLException { + String select = "SELECT " + qi("DayVoteStreakLastUpdate") + " FROM " + qi(table.getTableName()) + + " WHERE " + qi("uuid") + uuidCast() + " FOR UPDATE"; + try (PreparedStatement statement = connection.prepareStatement(select)) { + statement.setString(1, uuid); + try (ResultSet result = statement.executeQuery()) { + if (!result.next()) throw new SQLException("Daily streak user row is missing"); + return result.getString(1); + } + } + } + + static boolean sameLocalDay(String persistedUpdate, long updatedAt) { + if (persistedUpdate == null || persistedUpdate.isEmpty()) return false; + try { + long persisted = Long.parseLong(persistedUpdate); + ZoneId zone = ZoneId.systemDefault(); + return Instant.ofEpochMilli(persisted).atZone(zone).toLocalDate() + .equals(Instant.ofEpochMilli(updatedAt).atZone(zone).toLocalDate()); + } catch (NumberFormatException invalidLegacyTimestamp) { + return false; + } + } + void resetDailyStreakAtBoundary(String uuid, long boundaryUpdatedAt) throws SQLException { try (Connection connection = connection()) { connection.setAutoCommit(false); @@ -722,6 +755,10 @@ private void applyPendingAccounting(Connection connection, UUID voteId, Accounti throws SQLException { if (operation == DAILY_STREAK) { if (row.streakUpdatedAt() == null) throw new SQLException("Pending daily streak accounting payload is incomplete"); + if (sameLocalDay(findDailyStreakUpdate(connection, row.uuid()), row.streakUpdatedAt().longValue())) { + markAccountingComplete(connection, voteId, row.completed() | operation); + return; + } String sql = "UPDATE " + qi(table.getTableName()) + " SET " + qi("DayVoteStreak") + " = COALESCE(" + qi("DayVoteStreak") + ", 0) + 1, " + qi("DayVoteStreakLastUpdate") + " = ? WHERE " + qi("uuid") + uuidCast(); 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 daa38e980f..8f1fca7258 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 @@ -51,6 +51,7 @@ @Getter @Setter public class VoteShopPurchaseService { + public enum MysqlDailyStreakResult { APPLIED, ALREADY_UPDATED, DEFERRED, FAILED } private static final ConcurrentMap ADMITTED_ACCOUNTING = new ConcurrentHashMap<>(); private static final int PURCHASE_LOCK_STRIPES = 256; private static final Object[] PURCHASE_LOCKS = createPurchaseLocks(); @@ -685,21 +686,30 @@ public static void finishMysqlVoteAccounting(UUID voteId) { /** Atomically publishes an accepted daily streak under the shared boundary lock. */ public static boolean updateMysqlDailyStreak(VotingPluginMain plugin, UUID voteId, String uuid, int streak, long updatedAt) { - if (!canRecoverSharedMysqlPurchases(plugin)) return false; + return updateMysqlDailyStreakResult(plugin, voteId, uuid, streak, updatedAt) != MysqlDailyStreakResult.FAILED; + } + + public static MysqlDailyStreakResult updateMysqlDailyStreakResult(VotingPluginMain plugin, UUID voteId, String uuid, + int streak, long updatedAt) { + if (!canRecoverSharedMysqlPurchases(plugin)) return MysqlDailyStreakResult.FAILED; try { MySQL table = plugin.getMysql(); table.checkColumn("DayVoteStreak", DataType.INTEGER); table.checkColumn("DayVoteStreakLastUpdate", DataType.STRING); - boolean applied = SharedMysqlPurchaseJournal.forTable(table).updateDailyStreak( + SharedMysqlPurchaseJournal.DailyStreakOutcome outcome = SharedMysqlPurchaseJournal.forTable(table).updateDailyStreak( voteId == null ? UUID.randomUUID() : voteId, uuid, streak, updatedAt); - if (!applied) plugin.getLogger().warning( + if (outcome == SharedMysqlPurchaseJournal.DailyStreakOutcome.DEFERRED) plugin.getLogger().warning( "Shared MySQL daily streak was retained for retry after a persistence failure"); - return true; + return switch (outcome) { + case APPLIED -> MysqlDailyStreakResult.APPLIED; + case ALREADY_UPDATED -> MysqlDailyStreakResult.ALREADY_UPDATED; + case DEFERRED -> MysqlDailyStreakResult.DEFERRED; + }; } catch (SQLException failure) { plugin.getLogger().severe("Unable to atomically update MySQL daily streak: " + failure.getClass().getSimpleName()); plugin.debug(failure); - return false; + return MysqlDailyStreakResult.FAILED; } finally { SharedMysqlCacheReconciler.invalidate(plugin, uuid, "DayVoteStreak", "DayVoteStreakLastUpdate"); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java index 795606462e..cb3a2c064b 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java @@ -60,7 +60,7 @@ void onlineVoteKeepsProductionPhaseOrderAndCounts() { InOrder order = inOrder(ops); order.verify(ops).lastVoteTime(user, site); - order.verify(ops).prepareAccounting(eq(user), any(UUID.class), eq(true)); + order.verify(ops).prepareAccounting(eq(user), any(UUID.class), eq(true)); order.verify(ops).cache(user); order.verify(ops).updateName(user); order.verify(ops).voteParty(eq(user), eq(true), eq(false), any(UUID.class)); @@ -81,7 +81,20 @@ void onlineVoteKeepsProductionPhaseOrderAndCounts() { order.verify(ops).postVote(eq(site), eq(user), eq("Ben"), eq(123L), any(UUID.class), eq(false)); order.verify(ops).updatePlaceholders(user); order.verify(ops).setUpdate(); - order.verify(ops).finishAccounting(any(UUID.class)); + order.verify(ops).finishAccounting(any(UUID.class)); + } + + @Test + void totalsUseTheEligibilityCapturedAtAdmission() { + var ops = accepted(); + when(ops.userOnline(user)).thenReturn(true, false); + + SharedVoteProcessor.process(ops); + + verify(ops).prepareAccounting(eq(user), any(UUID.class), eq(true)); + verify(ops).addTotal(eq(user), any(UUID.class)); + verify(ops).addTotalDaily(eq(user), any(UUID.class)); + verify(ops).addTotalWeekly(eq(user), any(UUID.class)); } @Test @@ -90,7 +103,7 @@ void proxyVoteUsesHistoricalOnlineForDeliveryButCurrentOnlineForTotals() { UUID proxyId = UUID.randomUUID(); when(ops.proxyVote()).thenReturn(true); when(ops.hasProxyTextTotals()).thenReturn(true); - when(ops.incomingVoteId()).thenReturn(proxyId); + when(ops.incomingVoteId()).thenReturn(proxyId); when(ops.wasOnline()).thenReturn(true); when(ops.incomingTime()).thenReturn(321L); when(ops.lastVoteTime(user, site)).thenReturn(321L); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java index 6c3994cf37..b0d157207b 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java @@ -29,6 +29,28 @@ import com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue; class VotifierVoteOverflowQueueTest { + @Test + void failedQueuedAttemptKeepsTheExistingEntry(@TempDir Path dataFolder) throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + ScheduledExecutorService voteTimer = Executors.newSingleThreadScheduledExecutor(); + CountDownLatch attempted = new CountDownLatch(1); + when(plugin.getDataFolder()).thenReturn(dataFolder.toFile()); + when(plugin.getVoteTimer()).thenReturn(voteTimer); + VotifierVoteOverflowQueue queue = new VotifierVoteOverflowQueue(plugin, (site, user, voteId) -> { + attempted.countDown(); + return false; + }); + try { + assertTrue(queue.enqueue("Steve", "example.org", java.util.UUID.randomUUID())); + queue.start(); + assertTrue(attempted.await(2, TimeUnit.SECONDS)); + assertEquals(1, queue.size()); + } finally { + queue.close(); + voteTimer.shutdownNow(); + } + } + @Test void enqueueCannotChangeVersionDuringDurableAdmission(@TempDir Path dataFolder) throws Exception { VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java index 9c3e184e46..6044a9ccbe 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java @@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -27,7 +28,9 @@ import com.bencodez.advancedcore.api.time.events.DateChangedEvent; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.data.ServerData; +import com.bencodez.votingplugin.events.PlayerVoteEvent; import com.bencodez.votingplugin.timequeue.TimeQueueHandler; +import com.bencodez.votingplugin.timequeue.VoteTimeQueue; class TimeQueueHandlerRejectionTest { private VotingPluginMain plugin; @@ -90,6 +93,25 @@ void rejectedProcessingSchedulesOneBoundedRetry() { assertEquals(1, handler.getTimeChangeQueue().size()); } + @Test + void accountingAdmissionFailureRetainsTheSameQueuedVote() { + when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of()); + TimeQueueHandler handler = new TimeQueueHandler(plugin); + VoteTimeQueue vote = new VoteTimeQueue(java.util.UUID.randomUUID(), "Alex", "example.org", 123L); + handler.getTimeChangeQueue().add(vote); + org.bukkit.plugin.PluginManager pluginManager = plugin.getServer().getPluginManager(); + doAnswer(invocation -> { + PlayerVoteEvent event = invocation.getArgument(0); + event.setAccountingAdmissionFailed(true); + return null; + }).when(pluginManager).callEvent(any(PlayerVoteEvent.class)); + + handler.processQueue(); + + assertEquals(1, handler.getTimeChangeQueue().size()); + assertEquals(vote, handler.getTimeChangeQueue().peek()); + } + @Test void disabledPluginDoesNotRegisterABukkitRetry() { when(plugin.isEnabled()).thenReturn(false); 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 index 8b05fe7a88..4b1c61a57d 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -21,6 +21,16 @@ import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; class SharedMysqlPurchaseJournalTest { + @Test + void dailyStreakDateFenceRecognizesTheSameLocalDay() { + java.time.ZoneId zone = java.time.ZoneId.systemDefault(); + long morning = java.time.LocalDate.of(2026, 9, 22).atTime(1, 0).atZone(zone).toInstant().toEpochMilli(); + long evening = java.time.LocalDate.of(2026, 9, 22).atTime(23, 0).atZone(zone).toInstant().toEpochMilli(); + long nextDay = java.time.LocalDate.of(2026, 9, 23).atStartOfDay(zone).toInstant().toEpochMilli(); + + assertTrue(SharedMysqlPurchaseJournal.sameLocalDay(Long.toString(morning), evening)); + assertFalse(SharedMysqlPurchaseJournal.sameLocalDay(Long.toString(morning), nextDay)); + } @Test void journalTableNameIsPortableAndCollisionResistantForLongSourceNames() { String source = "u".repeat(80); @@ -482,6 +492,7 @@ void dailyStreakUpdateLocksTheSameBoundaryRow() throws Exception { PreparedStatement resetMarkerInsert = mock(PreparedStatement.class); PreparedStatement resetMarkerSelect = mock(PreparedStatement.class); PreparedStatement accountingSelect = mock(PreparedStatement.class); + PreparedStatement persistedUpdateSelect = mock(PreparedStatement.class); PreparedStatement update = mock(PreparedStatement.class); PreparedStatement accountingUpdate = mock(PreparedStatement.class); ResultSet requestEpoch = mock(ResultSet.class); @@ -489,6 +500,7 @@ void dailyStreakUpdateLocksTheSameBoundaryRow() throws Exception { ResultSet resetEpoch = mock(ResultSet.class); ResultSet requested = accountingRow("00000000-0000-0000-0000-000000000001", 0, 0); ResultSet accounting = accountingRow("00000000-0000-0000-0000-000000000001", 16, 0); + ResultSet persistedUpdate = mock(ResultSet.class); when(requestEpoch.next()).thenReturn(true); when(copyEpoch.next()).thenReturn(true); when(resetEpoch.next()).thenReturn(true); @@ -497,12 +509,15 @@ void dailyStreakUpdateLocksTheSameBoundaryRow() throws Exception { when(resetMarkerSelect.executeQuery()).thenReturn(resetEpoch); when(requestSelect.executeQuery()).thenReturn(requested); when(accountingSelect.executeQuery()).thenReturn(accounting); + when(persistedUpdate.next()).thenReturn(true); + when(persistedUpdate.getString(1)).thenReturn(""); + when(persistedUpdateSelect.executeQuery()).thenReturn(persistedUpdate); when(update.executeUpdate()).thenReturn(1); when(accountingUpdate.executeUpdate()).thenReturn(1); when(requestUpdate.executeUpdate()).thenReturn(1); when(fixture.work.prepareStatement(anyString())).thenReturn(requestMarkerInsert, requestMarkerSelect, requestInsert, requestSelect, requestUpdate, copyMarkerInsert, copyMarkerSelect, - resetMarkerInsert, resetMarkerSelect, accountingSelect, update, accountingUpdate); + resetMarkerInsert, resetMarkerSelect, accountingSelect, persistedUpdateSelect, update, accountingUpdate); new SharedMysqlPurchaseJournal(fixture.table, false).updateDailyStreak(java.util.UUID.randomUUID(), "00000000-0000-0000-0000-000000000001", 7, 1234L); From 4c35e11177f4b42bcf8f3f2a3e74ad75b9f0cd19 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:43:30 -0600 Subject: [PATCH 17/61] Admit daily streak work before vote effects --- .../listeners/PlayerVoteListener.java | 2 +- .../votingplugin/user/VotingPluginUser.java | 42 ++- .../service/SharedMysqlPurchaseJournal.java | 270 ++++++++++++++---- .../service/VoteShopPurchaseService.java | 86 ++++-- .../SharedMysqlPurchaseJournalTest.java | 114 +++++++- .../service/VoteShopPurchaseServiceTest.java | 41 +++ 6 files changed, 452 insertions(+), 103 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java index 005ac3b3f5..eef00945f1 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java @@ -142,7 +142,7 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { && (plugin.getSpecialRewardsConfig().isVotePartyCountFakeVotes() || event.isRealVote()) && (plugin.getSpecialRewardsConfig().isVotePartyCountOfflineVotes() || user.isOnline()); if (!VoteShopPurchaseService.prepareMysqlVoteAccounting(plugin, voteId, user.getUUID(), countTotals, - countVoteParty)) { + countVoteParty, event.isForceBungee())) { throw new SharedVoteAdmissionException("Unable to admit shared MySQL vote accounting before processing"); } } 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 faa3062cf9..560d3386e7 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -1149,6 +1149,17 @@ public void checkDayVoteStreak(boolean forceBungee) { public void checkDayVoteStreak(boolean forceBungee, UUID voteId) { PeriodTotalMutationFence.withMutation(() -> { + if (UserStorage.MYSQL.equals(plugin.getStorageType())) { + VoteShopPurchaseService.MysqlDailyStreakUpdate update = VoteShopPurchaseService + .applyPreparedMysqlDailyStreak(plugin, voteId, getUUID()); + if (update.result() == VoteShopPurchaseService.MysqlDailyStreakResult.FAILED) { + throw new IllegalStateException("Unable to retain shared MySQL daily streak"); + } + if (update.result() == VoteShopPurchaseService.MysqlDailyStreakResult.APPLIED) { + completeRecoveredDailyStreak(update.streak(), update.forceProxyRouting()); + } + return; + } if (!voteStreakUpdatedToday(LocalDateTime.now())) { if (!plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage() || hasPercentageTotal( TopVoter.Daily, plugin.getSpecialRewardsConfig().getVoteStreakRequirementDay(), null)) { @@ -1158,31 +1169,18 @@ public void checkDayVoteStreak(boolean forceBungee, UUID voteId) { plugin.getSpecialRewardsConfig().getVoteStreakRequirementDay(), null)); int streak = getDayVoteStreak() + 1; long updatedAt = System.currentTimeMillis(); - boolean sharedStreakPersisted = false; - boolean sharedStreakAlreadyUpdated = false; - if (UserStorage.MYSQL.equals(plugin.getStorageType())) { - if (getCache() != null) getCache().clearChanges(); - VoteShopPurchaseService.MysqlDailyStreakResult result = VoteShopPurchaseService - .updateMysqlDailyStreakResult(plugin, voteId, getUUID(), streak, updatedAt); - sharedStreakPersisted = result != VoteShopPurchaseService.MysqlDailyStreakResult.FAILED; - sharedStreakAlreadyUpdated = result == VoteShopPurchaseService.MysqlDailyStreakResult.ALREADY_UPDATED; - if (!sharedStreakPersisted) { - throw new IllegalStateException("Unable to retain shared MySQL daily streak"); - } - if (!sharedStreakAlreadyUpdated && getBestDayVoteStreak() < streak) { - setBestDayVoteStreak(streak); - } - } else { - setDayVoteStreak(streak); - } - if (!sharedStreakAlreadyUpdated) { - plugin.getSpecialRewards().checkVoteStreak(null, this, "Day", forceBungee); - } - if (!sharedStreakPersisted) setDayVoteStreakLastUpdate(updatedAt); + setDayVoteStreak(streak); + plugin.getSpecialRewards().checkVoteStreak(null, this, "Day", forceBungee); + setDayVoteStreakLastUpdate(updatedAt); } } }); - } + } + + public void completeRecoveredDailyStreak(int streak, boolean forceBungee) { + if (getBestDayVoteStreak() < streak) setBestDayVoteStreak(streak); + plugin.getSpecialRewards().checkVoteStreak(null, this, "Day", forceBungee); + } /** * Clears the offline votes. 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 index c4ec067369..e02390ba0a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -33,6 +33,9 @@ */ final class SharedMysqlPurchaseJournal { enum DailyStreakOutcome { APPLIED, ALREADY_UPDATED, DEFERRED } + record DailyStreakResult(DailyStreakOutcome outcome, int streak) { } + record RecoveredDailyStreak(UUID voteId, String uuid, int streak, boolean forceProxyRouting) { } + record AccountingRecoveryBatch(boolean hadRows, List pendingRewards) { } private static final String PENDING = "PENDING"; private static final String HOOK_STARTED = "HOOK_STARTED"; private static final String COMPENSATING = "COMPENSATING"; @@ -58,6 +61,8 @@ enum DailyStreakOutcome { APPLIED, ALREADY_UPDATED, DEFERRED } private static final int MONTH_TOTAL = 4; private static final int VOTE_PARTY_TOTAL = 8; private static final int DAILY_STREAK = 16; + private static final int DAILY_STREAK_REWARD = 32; + private static final int ACCOUNTING_DECIDED = 64; private static final ReferenceQueue INITIALIZED_QUEUE = new ReferenceQueue<>(); private static final Set INITIALIZED = new HashSet<>(); @@ -328,10 +333,10 @@ boolean incrementPeriodTotals(UUID voteId, String uuid, String boundaryColumn, S } int prepareVoteAccounting(UUID voteId, String uuid, boolean countTotals, boolean countVoteParty, - String monthColumn, Integer monthMaximum) throws SQLException { + String monthColumn, Integer monthMaximum, boolean streakUsesPercentage, double streakPercentage, + int enabledSiteCount, boolean forceProxyRouting, long acceptedAt) throws SQLException { int requested = (countTotals ? DAILY_TOTAL | WEEKLY_TOTAL | MONTH_TOTAL : 0) | (countVoteParty ? VOTE_PARTY_TOTAL : 0); - if (requested == 0) return 0; try (Connection connection = connection()) { connection.setAutoCommit(false); try { @@ -339,26 +344,52 @@ int prepareVoteAccounting(UUID voteId, String uuid, boolean countTotals, boolean if ((requested & MONTH_TOTAL) != 0) lockLimitEpochRow(connection, "period-copy:MonthTotal"); if ((requested & VOTE_PARTY_TOTAL) != 0) lockLimitEpochRow(connection, "period-copy:VotePartyVotes"); if ((requested & WEEKLY_TOTAL) != 0) lockLimitEpochRow(connection, "period-copy:WeeklyTotal"); + lockLimitEpochRow(connection, "streak-copy:DayVoteStreak"); + lockLimitEpochRow(connection, "streak-reset:DayVoteStreak"); ensureAccountingVote(connection, voteId, uuid); AccountingRow row = findAndLockAccountingVote(connection, voteId); if (row == null || row.uuid() != null && !row.uuid().equals(uuid)) { throw new SQLException("Vote accounting identity does not match"); } + boolean accountingAlreadyDecided = (row.requested() & ACCOUNTING_DECIDED) != 0; + DailyStreakCandidate streak = accountingAlreadyDecided ? null : findDailyStreakCandidate(connection, uuid); + boolean dailyAlreadyApplied = (row.completed() & DAILY_TOTAL) != 0; + int projectedDailyTotal = accountingAlreadyDecided ? 0 + : streak.dailyTotal() + (countTotals && !dailyAlreadyApplied ? 1 : 0); + boolean percentageMet = accountingAlreadyDecided || !streakUsesPercentage || enabledSiteCount > 0 + && (double) projectedDailyTotal / (double) enabledSiteCount * 100 > streakPercentage; + boolean requestStreak = !accountingAlreadyDecided + && !sameLocalDay(streak.lastUpdate(), acceptedAt) && percentageMet; + if (accountingAlreadyDecided) requested = row.requested(); + else if (requestStreak) requested |= DAILY_STREAK | DAILY_STREAK_REWARD; + int persistedRequested = row.requested() | requested | ACCOUNTING_DECIDED; + int persistedCompleted = row.completed() | ACCOUNTING_DECIDED; String update = "UPDATE " + qiAccounting() + " SET " + qi("player_uuid") + " = ?, " - + qi("requested") + " = ?, " + qi("month_column") + " = COALESCE(?, " + + qi("requested") + " = ?, " + qi("completed") + " = ?, " + + qi("month_column") + " = COALESCE(?, " + qi("month_column") + "), " + qi("month_maximum") + " = COALESCE(?, " - + qi("month_maximum") + ") WHERE " + qi("vote_id") + " = ?"; + + qi("month_maximum") + "), " + qi("streak_value") + " = COALESCE(?, " + + qi("streak_value") + "), " + qi("streak_updated_at") + " = COALESCE(?, " + + qi("streak_updated_at") + "), " + qi("streak_force_proxy") + " = COALESCE(?, " + + qi("streak_force_proxy") + ") WHERE " + qi("vote_id") + " = ?"; try (PreparedStatement statement = connection.prepareStatement(update)) { statement.setString(1, uuid); - statement.setInt(2, row.requested() | requested); - statement.setString(3, monthColumn); - if (monthMaximum == null) statement.setNull(4, java.sql.Types.INTEGER); - else statement.setInt(4, monthMaximum.intValue()); - statement.setString(5, voteId.toString()); + statement.setInt(2, persistedRequested); + statement.setInt(3, persistedCompleted); + statement.setString(4, monthColumn); + if (monthMaximum == null) statement.setNull(5, java.sql.Types.INTEGER); + else statement.setInt(5, monthMaximum.intValue()); + if (requestStreak) statement.setInt(6, streak.streak() + 1); + else statement.setNull(6, java.sql.Types.INTEGER); + if (requestStreak) statement.setLong(7, acceptedAt); + else statement.setNull(7, java.sql.Types.BIGINT); + if (requestStreak) statement.setInt(8, forceProxyRouting ? 1 : 0); + else statement.setNull(8, java.sql.Types.INTEGER); + statement.setString(9, voteId.toString()); if (statement.executeUpdate() != 1) throw new SQLException("Vote accounting marker is missing"); } - commitAndConfirmRequested(connection, voteId, requested); - return requested; + commitAndConfirmRequested(connection, voteId, persistedRequested); + return persistedRequested & ~ACCOUNTING_DECIDED; } catch (SQLException failure) { rollback(connection); throw failure; @@ -366,6 +397,19 @@ int prepareVoteAccounting(UUID voteId, String uuid, boolean countTotals, boolean } } + private DailyStreakCandidate findDailyStreakCandidate(Connection connection, String uuid) throws SQLException { + String select = "SELECT COALESCE(" + qi("DailyTotal") + ", 0), COALESCE(" + qi("DayVoteStreak") + + ", 0), " + qi("DayVoteStreakLastUpdate") + " FROM " + qi(table.getTableName()) + " WHERE " + + qi("uuid") + uuidCast() + " FOR UPDATE"; + try (PreparedStatement statement = connection.prepareStatement(select)) { + statement.setString(1, uuid); + try (ResultSet result = statement.executeQuery()) { + if (!result.next()) return new DailyStreakCandidate(0, 0, null); + return new DailyStreakCandidate(result.getInt(1), result.getInt(2), result.getString(3)); + } + } + } + private void applyPeriodTotals(UUID voteId, String uuid, String boundaryColumn, String previousColumn, List columns, Integer maximum, int operation) throws SQLException { try (Connection connection = connection()) { @@ -488,13 +532,21 @@ private AccountingRow findAndLockAccountingVote(Connection connection, UUID vote } private void markAccountingComplete(Connection connection, UUID voteId, int completed) throws SQLException { + markAccountingComplete(connection, voteId, completed, null); + } + + private void markAccountingComplete(Connection connection, UUID voteId, int completed, Integer appliedStreak) + throws SQLException { String update = "UPDATE " + qiAccounting() + " SET " + qi("completed") + " = ?, " + + qi("streak_applied_value") + " = COALESCE(?, " + qi("streak_applied_value") + "), " + qi("created_at") + " = ? WHERE " + qi("vote_id") + " = ?"; try (PreparedStatement statement = connection.prepareStatement(update)) { statement.setInt(1, completed); - statement.setLong(2, System.currentTimeMillis()); - statement.setString(3, voteId.toString()); + if (appliedStreak == null) statement.setNull(2, java.sql.Types.INTEGER); + else statement.setInt(2, appliedStreak.intValue()); + statement.setLong(3, System.currentTimeMillis()); + statement.setString(4, voteId.toString()); if (statement.executeUpdate() != 1) throw new SQLException("Vote accounting marker is missing"); } } @@ -506,7 +558,7 @@ private void commitAndConfirmAccounting(Connection connection, UUID voteId, int closeQuietly(connection); try { AccountingRow row = findAccountingVote(voteId); - if (row != null && (row.completed() & operation) != 0) return; + if (row != null && (row.completed() & operation) == operation) return; } catch (SQLException confirmationFailure) { ambiguousCommit.addSuppressed(confirmationFailure); } @@ -525,15 +577,43 @@ private AccountingRow findAccountingVote(UUID voteId) throws SQLException { } } + RecoveredDailyStreak claimDailyStreakReward(UUID voteId) throws SQLException { + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try { + AccountingRow row = findAndLockAccountingVote(connection, voteId); + if (row == null || (row.requested() & DAILY_STREAK_REWARD) == 0 + || (row.completed() & DAILY_STREAK) == 0 + || (row.completed() & DAILY_STREAK_REWARD) != 0) { + rollback(connection); + return null; + } + if (row.streakAppliedValue() == null) { + throw new SQLException("Applied daily streak value is missing"); + } + int streak = row.streakAppliedValue().intValue(); + markAccountingComplete(connection, voteId, row.completed() | DAILY_STREAK_REWARD); + commitAndConfirmAccounting(connection, voteId, DAILY_STREAK_REWARD); + return new RecoveredDailyStreak(voteId, row.uuid(), streak, + row.streakForceProxy() != null && row.streakForceProxy().intValue() != 0); + } catch (SQLException failure) { + rollback(connection); + throw failure; + } + } + } + private String accountingColumns() { return qi("player_uuid") + ", " + qi("requested") + ", " + qi("completed") + ", " + qi("month_column") + ", " + qi("month_maximum") + ", " + qi("streak_value") + ", " - + qi("streak_updated_at"); + + qi("streak_updated_at") + ", " + qi("streak_force_proxy") + ", " + + qi("streak_applied_value"); } private static AccountingRow accountingRow(ResultSet result) throws SQLException { return new AccountingRow(result.getString(1), result.getInt(2), result.getInt(3), result.getString(4), - nullableInteger(result, 5), nullableInteger(result, 6), nullableLong(result, 7)); + nullableInteger(result, 5), nullableInteger(result, 6), nullableLong(result, 7), + nullableInteger(result, 8), nullableInteger(result, 9)); } private boolean resetPending(Connection connection, String boundaryColumn, EpochRow copyMarker) @@ -549,17 +629,30 @@ private static String generationTransition(String generation, String prefix) { } /** Serializes the accepted daily-streak value and timestamp with its shared boundary. */ - DailyStreakOutcome updateDailyStreak(UUID voteId, String uuid, int streak, long updatedAt) throws SQLException { - requestAccounting(voteId, uuid, DAILY_STREAK, "streak-copy:DayVoteStreak", null, null, - Integer.valueOf(streak), Long.valueOf(updatedAt)); + DailyStreakResult updateDailyStreak(UUID voteId, String uuid, int streak, long updatedAt, + boolean alreadyRequested) throws SQLException { + if (alreadyRequested) { + AccountingRow row = findAccountingVote(voteId); + if (row == null || (row.requested() & DAILY_STREAK) == 0) { + return new DailyStreakResult(DailyStreakOutcome.ALREADY_UPDATED, 0); + } + if (row.streakValue() == null || row.streakUpdatedAt() == null) { + throw new SQLException("Admitted daily streak payload is incomplete"); + } + streak = row.streakValue().intValue(); + updatedAt = row.streakUpdatedAt().longValue(); + } else { + requestAccounting(voteId, uuid, DAILY_STREAK | DAILY_STREAK_REWARD, + "streak-copy:DayVoteStreak", null, null, Integer.valueOf(streak), Long.valueOf(updatedAt)); + } try { return applyDailyStreak(voteId, uuid, streak, updatedAt); } catch (SQLException deferred) { - return DailyStreakOutcome.DEFERRED; + return new DailyStreakResult(DailyStreakOutcome.DEFERRED, 0); } } - private DailyStreakOutcome applyDailyStreak(UUID voteId, String uuid, int streak, long updatedAt) throws SQLException { + private DailyStreakResult applyDailyStreak(UUID voteId, String uuid, int streak, long updatedAt) throws SQLException { try (Connection connection = connection()) { connection.setAutoCommit(false); try { @@ -574,14 +667,26 @@ private DailyStreakOutcome applyDailyStreak(UUID voteId, String uuid, int streak if (accounting == null) throw new SQLException("Vote accounting marker is missing"); int completed = accounting.completed(); if ((completed & DAILY_STREAK) != 0) { + Integer appliedStreak = accounting.streakAppliedValue(); rollback(connection); - return DailyStreakOutcome.ALREADY_UPDATED; + DailyStreakOutcome outcome = (completed & DAILY_STREAK_REWARD) == 0 + ? DailyStreakOutcome.APPLIED : DailyStreakOutcome.ALREADY_UPDATED; + if (outcome == DailyStreakOutcome.APPLIED && appliedStreak == null) { + throw new SQLException("Applied daily streak value is missing"); + } + return new DailyStreakResult(outcome, appliedStreak == null ? 0 : appliedStreak.intValue()); + } + if (hasEarlierPendingDailyStreak(connection, voteId, uuid, updatedAt)) { + throw new SQLException("An earlier daily streak is still pending"); } String persistedUpdate = findDailyStreakUpdate(connection, uuid); if (sameLocalDay(persistedUpdate, updatedAt)) { - markAccountingComplete(connection, voteId, completed | DAILY_STREAK); - commitAndConfirmAccounting(connection, voteId, DAILY_STREAK); - return DailyStreakOutcome.ALREADY_UPDATED; + int persistedStreak = findDailyStreakValue(connection, uuid); + markAccountingComplete(connection, voteId, + completed | DAILY_STREAK | (accounting.requested() & DAILY_STREAK_REWARD)); + commitAndConfirmAccounting(connection, voteId, + DAILY_STREAK | (accounting.requested() & DAILY_STREAK_REWARD)); + return new DailyStreakResult(DailyStreakOutcome.ALREADY_UPDATED, persistedStreak); } String sql = "UPDATE " + qi(table.getTableName()) + " SET " + qi("DayVoteStreak") + " = COALESCE(" + qi("DayVoteStreak") + ", 0) + 1, " + qi("DayVoteStreakLastUpdate") + " = ? WHERE " @@ -591,9 +696,11 @@ private DailyStreakOutcome applyDailyStreak(UUID voteId, String uuid, int streak update.setString(2, uuid); if (update.executeUpdate() != 1) throw new SQLException("Daily streak user row is missing"); } - markAccountingComplete(connection, voteId, completed | DAILY_STREAK); + int persistedStreak = findDailyStreakValue(connection, uuid); + markAccountingComplete(connection, voteId, completed | DAILY_STREAK, + Integer.valueOf(persistedStreak)); commitAndConfirmAccounting(connection, voteId, DAILY_STREAK); - return DailyStreakOutcome.APPLIED; + return new DailyStreakResult(DailyStreakOutcome.APPLIED, persistedStreak); } catch (SQLException failure) { rollback(connection); throw failure; @@ -601,6 +708,37 @@ private DailyStreakOutcome applyDailyStreak(UUID voteId, String uuid, int streak } } + private boolean hasEarlierPendingDailyStreak(Connection connection, UUID voteId, String uuid, long updatedAt) + throws SQLException { + int streakOperations = DAILY_STREAK | DAILY_STREAK_REWARD; + String select = "SELECT " + qi("vote_id") + " FROM " + qiAccounting() + " WHERE " + + qi("player_uuid") + " = ? AND " + qi("vote_id") + " <> ? AND " + qi("streak_updated_at") + + " < ? AND (" + qi("requested") + " & ?) <> (" + qi("completed") + + " & ?) LIMIT 1 FOR UPDATE"; + try (PreparedStatement statement = connection.prepareStatement(select)) { + statement.setString(1, uuid); + statement.setString(2, voteId.toString()); + statement.setLong(3, updatedAt); + statement.setInt(4, streakOperations); + statement.setInt(5, streakOperations); + try (ResultSet result = statement.executeQuery()) { + return result.next(); + } + } + } + + private int findDailyStreakValue(Connection connection, String uuid) throws SQLException { + String select = "SELECT COALESCE(" + qi("DayVoteStreak") + ", 0) FROM " + qi(table.getTableName()) + + " WHERE " + qi("uuid") + uuidCast(); + try (PreparedStatement statement = connection.prepareStatement(select)) { + statement.setString(1, uuid); + try (ResultSet result = statement.executeQuery()) { + if (!result.next()) throw new SQLException("Daily streak user row is missing"); + return result.getInt(1); + } + } + } + private String findDailyStreakUpdate(Connection connection, String uuid) throws SQLException { String select = "SELECT " + qi("DayVoteStreakLastUpdate") + " FROM " + qi(table.getTableName()) + " WHERE " + qi("uuid") + uuidCast() + " FOR UPDATE"; @@ -734,9 +872,11 @@ private void copyBoundary(String markerKey, String generation, int accountingOpe } private void drainPendingAccounting(Connection connection, int operation) throws SQLException { + String order = operation == DAILY_STREAK + ? " ORDER BY " + qi("streak_updated_at") + " ASC, " + qi("created_at") + " ASC" : ""; String select = "SELECT " + qi("vote_id") + ", " + accountingColumns() + " FROM " + qiAccounting() + " WHERE (" + qi("requested") + " & ?) <> 0 AND (" + qi("completed") - + " & ?) = 0 FOR UPDATE"; + + " & ?) = 0" + order + " FOR UPDATE"; try (PreparedStatement statement = connection.prepareStatement(select)) { statement.setInt(1, operation); statement.setInt(2, operation); @@ -744,7 +884,8 @@ private void drainPendingAccounting(Connection connection, int operation) throws while (result.next()) { UUID voteId = UUID.fromString(result.getString(1)); AccountingRow row = new AccountingRow(result.getString(2), result.getInt(3), result.getInt(4), - result.getString(5), nullableInteger(result, 6), nullableInteger(result, 7), nullableLong(result, 8)); + result.getString(5), nullableInteger(result, 6), nullableInteger(result, 7), + nullableLong(result, 8), nullableInteger(result, 9), nullableInteger(result, 10)); applyPendingAccounting(connection, voteId, row, operation); } } @@ -756,7 +897,8 @@ private void applyPendingAccounting(Connection connection, UUID voteId, Accounti if (operation == DAILY_STREAK) { if (row.streakUpdatedAt() == null) throw new SQLException("Pending daily streak accounting payload is incomplete"); if (sameLocalDay(findDailyStreakUpdate(connection, row.uuid()), row.streakUpdatedAt().longValue())) { - markAccountingComplete(connection, voteId, row.completed() | operation); + markAccountingComplete(connection, voteId, + row.completed() | DAILY_STREAK | (row.requested() & DAILY_STREAK_REWARD)); return; } String sql = "UPDATE " + qi(table.getTableName()) + " SET " + qi("DayVoteStreak") + " = COALESCE(" @@ -767,7 +909,9 @@ private void applyPendingAccounting(Connection connection, UUID voteId, Accounti update.setString(2, row.uuid()); if (update.executeUpdate() != 1) throw new SQLException("Daily streak user row is missing"); } - markAccountingComplete(connection, voteId, row.completed() | operation); + int persistedStreak = findDailyStreakValue(connection, row.uuid()); + markAccountingComplete(connection, voteId, row.completed() | operation, + Integer.valueOf(persistedStreak)); return; } String boundary = operation == DAILY_TOTAL ? "DailyTotal" @@ -1075,35 +1219,38 @@ List recoverAndCleanup(long now) throws SQLException { return List.copyOf(refunded); } - void recoverAccounting(long now) throws SQLException { - while (true) { - List pending = findPendingAccounting(RECOVERY_BATCH_SIZE); - if (pending.isEmpty()) break; - for (AccountingRecovery recovery : pending) { - AccountingRow row = recovery.row(); - int missing = row.requested() & ~row.completed(); - if ((missing & DAILY_TOTAL) != 0) applyPeriodTotals(recovery.voteId(), row.uuid(), "DailyTotal", + AccountingRecoveryBatch recoverAccounting(long now) throws SQLException { + List pendingRewards = new ArrayList<>(); + List pending = findPendingAccounting(RECOVERY_BATCH_SIZE); + for (AccountingRecovery recovery : pending) { + AccountingRow row = recovery.row(); + int missing = row.requested() & ~row.completed(); + if ((missing & DAILY_TOTAL) != 0) applyPeriodTotals(recovery.voteId(), row.uuid(), "DailyTotal", "LastDailyTotal", List.of("DailyTotal"), null, DAILY_TOTAL); - if ((missing & WEEKLY_TOTAL) != 0) applyPeriodTotals(recovery.voteId(), row.uuid(), "WeeklyTotal", + if ((missing & WEEKLY_TOTAL) != 0) applyPeriodTotals(recovery.voteId(), row.uuid(), "WeeklyTotal", "LastWeeklyTotal", List.of("WeeklyTotal"), null, WEEKLY_TOTAL); - if ((missing & MONTH_TOTAL) != 0) { - List columns = row.monthColumn() == null ? List.of("MonthTotal") + if ((missing & MONTH_TOTAL) != 0) { + List columns = row.monthColumn() == null ? List.of("MonthTotal") : List.of("MonthTotal", row.monthColumn()); - applyPeriodTotals(recovery.voteId(), row.uuid(), "MonthTotal", "LastMonthTotal", columns, + applyPeriodTotals(recovery.voteId(), row.uuid(), "MonthTotal", "LastMonthTotal", columns, row.monthMaximum(), MONTH_TOTAL); - } - if ((missing & VOTE_PARTY_TOTAL) != 0) applyPeriodTotals(recovery.voteId(), row.uuid(), + } + if ((missing & VOTE_PARTY_TOTAL) != 0) applyPeriodTotals(recovery.voteId(), row.uuid(), "VotePartyVotes", "LastVotePartyVotes", List.of("VotePartyVotes"), null, VOTE_PARTY_TOTAL); - if ((missing & DAILY_STREAK) != 0) { - if (row.streakValue() == null || row.streakUpdatedAt() == null) { + if ((missing & DAILY_STREAK) != 0) { + if (row.streakValue() == null || row.streakUpdatedAt() == null) { throw new SQLException("Pending daily streak accounting payload is incomplete"); - } - applyDailyStreak(recovery.voteId(), row.uuid(), row.streakValue().intValue(), - row.streakUpdatedAt().longValue()); } + applyDailyStreak(recovery.voteId(), row.uuid(), row.streakValue().intValue(), + row.streakUpdatedAt().longValue()); + } + if ((row.requested() & DAILY_STREAK_REWARD) != 0) { + pendingRewards.add(recovery.voteId()); + break; } } cleanupAccounting(now - ACCOUNTING_RETENTION_MILLIS); + return new AccountingRecoveryBatch(!pending.isEmpty(), List.copyOf(pendingRewards)); } private List findPendingAccounting(int limit) throws SQLException { @@ -1116,7 +1263,8 @@ private List findPendingAccounting(int limit) throws SQLExce try (ResultSet result = statement.executeQuery()) { while (result.next()) pending.add(new AccountingRecovery(UUID.fromString(result.getString(1)), new AccountingRow(result.getString(2), result.getInt(3), result.getInt(4), result.getString(5), - nullableInteger(result, 6), nullableInteger(result, 7), nullableLong(result, 8)))); + nullableInteger(result, 6), nullableInteger(result, 7), nullableLong(result, 8), + nullableInteger(result, 9), nullableInteger(result, 10)))); } } return pending; @@ -1202,7 +1350,9 @@ private void ensureAccountingSchema(Connection connection) throws SQLException { + " VARCHAR(36) NOT NULL, " + qi("player_uuid") + " VARCHAR(37) NULL, " + qi("requested") + " INT NOT NULL DEFAULT 0, " + qi("completed") + " INT NOT NULL, " + qi("month_column") + " VARCHAR(128) NULL, " + qi("month_maximum") + " INT NULL, " - + qi("streak_value") + " INT NULL, " + qi("streak_updated_at") + " BIGINT NULL, " + qi("created_at") + + qi("streak_value") + " INT NULL, " + qi("streak_updated_at") + " BIGINT NULL, " + + qi("streak_force_proxy") + " INT NULL, " + qi("streak_applied_value") + " INT NULL, " + + qi("created_at") + " BIGINT NOT NULL, PRIMARY KEY (" + qi("vote_id") + "));"; try (PreparedStatement statement = connection.prepareStatement(create)) { statement.executeUpdate(); @@ -1213,11 +1363,22 @@ private void ensureAccountingSchema(Connection connection) throws SQLException { ensureAccountingColumn(connection, "month_maximum", "INT NULL"); ensureAccountingColumn(connection, "streak_value", "INT NULL"); ensureAccountingColumn(connection, "streak_updated_at", "BIGINT NULL"); + ensureAccountingColumn(connection, "streak_force_proxy", "INT NULL"); + ensureAccountingColumn(connection, "streak_applied_value", "INT NULL"); try (PreparedStatement migrate = connection.prepareStatement("UPDATE " + qiAccounting() + " SET " + qi("requested") + " = " + qi("completed") + " WHERE " + qi("requested") + " = 0 AND " + qi("completed") + " <> 0")) { migrate.executeUpdate(); } + String index = "vp_vsa_streak_" + hash(accountingTable).substring(0, 16); + String createIndex = "CREATE INDEX " + (table.getDbType() == DbType.POSTGRESQL ? "IF NOT EXISTS " : "") + + qi(index) + " ON " + qiAccounting() + " (" + qi("player_uuid") + ", " + + qi("streak_updated_at") + ");"; + try (PreparedStatement statement = connection.prepareStatement(createIndex)) { + statement.executeUpdate(); + } catch (SQLException failure) { + if (failure.getErrorCode() != 1061 && !"42P07".equals(failure.getSQLState())) throw failure; + } } private void ensureAccountingColumn(Connection connection, String column, String definition) throws SQLException { @@ -1331,9 +1492,12 @@ private record EpochRow(long epoch, String lastResetGeneration) { } private record AccountingRow(String uuid, int requested, int completed, String monthColumn, - Integer monthMaximum, Integer streakValue, Long streakUpdatedAt) { + Integer monthMaximum, Integer streakValue, Long streakUpdatedAt, Integer streakForceProxy, + Integer streakAppliedValue) { } + private record DailyStreakCandidate(int dailyTotal, int streak, String lastUpdate) { } + private record AccountingRecovery(UUID voteId, AccountingRow row) { } 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 8f1fca7258..57ebe9287c 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 @@ -51,8 +51,10 @@ @Getter @Setter public class VoteShopPurchaseService { - public enum MysqlDailyStreakResult { APPLIED, ALREADY_UPDATED, DEFERRED, FAILED } + public enum MysqlDailyStreakResult { APPLIED, ALREADY_UPDATED, NOT_REQUESTED, DEFERRED, FAILED } + public record MysqlDailyStreakUpdate(MysqlDailyStreakResult result, int streak, boolean forceProxyRouting) { } private static final ConcurrentMap ADMITTED_ACCOUNTING = new ConcurrentHashMap<>(); + private static final int ACCOUNTING_DAILY_STREAK = 16; private static final int PURCHASE_LOCK_STRIPES = 256; private static final Object[] PURCHASE_LOCKS = createPurchaseLocks(); private static final int COMPLETION_PENDING = 0; @@ -649,9 +651,8 @@ public static boolean incrementMysqlPeriodTotals(VotingPluginMain plugin, UUID v /** Durably admits shared total mutations before vote rewards or broadcasts run. */ public static boolean prepareMysqlVoteAccounting(VotingPluginMain plugin, UUID voteId, String uuid, - boolean countTotals, boolean countVoteParty) { + boolean countTotals, boolean countVoteParty, boolean forceProxyRouting) { if (!canRecoverSharedMysqlPurchases(plugin) || voteId == null) return true; - if (!countTotals && !countVoteParty) return true; try { MySQL table = plugin.getMysql(); String monthColumn = plugin.getConfigFile().isStoreMonthTotalsWithDate() @@ -667,8 +668,15 @@ public static boolean prepareMysqlVoteAccounting(VotingPluginMain plugin, UUID v if (maximum != null) table.checkColumn("LastMonthTotal", DataType.INTEGER); } if (countVoteParty) table.checkColumn("VotePartyVotes", DataType.INTEGER); + table.checkColumn("DailyTotal", DataType.INTEGER); + table.checkColumn("DayVoteStreak", DataType.INTEGER); + table.checkColumn("DayVoteStreakLastUpdate", DataType.STRING); int bits = SharedMysqlPurchaseJournal.forTable(table).prepareVoteAccounting(voteId, uuid, countTotals, - countVoteParty, monthColumn, maximum); + countVoteParty, monthColumn, maximum, + plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage(), + plugin.getSpecialRewardsConfig().getVoteStreakRequirementDay(), + plugin.getVoteSiteManager().getVoteSitesEnabled().size(), forceProxyRouting, + System.currentTimeMillis()); ADMITTED_ACCOUNTING.put(voteId, Integer.valueOf(bits)); return true; } catch (SQLException failure) { @@ -683,33 +691,45 @@ public static void finishMysqlVoteAccounting(UUID voteId) { if (voteId != null) ADMITTED_ACCOUNTING.remove(voteId); } - /** Atomically publishes an accepted daily streak under the shared boundary lock. */ - public static boolean updateMysqlDailyStreak(VotingPluginMain plugin, UUID voteId, String uuid, int streak, - long updatedAt) { - return updateMysqlDailyStreakResult(plugin, voteId, uuid, streak, updatedAt) != MysqlDailyStreakResult.FAILED; - } - - public static MysqlDailyStreakResult updateMysqlDailyStreakResult(VotingPluginMain plugin, UUID voteId, String uuid, - int streak, long updatedAt) { - if (!canRecoverSharedMysqlPurchases(plugin)) return MysqlDailyStreakResult.FAILED; + /** Applies only daily-streak work that was durably admitted before vote effects. */ + public static MysqlDailyStreakUpdate applyPreparedMysqlDailyStreak(VotingPluginMain plugin, UUID voteId, + String uuid) { + if (!canRecoverSharedMysqlPurchases(plugin)) { + return new MysqlDailyStreakUpdate(MysqlDailyStreakResult.FAILED, 0, false); + } + Integer admittedBits = voteId == null ? null : ADMITTED_ACCOUNTING.get(voteId); + if (admittedBits == null) return new MysqlDailyStreakUpdate(MysqlDailyStreakResult.FAILED, 0, false); + if ((admittedBits.intValue() & ACCOUNTING_DAILY_STREAK) == 0) { + return new MysqlDailyStreakUpdate(MysqlDailyStreakResult.NOT_REQUESTED, 0, false); + } + boolean admitted = (admittedBits.intValue() & ACCOUNTING_DAILY_STREAK) != 0; try { MySQL table = plugin.getMysql(); table.checkColumn("DayVoteStreak", DataType.INTEGER); table.checkColumn("DayVoteStreakLastUpdate", DataType.STRING); - SharedMysqlPurchaseJournal.DailyStreakOutcome outcome = SharedMysqlPurchaseJournal.forTable(table).updateDailyStreak( - voteId == null ? UUID.randomUUID() : voteId, uuid, streak, updatedAt); - if (outcome == SharedMysqlPurchaseJournal.DailyStreakOutcome.DEFERRED) plugin.getLogger().warning( + UUID accountingId = voteId; + SharedMysqlPurchaseJournal journal = SharedMysqlPurchaseJournal.forTable(table); + SharedMysqlPurchaseJournal.DailyStreakResult update = journal.updateDailyStreak( + accountingId, uuid, 0, 0L, true); + if (update.outcome() == SharedMysqlPurchaseJournal.DailyStreakOutcome.DEFERRED) plugin.getLogger().warning( "Shared MySQL daily streak was retained for retry after a persistence failure"); - return switch (outcome) { - case APPLIED -> MysqlDailyStreakResult.APPLIED; - case ALREADY_UPDATED -> MysqlDailyStreakResult.ALREADY_UPDATED; - case DEFERRED -> MysqlDailyStreakResult.DEFERRED; - }; + if (update.outcome() == SharedMysqlPurchaseJournal.DailyStreakOutcome.APPLIED) { + SharedMysqlPurchaseJournal.RecoveredDailyStreak reward = journal.claimDailyStreakReward(accountingId); + if (reward != null) return new MysqlDailyStreakUpdate(MysqlDailyStreakResult.APPLIED, + reward.streak(), reward.forceProxyRouting()); + return new MysqlDailyStreakUpdate(MysqlDailyStreakResult.ALREADY_UPDATED, update.streak(), false); + } + return new MysqlDailyStreakUpdate( + update.outcome() == SharedMysqlPurchaseJournal.DailyStreakOutcome.ALREADY_UPDATED + ? MysqlDailyStreakResult.ALREADY_UPDATED : MysqlDailyStreakResult.DEFERRED, + update.streak(), false); } catch (SQLException failure) { - plugin.getLogger().severe("Unable to atomically update MySQL daily streak: " - + failure.getClass().getSimpleName()); + plugin.getLogger().log(admitted ? java.util.logging.Level.WARNING : java.util.logging.Level.SEVERE, + admitted ? "Shared MySQL daily streak remains retained for retry" + : "Unable to atomically update MySQL daily streak", failure); plugin.debug(failure); - return MysqlDailyStreakResult.FAILED; + return new MysqlDailyStreakUpdate( + admitted ? MysqlDailyStreakResult.DEFERRED : MysqlDailyStreakResult.FAILED, 0, false); } finally { SharedMysqlCacheReconciler.invalidate(plugin, uuid, "DayVoteStreak", "DayVoteStreakLastUpdate"); } @@ -813,7 +833,23 @@ public static void recoverSharedMysqlPurchases(VotingPluginMain plugin) { static void recoverSharedMysqlPurchases(VotingPluginMain plugin, SharedMysqlPurchaseJournal journal) throws SQLException { retryPendingCompensationMarkers(plugin, journal); - journal.recoverAccounting(System.currentTimeMillis()); + SharedMysqlPurchaseJournal.AccountingRecoveryBatch accounting; + do { + accounting = journal.recoverAccounting(System.currentTimeMillis()); + boolean deferredReward = false; + for (UUID voteId : accounting.pendingRewards()) { + SharedMysqlPurchaseJournal.RecoveredDailyStreak streak = journal.claimDailyStreakReward(voteId); + if (streak == null) { + deferredReward = true; + continue; + } + SharedMysqlCacheReconciler.invalidate(plugin, streak.uuid(), "DayVoteStreak", "DayVoteStreakLastUpdate"); + VotingPluginUser user = plugin.getVotingPluginUserManager() + .getVotingPluginUser(UUID.fromString(streak.uuid()), false); + user.completeRecoveredDailyStreak(streak.streak(), streak.forceProxyRouting()); + } + if (deferredReward) break; + } while (accounting.hadRows()); for (SharedMysqlPurchaseJournal.RefundedPurchase refund : journal.recoverAndCleanup(System.currentTimeMillis())) { SharedMysqlCacheReconciler.invalidateAndRefresh(plugin, refund.uuid(), refund.pointsColumn(), refund.limitColumn()); 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 index 4b1c61a57d..74f9faaa24 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -21,6 +21,106 @@ import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; class SharedMysqlPurchaseJournalTest { + @Test + void voteAdmissionDurablyIncludesEligibleDailyStreakAndReward() throws Exception { + Fixture fixture = fixture(); + PreparedStatement copyInsert = mock(PreparedStatement.class); + PreparedStatement copySelect = mock(PreparedStatement.class); + PreparedStatement resetInsert = mock(PreparedStatement.class); + PreparedStatement resetSelect = mock(PreparedStatement.class); + PreparedStatement accountingInsert = mock(PreparedStatement.class); + PreparedStatement accountingSelect = mock(PreparedStatement.class); + PreparedStatement candidateSelect = mock(PreparedStatement.class); + PreparedStatement accountingUpdate = mock(PreparedStatement.class); + ResultSet copyEpoch = mock(ResultSet.class); + ResultSet resetEpoch = mock(ResultSet.class); + ResultSet accounting = accountingRow("00000000-0000-0000-0000-000000000001", 0, 0); + ResultSet candidate = mock(ResultSet.class); + when(copyEpoch.next()).thenReturn(true); + when(resetEpoch.next()).thenReturn(true); + when(copySelect.executeQuery()).thenReturn(copyEpoch); + when(resetSelect.executeQuery()).thenReturn(resetEpoch); + when(accountingSelect.executeQuery()).thenReturn(accounting); + when(candidate.next()).thenReturn(true); + when(candidate.getInt(1)).thenReturn(0); + when(candidate.getInt(2)).thenReturn(4); + when(candidate.getString(3)).thenReturn(""); + when(candidateSelect.executeQuery()).thenReturn(candidate); + when(accountingUpdate.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(copyInsert, copySelect, resetInsert, resetSelect, + accountingInsert, accountingSelect, candidateSelect, accountingUpdate); + + int requested = new SharedMysqlPurchaseJournal(fixture.table, false).prepareVoteAccounting( + java.util.UUID.randomUUID(), "00000000-0000-0000-0000-000000000001", false, false, + null, null, false, 0.0, 1, true, 1234L); + + assertEquals(48, requested); + verify(accountingUpdate).setInt(2, 112); + verify(accountingUpdate).setInt(3, 64); + verify(accountingUpdate).setInt(6, 5); + verify(accountingUpdate).setLong(7, 1234L); + verify(accountingUpdate).setInt(8, 1); + verify(fixture.work).commit(); + } + + @Test + void firstVoteCanBeAdmittedBeforeTheUserRowIsCreated() throws Exception { + Fixture fixture = fixture(); + PreparedStatement copyInsert = mock(PreparedStatement.class); + PreparedStatement copySelect = mock(PreparedStatement.class); + PreparedStatement resetInsert = mock(PreparedStatement.class); + PreparedStatement resetSelect = mock(PreparedStatement.class); + PreparedStatement accountingInsert = mock(PreparedStatement.class); + PreparedStatement accountingSelect = mock(PreparedStatement.class); + PreparedStatement candidateSelect = mock(PreparedStatement.class); + PreparedStatement accountingUpdate = mock(PreparedStatement.class); + ResultSet copyEpoch = mock(ResultSet.class); + ResultSet resetEpoch = mock(ResultSet.class); + ResultSet accounting = accountingRow("00000000-0000-0000-0000-000000000001", 0, 0); + ResultSet missingCandidate = mock(ResultSet.class); + when(copyEpoch.next()).thenReturn(true); + when(resetEpoch.next()).thenReturn(true); + when(copySelect.executeQuery()).thenReturn(copyEpoch); + when(resetSelect.executeQuery()).thenReturn(resetEpoch); + when(accountingSelect.executeQuery()).thenReturn(accounting); + when(missingCandidate.next()).thenReturn(false); + when(candidateSelect.executeQuery()).thenReturn(missingCandidate); + when(accountingUpdate.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(copyInsert, copySelect, resetInsert, resetSelect, + accountingInsert, accountingSelect, candidateSelect, accountingUpdate); + + int requested = new SharedMysqlPurchaseJournal(fixture.table, false).prepareVoteAccounting( + java.util.UUID.randomUUID(), "00000000-0000-0000-0000-000000000001", false, false, + null, null, false, 0.0, 1, false, 1234L); + + assertEquals(48, requested); + verify(accountingUpdate).setInt(6, 1); + verify(fixture.work).commit(); + } + + @Test + void completedDailyStreakCanClaimItsDeferredRewardOnce() throws Exception { + Fixture fixture = fixture(); + PreparedStatement accountingSelect = mock(PreparedStatement.class); + PreparedStatement accountingUpdate = mock(PreparedStatement.class); + ResultSet accounting = accountingRow("00000000-0000-0000-0000-000000000001", 48, 16); + when(accounting.getObject(8)).thenReturn(Integer.valueOf(1)); + when(accounting.getObject(9)).thenReturn(Integer.valueOf(7)); + when(accountingSelect.executeQuery()).thenReturn(accounting); + when(accountingUpdate.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(accountingSelect, accountingUpdate); + java.util.UUID voteId = java.util.UUID.randomUUID(); + + SharedMysqlPurchaseJournal.RecoveredDailyStreak reward = + new SharedMysqlPurchaseJournal(fixture.table, false).claimDailyStreakReward(voteId); + + assertEquals(voteId, reward.voteId()); + assertEquals(7, reward.streak()); + assertTrue(reward.forceProxyRouting()); + verify(accountingUpdate).setInt(1, 48); + verify(fixture.work).commit(); + } + @Test void dailyStreakDateFenceRecognizesTheSameLocalDay() { java.time.ZoneId zone = java.time.ZoneId.systemDefault(); @@ -492,15 +592,19 @@ void dailyStreakUpdateLocksTheSameBoundaryRow() throws Exception { PreparedStatement resetMarkerInsert = mock(PreparedStatement.class); PreparedStatement resetMarkerSelect = mock(PreparedStatement.class); PreparedStatement accountingSelect = mock(PreparedStatement.class); + PreparedStatement earlierStreakSelect = mock(PreparedStatement.class); PreparedStatement persistedUpdateSelect = mock(PreparedStatement.class); PreparedStatement update = mock(PreparedStatement.class); PreparedStatement accountingUpdate = mock(PreparedStatement.class); + PreparedStatement streakValueSelect = mock(PreparedStatement.class); ResultSet requestEpoch = mock(ResultSet.class); ResultSet copyEpoch = mock(ResultSet.class); ResultSet resetEpoch = mock(ResultSet.class); ResultSet requested = accountingRow("00000000-0000-0000-0000-000000000001", 0, 0); ResultSet accounting = accountingRow("00000000-0000-0000-0000-000000000001", 16, 0); + ResultSet noEarlierStreak = ids(); ResultSet persistedUpdate = mock(ResultSet.class); + ResultSet streakValue = mock(ResultSet.class); when(requestEpoch.next()).thenReturn(true); when(copyEpoch.next()).thenReturn(true); when(resetEpoch.next()).thenReturn(true); @@ -509,23 +613,29 @@ void dailyStreakUpdateLocksTheSameBoundaryRow() throws Exception { when(resetMarkerSelect.executeQuery()).thenReturn(resetEpoch); when(requestSelect.executeQuery()).thenReturn(requested); when(accountingSelect.executeQuery()).thenReturn(accounting); + when(earlierStreakSelect.executeQuery()).thenReturn(noEarlierStreak); when(persistedUpdate.next()).thenReturn(true); when(persistedUpdate.getString(1)).thenReturn(""); when(persistedUpdateSelect.executeQuery()).thenReturn(persistedUpdate); + when(streakValue.next()).thenReturn(true); + when(streakValue.getInt(1)).thenReturn(8); + when(streakValueSelect.executeQuery()).thenReturn(streakValue); when(update.executeUpdate()).thenReturn(1); when(accountingUpdate.executeUpdate()).thenReturn(1); when(requestUpdate.executeUpdate()).thenReturn(1); when(fixture.work.prepareStatement(anyString())).thenReturn(requestMarkerInsert, requestMarkerSelect, requestInsert, requestSelect, requestUpdate, copyMarkerInsert, copyMarkerSelect, - resetMarkerInsert, resetMarkerSelect, accountingSelect, persistedUpdateSelect, update, accountingUpdate); + resetMarkerInsert, resetMarkerSelect, accountingSelect, earlierStreakSelect, persistedUpdateSelect, update, + streakValueSelect, accountingUpdate); new SharedMysqlPurchaseJournal(fixture.table, false).updateDailyStreak(java.util.UUID.randomUUID(), - "00000000-0000-0000-0000-000000000001", 7, 1234L); + "00000000-0000-0000-0000-000000000001", 7, 1234L, false); verify(requestMarkerSelect).setString(1, "streak-copy:DayVoteStreak"); verify(copyMarkerSelect).setString(1, "streak-copy:DayVoteStreak"); verify(update).setString(1, "1234"); verify(update).setString(2, "00000000-0000-0000-0000-000000000001"); + verify(accountingUpdate).setInt(2, 8); verify(fixture.work, org.mockito.Mockito.times(2)).commit(); } 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 index d048e21f94..71a867b07e 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -958,6 +958,8 @@ void failedCompensationMarkerIsRetriedByPeriodicRecovery(@TempDir Path temporary .thenThrow(new java.sql.SQLException("down")) .thenThrow(new java.sql.SQLException("still down")) .thenReturn(true); + when(journal.recoverAccounting(anyLong())).thenReturn( + new SharedMysqlPurchaseJournal.AccountingRecoveryBatch(false, java.util.List.of())); when(journal.recoverAndCleanup(anyLong())).thenReturn(java.util.List.of()); VoteShopPurchaseService.SharedPurchaseDebit debit = new VoteShopPurchaseService.SharedPurchaseDebit( VoteShopPurchaseResult.SUCCESS, journal, "purchase-1", "Points", null); @@ -981,6 +983,45 @@ void failedCompensationMarkerIsRetriedByPeriodicRecovery(@TempDir Path temporary verify(scheduler, never()).runTaskAsynchronously(eq(plugin), any(Runnable.class)); } + @Test + void recoveredDailyStreakRunsRewardOnlyAfterThePersistedIncrement(@TempDir Path temporaryDirectory) throws Exception { + VotingPluginMain plugin = mockPluginForCompensation(temporaryDirectory); + SharedMysqlPurchaseJournal journal = mock(SharedMysqlPurchaseJournal.class); + VotingPluginUser user = mock(VotingPluginUser.class); + java.util.UUID voteId = java.util.UUID.randomUUID(); + java.util.UUID playerId = java.util.UUID.randomUUID(); + when(journal.recoverAccounting(anyLong())).thenReturn( + new SharedMysqlPurchaseJournal.AccountingRecoveryBatch(true, java.util.List.of(voteId)), + new SharedMysqlPurchaseJournal.AccountingRecoveryBatch(false, java.util.List.of())); + when(journal.claimDailyStreakReward(voteId)).thenReturn( + new SharedMysqlPurchaseJournal.RecoveredDailyStreak(voteId, playerId.toString(), 7, true)); + when(journal.recoverAndCleanup(anyLong())).thenReturn(java.util.List.of()); + when(plugin.getVotingPluginUserManager().getVotingPluginUser(playerId, false)).thenReturn(user); + + VoteShopPurchaseService.recoverSharedMysqlPurchases(plugin, journal); + + org.mockito.InOrder order = org.mockito.Mockito.inOrder(journal, user); + order.verify(journal).claimDailyStreakReward(voteId); + order.verify(user).completeRecoveredDailyStreak(7, true); + } + + @Test + void deferredDailyStreakRecoveryYieldsUntilTheNextRecoveryPass(@TempDir Path temporaryDirectory) throws Exception { + VotingPluginMain plugin = mockPluginForCompensation(temporaryDirectory); + SharedMysqlPurchaseJournal journal = mock(SharedMysqlPurchaseJournal.class); + java.util.UUID voteId = java.util.UUID.randomUUID(); + when(journal.recoverAccounting(anyLong())).thenReturn( + new SharedMysqlPurchaseJournal.AccountingRecoveryBatch(true, java.util.List.of(voteId))); + when(journal.claimDailyStreakReward(voteId)).thenReturn(null); + when(journal.recoverAndCleanup(anyLong())).thenReturn(java.util.List.of()); + + VoteShopPurchaseService.recoverSharedMysqlPurchases(plugin, journal); + + verify(journal).recoverAccounting(anyLong()); + verify(journal).claimDailyStreakReward(voteId); + verify(journal).recoverAndCleanup(anyLong()); + } + private static VotingPluginMain mockPluginForCompensation(Path dataDirectory) { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); when(plugin.getDataFolder()).thenReturn(dataDirectory.toFile()); From b751979b3f6e89e6448dfc03f03a5055f8aaaf14 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:59:41 -0600 Subject: [PATCH 18/61] Complete streak rewards after delivery --- .../votingplugin/data/ServerData.java | 28 +++++-- .../timequeue/TimeQueueHandler.java | 73 ++++++++++++++----- .../votingplugin/user/VotingPluginUser.java | 3 + .../service/SharedMysqlPurchaseJournal.java | 31 +++++++- .../service/VoteShopPurchaseService.java | 15 ++++ .../TimeQueueHandlerRejectionTest.java | 66 ++++++++++++++++- .../SharedMysqlPurchaseJournalTest.java | 32 ++++++++ .../service/VoteShopPurchaseServiceTest.java | 1 + 8 files changed, 222 insertions(+), 27 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index 7fbf583d5b..5603e5ed6f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -110,12 +110,28 @@ public void addSign(Location location, String data, int position) { * @param num the vote number * @param vote the vote time queue entry */ - public void addTimeVoted(int num, VoteTimeQueue vote) { - getData().set("TimedVoteCache." + num + ".Name", vote.getName()); - getData().set("TimedVoteCache." + num + ".Service", vote.getService()); - getData().set("TimedVoteCache." + num + ".Time", vote.getTime()); - saveData(); - } + public void addTimeVoted(int num, VoteTimeQueue vote) { + getData().set("TimedVoteCache." + num + ".Name", vote.getName()); + getData().set("TimedVoteCache." + num + ".Service", vote.getService()); + getData().set("TimedVoteCache." + num + ".Time", vote.getTime()); + getData().set("TimedVoteCache." + num + ".VoteId", + vote.getVoteId() == null ? null : vote.getVoteId().toString()); + saveData(); + } + + /** Replaces the durable timed-vote snapshot with one ordered in-memory queue. */ + public synchronized void replaceTimedVoteCache(List votes) { + getData().set("TimedVoteCache", null); + int index = 0; + for (VoteTimeQueue vote : votes) { + String path = "TimedVoteCache." + index++; + getData().set(path + ".Name", vote.getName()); + getData().set(path + ".Service", vote.getService()); + getData().set(path + ".Time", vote.getTime()); + getData().set(path + ".VoteId", vote.getVoteId() == null ? null : vote.getVoteId().toString()); + } + saveData(); + } /** * Adds a vote shop purchase for the specified identifier. diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java index aca6579d6c..3ebe37048b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java @@ -2,9 +2,14 @@ import java.time.LocalDateTime; import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Deque; +import java.util.List; import java.util.Queue; +import java.util.UUID; import java.util.concurrent.CancellationException; -import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ConcurrentLinkedDeque; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -20,20 +25,22 @@ import com.bencodez.votingplugin.events.PlayerVoteEvent; import com.bencodez.votingplugin.util.VoteTaskAdmission; -import lombok.Getter; - /** * The TimeQueueHandler class manages time-based vote queue processing. */ public class TimeQueueHandler implements Listener { private static final long TICKS_PER_SECOND = 20L; - @Getter - private Queue timeChangeQueue = new ConcurrentLinkedQueue<>(); + private final Deque timeChangeQueue = new ConcurrentLinkedDeque<>(); private VotingPluginMain plugin; private final AtomicBoolean retryPending = new AtomicBoolean(); + private final AtomicBoolean retryPersistenceRequired = new AtomicBoolean(); private final AtomicInteger retryAttempts = new AtomicInteger(); + public Queue getTimeChangeQueue() { + return timeChangeQueue; + } + /** * Constructs a new TimeQueueHandler. * @@ -59,14 +66,31 @@ public void addVote(String voteUsername, String voteSiteName) { * Loads cached votes from server data and schedules queue processing. */ public void load() { - for (String str : plugin.getServerData().getTimedVoteCacheKeys()) { + List keys = new ArrayList<>(plugin.getServerData().getTimedVoteCacheKeys()); + keys.sort(Comparator.comparingInt(TimeQueueHandler::timedVoteCacheIndex)); + for (String str : keys) { ConfigurationSection data = plugin.getServerData().getTimedVoteCacheSection(str); - timeChangeQueue - .add(new VoteTimeQueue(data.getString("Name"), data.getString("Service"), data.getLong("Time"))); + UUID voteId = null; + try { + String storedVoteId = data.getString("VoteId", ""); + if (storedVoteId != null && !storedVoteId.isEmpty()) voteId = UUID.fromString(storedVoteId); + } catch (IllegalArgumentException invalidVoteId) { + plugin.debug(invalidVoteId); + } + timeChangeQueue.add(new VoteTimeQueue(voteId, data.getString("Name"), data.getString("Service"), + data.getLong("Time"))); } scheduleQueueProcessing(120, TimeUnit.SECONDS); } + private static int timedVoteCacheIndex(String key) { + try { + return Integer.parseInt(key); + } catch (NumberFormatException ignored) { + return Integer.MAX_VALUE; + } + } + /** * Handles date change event and schedules queue processing. * @@ -102,13 +126,14 @@ private void scheduleQueueProcessing(long delay, TimeUnit unit) { }, delay, unit); if (!admitted) { plugin.getLogger().warning("Unable to schedule time-queue processing because vote processing is busy; queued votes were retained."); - scheduleRetry(); + scheduleRetry(false); } else { retryAttempts.set(0); } } - private void scheduleRetry() { + private void scheduleRetry(boolean persistenceRequired) { + if (persistenceRequired) retryPersistenceRequired.set(true); if (timeChangeQueue.isEmpty() || !retryPending.compareAndSet(false, true)) return; if (!plugin.isEnabled()) { retryPending.set(false); @@ -120,7 +145,12 @@ private void scheduleRetry() { try { plugin.getBukkitScheduler().runTaskLaterAsynchronously(plugin, () -> { retryPending.set(false); - if (!timeChangeQueue.isEmpty()) scheduleQueueProcessing(0, TimeUnit.SECONDS); + if (timeChangeQueue.isEmpty()) return; + if (retryPersistenceRequired.getAndSet(false) && !persistQueueSnapshot()) { + scheduleRetry(true); + return; + } + scheduleQueueProcessing(0, TimeUnit.SECONDS); }, delayTicks); } catch (RuntimeException rejected) { retryPending.set(false); @@ -128,6 +158,17 @@ private void scheduleRetry() { } } + private boolean persistQueueSnapshot() { + try { + plugin.getServerData().replaceTimedVoteCache(new ArrayList<>(timeChangeQueue)); + return true; + } catch (RuntimeException persistenceFailure) { + plugin.getLogger().severe("Unable to persist the time-queue retry; the vote remains in memory"); + plugin.debug(persistenceFailure); + return false; + } + } + private void ensureTransitionActive(TimeChangeTransition transition) { if (!plugin.isEnabled() || transition.isCancellationRequested()) { throw new CancellationException("Time transition was cancelled before time-queue scheduling completed"); @@ -147,8 +188,8 @@ public void processQueue() { voteEvent.setVoteId(vote.getVoteId() == null ? vote.legacyTimedVoteId() : vote.getVoteId()); plugin.getServer().getPluginManager().callEvent(voteEvent); if (voteEvent.isAccountingAdmissionFailed()) { - timeChangeQueue.add(vote); - scheduleRetry(); + timeChangeQueue.addFirst(vote); + scheduleRetry(!persistQueueSnapshot()); return; } @@ -164,11 +205,7 @@ public void processQueue() { */ public void save() { if (!timeChangeQueue.isEmpty()) { - int num = 0; - for (VoteTimeQueue vote : timeChangeQueue) { - plugin.getServerData().addTimeVoted(num, vote); - num++; - } + plugin.getServerData().replaceTimedVoteCache(new ArrayList<>(timeChangeQueue)); } timeChangeQueue.clear(); } 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 560d3386e7..846af40164 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -1157,6 +1157,9 @@ public void checkDayVoteStreak(boolean forceBungee, UUID voteId) { } if (update.result() == VoteShopPurchaseService.MysqlDailyStreakResult.APPLIED) { completeRecoveredDailyStreak(update.streak(), update.forceProxyRouting()); + if (!VoteShopPurchaseService.completeMysqlDailyStreakReward(plugin, voteId)) { + throw new IllegalStateException("Unable to complete shared MySQL daily streak reward"); + } } return; } 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 index e02390ba0a..59fcbf8140 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -63,6 +63,7 @@ record AccountingRecoveryBatch(boolean hadRows, List pendingRewards) { } private static final int DAILY_STREAK = 16; private static final int DAILY_STREAK_REWARD = 32; private static final int ACCOUNTING_DECIDED = 64; + private static final int DAILY_STREAK_REWARD_CLAIMED = 128; private static final ReferenceQueue INITIALIZED_QUEUE = new ReferenceQueue<>(); private static final Set INITIALIZED = new HashSet<>(); @@ -588,12 +589,15 @@ RecoveredDailyStreak claimDailyStreakReward(UUID voteId) throws SQLException { rollback(connection); return null; } + if ((row.completed() & DAILY_STREAK_REWARD_CLAIMED) != 0) { + throw new SQLException("Daily streak reward may already have run and requires manual reconciliation"); + } if (row.streakAppliedValue() == null) { throw new SQLException("Applied daily streak value is missing"); } int streak = row.streakAppliedValue().intValue(); - markAccountingComplete(connection, voteId, row.completed() | DAILY_STREAK_REWARD); - commitAndConfirmAccounting(connection, voteId, DAILY_STREAK_REWARD); + markAccountingComplete(connection, voteId, row.completed() | DAILY_STREAK_REWARD_CLAIMED); + commitAndConfirmAccounting(connection, voteId, DAILY_STREAK_REWARD_CLAIMED); return new RecoveredDailyStreak(voteId, row.uuid(), streak, row.streakForceProxy() != null && row.streakForceProxy().intValue() != 0); } catch (SQLException failure) { @@ -603,6 +607,29 @@ RecoveredDailyStreak claimDailyStreakReward(UUID voteId) throws SQLException { } } + void completeDailyStreakReward(UUID voteId) throws SQLException { + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try { + AccountingRow row = findAndLockAccountingVote(connection, voteId); + if (row == null) throw new SQLException("Vote accounting marker is missing"); + if ((row.completed() & DAILY_STREAK_REWARD) != 0) { + rollback(connection); + return; + } + if ((row.completed() & DAILY_STREAK_REWARD_CLAIMED) == 0) { + throw new SQLException("Daily streak reward was not claimed"); + } + int completed = (row.completed() | DAILY_STREAK_REWARD) & ~DAILY_STREAK_REWARD_CLAIMED; + markAccountingComplete(connection, voteId, completed); + commitAndConfirmAccounting(connection, voteId, DAILY_STREAK_REWARD); + } catch (SQLException failure) { + rollback(connection); + throw failure; + } + } + } + private String accountingColumns() { return qi("player_uuid") + ", " + qi("requested") + ", " + qi("completed") + ", " + qi("month_column") + ", " + qi("month_maximum") + ", " + qi("streak_value") + ", " 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 57ebe9287c..f8bc9f110d 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 @@ -735,6 +735,20 @@ public static MysqlDailyStreakUpdate applyPreparedMysqlDailyStreak(VotingPluginM } } + /** Completes a claimed daily-streak reward only after its existing reward API returned. */ + public static boolean completeMysqlDailyStreakReward(VotingPluginMain plugin, UUID voteId) { + if (!canRecoverSharedMysqlPurchases(plugin) || voteId == null) return false; + try { + SharedMysqlPurchaseJournal.forTable(plugin.getMysql()).completeDailyStreakReward(voteId); + return true; + } catch (SQLException failure) { + plugin.getLogger().log(java.util.logging.Level.SEVERE, + "Daily streak reward may have run but its completion could not be persisted", failure); + plugin.debug(failure); + return false; + } + } + /** Resets the copied daily streak without overwriting a new-day vote from another backend. */ public static boolean resetMysqlDailyStreakAtBoundary(VotingPluginMain plugin, String uuid, long boundaryUpdatedAt) { @@ -847,6 +861,7 @@ static void recoverSharedMysqlPurchases(VotingPluginMain plugin, SharedMysqlPurc VotingPluginUser user = plugin.getVotingPluginUserManager() .getVotingPluginUser(UUID.fromString(streak.uuid()), false); user.completeRecoveredDailyStreak(streak.streak(), streak.forceProxyRouting()); + journal.completeDailyStreakReward(voteId); } if (deferredReward) break; } while (accounting.hadRows()); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java index 6044a9ccbe..64810210f6 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java @@ -14,13 +14,16 @@ import static org.mockito.Mockito.when; import static org.mockito.Mockito.reset; +import java.util.List; import java.util.Set; +import java.util.UUID; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.logging.Logger; import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -98,7 +101,9 @@ void accountingAdmissionFailureRetainsTheSameQueuedVote() { when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of()); TimeQueueHandler handler = new TimeQueueHandler(plugin); VoteTimeQueue vote = new VoteTimeQueue(java.util.UUID.randomUUID(), "Alex", "example.org", 123L); + VoteTimeQueue following = new VoteTimeQueue(java.util.UUID.randomUUID(), "Steve", "second.example", 124L); handler.getTimeChangeQueue().add(vote); + handler.getTimeChangeQueue().add(following); org.bukkit.plugin.PluginManager pluginManager = plugin.getServer().getPluginManager(); doAnswer(invocation -> { PlayerVoteEvent event = invocation.getArgument(0); @@ -108,8 +113,67 @@ void accountingAdmissionFailureRetainsTheSameQueuedVote() { handler.processQueue(); - assertEquals(1, handler.getTimeChangeQueue().size()); + assertEquals(2, handler.getTimeChangeQueue().size()); assertEquals(vote, handler.getTimeChangeQueue().peek()); + @SuppressWarnings("unchecked") + org.mockito.ArgumentCaptor> persisted = org.mockito.ArgumentCaptor.forClass(List.class); + verify(serverData).replaceTimedVoteCache(persisted.capture()); + assertEquals(List.of(vote, following), persisted.getValue()); + } + + @Test + void timedVoteSnapshotPersistsStableIdsWithOneSave() { + VotingPluginMain snapshotPlugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.data.ServerData coreData = + mock(com.bencodez.advancedcore.data.ServerData.class); + YamlConfiguration yaml = new YamlConfiguration(); + when(snapshotPlugin.getServerDataFile()).thenReturn(coreData); + when(coreData.getData()).thenReturn(yaml); + ServerData data = new ServerData(snapshotPlugin); + UUID firstId = UUID.randomUUID(); + UUID secondId = UUID.randomUUID(); + + data.replaceTimedVoteCache(List.of( + new VoteTimeQueue(firstId, "Alex", "first.example", 123L), + new VoteTimeQueue(secondId, "Steve", "second.example", 124L))); + + assertEquals(firstId.toString(), yaml.getString("VotingPlugin.TimedVoteCache.0.VoteId")); + assertEquals(secondId.toString(), yaml.getString("VotingPlugin.TimedVoteCache.1.VoteId")); + assertEquals("Alex", yaml.getString("VotingPlugin.TimedVoteCache.0.Name")); + verify(coreData).saveData(); + } + + @Test + void processingRetryWaitsForFailedSnapshotPersistence() { + when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of()); + reset(voteTimer); + TimeQueueHandler handler = new TimeQueueHandler(plugin); + reset(voteTimer); + VoteTimeQueue vote = new VoteTimeQueue(UUID.randomUUID(), "Alex", "example.org", 123L); + handler.getTimeChangeQueue().add(vote); + doThrow(new IllegalStateException("disk unavailable")).doNothing() + .when(serverData).replaceTimedVoteCache(any()); + org.bukkit.plugin.PluginManager pluginManager = plugin.getServer().getPluginManager(); + doAnswer(invocation -> { + PlayerVoteEvent event = invocation.getArgument(0); + event.setAccountingAdmissionFailed(true); + return null; + }).when(pluginManager).callEvent(any(PlayerVoteEvent.class)); + + handler.processQueue(); + + org.mockito.ArgumentCaptor retry = org.mockito.ArgumentCaptor.forClass(Runnable.class); + verify(plugin.getBukkitScheduler()).runTaskLaterAsynchronously( + org.mockito.ArgumentMatchers.eq(plugin), retry.capture(), org.mockito.ArgumentMatchers.eq(20L)); + verify(voteTimer, never()).schedule(any(Runnable.class), org.mockito.ArgumentMatchers.eq(0L), + org.mockito.ArgumentMatchers.eq(TimeUnit.SECONDS)); + + retry.getValue().run(); + + org.mockito.InOrder persistedBeforeProcessing = org.mockito.Mockito.inOrder(serverData, voteTimer); + persistedBeforeProcessing.verify(serverData, org.mockito.Mockito.times(2)).replaceTimedVoteCache(any()); + persistedBeforeProcessing.verify(voteTimer).schedule(any(Runnable.class), + org.mockito.ArgumentMatchers.eq(0L), org.mockito.ArgumentMatchers.eq(TimeUnit.SECONDS)); } @Test 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 index 74f9faaa24..1b9b3a37fe 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -3,6 +3,7 @@ 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.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; @@ -117,10 +118,41 @@ void completedDailyStreakCanClaimItsDeferredRewardOnce() throws Exception { assertEquals(voteId, reward.voteId()); assertEquals(7, reward.streak()); assertTrue(reward.forceProxyRouting()); + verify(accountingUpdate).setInt(1, 144); + verify(fixture.work).commit(); + } + + @Test + void claimedDailyStreakRewardCompletesOnlyAfterDeliveryReturns() throws Exception { + Fixture fixture = fixture(); + PreparedStatement accountingSelect = mock(PreparedStatement.class); + PreparedStatement accountingUpdate = mock(PreparedStatement.class); + ResultSet accounting = accountingRow("00000000-0000-0000-0000-000000000001", 48, 144); + when(accountingSelect.executeQuery()).thenReturn(accounting); + when(accountingUpdate.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(accountingSelect, accountingUpdate); + + new SharedMysqlPurchaseJournal(fixture.table, false) + .completeDailyStreakReward(java.util.UUID.randomUUID()); + verify(accountingUpdate).setInt(1, 48); verify(fixture.work).commit(); } + @Test + void ambiguousDailyStreakRewardIsNotClaimedAgain() throws Exception { + Fixture fixture = fixture(); + PreparedStatement accountingSelect = mock(PreparedStatement.class); + ResultSet accounting = accountingRow("00000000-0000-0000-0000-000000000001", 48, 144); + when(accountingSelect.executeQuery()).thenReturn(accounting); + when(fixture.work.prepareStatement(anyString())).thenReturn(accountingSelect); + + assertThrows(java.sql.SQLException.class, () -> new SharedMysqlPurchaseJournal(fixture.table, false) + .claimDailyStreakReward(java.util.UUID.randomUUID())); + + verify(fixture.work).rollback(); + } + @Test void dailyStreakDateFenceRecognizesTheSameLocalDay() { java.time.ZoneId zone = java.time.ZoneId.systemDefault(); 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 index 71a867b07e..db6b0293ff 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -1003,6 +1003,7 @@ void recoveredDailyStreakRunsRewardOnlyAfterThePersistedIncrement(@TempDir Path org.mockito.InOrder order = org.mockito.Mockito.inOrder(journal, user); order.verify(journal).claimDailyStreakReward(voteId); order.verify(user).completeRecoveredDailyStreak(7, true); + order.verify(journal).completeDailyStreakReward(voteId); } @Test From 06bd000376a17fa77ea652f5fa5d33069cb97c7b Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:32:23 -0600 Subject: [PATCH 19/61] Keep ambiguous streak rewards from blocking recovery --- .../votingplugin/user/VotingPluginUser.java | 6 ++++- .../service/SharedMysqlPurchaseJournal.java | 13 +++++++-- .../VotingPluginUserVoteShopLimitTest.java | 24 +++++++++++++++++ .../SharedMysqlPurchaseJournalTest.java | 27 +++++++++++++++++++ 4 files changed, 67 insertions(+), 3 deletions(-) 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 846af40164..ffc35f6221 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -1181,7 +1181,11 @@ public void checkDayVoteStreak(boolean forceBungee, UUID voteId) { } public void completeRecoveredDailyStreak(int streak, boolean forceBungee) { - if (getBestDayVoteStreak() < streak) setBestDayVoteStreak(streak); + if (getBestDayVoteStreak() < streak) { + setBestDayVoteStreak(streak); + UserDataCache cache = getCache(); + if (cache != null) cache.flushChangesAndRun(() -> { }); + } plugin.getSpecialRewards().checkVoteStreak(null, this, "Day", forceBungee); } 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 index 59fcbf8140..39ac56cb2c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -64,6 +64,8 @@ record AccountingRecoveryBatch(boolean hadRows, List pendingRewards) { } private static final int DAILY_STREAK_REWARD = 32; private static final int ACCOUNTING_DECIDED = 64; private static final int DAILY_STREAK_REWARD_CLAIMED = 128; + private static final int RECOVERABLE_NON_REWARD_ACCOUNTING = DAILY_TOTAL | WEEKLY_TOTAL | MONTH_TOTAL + | VOTE_PARTY_TOTAL | DAILY_STREAK; private static final ReferenceQueue INITIALIZED_QUEUE = new ReferenceQueue<>(); private static final Set INITIALIZED = new HashSet<>(); @@ -1271,7 +1273,8 @@ AccountingRecoveryBatch recoverAccounting(long now) throws SQLException { applyDailyStreak(recovery.voteId(), row.uuid(), row.streakValue().intValue(), row.streakUpdatedAt().longValue()); } - if ((row.requested() & DAILY_STREAK_REWARD) != 0) { + if ((row.requested() & DAILY_STREAK_REWARD) != 0 + && (row.completed() & (DAILY_STREAK_REWARD | DAILY_STREAK_REWARD_CLAIMED)) == 0) { pendingRewards.add(recovery.voteId()); break; } @@ -1281,8 +1284,14 @@ AccountingRecoveryBatch recoverAccounting(long now) throws SQLException { } private List findPendingAccounting(int limit) throws SQLException { + String requested = qi("requested"); + String completed = qi("completed"); String select = "SELECT " + qi("vote_id") + ", " + accountingColumns() + " FROM " + qiAccounting() - + " WHERE " + qi("requested") + " <> " + qi("completed") + " ORDER BY " + qi("created_at") + + " WHERE " + requested + " <> " + completed + " AND (((" + requested + " & " + + RECOVERABLE_NON_REWARD_ACCOUNTING + ") <> (" + completed + " & " + + RECOVERABLE_NON_REWARD_ACCOUNTING + ")) OR ((" + requested + " & " + DAILY_STREAK_REWARD + + ") <> 0 AND (" + completed + " & " + DAILY_STREAK_REWARD + ") = 0 AND (" + completed + + " & " + DAILY_STREAK_REWARD_CLAIMED + ") = 0)) ORDER BY " + qi("created_at") + " ASC LIMIT ?"; List pending = new ArrayList<>(); try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(select)) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java index 61d6003e8d..a113139992 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java @@ -19,10 +19,34 @@ import com.bencodez.advancedcore.api.user.UserData; 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.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.specialrewards.SpecialRewards; import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService; class VotingPluginUserVoteShopLimitTest { + @Test + void recoveredBestDailyStreakIsFlushedBeforeItsRewardRuns() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + SpecialRewards specialRewards = mock(SpecialRewards.class); + when(plugin.getSpecialRewards()).thenReturn(specialRewards); + AdvancedCoreUser base = mock(AdvancedCoreUser.class); + when(base.getUserData()).thenReturn(mock(UserData.class)); + when(base.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + VotingPluginUser user = spy(new VotingPluginUser(plugin, base)); + UserDataCache cache = mock(UserDataCache.class); + doReturn(3).when(user).getBestDayVoteStreak(); + doNothing().when(user).setBestDayVoteStreak(7); + doReturn(cache).when(user).getCache(); + + user.completeRecoveredDailyStreak(7, true); + + org.mockito.InOrder order = org.mockito.Mockito.inOrder(user, cache, specialRewards); + order.verify(user).setBestDayVoteStreak(7); + order.verify(cache).flushChangesAndRun(org.mockito.ArgumentMatchers.any(Runnable.class)); + order.verify(specialRewards).checkVoteStreak(null, user, "Day", true); + } + @Test void sharedMysqlDailyVotesUseTheCrossBackendBoundaryMutation() { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); 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 index 1b9b3a37fe..1c7664b2e6 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -15,6 +15,7 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; +import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; import org.junit.jupiter.api.Test; @@ -732,6 +733,32 @@ void completedAccountingRowsAreDeletedAfterTheReplayWindow() throws Exception { verify(cleanup).executeUpdate(); } + @Test + void claimedRewardIsSkippedWithoutStarvingLaterRecoverableReward() throws Exception { + Fixture fixture = fixture(); + PreparedStatement pending = mock(PreparedStatement.class); + PreparedStatement cleanup = mock(PreparedStatement.class); + ResultSet rows = mock(ResultSet.class); + UUID ambiguous = UUID.randomUUID(); + UUID recoverable = UUID.randomUUID(); + when(rows.next()).thenReturn(true, true, false); + when(rows.getString(1)).thenReturn(ambiguous.toString(), recoverable.toString()); + when(rows.getString(2)).thenReturn("player-a", "player-b"); + when(rows.getInt(3)).thenReturn(48, 48); + when(rows.getInt(4)).thenReturn(144, 16); + when(pending.executeQuery()).thenReturn(rows); + when(fixture.work.prepareStatement(anyString())).thenReturn(pending, cleanup); + + SharedMysqlPurchaseJournal.AccountingRecoveryBatch batch = + new SharedMysqlPurchaseJournal(fixture.table, false).recoverAccounting(1L); + + assertEquals(java.util.List.of(recoverable), batch.pendingRewards()); + org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class); + verify(fixture.work, org.mockito.Mockito.times(2)).prepareStatement(sql.capture()); + assertTrue(sql.getAllValues().get(0).contains("& 128"), + "claimed reward rows must be excluded from the bounded recovery page"); + } + @Test void ambiguousResetCommitIsConfirmedAfterTheConnectionIsReleased() throws Exception { Fixture fixture = fixture(); From e47d8a27336cc9af0e072d0373d5d9575f2db912 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:42:47 -0600 Subject: [PATCH 20/61] Keep vote accounting decisions consistent --- .../core/vote/SharedVoteProcessor.java | 8 ++-- .../listeners/PlayerVoteListener.java | 7 +-- .../specialrewards/voteparty/VoteParty.java | 23 +++++----- .../service/SharedMysqlPurchaseJournal.java | 21 ++++++++- .../core/vote/SharedVoteProcessorTest.java | 4 +- .../votingplugin/tests/VotePartyTest.java | 18 ++++++++ .../SharedMysqlPurchaseJournalTest.java | 43 +++++++++++++++++++ 7 files changed, 104 insertions(+), 20 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java index 8c340499ab..98eb14c36a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java @@ -59,11 +59,11 @@ public interface Operations { void broadcast(UUID uuid, String name, String siteDisplayName, boolean online); boolean hasProxyTextTotals(); UUID incomingVoteId(); - void prepareAccounting(U user, UUID voteId, boolean countTotals); + boolean prepareAccounting(U user, UUID voteId, boolean countTotals); void finishAccounting(UUID voteId); void cache(U user); void updateName(U user); - void voteParty(U user, boolean realVote, boolean forceProxyRouting, UUID voteId); + void voteParty(U user, boolean forceProxyRouting, UUID voteId, boolean eligible); long incomingTime(); void setTime(U user, S site, long time); void setTimeNow(U user, S site); @@ -172,11 +172,11 @@ public static void process(Operations ops) { ops.incomingTime(), ops.realVote(), ops.addTotals(), ops.proxyVote(), ops.forceProxyRouting(), ops.wasOnline()); boolean countTotals = policy.shouldCountTotals(accountingInput, () -> ops.userOnline(user)); - ops.prepareAccounting(user, voteId, countTotals); + boolean votePartyEligible = ops.prepareAccounting(user, voteId, countTotals); try { ops.cache(user); ops.updateName(user); - ops.voteParty(user, ops.realVote(), ops.forceProxyRouting(), voteId); + ops.voteParty(user, ops.forceProxyRouting(), voteId, votePartyEligible); if (ops.broadcastEnabled() && ops.hasBroadcastHandler()) { boolean currentOnline = ops.userOnline(user); boolean online = currentOnline; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java index eef00945f1..0c2bdcd66a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java @@ -137,7 +137,7 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { if (event.getVoteId() != null) return event.getVoteId(); return event.getBungeeTextTotals() == null ? null : event.getBungeeTextTotals().getVoteUUID(); } - @Override public void prepareAccounting(VotingPluginUser user, UUID voteId, boolean countTotals) { + @Override public boolean prepareAccounting(VotingPluginUser user, UUID voteId, boolean countTotals) { boolean countVoteParty = plugin.getSpecialRewardsConfig().isVotePartyEnabled() && (plugin.getSpecialRewardsConfig().isVotePartyCountFakeVotes() || event.isRealVote()) && (plugin.getSpecialRewardsConfig().isVotePartyCountOfflineVotes() || user.isOnline()); @@ -145,14 +145,15 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { countVoteParty, event.isForceBungee())) { throw new SharedVoteAdmissionException("Unable to admit shared MySQL vote accounting before processing"); } + return countVoteParty; } @Override public void finishAccounting(UUID voteId) { VoteShopPurchaseService.finishMysqlVoteAccounting(voteId); } @Override public void cache(VotingPluginUser user) { user.cache(); } @Override public void updateName(VotingPluginUser user) { user.updateName(true); } - @Override public void voteParty(VotingPluginUser user, boolean realVote, boolean forceProxyRouting, UUID voteId) { - plugin.getVoteParty().vote(user, realVote, forceProxyRouting, voteId); + @Override public void voteParty(VotingPluginUser user, boolean forceProxyRouting, UUID voteId, boolean eligible) { + plugin.getVoteParty().voteAdmitted(user, forceProxyRouting, voteId, eligible); } @Override public long incomingTime() { return event.getTime(); } @Override public void setTime(VotingPluginUser user, VoteSite site, long time) { user.setTime(site, time); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java index c872f6516b..12eddece63 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java @@ -415,15 +415,18 @@ public synchronized void vote(VotingPluginUser user, boolean realVote, boolean f } public synchronized void vote(VotingPluginUser user, boolean realVote, boolean forceBungee, UUID voteId) { - if (plugin.getSpecialRewardsConfig().isVotePartyEnabled()) { - if (plugin.getSpecialRewardsConfig().isVotePartyCountFakeVotes() || realVote) { - if (plugin.getSpecialRewardsConfig().isVotePartyCountOfflineVotes() || user.isOnline()) { - addTotal(user, voteId); - addVotePlayer(user); - check(user, forceBungee); - checkVoteReminder(user); - } - } - } + boolean eligible = plugin.getSpecialRewardsConfig().isVotePartyEnabled() + && (plugin.getSpecialRewardsConfig().isVotePartyCountFakeVotes() || realVote) + && (plugin.getSpecialRewardsConfig().isVotePartyCountOfflineVotes() || user.isOnline()); + voteAdmitted(user, forceBungee, voteId, eligible); + } + + /** Applies the VoteParty eligibility decision captured during durable vote admission. */ + public synchronized void voteAdmitted(VotingPluginUser user, boolean forceBungee, UUID voteId, boolean eligible) { + if (!eligible) return; + addTotal(user, voteId); + addVotePlayer(user); + check(user, forceBungee); + checkVoteReminder(user); } } 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 index 39ac56cb2c..673ba42dae 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -357,8 +357,9 @@ int prepareVoteAccounting(UUID voteId, String uuid, boolean countTotals, boolean boolean accountingAlreadyDecided = (row.requested() & ACCOUNTING_DECIDED) != 0; DailyStreakCandidate streak = accountingAlreadyDecided ? null : findDailyStreakCandidate(connection, uuid); boolean dailyAlreadyApplied = (row.completed() & DAILY_TOTAL) != 0; - int projectedDailyTotal = accountingAlreadyDecided ? 0 - : streak.dailyTotal() + (countTotals && !dailyAlreadyApplied ? 1 : 0); + int projectedDailyTotal = accountingAlreadyDecided || !streakUsesPercentage ? 0 + : streak.dailyTotal() + countPendingDailyTotals(connection, voteId, uuid) + + (countTotals && !dailyAlreadyApplied ? 1 : 0); boolean percentageMet = accountingAlreadyDecided || !streakUsesPercentage || enabledSiteCount > 0 && (double) projectedDailyTotal / (double) enabledSiteCount * 100 > streakPercentage; boolean requestStreak = !accountingAlreadyDecided @@ -400,6 +401,22 @@ int prepareVoteAccounting(UUID voteId, String uuid, boolean countTotals, boolean } } + private int countPendingDailyTotals(Connection connection, UUID voteId, String uuid) throws SQLException { + String select = "SELECT COUNT(*) FROM " + qiAccounting() + " WHERE " + qi("player_uuid") + + " = ? AND " + qi("vote_id") + " <> ? AND (" + qi("requested") + " & ?) <> 0 AND (" + + qi("completed") + " & ?) = 0 AND (" + qi("completed") + " & ?) <> 0"; + try (PreparedStatement statement = connection.prepareStatement(select)) { + statement.setString(1, uuid); + statement.setString(2, voteId.toString()); + statement.setInt(3, DAILY_TOTAL); + statement.setInt(4, DAILY_TOTAL); + statement.setInt(5, ACCOUNTING_DECIDED); + try (ResultSet result = statement.executeQuery()) { + return result.next() ? result.getInt(1) : 0; + } + } + } + private DailyStreakCandidate findDailyStreakCandidate(Connection connection, String uuid) throws SQLException { String select = "SELECT COALESCE(" + qi("DailyTotal") + ", 0), COALESCE(" + qi("DayVoteStreak") + ", 0), " + qi("DayVoteStreakLastUpdate") + " FROM " + qi(table.getTableName()) + " WHERE " diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java index cb3a2c064b..0359d267f8 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.anyLong; @@ -38,6 +39,7 @@ private SharedVoteProcessor.Operations accepted() { when(ops.siteKey(site)).thenReturn("ExampleKey"); when(ops.userId(user)).thenReturn("user-id"); when(ops.userUuid(user)).thenReturn(UUID.randomUUID()); + when(ops.prepareAccounting(eq(user), any(UUID.class), anyBoolean())).thenReturn(true); return ops; } @@ -63,7 +65,7 @@ void onlineVoteKeepsProductionPhaseOrderAndCounts() { order.verify(ops).prepareAccounting(eq(user), any(UUID.class), eq(true)); order.verify(ops).cache(user); order.verify(ops).updateName(user); - order.verify(ops).voteParty(eq(user), eq(true), eq(false), any(UUID.class)); + order.verify(ops).voteParty(eq(user), eq(false), any(UUID.class), eq(true)); order.verify(ops).broadcast(any(UUID.class), eq("Ben"), any(), eq(true)); order.verify(ops).setTime(user, site, 123L); order.verify(ops).playerVote(user, site, true, false); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java index 467e114e3e..27530ff782 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java @@ -13,6 +13,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.UUID; import org.bukkit.Bukkit; import org.bukkit.ChatColor; @@ -95,6 +96,23 @@ public void addTotal_IncrementsTotalVotes() { verify(plugin.getServerData().getData()).set("VoteParty.Total", 6); } + @Test + public void admittedVotePartyUsesTheCapturedEligibilityWithoutRecheckingOnlineState() { + UUID voteId = UUID.randomUUID(); + Mockito.doNothing().when(voteParty).addTotal(user, voteId); + Mockito.doNothing().when(voteParty).addVotePlayer(user); + Mockito.doNothing().when(voteParty).check(user, false); + Mockito.doNothing().when(voteParty).checkVoteReminder(user); + + voteParty.voteAdmitted(user, false, voteId, true); + + verify(voteParty).addTotal(user, voteId); + verify(voteParty).addVotePlayer(user); + verify(voteParty).check(user, false); + verify(voteParty).checkVoteReminder(user); + verify(user, never()).isOnline(); + } + @Test public void check_EnoughVotes_TriggersVoteParty() { when(plugin.getServerData().getData().getInt("VoteParty.Total")).thenReturn(10); 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 index 1c7664b2e6..65628d49ce 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -65,6 +65,49 @@ void voteAdmissionDurablyIncludesEligibleDailyStreakAndReward() throws Exception verify(fixture.work).commit(); } + @Test + void pendingDailyTotalsContributeToPercentageStreakAdmission() throws Exception { + Fixture fixture = fixture(); + PreparedStatement copyInsert = mock(PreparedStatement.class); + PreparedStatement copySelect = mock(PreparedStatement.class); + PreparedStatement resetInsert = mock(PreparedStatement.class); + PreparedStatement resetSelect = mock(PreparedStatement.class); + PreparedStatement accountingInsert = mock(PreparedStatement.class); + PreparedStatement accountingSelect = mock(PreparedStatement.class); + PreparedStatement candidateSelect = mock(PreparedStatement.class); + PreparedStatement pendingTotalSelect = mock(PreparedStatement.class); + PreparedStatement accountingUpdate = mock(PreparedStatement.class); + ResultSet copyEpoch = mock(ResultSet.class); + ResultSet resetEpoch = mock(ResultSet.class); + ResultSet accounting = accountingRow("00000000-0000-0000-0000-000000000001", 0, 0); + ResultSet candidate = mock(ResultSet.class); + ResultSet pendingTotals = mock(ResultSet.class); + when(copyEpoch.next()).thenReturn(true); + when(resetEpoch.next()).thenReturn(true); + when(copySelect.executeQuery()).thenReturn(copyEpoch); + when(resetSelect.executeQuery()).thenReturn(resetEpoch); + when(accountingSelect.executeQuery()).thenReturn(accounting); + when(candidate.next()).thenReturn(true); + when(candidate.getInt(1)).thenReturn(0); + when(candidate.getInt(2)).thenReturn(4); + when(candidate.getString(3)).thenReturn(""); + when(candidateSelect.executeQuery()).thenReturn(candidate); + when(pendingTotals.next()).thenReturn(true); + when(pendingTotals.getInt(1)).thenReturn(1); + when(pendingTotalSelect.executeQuery()).thenReturn(pendingTotals); + when(accountingUpdate.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(copyInsert, copySelect, resetInsert, resetSelect, + accountingInsert, accountingSelect, candidateSelect, pendingTotalSelect, accountingUpdate); + + int requested = new SharedMysqlPurchaseJournal(fixture.table, false).prepareVoteAccounting( + UUID.randomUUID(), "00000000-0000-0000-0000-000000000001", false, false, + null, null, true, 50.0, 1, false, 1234L); + + assertEquals(48, requested); + verify(accountingUpdate).setInt(2, 112); + verify(pendingTotalSelect).setInt(5, 64); + } + @Test void firstVoteCanBeAdmittedBeforeTheUserRowIsCreated() throws Exception { Fixture fixture = fixture(); From 1caceafc993741fd7b8d51c3c030536b2849b97f Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:59:40 -0600 Subject: [PATCH 21/61] Fence recovered daily streak rewards --- .../specialrewards/SpecialRewards.java | 36 ++++++++------- .../votingplugin/user/VotingPluginUser.java | 2 +- .../service/SharedMysqlPurchaseJournal.java | 22 +++++++-- .../specialrewards/SpecialRewardsTest.java | 46 +++++++++++++++++++ .../VotingPluginUserVoteShopLimitTest.java | 2 +- .../SharedMysqlPurchaseJournalTest.java | 21 ++++++++- 6 files changed, 103 insertions(+), 26 deletions(-) create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/SpecialRewardsTest.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/SpecialRewards.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/SpecialRewards.java index ed50217205..89becc40c8 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/SpecialRewards.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/SpecialRewards.java @@ -33,9 +33,19 @@ public SpecialRewards(VotingPluginMain plugin) { * @param type the streak type (day/week/month) * @param forceBungee whether to force bungee mode * @return true if a reward was given - */ - public boolean checkVoteStreak(UUID voteUUID, VotingPluginUser user, String type, boolean forceBungee) { - boolean gotReward = false; + */ + public boolean checkVoteStreak(UUID voteUUID, VotingPluginUser user, String type, boolean forceBungee) { + int currentStreak = 0; + if (type.equalsIgnoreCase("day")) currentStreak = user.getDayVoteStreak(); + else if (type.equalsIgnoreCase("week")) currentStreak = user.getWeekVoteStreak(); + else if (type.equalsIgnoreCase("month")) currentStreak = user.getMonthVoteStreak(); + return checkVoteStreakAt(voteUUID, user, type, currentStreak, forceBungee); + } + + /** Checks rewards against a durable streak value captured before later period changes. */ + public boolean checkVoteStreakAt(UUID voteUUID, VotingPluginUser user, String type, int currentStreak, + boolean forceBungee) { + boolean gotReward = false; Set streaks = plugin.getSpecialRewardsConfig().getVoteStreakVotes(type); for (String streak : streaks) { @@ -54,25 +64,17 @@ public boolean checkVoteStreak(UUID voteUUID, VotingPluginUser user, String type if (plugin.getSpecialRewardsConfig().getVoteStreakRewardEnabled(type, streak) && plugin.getRewardHandler().hasRewards(plugin.getSpecialRewardsConfig().getData(), plugin.getSpecialRewardsConfig().getVoteStreakRewardsPath(type, "" + streak))) { - int curStreak = 0; - if (type.equalsIgnoreCase("day")) { - curStreak = user.getDayVoteStreak(); - } else if (type.equalsIgnoreCase("week")) { - curStreak = user.getWeekVoteStreak(); - } else if (type.equalsIgnoreCase("month")) { - curStreak = user.getMonthVoteStreak(); - } - if (!multiple) { - if (curStreak == streakRequired) { - giveVoteStreakReward(voteUUID, user, user.isOnline(), type, "" + streakRequired, - curStreak, forceBungee); + if (!multiple) { + if (currentStreak == streakRequired) { + giveVoteStreakReward(voteUUID, user, user.isOnline(), type, "" + streakRequired, + currentStreak, forceBungee); gotReward = true; plugin.debug( user.getPlayerName() + " got VoteStreak " + streakRequired + " for " + type); } } else { - if (curStreak != 0 && curStreak % streakRequired == 0) { - giveVoteStreakReward(voteUUID, user, user.isOnline(), type, streak, curStreak, + if (currentStreak != 0 && currentStreak % streakRequired == 0) { + giveVoteStreakReward(voteUUID, user, user.isOnline(), type, streak, currentStreak, forceBungee); gotReward = true; plugin.debug( 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 ffc35f6221..b1b46b7e8d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -1186,7 +1186,7 @@ public void completeRecoveredDailyStreak(int streak, boolean forceBungee) { UserDataCache cache = getCache(); if (cache != null) cache.flushChangesAndRun(() -> { }); } - plugin.getSpecialRewards().checkVoteStreak(null, this, "Day", forceBungee); + plugin.getSpecialRewards().checkVoteStreakAt(null, this, "Day", streak, forceBungee); } /** 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 index 673ba42dae..13629310b2 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -343,7 +343,10 @@ int prepareVoteAccounting(UUID voteId, String uuid, boolean countTotals, boolean try (Connection connection = connection()) { connection.setAutoCommit(false); try { - if ((requested & DAILY_TOTAL) != 0) lockLimitEpochRow(connection, "period-copy:DailyTotal"); + EpochRow dailyCopyMarker = null; + if ((requested & DAILY_TOTAL) != 0 || streakUsesPercentage) { + dailyCopyMarker = lockLimitEpochRow(connection, "period-copy:DailyTotal"); + } if ((requested & MONTH_TOTAL) != 0) lockLimitEpochRow(connection, "period-copy:MonthTotal"); if ((requested & VOTE_PARTY_TOTAL) != 0) lockLimitEpochRow(connection, "period-copy:VotePartyVotes"); if ((requested & WEEKLY_TOTAL) != 0) lockLimitEpochRow(connection, "period-copy:WeeklyTotal"); @@ -355,7 +358,10 @@ int prepareVoteAccounting(UUID voteId, String uuid, boolean countTotals, boolean throw new SQLException("Vote accounting identity does not match"); } boolean accountingAlreadyDecided = (row.requested() & ACCOUNTING_DECIDED) != 0; - DailyStreakCandidate streak = accountingAlreadyDecided ? null : findDailyStreakCandidate(connection, uuid); + boolean dailyResetPending = !accountingAlreadyDecided && streakUsesPercentage + && resetPending(connection, "DailyTotal", dailyCopyMarker); + DailyStreakCandidate streak = accountingAlreadyDecided ? null + : findDailyStreakCandidate(connection, uuid, dailyResetPending); boolean dailyAlreadyApplied = (row.completed() & DAILY_TOTAL) != 0; int projectedDailyTotal = accountingAlreadyDecided || !streakUsesPercentage ? 0 : streak.dailyTotal() + countPendingDailyTotals(connection, voteId, uuid) @@ -417,8 +423,12 @@ private int countPendingDailyTotals(Connection connection, UUID voteId, String u } } - private DailyStreakCandidate findDailyStreakCandidate(Connection connection, String uuid) throws SQLException { - String select = "SELECT COALESCE(" + qi("DailyTotal") + ", 0), COALESCE(" + qi("DayVoteStreak") + private DailyStreakCandidate findDailyStreakCandidate(Connection connection, String uuid, boolean resetPending) + throws SQLException { + String dailyTotal = resetPending + ? "GREATEST(0, COALESCE(" + qi("DailyTotal") + ", 0) - COALESCE(" + qi("LastDailyTotal") + ", 0))" + : "COALESCE(" + qi("DailyTotal") + ", 0)"; + String select = "SELECT " + dailyTotal + ", COALESCE(" + qi("DayVoteStreak") + ", 0), " + qi("DayVoteStreakLastUpdate") + " FROM " + qi(table.getTableName()) + " WHERE " + qi("uuid") + uuidCast() + " FOR UPDATE"; try (PreparedStatement statement = connection.prepareStatement(select)) { @@ -664,8 +674,10 @@ private static AccountingRow accountingRow(ResultSet result) throws SQLException private boolean resetPending(Connection connection, String boundaryColumn, EpochRow copyMarker) throws SQLException { - EpochRow resetMarker = lockLimitEpochRow(connection, "period-reset:" + boundaryColumn); + if (copyMarker == null) return false; String copyTransition = generationTransition(copyMarker.lastResetGeneration(), "time-copy:"); + if (copyTransition == null) return false; + EpochRow resetMarker = lockLimitEpochRow(connection, "period-reset:" + boundaryColumn); String resetTransition = generationTransition(resetMarker.lastResetGeneration(), "time-total:"); return copyTransition != null && !copyTransition.equals(resetTransition); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/SpecialRewardsTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/SpecialRewardsTest.java new file mode 100644 index 0000000000..10a11d6596 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/SpecialRewardsTest.java @@ -0,0 +1,46 @@ +package com.bencodez.votingplugin.specialrewards; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +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 java.util.Set; + +import org.bukkit.configuration.file.FileConfiguration; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import com.bencodez.advancedcore.api.rewards.RewardHandler; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.config.SpecialRewardsConfig; +import com.bencodez.votingplugin.user.VotingPluginUser; + +class SpecialRewardsTest { + @Test + void recoveredStreakRewardUsesJournaledValueInsteadOfMutableUserValue() { + VotingPluginMain plugin = Mockito.mock(VotingPluginMain.class); + SpecialRewardsConfig config = Mockito.mock(SpecialRewardsConfig.class); + RewardHandler rewardHandler = Mockito.mock(RewardHandler.class); + FileConfiguration data = Mockito.mock(FileConfiguration.class); + VotingPluginUser user = Mockito.mock(VotingPluginUser.class); + SpecialRewards rewards = spy(new SpecialRewards(plugin)); + when(plugin.getSpecialRewardsConfig()).thenReturn(config); + when(plugin.getRewardHandler()).thenReturn(rewardHandler); + when(config.getVoteStreakVotes("Day")).thenReturn(Set.of("7")); + when(config.getVoteStreakRewardEnabled("Day", "7")).thenReturn(true); + when(config.getVoteStreakRewardsPath("Day", "7")).thenReturn("VoteStreak.Day.7"); + when(config.getData()).thenReturn(data); + when(rewardHandler.hasRewards(any(), anyString())).thenReturn(true); + when(user.isOnline()).thenReturn(true); + doNothing().when(rewards).giveVoteStreakReward(null, user, true, "Day", "7", 7, true); + + rewards.checkVoteStreakAt(null, user, "Day", 7, true); + + verify(rewards).giveVoteStreakReward(null, user, true, "Day", "7", 7, true); + verify(user, never()).getDayVoteStreak(); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java index a113139992..c7a24b71ab 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java @@ -44,7 +44,7 @@ void recoveredBestDailyStreakIsFlushedBeforeItsRewardRuns() { org.mockito.InOrder order = org.mockito.Mockito.inOrder(user, cache, specialRewards); order.verify(user).setBestDayVoteStreak(7); order.verify(cache).flushChangesAndRun(org.mockito.ArgumentMatchers.any(Runnable.class)); - order.verify(specialRewards).checkVoteStreak(null, user, "Day", true); + order.verify(specialRewards).checkVoteStreakAt(null, user, "Day", 7, true); } @Test 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 index 65628d49ce..808aaea0eb 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -68,24 +68,36 @@ void voteAdmissionDurablyIncludesEligibleDailyStreakAndReward() throws Exception @Test void pendingDailyTotalsContributeToPercentageStreakAdmission() throws Exception { Fixture fixture = fixture(); + PreparedStatement dailyCopyInsert = mock(PreparedStatement.class); + PreparedStatement dailyCopySelect = mock(PreparedStatement.class); PreparedStatement copyInsert = mock(PreparedStatement.class); PreparedStatement copySelect = mock(PreparedStatement.class); PreparedStatement resetInsert = mock(PreparedStatement.class); PreparedStatement resetSelect = mock(PreparedStatement.class); PreparedStatement accountingInsert = mock(PreparedStatement.class); PreparedStatement accountingSelect = mock(PreparedStatement.class); + PreparedStatement dailyResetInsert = mock(PreparedStatement.class); + PreparedStatement dailyResetSelect = mock(PreparedStatement.class); PreparedStatement candidateSelect = mock(PreparedStatement.class); PreparedStatement pendingTotalSelect = mock(PreparedStatement.class); PreparedStatement accountingUpdate = mock(PreparedStatement.class); + ResultSet dailyCopyEpoch = mock(ResultSet.class); ResultSet copyEpoch = mock(ResultSet.class); ResultSet resetEpoch = mock(ResultSet.class); + ResultSet dailyResetEpoch = mock(ResultSet.class); ResultSet accounting = accountingRow("00000000-0000-0000-0000-000000000001", 0, 0); ResultSet candidate = mock(ResultSet.class); ResultSet pendingTotals = mock(ResultSet.class); + when(dailyCopyEpoch.next()).thenReturn(true); + when(dailyCopyEpoch.getString(2)).thenReturn("time-copy:DAY:2026-09-22"); when(copyEpoch.next()).thenReturn(true); when(resetEpoch.next()).thenReturn(true); + when(dailyResetEpoch.next()).thenReturn(true); + when(dailyResetEpoch.getString(2)).thenReturn("time-total:DAY:2026-09-21"); + when(dailyCopySelect.executeQuery()).thenReturn(dailyCopyEpoch); when(copySelect.executeQuery()).thenReturn(copyEpoch); when(resetSelect.executeQuery()).thenReturn(resetEpoch); + when(dailyResetSelect.executeQuery()).thenReturn(dailyResetEpoch); when(accountingSelect.executeQuery()).thenReturn(accounting); when(candidate.next()).thenReturn(true); when(candidate.getInt(1)).thenReturn(0); @@ -96,8 +108,9 @@ void pendingDailyTotalsContributeToPercentageStreakAdmission() throws Exception when(pendingTotals.getInt(1)).thenReturn(1); when(pendingTotalSelect.executeQuery()).thenReturn(pendingTotals); when(accountingUpdate.executeUpdate()).thenReturn(1); - when(fixture.work.prepareStatement(anyString())).thenReturn(copyInsert, copySelect, resetInsert, resetSelect, - accountingInsert, accountingSelect, candidateSelect, pendingTotalSelect, accountingUpdate); + when(fixture.work.prepareStatement(anyString())).thenReturn(dailyCopyInsert, dailyCopySelect, + copyInsert, copySelect, resetInsert, resetSelect, accountingInsert, accountingSelect, + dailyResetInsert, dailyResetSelect, candidateSelect, pendingTotalSelect, accountingUpdate); int requested = new SharedMysqlPurchaseJournal(fixture.table, false).prepareVoteAccounting( UUID.randomUUID(), "00000000-0000-0000-0000-000000000001", false, false, @@ -106,6 +119,10 @@ void pendingDailyTotalsContributeToPercentageStreakAdmission() throws Exception assertEquals(48, requested); verify(accountingUpdate).setInt(2, 112); verify(pendingTotalSelect).setInt(5, 64); + org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class); + verify(fixture.work, org.mockito.Mockito.times(13)).prepareStatement(sql.capture()); + assertTrue(sql.getAllValues().get(10).contains("LastDailyTotal"), + "active daily reset must exclude the copied boundary total"); } @Test From 6c49ff5b799097779ada0bcd899b3800c7aa1fa8 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:10:53 -0600 Subject: [PATCH 22/61] Finish copied streak transitions after config changes --- .../topvoter/TopVoterHandler.java | 3 +- .../service/SharedMysqlPurchaseJournal.java | 8 +++++- .../SharedMysqlPurchaseJournalTest.java | 28 +++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) 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 f78ce29d58..dd6852b533 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -626,8 +626,7 @@ private void runRecoverablePeriod(TopVoter top, TimeChangeTransition transition) processRecoverableUsers(top, transition); plugin.getServerData().completeTimeChangePhase(transition, USER_UPDATES); } - if (top == TopVoter.Daily && plugin.getConfigFile().isUseVoteStreaks() - && plugin.getStorageType().equals(UserStorage.MYSQL) + if (top == TopVoter.Daily && plugin.getStorageType().equals(UserStorage.MYSQL) && !VoteShopPurchaseService.completeMysqlDailyStreakReset(plugin, "time-streak-reset:" + transition.getId())) { throw new IllegalStateException("Unable to publish the completed daily streak reset"); 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 index 13629310b2..8f802f845f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -852,12 +852,18 @@ void completeDailyStreakReset(String generation) throws SQLException { try (Connection connection = connection()) { connection.setAutoCommit(false); try { - lockLimitEpochRow(connection, "streak-copy:DayVoteStreak"); + EpochRow copyMarker = lockLimitEpochRow(connection, "streak-copy:DayVoteStreak"); EpochRow resetMarker = lockLimitEpochRow(connection, "streak-reset:DayVoteStreak"); if (generation.equals(resetMarker.lastResetGeneration())) { rollback(connection); return; } + String copiedTransition = generationTransition(copyMarker.lastResetGeneration(), "time-streak-copy:"); + String resetTransition = generationTransition(generation, "time-streak-reset:"); + if (!resetTransition.equals(copiedTransition)) { + rollback(connection); + return; + } String update = "UPDATE " + qiEpoch() + " SET " + qi("epoch") + " = ?, " + qi("last_reset_generation") + " = ? WHERE " + qi("limit_column") + " = ?"; try (PreparedStatement statement = connection.prepareStatement(update)) { 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 index 808aaea0eb..c9071e86de 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -23,6 +23,34 @@ import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; class SharedMysqlPurchaseJournalTest { + @Test + void dailyStreakResetCompletionFollowsThePersistedCopyMarker() throws Exception { + Fixture fixture = fixture(); + PreparedStatement copyInsert = mock(PreparedStatement.class); + PreparedStatement copySelect = mock(PreparedStatement.class); + PreparedStatement resetInsert = mock(PreparedStatement.class); + PreparedStatement resetSelect = mock(PreparedStatement.class); + PreparedStatement update = mock(PreparedStatement.class); + ResultSet copyMarker = mock(ResultSet.class); + ResultSet resetMarker = mock(ResultSet.class); + when(copyMarker.next()).thenReturn(true); + when(copyMarker.getString(2)).thenReturn("time-streak-copy:DAY:2026-09-22"); + when(resetMarker.next()).thenReturn(true); + when(resetMarker.getLong(1)).thenReturn(3L); + when(copySelect.executeQuery()).thenReturn(copyMarker); + when(resetSelect.executeQuery()).thenReturn(resetMarker); + when(update.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(copyInsert, copySelect, resetInsert, resetSelect, + update); + + new SharedMysqlPurchaseJournal(fixture.table, false) + .completeDailyStreakReset("time-streak-reset:DAY:2026-09-22"); + + verify(update).setLong(1, 4L); + verify(update).setString(2, "time-streak-reset:DAY:2026-09-22"); + verify(fixture.work).commit(); + } + @Test void voteAdmissionDurablyIncludesEligibleDailyStreakAndReward() throws Exception { Fixture fixture = fixture(); From 3d49497b3a5dec99e9d35b5fe248a4142b87b86a Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:22:37 -0600 Subject: [PATCH 23/61] Recover copied streaks after settings change --- .../topvoter/TopVoterHandler.java | 16 ++++++++++--- .../service/SharedMysqlPurchaseJournal.java | 8 +++++++ .../service/VoteShopPurchaseService.java | 12 ++++++++++ .../TopVoterTimeChangeRecoveryTest.java | 24 +++++++++++++++++++ 4 files changed, 57 insertions(+), 3 deletions(-) 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 dd6852b533..b357fdc49a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -674,7 +674,11 @@ private void copyTotalBoundary(TopVoter top, TimeChangeTransition transition) { } void processRecoverableUsers(TopVoter top, TimeChangeTransition transition) { - if (!plugin.getConfigFile().isUseVoteStreaks() && !plugin.getConfigFile().isUseHighestTotals()) return; + boolean copiedDailyStreak = top == TopVoter.Daily && UserStorage.MYSQL.equals(plugin.getStorageType()) + && VoteShopPurchaseService.hasMysqlDailyStreakBoundary(plugin, + "time-streak-copy:" + transition.getId()); + if (!copiedDailyStreak && !plugin.getConfigFile().isUseVoteStreaks() + && !plugin.getConfigFile().isUseHighestTotals()) return; AtomicReference cursor = new AtomicReference<>(plugin.getServerData().getTimeChangeCursor(transition)); CountDownLatch finished = new CountDownLatch(1); record BoundaryUser(UUID uuid, ArrayList columns) { } @@ -700,7 +704,8 @@ record BoundaryUser(UUID uuid, ArrayList columns) { } user.userDataFetechMode(UserDataFetchMode.TEMP_ONLY); user.updateTempCacheWithColumns(boundary.columns()); try { - if (top == TopVoter.Daily) processDailyUser(user, transition, value); + if (top == TopVoter.Daily) processDailyUser(user, transition, value, + copiedDailyStreak || plugin.getConfigFile().isUseVoteStreaks()); else if (top == TopVoter.Weekly) processWeeklyUser(user, transition, value); else processMonthlyUser(user, lastMonthTime, transition, value); if (user.getCache() != null) user.getCache().clearChanges(); @@ -716,8 +721,13 @@ record BoundaryUser(UUID uuid, ArrayList columns) { } } void processDailyUser(VotingPluginUser user, TimeChangeTransition transition, String uuid) { + processDailyUser(user, transition, uuid, plugin.getConfigFile().isUseVoteStreaks()); + } + + void processDailyUser(VotingPluginUser user, TimeChangeTransition transition, String uuid, + boolean processVoteStreaks) { int boundaryTotal = user.getLastDailyTotal(); - if (plugin.getConfigFile().isUseVoteStreaks()) { + if (processVoteStreaks) { PeriodTotalMutationFence.withReset(() -> { int boundaryStreak = user.getLastDayVoteStreak(); long boundaryUpdate = user.getLastDayVoteStreakLastUpdate(); 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 index 8f802f845f..d3ab5aee54 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -845,6 +845,14 @@ void resetDailyStreakAtBoundary(String uuid, long boundaryUpdatedAt) throws SQLE } /** Publishes that every copied daily streak has been reset for this transition. */ + boolean hasDailyStreakBoundary(String generation) throws SQLException { + if (generation == null || !generation.startsWith("time-streak-copy:") || generation.length() > 128) { + throw new SQLException("Invalid daily streak copy generation"); + } + EpochRow marker = findLimitEpoch("streak-copy:DayVoteStreak"); + return marker != null && generation.equals(marker.lastResetGeneration()); + } + void completeDailyStreakReset(String generation) throws SQLException { if (generation == null || !generation.startsWith("time-streak-reset:") || generation.length() > 128) { throw new SQLException("Invalid daily streak reset generation"); 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 f8bc9f110d..063e79538f 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 @@ -767,6 +767,18 @@ public static boolean resetMysqlDailyStreakAtBoundary(VotingPluginMain plugin, S } /** Releases accepted daily streak increments after every copied user is reset. */ + public static boolean hasMysqlDailyStreakBoundary(VotingPluginMain plugin, String generation) { + if (!canRecoverSharedMysqlPurchases(plugin)) return false; + try { + return SharedMysqlPurchaseJournal.forTable(plugin.getMysql()).hasDailyStreakBoundary(generation); + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to read the shared MySQL daily streak boundary: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + throw new IllegalStateException("Unable to read the shared MySQL daily streak boundary", failure); + } + } + public static boolean completeMysqlDailyStreakReset(VotingPluginMain plugin, String generation) { if (!canRecoverSharedMysqlPurchases(plugin)) return false; try { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java index 592d740324..db0a8e1b71 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java @@ -212,6 +212,30 @@ void delayedDailyRecoveryPreservesAStreakStartedAfterTheBoundary() { verify(user).setDayVoteStreak(1); } + @Test + void copiedDailyBoundaryStillResetsStreakAfterSettingIsDisabled() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + ServerData serverData = mock(ServerData.class); + VotingPluginUser user = mock(VotingPluginUser.class); + TimeChangeTransition transition = mock(TimeChangeTransition.class); + String uuid = "00000000-0000-0000-0000-000000000001"; + long oldUpdate = LocalDateTime.of(2026, 9, 19, 12, 0).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + when(plugin.getServerData()).thenReturn(serverData); + when(plugin.getConfigFile().isUseVoteStreaks()).thenReturn(false); + when(transition.getPeriodKey()).thenReturn("2026-09-21"); + when(user.getLastDayVoteStreak()).thenReturn(4); + when(user.getLastDayVoteStreakLastUpdate()).thenReturn(oldUpdate); + when(user.getDayVoteStreakLastUpdate()).thenReturn(oldUpdate); + when(user.getDayVoteStreak()).thenReturn(4); + when(serverData.prepareTimeChangeUserStreak(transition, uuid, 0, false)) + .thenReturn(new TimeChangeUserProgress(uuid, 0, false, false)); + + new TopVoterHandler(plugin).processDailyUser(user, transition, uuid, true); + + verify(serverData).prepareTimeChangeUserStreak(transition, uuid, 0, false); + verify(user).setDayVoteStreak(0); + } + @Test void dailyRecoveryClearsAnIneligibleBoundaryStreakWithoutANewVote() { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); From b24b3c32c10775341f9f2709e42df3b80b55c3b5 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 04:39:58 -0600 Subject: [PATCH 24/61] Bound period recovery memory and checkpoints --- .../votingplugin/data/ServerData.java | 59 ++++- .../data/TimeChangeUserCheckpointStore.java | 204 ++++++++++++++++++ .../topvoter/TopVoterHandler.java | 31 +-- .../ServerDataTimeChangeRecoveryTest.java | 54 +++++ .../TopVoterTimeChangeRecoveryTest.java | 30 +-- 5 files changed, 336 insertions(+), 42 deletions(-) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/data/TimeChangeUserCheckpointStore.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index 5603e5ed6f..a09568e910 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -44,16 +44,19 @@ public record TimeChangeArchiveSnapshot(List sections) "USER_UPDATES", "TOP_REWARDS", "VOTE_SHOP", "BUNGEE_WAIT", "TOTALS_RESET", "CACHE_CLEAR", "POST_DATE", "COMPLETE"); - private VotingPluginMain plugin = VotingPluginMain.plugin; + private VotingPluginMain plugin = VotingPluginMain.plugin; + private final TimeChangeUserCheckpointStore timeChangeUsers; /** * Constructs a new ServerData. * * @param plugin the main plugin instance */ - public ServerData(VotingPluginMain plugin) { - this.plugin = plugin; - } + public ServerData(VotingPluginMain plugin) { + this.plugin = plugin; + timeChangeUsers = new TimeChangeUserCheckpointStore( + plugin == null || plugin.getDataFolder() == null ? null : plugin.getDataFolder().toPath()); + } /** * Adds an auto-cached placeholder. @@ -440,7 +443,27 @@ public synchronized void saveData() { */ public synchronized void beginTimeChangeRecovery(TimeChangeTransition transition) { String path = timeChangeRecoveryPath(transition.getType()); - if (transition.getId().equals(getData().getString(path + ".Id", ""))) return; + boolean sameTransition = transition.getId().equals(getData().getString(path + ".Id", "")); + if (timeChangeUsers.isFileBacked()) { + TimeChangeUserProgress legacyUser = null; + TimeChangeRewardState legacyReward = TimeChangeRewardState.UNCLAIMED; + if (sameTransition) { + String userPath = path + ".CurrentUser"; + String uuid = getData().getString(userPath + ".Uuid", ""); + if (!uuid.isEmpty()) { + boolean complete = getData().getBoolean(userPath + ".RewardComplete", false); + legacyUser = new TimeChangeUserProgress(uuid, + getData().getInt(userPath + ".StreakTarget"), + getData().getBoolean(userPath + ".RewardRequired", false), complete); + legacyReward = complete ? TimeChangeRewardState.COMPLETE + : getData().getBoolean(userPath + ".RewardClaimed", false) + ? TimeChangeRewardState.CLAIMED : TimeChangeRewardState.UNCLAIMED; + } + } + timeChangeUsers.begin(transition, + sameTransition ? getData().getString(path + ".Cursor", "") : "", legacyUser, legacyReward); + } + if (sameTransition) return; getData().set(path, null); getData().set(path + ".Id", transition.getId()); getData().set(path + ".Period", transition.getPeriodKey()); @@ -472,6 +495,7 @@ public synchronized void completeTimeChangePhase(TimeChangeTransition transition public synchronized String getTimeChangeCursor(TimeChangeTransition transition) { String path = timeChangeRecoveryPath(transition.getType()); if (!transition.getId().equals(getData().getString(path + ".Id", ""))) return ""; + if (timeChangeUsers.isFileBacked()) return timeChangeUsers.cursor(transition); return getData().getString(path + ".Cursor", ""); } @@ -481,6 +505,10 @@ public synchronized void completeTimeChangeUser(TimeChangeTransition transition, if (!transition.getId().equals(getData().getString(path + ".Id", ""))) { throw new IllegalStateException("Time change recovery transition does not match"); } + if (timeChangeUsers.isFileBacked()) { + timeChangeUsers.completeUser(transition, uuid); + return; + } getData().set(path + ".Cursor", uuid); getData().set(path + ".CurrentUser", null); saveData(); @@ -496,6 +524,9 @@ public synchronized TimeChangeUserProgress prepareTimeChangeUserStreak(TimeChang if (!transition.getId().equals(getData().getString(path + ".Id", ""))) { throw new IllegalStateException("Time change recovery transition does not match"); } + if (timeChangeUsers.isFileBacked()) { + return timeChangeUsers.prepareStreak(transition, uuid, streakTarget, rewardRequired); + } String userPath = path + ".CurrentUser"; if (!uuid.equals(getData().getString(userPath + ".Uuid", ""))) { getData().set(userPath, null); @@ -514,6 +545,10 @@ public synchronized TimeChangeUserProgress prepareTimeChangeUserStreak(TimeChang public synchronized TimeChangeRewardState getTimeChangeUserStreakRewardState( TimeChangeTransition transition, String uuid) { String path = timeChangeRecoveryPath(transition.getType()); + if (!transition.getId().equals(getData().getString(path + ".Id", ""))) { + throw new IllegalStateException("Time change recovery transition does not match"); + } + if (timeChangeUsers.isFileBacked()) return timeChangeUsers.rewardState(transition, uuid); String userPath = path + ".CurrentUser"; if (!transition.getId().equals(getData().getString(path + ".Id", "")) || !uuid.equals(getData().getString(userPath + ".Uuid", ""))) { @@ -529,6 +564,10 @@ public synchronized void claimTimeChangeUserStreakReward(TimeChangeTransition tr if (getTimeChangeUserStreakRewardState(transition, uuid) != TimeChangeRewardState.UNCLAIMED) { throw new IllegalStateException("Time change streak reward is already claimed"); } + if (timeChangeUsers.isFileBacked()) { + timeChangeUsers.setRewardState(transition, uuid, TimeChangeRewardState.CLAIMED); + return; + } String userPath = timeChangeRecoveryPath(transition.getType()) + ".CurrentUser"; getData().set(userPath + ".RewardClaimed", true); try { @@ -542,6 +581,16 @@ public synchronized void claimTimeChangeUserStreakReward(TimeChangeTransition tr /** Marks the in-flight user's streak reward call as returned successfully. */ public synchronized void completeTimeChangeUserStreakReward(TimeChangeTransition transition, String uuid) { String path = timeChangeRecoveryPath(transition.getType()); + if (!transition.getId().equals(getData().getString(path + ".Id", ""))) { + throw new IllegalStateException("Time change recovery transition does not match"); + } + if (timeChangeUsers.isFileBacked()) { + if (timeChangeUsers.rewardState(transition, uuid) != TimeChangeRewardState.CLAIMED) { + throw new IllegalStateException("Time change streak reward is not claimed"); + } + timeChangeUsers.setRewardState(transition, uuid, TimeChangeRewardState.COMPLETE); + return; + } String userPath = path + ".CurrentUser"; if (!transition.getId().equals(getData().getString(path + ".Id", "")) || !uuid.equals(getData().getString(userPath + ".Uuid", ""))) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/TimeChangeUserCheckpointStore.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/TimeChangeUserCheckpointStore.java new file mode 100644 index 0000000000..8b4e85faee --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/TimeChangeUserCheckpointStore.java @@ -0,0 +1,204 @@ +package com.bencodez.votingplugin.data; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.EnumMap; +import java.util.Map; +import java.util.Properties; + +import com.bencodez.advancedcore.api.time.TimeChangeTransition; +import com.bencodez.advancedcore.api.time.TimeType; +import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardState; +import com.bencodez.votingplugin.data.ServerData.TimeChangeUserProgress; +import com.bencodez.votingplugin.util.DurableFiles; + +/** + * Compact durable state for the per-user portion of period transitions. + * + *

The general server YAML can contain large top-voter archives. Keeping the + * hot cursor and one in-flight streak receipt here makes each user commit + * constant-sized while retaining the existing write-before-next-user ordering.

+ */ +final class TimeChangeUserCheckpointStore { + private static final String FILE_NAME = "TimeChangeUserCheckpoint.properties"; + + private final Path file; + private final Map states = new EnumMap<>(TimeType.class); + + TimeChangeUserCheckpointStore(Path dataDirectory) { + file = dataDirectory == null ? null : dataDirectory.resolve(FILE_NAME); + if (file != null) load(); + } + + boolean isFileBacked() { + return file != null; + } + + synchronized void begin(TimeChangeTransition transition, String legacyCursor, + TimeChangeUserProgress legacyUser, TimeChangeRewardState legacyRewardState) { + State current = states.get(transition.getType()); + if (current != null && transition.getId().equals(current.id)) return; + State replacement = new State(transition.getId()); + replacement.cursor = legacyCursor == null ? "" : legacyCursor; + if (legacyUser != null && legacyUser.uuid() != null && !legacyUser.uuid().isEmpty()) { + replacement.userUuid = legacyUser.uuid(); + replacement.streakTarget = legacyUser.streakTarget(); + replacement.rewardRequired = legacyUser.rewardRequired(); + replacement.rewardState = legacyRewardState == null + ? (legacyUser.rewardComplete() ? TimeChangeRewardState.COMPLETE : TimeChangeRewardState.UNCLAIMED) + : legacyRewardState; + } + persist(transition.getType(), replacement); + } + + synchronized String cursor(TimeChangeTransition transition) { + State state = state(transition); + return state == null ? "" : state.cursor; + } + + synchronized void completeUser(TimeChangeTransition transition, String uuid) { + State replacement = copy(requiredState(transition)); + replacement.cursor = uuid; + replacement.clearUser(); + persist(transition.getType(), replacement); + } + + synchronized TimeChangeUserProgress prepareStreak(TimeChangeTransition transition, String uuid, + int streakTarget, boolean rewardRequired) { + State state = requiredState(transition); + if (!uuid.equals(state.userUuid)) { + State replacement = copy(state); + replacement.userUuid = uuid; + replacement.streakTarget = streakTarget; + replacement.rewardRequired = rewardRequired; + replacement.rewardState = TimeChangeRewardState.UNCLAIMED; + persist(transition.getType(), replacement); + state = requiredState(transition); + } + return new TimeChangeUserProgress(uuid, state.streakTarget, state.rewardRequired, + state.rewardState == TimeChangeRewardState.COMPLETE); + } + + synchronized TimeChangeRewardState rewardState(TimeChangeTransition transition, String uuid) { + State state = requiredState(transition); + if (!uuid.equals(state.userUuid)) throw new IllegalStateException("Time change recovery user does not match"); + return state.rewardState; + } + + synchronized void setRewardState(TimeChangeTransition transition, String uuid, TimeChangeRewardState value) { + State state = requiredState(transition); + if (!uuid.equals(state.userUuid)) throw new IllegalStateException("Time change recovery user does not match"); + State replacement = copy(state); + replacement.rewardState = value; + persist(transition.getType(), replacement); + } + + private State state(TimeChangeTransition transition) { + State state = states.get(transition.getType()); + return state != null && transition.getId().equals(state.id) ? state : null; + } + + private State requiredState(TimeChangeTransition transition) { + State state = state(transition); + if (state == null) throw new IllegalStateException("Time change recovery transition does not match"); + return state; + } + + private void persist(TimeType type, State replacement) { + State previous = states.put(type, replacement); + if (file == null) return; + Path temporary = null; + try { + Path parent = file.toAbsolutePath().normalize().getParent(); + Files.createDirectories(parent); + temporary = Files.createTempFile(parent, FILE_NAME, ".tmp"); + Properties properties = encode(); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + properties.store(bytes, "VotingPlugin time-change user checkpoint"); + Files.write(temporary, bytes.toByteArray(), StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE); + DurableFiles.publishStagedFile(temporary, file); + } catch (DurableFiles.PublishedException published) { + throw new IllegalStateException("Checkpoint was published but directory durability failed", published); + } catch (IOException failure) { + if (previous == null) states.remove(type); else states.put(type, previous); + throw new IllegalStateException("Unable to persist time-change user checkpoint", failure); + } finally { + if (temporary != null) { + try { Files.deleteIfExists(temporary); } + catch (IOException ignored) { } + } + } + } + + private Properties encode() { + Properties properties = new Properties(); + for (Map.Entry entry : states.entrySet()) { + String prefix = entry.getKey().name() + "."; + State state = entry.getValue(); + properties.setProperty(prefix + "id", state.id); + properties.setProperty(prefix + "cursor", state.cursor); + if (!state.userUuid.isEmpty()) { + properties.setProperty(prefix + "user.uuid", state.userUuid); + properties.setProperty(prefix + "user.streakTarget", Integer.toString(state.streakTarget)); + properties.setProperty(prefix + "user.rewardRequired", Boolean.toString(state.rewardRequired)); + properties.setProperty(prefix + "user.rewardState", state.rewardState.name()); + } + } + return properties; + } + + private void load() { + if (!Files.isRegularFile(file)) return; + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(file)) { + properties.load(input); + for (TimeType type : TimeType.values()) { + String prefix = type.name() + "."; + String id = properties.getProperty(prefix + "id", ""); + if (id.isEmpty()) continue; + State state = new State(id); + state.cursor = properties.getProperty(prefix + "cursor", ""); + state.userUuid = properties.getProperty(prefix + "user.uuid", ""); + state.streakTarget = Integer.parseInt(properties.getProperty(prefix + "user.streakTarget", "0")); + state.rewardRequired = Boolean.parseBoolean(properties.getProperty(prefix + "user.rewardRequired", "false")); + state.rewardState = TimeChangeRewardState.valueOf( + properties.getProperty(prefix + "user.rewardState", TimeChangeRewardState.UNCLAIMED.name())); + states.put(type, state); + } + } catch (IOException | RuntimeException failure) { + throw new IllegalStateException("Unable to read time-change user checkpoint", failure); + } + } + + private static State copy(State source) { + State copy = new State(source.id); + copy.cursor = source.cursor; + copy.userUuid = source.userUuid; + copy.streakTarget = source.streakTarget; + copy.rewardRequired = source.rewardRequired; + copy.rewardState = source.rewardState; + return copy; + } + + private static final class State { + private final String id; + private String cursor = ""; + private String userUuid = ""; + private int streakTarget; + private boolean rewardRequired; + private TimeChangeRewardState rewardState = TimeChangeRewardState.UNCLAIMED; + + private State(String id) { this.id = id; } + private void clearUser() { + userUuid = ""; + streakTarget = 0; + rewardRequired = false; + rewardState = TimeChangeRewardState.UNCLAIMED; + } + } +} 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 b357fdc49a..fb832613ef 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -7,7 +7,6 @@ import java.time.YearMonth; import java.util.ArrayList; import java.util.Collections; -import java.util.Comparator; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedList; @@ -16,7 +15,6 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.CancellationException; -import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; @@ -680,29 +678,18 @@ void processRecoverableUsers(TopVoter top, TimeChangeTransition transition) { if (!copiedDailyStreak && !plugin.getConfigFile().isUseVoteStreaks() && !plugin.getConfigFile().isUseHighestTotals()) return; AtomicReference cursor = new AtomicReference<>(plugin.getServerData().getTimeChangeCursor(transition)); - CountDownLatch finished = new CountDownLatch(1); - record BoundaryUser(UUID uuid, ArrayList columns) { } - List users = Collections.synchronizedList(new ArrayList<>()); LocalDateTime lastMonthTime = top == TopVoter.Monthly ? previousMonthTime(transition) : null; - plugin.getUserManager().forEachUserKeys((uuid, columns) -> users.add(new BoundaryUser(uuid, columns)), - count -> finished.countDown()); - try { - if (!finished.await(10, TimeUnit.MINUTES)) { - throw new IllegalStateException("Timed out loading time-change users"); - } - } catch (InterruptedException interrupted) { - Thread.currentThread().interrupt(); - throw new CancellationException("Interrupted while loading time-change users"); - } - users.sort(Comparator.comparing(boundary -> boundary.uuid().toString())); - for (BoundaryUser boundary : users) { - String value = boundary.uuid().toString(); - if (value.compareTo(cursor.get()) <= 0) continue; + // AdvancedCore streams deterministic UUID-ordered SQL pages synchronously. + // Process each row as it arrives so memory stays bounded and a failure cannot + // leave an uncancelled enumeration running behind a falsely failed transition. + plugin.getUserManager().forEachUserKeys((uuid, columns) -> { + String value = uuid.toString(); + if (value.compareTo(cursor.get()) <= 0) return; try { ensureTransitionActive(transition); - VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(boundary.uuid(), false); + VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, false); user.userDataFetechMode(UserDataFetchMode.TEMP_ONLY); - user.updateTempCacheWithColumns(boundary.columns()); + user.updateTempCacheWithColumns(columns); try { if (top == TopVoter.Daily) processDailyUser(user, transition, value, copiedDailyStreak || plugin.getConfigFile().isUseVoteStreaks()); @@ -717,7 +704,7 @@ record BoundaryUser(UUID uuid, ArrayList columns) { } } catch (Throwable userFailure) { throw new IllegalStateException("Unable to durably process time-change user " + value, userFailure); } - } + }, count -> { }); } void processDailyUser(VotingPluginUser user, TimeChangeTransition transition, String uuid) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java index 2f62f6b490..d1e14c70bf 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java @@ -7,14 +7,18 @@ import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.clearInvocations; 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.List; +import java.nio.file.Path; import org.bukkit.configuration.file.YamlConfiguration; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import com.bencodez.advancedcore.api.time.TimeChangeTransition; import com.bencodez.advancedcore.api.time.TimeType; @@ -27,6 +31,56 @@ import com.bencodez.votingplugin.data.ServerData.TimeChangeUserProgress; class ServerDataTimeChangeRecoveryTest { + @TempDir + Path temporaryDirectory; + + @Test + void perUserCursorUsesTheCompactDurableCheckpointWithoutRewritingServerData() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); + YamlConfiguration yaml = new YamlConfiguration(); + when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile()); + when(plugin.getServerDataFile()).thenReturn(coreData); + when(coreData.getData()).thenReturn(yaml); + TimeChangeTransition transition = transition("WEEK:2026-W38", "2026-W38", TimeType.WEEK); + ServerData data = new ServerData(plugin); + String uuid = "00000000-0000-0000-0000-000000000002"; + + data.beginTimeChangeRecovery(transition); + clearInvocations(coreData); + data.completeTimeChangeUser(transition, uuid); + String nextUuid = "00000000-0000-0000-0000-000000000003"; + data.prepareTimeChangeUserStreak(transition, nextUuid, 4, true); + data.claimTimeChangeUserStreakReward(transition, nextUuid); + + verify(coreData, never()).saveData(); + ServerData recovered = new ServerData(plugin); + assertEquals(uuid, recovered.getTimeChangeCursor(transition)); + assertEquals(TimeChangeRewardState.CLAIMED, + recovered.getTimeChangeUserStreakRewardState(transition, nextUuid)); + assertTrue(temporaryDirectory.resolve("TimeChangeUserCheckpoint.properties").toFile().length() < 4096L); + } + + @Test + void compactCheckpointImportsAnExistingServerDataCursor() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); + YamlConfiguration yaml = new YamlConfiguration(); + when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile()); + when(plugin.getServerDataFile()).thenReturn(coreData); + when(coreData.getData()).thenReturn(yaml); + TimeChangeTransition transition = transition("MONTH:2026-08", "2026-08", TimeType.MONTH); + String cursor = "00000000-0000-0000-0000-000000000009"; + yaml.set("VotingPlugin.TimeChangeRecovery.MONTH.Id", transition.getId()); + yaml.set("VotingPlugin.TimeChangeRecovery.MONTH.Cursor", cursor); + + ServerData data = new ServerData(plugin); + data.beginTimeChangeRecovery(transition); + + assertEquals(cursor, data.getTimeChangeCursor(transition)); + assertEquals(cursor, new ServerData(plugin).getTimeChangeCursor(transition)); + } + @Test void checkpointRetainsPhaseCursorAndRewardReceiptsForTheTransition() { VotingPluginMain plugin = mock(VotingPluginMain.class); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java index db0a8e1b71..782e84b25a 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java @@ -2,7 +2,6 @@ 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.assertThrows; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; @@ -26,9 +25,6 @@ import java.util.Set; import java.util.UUID; import java.util.ArrayList; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import org.junit.jupiter.api.Test; @@ -98,29 +94,33 @@ void ambiguousStreakRewardClaimStopsAutomaticReplay() { } @Test - void userRecoveryWaitsForTheStorageIterationCompletionCallback() throws Exception { + void userRecoveryProcessesRowsDuringTheBoundedStorageWalk() { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); com.bencodez.advancedcore.api.user.UserManager userManager = mock(com.bencodez.advancedcore.api.user.UserManager.class); + com.bencodez.votingplugin.user.UserManager votingUsers = + mock(com.bencodez.votingplugin.user.UserManager.class); ServerData serverData = mock(ServerData.class); + VotingPluginUser user = mock(VotingPluginUser.class); TimeChangeTransition transition = mock(TimeChangeTransition.class); - AtomicReference> completion = new AtomicReference<>(); + UUID uuid = UUID.fromString("00000000-0000-0000-0000-000000000001"); when(plugin.getUserManager()).thenReturn(userManager); + when(plugin.getVotingPluginUserManager()).thenReturn(votingUsers); when(plugin.getServerData()).thenReturn(serverData); when(plugin.getConfigFile().isUseHighestTotals()).thenReturn(true); + when(serverData.getTimeChangeCursor(transition)).thenReturn(""); + when(votingUsers.getVotingPluginUser(uuid, false)).thenReturn(user); doAnswer(invocation -> { - completion.set(invocation.getArgument(1)); + java.util.function.BiConsumer> perUser = invocation.getArgument(0); + Consumer finished = invocation.getArgument(1); + perUser.accept(uuid, new ArrayList<>()); + verify(serverData).completeTimeChangeUser(transition, uuid.toString()); + finished.accept(1); return null; }).when(userManager).forEachUserKeys(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); - CompletableFuture recovery = CompletableFuture.runAsync( - () -> new TopVoterHandler(plugin).processRecoverableUsers(TopVoter.Daily, transition)); - for (int attempt = 0; attempt < 100 && completion.get() == null; attempt++) Thread.sleep(5L); - assertNotNull(completion.get()); - assertFalse(recovery.isDone()); - completion.get().accept(0); - recovery.get(2, TimeUnit.SECONDS); + new TopVoterHandler(plugin).processRecoverableUsers(TopVoter.Daily, transition); } @Test @@ -147,8 +147,8 @@ void userRecoveryFlushesUserChangesBeforeAdvancingTheCursor() { doAnswer(invocation -> { java.util.function.BiConsumer> perUser = invocation.getArgument(0); Consumer finished = invocation.getArgument(1); - perUser.accept(secondUuid, new ArrayList<>()); perUser.accept(firstUuid, new ArrayList<>()); + perUser.accept(secondUuid, new ArrayList<>()); finished.accept(2); return null; }).when(userManager).forEachUserKeys(org.mockito.ArgumentMatchers.any(), From b8660f9dccc6999b42f89f02c5612aa0faf85e5f Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 04:58:03 -0600 Subject: [PATCH 25/61] Close period recovery ordering gaps --- .../votingplugin/listeners/VotiferEvent.java | 2 +- .../timequeue/TimeQueueHandler.java | 7 ++- .../topvoter/TopVoterHandler.java | 13 ++++- .../votingplugin/topvoter/TopVoterLoader.java | 56 ++++++++++++++----- .../service/SharedMysqlPurchaseJournal.java | 8 ++- .../VotiferEventDisabledVoteSiteTest.java | 16 ++++++ .../TimeQueueHandlerRejectionTest.java | 11 ++++ .../topvoter/TopVoterLoaderTest.java | 53 ++++++++++++++++++ .../SharedMysqlPurchaseJournalTest.java | 2 + 9 files changed, 147 insertions(+), 21 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java index b015217c55..7a721d000c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java @@ -92,7 +92,7 @@ private boolean processVoteAttempt(String voteSite, String voteUsername, UUID vo if (plugin.getTimeChecker().isActiveProcessing() && plugin.getConfigFile().isQueueVotesDuringTimeChange()) { plugin.debug("Adding vote to time queue " + voteUsername + "/" + voteSite); - plugin.getTimeQueueHandler().addVote(voteUsername, voteSite); + plugin.getTimeQueueHandler().addVote(voteId, voteUsername, voteSite); return true; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java index 3ebe37048b..382d0c0129 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java @@ -58,7 +58,12 @@ public TimeQueueHandler(VotingPluginMain plugin) { * @param voteSiteName the vote site name */ public void addVote(String voteUsername, String voteSiteName) { - timeChangeQueue.add(new VoteTimeQueue(voteUsername, voteSiteName, + addVote(null, voteUsername, voteSiteName); + } + + /** Adds a vote while preserving the identity assigned at reception. */ + public void addVote(UUID voteId, String voteUsername, String voteSiteName) { + timeChangeQueue.add(new VoteTimeQueue(voteId, voteUsername, voteSiteName, LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); } 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 fb832613ef..c2062198e7 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -1097,12 +1097,13 @@ public void storeTopVoters(TopVoter top) { TimeChangeArchiveSnapshot buildTopVoterArchiveSnapshot(TopVoter boundaryTop, TimeChangeTransition transition) { List sections = new ArrayList<>(); - LinkedHashMap boundaryRanking = boundaryTopVotersFor(boundaryTop, transition); + TopVoterLoader.BoundaryRanking boundary = boundaryRankingFor(boundaryTop, transition); + LinkedHashMap boundaryRanking = boundary.players(); for (TopVoter current : TopVoter.values()) { ArrayList lines = new ArrayList<>(); int total = 0; if (current == boundaryTop) { - for (Integer value : boundaryRanking.values()) total += value.intValue(); + total = boundary.combinedTotal(); } else { try { for (Integer value : plugin.getUserManager().getNumbersInColumn(current.getColumnName())) { @@ -1128,6 +1129,14 @@ TimeChangeArchiveSnapshot buildTopVoterArchiveSnapshot(TopVoter boundaryTop, return new TimeChangeArchiveSnapshot(sections); } + private TopVoterLoader.BoundaryRanking boundaryRankingFor(TopVoter top, + TimeChangeTransition transition) { + if (top == TopVoter.Monthly && plugin.getConfigFile().isUseMonthDateTotalsAsPrimaryTotal()) { + return loader.getBoundaryRanking(TopVoter.Monthly, previousMonthTime(transition)); + } + return loader.getBoundaryRanking(top, null); + } + void storeTopVoters(TopVoter top, TimeChangeTransition transition, TimeChangeArchiveSnapshot snapshot) { String fileName = timeChangeArchiveFileName(top, transition); YMLFileHandler file = new YMLFileHandler(plugin, new File(plugin.getDataFolder(), fileName)); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterLoader.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterLoader.java index 50f683c223..03b48a9cd5 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterLoader.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterLoader.java @@ -19,6 +19,7 @@ /** Loads top-voter rankings from user storage. */ public class TopVoterLoader { + record BoundaryRanking(LinkedHashMap players, int combinedTotal) { } private final VotingPluginMain plugin; @@ -52,18 +53,23 @@ public LinkedHashMap getMonthlyTopVotersAtTime(LocalDat /** Loads a ranking from the immutable last-period column copied at transition admission. */ public LinkedHashMap getBoundaryTopVoters(TopVoter top) { - return getBoundaryTopVoters(top, null); + return getBoundaryRanking(top, null).players(); } /** Loads an immutable dated-month ranking without stopping when shutdown starts. */ public LinkedHashMap getBoundaryMonthlyTopVotersAtTime(LocalDateTime atTime) { - return getBoundaryTopVoters(TopVoter.Monthly, atTime); + return getBoundaryRanking(TopVoter.Monthly, atTime).players(); } - private LinkedHashMap getBoundaryTopVoters(TopVoter top, + BoundaryRanking getBoundaryRanking(TopVoter top, LocalDateTime monthlyTime) { + return loadBoundaryRanking(top, monthlyTime); + } + + private BoundaryRanking loadBoundaryRanking(TopVoter top, LocalDateTime monthlyTime) { LinkedHashMap topVoters = new LinkedHashMap<>(); - CountDownLatch latch = new CountDownLatch(1); + int[] combinedTotal = { 0 }; + int limit = plugin.getConfigFile().getMaxiumNumberOfTopVotersToLoad(); plugin.getUserManager().forEachUserKeys((uuid, columns) -> { if (uuid == null) return; VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, false); @@ -76,20 +82,42 @@ private LinkedHashMap getBoundaryTopVoters(TopVoter top case Monthly -> user.getLastMonthTotal(); default -> 0; } : user.getTotal(TopVoter.Monthly, monthlyTime); - if (total > 0) topVoters.put(user.getTopVoterPlayer(), total); + if (total > 0) { + combinedTotal[0] += total; + addBounded(topVoters, user.getTopVoterPlayer(), total, limit); + } } finally { user.clearTempCache(); } - }, count -> latch.countDown()); - try { - if (!latch.await(10, TimeUnit.MINUTES)) { - throw new IllegalStateException("Timed out loading boundary top voters"); - } - } catch (InterruptedException interrupted) { - Thread.currentThread().interrupt(); - throw new IllegalStateException("Interrupted while loading boundary top voters", interrupted); + }, count -> { }); + return new BoundaryRanking(TopVoterRanking.sortByValues(topVoters, false), combinedTotal[0]); + } + + private static void addBounded(LinkedHashMap ranking, + TopVoterPlayer player, int total, int limit) { + if (limit <= 0 || ranking.size() < limit) { + ranking.put(player, total); + return; } - return TopVoterRanking.sortByValues(topVoters, false); + Entry worst = null; + for (Entry entry : ranking.entrySet()) { + if (worst == null || compareRank(entry.getKey(), entry.getValue(), + worst.getKey(), worst.getValue()) > 0) worst = entry; + } + if (worst != null && compareRank(player, total, worst.getKey(), worst.getValue()) < 0) { + ranking.remove(worst.getKey()); + ranking.put(player, total); + } + } + + /** Negative when the first entry ranks ahead of the second. */ + private static int compareRank(TopVoterPlayer first, int firstTotal, + TopVoterPlayer second, int secondTotal) { + int totals = Integer.compare(secondTotal, firstTotal); + if (totals != 0) return totals; + int times = first.getLastVoteTime().compareTo(second.getLastVoteTime()); + if (times != 0) return times; + return first.getUuid().toString().compareTo(second.getUuid().toString()); } public LinkedHashMap getTopVotersOfMonth(YearMonth month, 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 index d3ab5aee54..9c1a46bd46 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -768,7 +768,6 @@ private DailyStreakResult applyDailyStreak(UUID voteId, String uuid, int streak, private boolean hasEarlierPendingDailyStreak(Connection connection, UUID voteId, String uuid, long updatedAt) throws SQLException { - int streakOperations = DAILY_STREAK | DAILY_STREAK_REWARD; String select = "SELECT " + qi("vote_id") + " FROM " + qiAccounting() + " WHERE " + qi("player_uuid") + " = ? AND " + qi("vote_id") + " <> ? AND " + qi("streak_updated_at") + " < ? AND (" + qi("requested") + " & ?) <> (" + qi("completed") @@ -777,8 +776,11 @@ private boolean hasEarlierPendingDailyStreak(Connection connection, UUID voteId, statement.setString(1, uuid); statement.setString(2, voteId.toString()); statement.setLong(3, updatedAt); - statement.setInt(4, streakOperations); - statement.setInt(5, streakOperations); + // Only the ordered streak mutation blocks its successor. A reward claim can + // remain intentionally ambiguous forever, but its journaled streak value is + // independent and must not starve later-day mutations. + statement.setInt(4, DAILY_STREAK); + statement.setInt(5, DAILY_STREAK); try (ResultSet result = statement.executeQuery()) { return result.next(); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java index c7cf973665..6a8f21febc 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java @@ -10,6 +10,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; @@ -25,6 +26,7 @@ import com.bencodez.votingplugin.events.PlayerVoteEvent; import com.bencodez.votingplugin.listeners.VotiferEvent; import com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue; +import com.bencodez.votingplugin.timequeue.TimeQueueHandler; import com.bencodez.votingplugin.votesites.VoteSiteManager; import com.vexsoftware.votifier.model.Vote; @@ -157,4 +159,18 @@ public void testVoteIsQueuedWhenBoundedExecutorRejectsIt() { org.mockito.ArgumentMatchers.eq(SERVICE_SITE), any()); verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class)); } + + @Test + void timeChangeQueueReceivesTheOriginalVoteId() { + TimeQueueHandler timeQueue = mock(TimeQueueHandler.class); + UUID voteId = UUID.randomUUID(); + when(plugin.getTimeQueueHandler()).thenReturn(timeQueue); + when(plugin.getTimeChecker().isActiveProcessing()).thenReturn(true); + when(plugin.getConfigFile().isQueueVotesDuringTimeChange()).thenReturn(true); + + listener.processVote(SERVICE_SITE, "Steve", voteId); + + verify(timeQueue).addVote(voteId, "Steve", SERVICE_SITE); + verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class)); + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java index 64810210f6..d68a6f8fd2 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java @@ -81,6 +81,17 @@ void dateChangeRejectionDoesNotEscapeBukkitEventHandler() { verify(logger, org.mockito.Mockito.atLeastOnce()).warning(anyString()); } + @Test + void queueAdmissionPreservesTheReceptionVoteId() { + when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of()); + TimeQueueHandler handler = new TimeQueueHandler(plugin); + UUID voteId = UUID.randomUUID(); + + handler.addVote(voteId, "Alex", "example.org"); + + assertEquals(voteId, handler.getTimeChangeQueue().peek().getVoteId()); + } + @Test void rejectedProcessingSchedulesOneBoundedRetry() { TimeQueueHandler handler = new TimeQueueHandler(plugin); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterLoaderTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterLoaderTest.java index 84fa442e05..84575627c3 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterLoaderTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterLoaderTest.java @@ -8,6 +8,7 @@ import java.time.YearMonth; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.UUID; import java.util.function.BiConsumer; import java.util.function.Consumer; @@ -17,6 +18,7 @@ import com.bencodez.advancedcore.api.user.UserManager; import com.bencodez.simpleapi.sql.Column; import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.config.Config; import com.bencodez.votingplugin.user.VotingPluginUser; public class TopVoterLoaderTest { @@ -43,10 +45,12 @@ void boundaryRankingUsesCopiedTotalInsteadOfCurrentPeriodTotal() { com.bencodez.votingplugin.user.UserManager votingUserManager = mock(com.bencodez.votingplugin.user.UserManager.class); VotingPluginUser user = mock(VotingPluginUser.class); + Config config = mock(Config.class); UUID uuid = UUID.fromString("00000000-0000-0000-0000-000000000001"); TopVoterPlayer player = new TopVoterPlayer(uuid, "first", 1L); when(plugin.getUserManager()).thenReturn(userManager); when(plugin.getVotingPluginUserManager()).thenReturn(votingUserManager); + when(plugin.getConfigFile()).thenReturn(config); when(votingUserManager.getVotingPluginUser(uuid, false)).thenReturn(user); when(user.getLastDailyTotal()).thenReturn(20); when(user.getTotal(TopVoter.Daily)).thenReturn(99); @@ -62,4 +66,53 @@ void boundaryRankingUsesCopiedTotalInsteadOfCurrentPeriodTotal() { assertEquals(20, new TopVoterLoader(plugin).getBoundaryTopVoters(TopVoter.Daily).get(player)); } + + @Test + @SuppressWarnings("unchecked") + void boundaryRankingKeepsOnlyTheConfiguredBestPlayers() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + UserManager userManager = mock(UserManager.class); + com.bencodez.votingplugin.user.UserManager votingUserManager = + mock(com.bencodez.votingplugin.user.UserManager.class); + Config config = mock(Config.class); + when(plugin.getUserManager()).thenReturn(userManager); + when(plugin.getVotingPluginUserManager()).thenReturn(votingUserManager); + when(plugin.getConfigFile()).thenReturn(config); + when(config.getMaxiumNumberOfTopVotersToLoad()).thenReturn(2); + UUID first = UUID.fromString("00000000-0000-0000-0000-000000000001"); + UUID second = UUID.fromString("00000000-0000-0000-0000-000000000002"); + UUID third = UUID.fromString("00000000-0000-0000-0000-000000000003"); + VotingPluginUser firstUser = boundaryUser(first, "first", 10, votingUserManager); + VotingPluginUser secondUser = boundaryUser(second, "second", 30, votingUserManager); + VotingPluginUser thirdUser = boundaryUser(third, "third", 20, votingUserManager); + doAnswer(invocation -> { + BiConsumer> perUser = invocation.getArgument(0); + Consumer finished = invocation.getArgument(1); + perUser.accept(first, new ArrayList<>()); + perUser.accept(second, new ArrayList<>()); + perUser.accept(third, new ArrayList<>()); + finished.accept(3); + return null; + }).when(userManager).forEachUserKeys(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + + TopVoterLoader.BoundaryRanking boundary = + new TopVoterLoader(plugin).getBoundaryRanking(TopVoter.Daily, null); + LinkedHashMap ranking = boundary.players(); + + assertEquals(java.util.List.of(secondUser.getTopVoterPlayer(), thirdUser.getTopVoterPlayer()), + new ArrayList<>(ranking.keySet())); + assertEquals(java.util.List.of(30, 20), new ArrayList<>(ranking.values())); + assertEquals(60, boundary.combinedTotal()); + } + + private VotingPluginUser boundaryUser(UUID uuid, String name, int total, + com.bencodez.votingplugin.user.UserManager users) { + VotingPluginUser user = mock(VotingPluginUser.class); + TopVoterPlayer player = new TopVoterPlayer(uuid, name, (long) total); + when(users.getVotingPluginUser(uuid, false)).thenReturn(user); + when(user.getLastDailyTotal()).thenReturn(total); + when(user.getTopVoterPlayer()).thenReturn(player); + return user; + } } 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 index c9071e86de..cd7f2facc4 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -754,6 +754,8 @@ void dailyStreakUpdateLocksTheSameBoundaryRow() throws Exception { verify(requestMarkerSelect).setString(1, "streak-copy:DayVoteStreak"); verify(copyMarkerSelect).setString(1, "streak-copy:DayVoteStreak"); + verify(earlierStreakSelect).setInt(4, 16); + verify(earlierStreakSelect).setInt(5, 16); verify(update).setString(1, "1234"); verify(update).setString(2, "00000000-0000-0000-0000-000000000001"); verify(accountingUpdate).setInt(2, 8); From 45cb5d3ee937d1efe1e57546f6a8a04945939f3f Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 05:20:56 -0600 Subject: [PATCH 26/61] Preserve queued changes during period recovery --- .../topvoter/TopVoterHandler.java | 2 +- .../votingplugin/user/VotingPluginUser.java | 6 +-- .../TopVoterTimeChangeRecoveryTest.java | 39 ++++++++++++++++++- .../VotingPluginUserVoteShopLimitTest.java | 18 +++++++-- 4 files changed, 55 insertions(+), 10 deletions(-) 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 c2062198e7..d913ecdbbe 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -695,7 +695,7 @@ void processRecoverableUsers(TopVoter top, TimeChangeTransition transition) { copiedDailyStreak || plugin.getConfigFile().isUseVoteStreaks()); else if (top == TopVoter.Weekly) processWeeklyUser(user, transition, value); else processMonthlyUser(user, lastMonthTime, transition, value); - if (user.getCache() != null) user.getCache().clearChanges(); + if (user.getCache() != null) user.getCache().flushChangesAndRun(() -> { }); } finally { user.clearTempCache(); } 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 b1b46b7e8d..b06bc6f387 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -926,7 +926,6 @@ public void addTotalWeekly(UUID voteId) { private boolean incrementSharedMysqlMonthTotal(UUID voteId) { if (plugin == null || !UserStorage.MYSQL.equals(plugin.getStorageType())) return false; - if (getCache() != null) getCache().clearChanges(); ArrayList columns = new ArrayList<>(); columns.add("MonthTotal"); if (plugin.getConfigFile().isStoreMonthTotalsWithDate()) { @@ -941,16 +940,17 @@ private boolean incrementSharedMysqlMonthTotal(UUID voteId) { "LastMonthTotal", columns, maximum)) { throw new IllegalStateException("Unable to retain shared MySQL monthly vote total"); } + SharedMysqlCacheReconciler.invalidate(plugin, getUUID(), columns.toArray(String[]::new)); return true; } private boolean incrementSharedMysqlPeriodTotal(UUID voteId, TopVoter top, String column) { if (plugin == null || !UserStorage.MYSQL.equals(plugin.getStorageType())) return false; - if (getCache() != null) getCache().clearChanges(); if (!VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, getUUID(), column, top.getLastColumnName(), List.of(column), null)) { throw new IllegalStateException("Unable to retain shared MySQL " + top + " vote total"); } + SharedMysqlCacheReconciler.invalidate(plugin, getUUID(), column); return true; } @@ -2590,11 +2590,11 @@ public void addVotePartyVote() { public void addVotePartyVote(UUID voteId) { int fallbackTotal = getVotePartyVotes() + 1; if (plugin != null && UserStorage.MYSQL.equals(plugin.getStorageType())) { - if (getCache() != null) getCache().clearChanges(); if (!VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, getUUID(), "VotePartyVotes", "LastVotePartyVotes", List.of("VotePartyVotes"), null)) { throw new IllegalStateException("Unable to retain shared MySQL VoteParty count"); } + SharedMysqlCacheReconciler.invalidate(plugin, getUUID(), "VotePartyVotes"); return; } setVotePartyVotes(fallbackTotal); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java index 782e84b25a..c6d17f3dd9 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java @@ -157,12 +157,47 @@ void userRecoveryFlushesUserChangesBeforeAdvancingTheCursor() { new TopVoterHandler(plugin).processRecoverableUsers(TopVoter.Daily, transition); org.mockito.InOrder order = inOrder(cache, serverData); - order.verify(cache).clearChanges(); + order.verify(cache).flushChangesAndRun(org.mockito.ArgumentMatchers.any(Runnable.class)); order.verify(serverData).completeTimeChangeUser(transition, firstUuid.toString()); - order.verify(cache).clearChanges(); + order.verify(cache).flushChangesAndRun(org.mockito.ArgumentMatchers.any(Runnable.class)); order.verify(serverData).completeTimeChangeUser(transition, secondUuid.toString()); } + @Test + void failedUserFlushDoesNotAdvanceTheRecoveryCursor() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + com.bencodez.advancedcore.api.user.UserManager userManager = + mock(com.bencodez.advancedcore.api.user.UserManager.class); + com.bencodez.votingplugin.user.UserManager votingUsers = + mock(com.bencodez.votingplugin.user.UserManager.class); + ServerData serverData = mock(ServerData.class); + VotingPluginUser user = mock(VotingPluginUser.class); + UserDataCache cache = mock(UserDataCache.class); + TimeChangeTransition transition = mock(TimeChangeTransition.class); + UUID uuid = UUID.fromString("00000000-0000-0000-0000-000000000001"); + when(plugin.getUserManager()).thenReturn(userManager); + when(plugin.getVotingPluginUserManager()).thenReturn(votingUsers); + when(plugin.getServerData()).thenReturn(serverData); + when(plugin.getConfigFile().isUseHighestTotals()).thenReturn(true); + when(serverData.getTimeChangeCursor(transition)).thenReturn(""); + when(votingUsers.getVotingPluginUser(uuid, false)).thenReturn(user); + when(user.getCache()).thenReturn(cache); + doAnswer(invocation -> { + java.util.function.BiConsumer> perUser = invocation.getArgument(0); + perUser.accept(uuid, new ArrayList<>()); + return null; + }).when(userManager).forEachUserKeys(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + doAnswer(invocation -> { throw new IllegalStateException("write failed"); }) + .when(cache).flushChangesAndRun(org.mockito.ArgumentMatchers.any(Runnable.class)); + + assertThrows(IllegalStateException.class, + () -> new TopVoterHandler(plugin).processRecoverableUsers(TopVoter.Daily, transition)); + + verify(serverData, never()).completeTimeChangeUser(transition, uuid.toString()); + verify(user).clearTempCache(); + } + @Test void weeklyStreakUsesBoundaryTotalInsteadOfANewWeekVote() { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java index c7a24b71ab..7c344e123b 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java @@ -55,11 +55,14 @@ void sharedMysqlDailyVotesUseTheCrossBackendBoundaryMutation() { when(base.getUserData()).thenReturn(mock(UserData.class)); when(base.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); VotingPluginUser user = spy(new VotingPluginUser(plugin, base)); - doReturn(null).when(user).getCache(); + UserDataCache cache = mock(UserDataCache.class); + doReturn(cache).when(user).getCache(); doReturn(0).when(user).getDailyTotal(); UUID voteId = UUID.randomUUID(); try (MockedStatic service = org.mockito.Mockito - .mockStatic(VoteShopPurchaseService.class)) { + .mockStatic(VoteShopPurchaseService.class); + MockedStatic reconciler = org.mockito.Mockito + .mockStatic(SharedMysqlCacheReconciler.class)) { service.when(() -> VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, base.getUUID(), "DailyTotal", "LastDailyTotal", java.util.List.of("DailyTotal"), null)).thenReturn(true); @@ -67,6 +70,8 @@ void sharedMysqlDailyVotesUseTheCrossBackendBoundaryMutation() { service.verify(() -> VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, base.getUUID(), "DailyTotal", "LastDailyTotal", java.util.List.of("DailyTotal"), null)); + reconciler.verify(() -> SharedMysqlCacheReconciler.invalidate(plugin, base.getUUID(), "DailyTotal")); + verify(cache, never()).clearChanges(); } } @@ -78,11 +83,14 @@ void sharedMysqlVotePartyCountsUseTheCrossBackendBoundaryMutation() { when(base.getUserData()).thenReturn(mock(UserData.class)); when(base.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); VotingPluginUser user = spy(new VotingPluginUser(plugin, base)); - doReturn(null).when(user).getCache(); + UserDataCache cache = mock(UserDataCache.class); + doReturn(cache).when(user).getCache(); doReturn(0).when(user).getVotePartyVotes(); UUID voteId = UUID.randomUUID(); try (MockedStatic service = org.mockito.Mockito - .mockStatic(VoteShopPurchaseService.class)) { + .mockStatic(VoteShopPurchaseService.class); + MockedStatic reconciler = org.mockito.Mockito + .mockStatic(SharedMysqlCacheReconciler.class)) { service.when(() -> VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, base.getUUID(), "VotePartyVotes", "LastVotePartyVotes", java.util.List.of("VotePartyVotes"), null)) .thenReturn(true); @@ -91,6 +99,8 @@ void sharedMysqlVotePartyCountsUseTheCrossBackendBoundaryMutation() { service.verify(() -> VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, base.getUUID(), "VotePartyVotes", "LastVotePartyVotes", java.util.List.of("VotePartyVotes"), null)); + reconciler.verify(() -> SharedMysqlCacheReconciler.invalidate(plugin, base.getUUID(), "VotePartyVotes")); + verify(cache, never()).clearChanges(); } } From b3c1da709bd13e2844add44de44de1e5be7ffa0b Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 05:37:45 -0600 Subject: [PATCH 27/61] Freeze daily streak recovery policy --- .../votingplugin/data/ServerData.java | 27 ++++++++++++++ .../topvoter/TopVoterHandler.java | 20 ++++++----- .../ServerDataTimeChangeRecoveryTest.java | 36 +++++++++++++++++++ .../TopVoterTimeChangeRecoveryTest.java | 21 +++++++++++ 4 files changed, 96 insertions(+), 8 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index a09568e910..4f84784282 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -472,6 +472,33 @@ public synchronized void beginTimeChangeRecovery(TimeChangeTransition transition saveData(); } + /** Fixes the daily streak-boundary policy for the lifetime of one transition. */ + public synchronized boolean prepareTimeChangeDailyStreakBoundary(TimeChangeTransition transition, + boolean proposed) { + String path = timeChangeRecoveryPath(transition.getType()); + if (!transition.getId().equals(getData().getString(path + ".Id", ""))) { + throw new IllegalStateException("Time change recovery transition does not match"); + } + String decisionPath = path + ".DailyStreakBoundary"; + if (!getData().contains(decisionPath)) { + getData().set(decisionPath, proposed); + try { + saveData(); + } catch (RuntimeException failure) { + getData().set(decisionPath, null); + throw failure; + } + } + return getData().getBoolean(decisionPath); + } + + /** Returns the daily streak-boundary policy captured when recovery began. */ + public synchronized boolean isTimeChangeDailyStreakBoundaryRequired(TimeChangeTransition transition) { + String path = timeChangeRecoveryPath(transition.getType()); + return transition.getId().equals(getData().getString(path + ".Id", "")) + && getData().getBoolean(path + ".DailyStreakBoundary", false); + } + /** Returns whether the named phase has been durably completed. */ public synchronized boolean hasTimeChangePhase(TimeChangeTransition transition, String phase) { String path = timeChangeRecoveryPath(transition.getType()); 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 d913ecdbbe..f399a9301e 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -576,6 +576,10 @@ private void processRecoverableChange(TopVoter top, TimeChangeTransition transit try { synchronized (VotingPluginMain.plugin) { plugin.getServerData().beginTimeChangeRecovery(transition); + if (top == TopVoter.Daily) { + plugin.getServerData().prepareTimeChangeDailyStreakBoundary(transition, + plugin.getConfigFile().isUseVoteStreaks()); + } if (!plugin.getServerData().hasTimeChangePhase(transition, COMPLETE)) { runRecoverablePeriod(top, transition); } @@ -655,13 +659,14 @@ private void runRecoverablePeriod(TopVoter top, TimeChangeTransition transition) } } - private void copyTotalBoundary(TopVoter top, TimeChangeTransition transition) { + void copyTotalBoundary(TopVoter top, TimeChangeTransition transition) { ensureTransitionActive(transition); boolean[] copied = { false }; PeriodTotalMutationFence.withReset(() -> { copied[0] = TimeChangeTotalReset.copyBoundary(plugin, top.getColumnName(), top.getLastColumnName(), "time-copy:" + transition.getId()); - if (copied[0] && top == TopVoter.Daily && plugin.getConfigFile().isUseVoteStreaks()) { + if (copied[0] && top == TopVoter.Daily + && plugin.getServerData().isTimeChangeDailyStreakBoundaryRequired(transition)) { copied[0] = TimeChangeTotalReset.copyDailyStreakBoundary(plugin, "time-streak-copy:" + transition.getId()); } @@ -672,11 +677,10 @@ private void copyTotalBoundary(TopVoter top, TimeChangeTransition transition) { } void processRecoverableUsers(TopVoter top, TimeChangeTransition transition) { - boolean copiedDailyStreak = top == TopVoter.Daily && UserStorage.MYSQL.equals(plugin.getStorageType()) - && VoteShopPurchaseService.hasMysqlDailyStreakBoundary(plugin, - "time-streak-copy:" + transition.getId()); - if (!copiedDailyStreak && !plugin.getConfigFile().isUseVoteStreaks() - && !plugin.getConfigFile().isUseHighestTotals()) return; + boolean processVoteStreaks = top == TopVoter.Daily + ? plugin.getServerData().isTimeChangeDailyStreakBoundaryRequired(transition) + : plugin.getConfigFile().isUseVoteStreaks(); + if (!processVoteStreaks && !plugin.getConfigFile().isUseHighestTotals()) return; AtomicReference cursor = new AtomicReference<>(plugin.getServerData().getTimeChangeCursor(transition)); LocalDateTime lastMonthTime = top == TopVoter.Monthly ? previousMonthTime(transition) : null; // AdvancedCore streams deterministic UUID-ordered SQL pages synchronously. @@ -692,7 +696,7 @@ void processRecoverableUsers(TopVoter top, TimeChangeTransition transition) { user.updateTempCacheWithColumns(columns); try { if (top == TopVoter.Daily) processDailyUser(user, transition, value, - copiedDailyStreak || plugin.getConfigFile().isUseVoteStreaks()); + processVoteStreaks); else if (top == TopVoter.Weekly) processWeeklyUser(user, transition, value); else processMonthlyUser(user, lastMonthTime, transition, value); if (user.getCache() != null) user.getCache().flushChangesAndRun(() -> { }); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java index d1e14c70bf..a851bf8b8c 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java @@ -106,6 +106,42 @@ void checkpointRetainsPhaseCursorAndRewardReceiptsForTheTransition() { verify(coreData, atLeastOnce()).saveData(); } + @Test + void retryKeepsTheDailyStreakBoundaryDecisionFromTransitionStart() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); + YamlConfiguration yaml = new YamlConfiguration(); + when(plugin.getServerDataFile()).thenReturn(coreData); + when(coreData.getData()).thenReturn(yaml); + TimeChangeTransition transition = transition("DAY:2026-09-21", "2026-09-21", TimeType.DAY); + ServerData data = new ServerData(plugin); + data.beginTimeChangeRecovery(transition); + + assertFalse(data.prepareTimeChangeDailyStreakBoundary(transition, false)); + assertFalse(data.prepareTimeChangeDailyStreakBoundary(transition, true)); + assertFalse(new ServerData(plugin).isTimeChangeDailyStreakBoundaryRequired(transition)); + } + + @Test + void failedDailyStreakBoundarySaveDoesNotPublishTheDecision() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); + YamlConfiguration yaml = new YamlConfiguration(); + when(plugin.getServerDataFile()).thenReturn(coreData); + when(coreData.getData()).thenReturn(yaml); + TimeChangeTransition transition = transition("DAY:2026-09-21", "2026-09-21", TimeType.DAY); + ServerData data = new ServerData(plugin); + data.beginTimeChangeRecovery(transition); + doThrow(new IllegalStateException("disk unavailable")).when(coreData).saveData(); + + assertThrows(IllegalStateException.class, + () -> data.prepareTimeChangeDailyStreakBoundary(transition, true)); + + assertFalse(data.isTimeChangeDailyStreakBoundaryRequired(transition)); + doNothing().when(coreData).saveData(); + assertTrue(data.prepareTimeChangeDailyStreakBoundary(transition, true)); + } + @Test void newPeriodReplacesOnlyThatTimeTypesOldCheckpoint() { VotingPluginMain plugin = mock(VotingPluginMain.class); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java index c6d17f3dd9..5af2056db0 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java @@ -45,6 +45,27 @@ import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService; class TopVoterTimeChangeRecoveryTest { + @Test + void retryDoesNotCreateADailyStreakBoundaryAfterTheCapturedDecisionDisabledIt() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + ServerData serverData = mock(ServerData.class); + TimeChangeTransition transition = mock(TimeChangeTransition.class); + when(plugin.getServerData()).thenReturn(serverData); + when(plugin.getConfigFile().isUseVoteStreaks()).thenReturn(true); + when(serverData.isTimeChangeDailyStreakBoundaryRequired(transition)).thenReturn(false); + when(transition.getId()).thenReturn("DAY:2026-09-21"); + try (MockedStatic reset = org.mockito.Mockito.mockStatic(TimeChangeTotalReset.class)) { + reset.when(() -> TimeChangeTotalReset.copyBoundary(plugin, "DailyTotal", "LastDailyTotal", + "time-copy:DAY:2026-09-21")).thenReturn(true); + + new TopVoterHandler(plugin).copyTotalBoundary(TopVoter.Daily, transition); + + reset.verify(() -> TimeChangeTotalReset.copyBoundary(plugin, "DailyTotal", "LastDailyTotal", + "time-copy:DAY:2026-09-21")); + reset.verifyNoMoreInteractions(); + } + } + @Test void retryUsesPersistedAbsoluteTargetAndDoesNotDuplicateCompletedStreakReward() { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); From ef319470d3b713897e1630425e9be1b760911486 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 05:59:18 -0600 Subject: [PATCH 28/61] Freeze recoverable transition inputs --- .../votingplugin/data/ServerData.java | 64 +++++++++++++--- .../specialrewards/voteparty/VoteParty.java | 45 +++++------ .../topvoter/TopVoterHandler.java | 75 ++++++++++++------- .../service/SharedMysqlPurchaseJournal.java | 26 ++++++- .../service/VoteShopPurchaseService.java | 11 ++- .../votingplugin/tests/VotePartyTest.java | 23 ++++++ .../ServerDataTimeChangeRecoveryTest.java | 17 +++-- .../TopVoterTimeChangeRecoveryTest.java | 33 +++++++- .../SharedMysqlPurchaseJournalTest.java | 35 +++++++++ 9 files changed, 251 insertions(+), 78 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index 4f84784282..23a2a7e212 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -27,6 +27,9 @@ public enum TimeChangeRewardState { public record TimeChangeUserProgress(String uuid, int streakTarget, boolean rewardRequired, boolean rewardComplete) { } + public record TimeChangeUserPolicy(boolean voteStreaks, boolean highestTotals, + boolean monthDateTotalsPrimary, boolean streakUsesPercentage, + double dayPercentage, double weekPercentage, double monthPercentage) { } public record TimeChangeRewardTarget(String uuid, String playerName, int place, String reward, int votes) { } public record TimeChangeArchiveSection(String name, List lines) { public TimeChangeArchiveSection { @@ -472,31 +475,68 @@ public synchronized void beginTimeChangeRecovery(TimeChangeTransition transition saveData(); } - /** Fixes the daily streak-boundary policy for the lifetime of one transition. */ - public synchronized boolean prepareTimeChangeDailyStreakBoundary(TimeChangeTransition transition, - boolean proposed) { + /** Fixes per-user period processing policy for the lifetime of one transition. */ + public synchronized TimeChangeUserPolicy prepareTimeChangeUserPolicy(TimeChangeTransition transition, + TimeChangeUserPolicy proposed) { String path = timeChangeRecoveryPath(transition.getType()); if (!transition.getId().equals(getData().getString(path + ".Id", ""))) { throw new IllegalStateException("Time change recovery transition does not match"); } - String decisionPath = path + ".DailyStreakBoundary"; - if (!getData().contains(decisionPath)) { - getData().set(decisionPath, proposed); + String policyPath = path + ".UserPolicy"; + if (!getData().getBoolean(policyPath + ".Prepared", false)) { + getData().set(policyPath + ".VoteStreaks", proposed.voteStreaks()); + getData().set(policyPath + ".HighestTotals", proposed.highestTotals()); + getData().set(policyPath + ".MonthDateTotalsPrimary", proposed.monthDateTotalsPrimary()); + getData().set(policyPath + ".StreakUsesPercentage", proposed.streakUsesPercentage()); + getData().set(policyPath + ".DayPercentage", proposed.dayPercentage()); + getData().set(policyPath + ".WeekPercentage", proposed.weekPercentage()); + getData().set(policyPath + ".MonthPercentage", proposed.monthPercentage()); + getData().set(policyPath + ".Prepared", true); try { saveData(); } catch (RuntimeException failure) { - getData().set(decisionPath, null); + getData().set(policyPath, null); throw failure; } } - return getData().getBoolean(decisionPath); + return getTimeChangeUserPolicy(transition); } - /** Returns the daily streak-boundary policy captured when recovery began. */ - public synchronized boolean isTimeChangeDailyStreakBoundaryRequired(TimeChangeTransition transition) { + /** Returns the per-user policy captured when recovery began. */ + public synchronized TimeChangeUserPolicy getTimeChangeUserPolicy(TimeChangeTransition transition) { String path = timeChangeRecoveryPath(transition.getType()); - return transition.getId().equals(getData().getString(path + ".Id", "")) - && getData().getBoolean(path + ".DailyStreakBoundary", false); + String policyPath = path + ".UserPolicy"; + if (!transition.getId().equals(getData().getString(path + ".Id", "")) + || !getData().getBoolean(policyPath + ".Prepared", false)) { + throw new IllegalStateException("Time change user policy is not prepared"); + } + return new TimeChangeUserPolicy(getData().getBoolean(policyPath + ".VoteStreaks"), + getData().getBoolean(policyPath + ".HighestTotals"), + getData().getBoolean(policyPath + ".MonthDateTotalsPrimary"), + getData().getBoolean(policyPath + ".StreakUsesPercentage"), + getData().getDouble(policyPath + ".DayPercentage"), + getData().getDouble(policyPath + ".WeekPercentage"), + getData().getDouble(policyPath + ".MonthPercentage")); + } + + /** Fixes whether one listener effect belongs to this transition. */ + public synchronized boolean prepareTimeChangeEffectPolicy(TimeChangeTransition transition, String effect, + boolean proposed) { + String path = timeChangeRecoveryPath(transition.getType()); + if (!transition.getId().equals(getData().getString(path + ".Id", ""))) { + throw new IllegalStateException("Time change recovery transition does not match"); + } + String policyPath = path + ".EffectPolicies." + effect; + if (!getData().contains(policyPath)) { + getData().set(policyPath, proposed); + try { + saveData(); + } catch (RuntimeException failure) { + getData().set(policyPath, null); + throw failure; + } + } + return getData().getBoolean(policyPath); } /** Returns whether the named phase has been durably completed. */ diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java index 12eddece63..87b44ac9fc 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java @@ -258,9 +258,8 @@ public void giveRewards(VotingPluginUser orgUser, boolean forceBungee) { */ @EventHandler public void onDayChange(DayChangeEvent event) { - if (plugin.getSpecialRewardsConfig().isVotePartyResetEachDay()) { - runRecoverableVotePartyReset(event.getTransition(), "VotePartyDayReset"); - } + runRecoverableVotePartyReset(event.getTransition(), "VotePartyDayReset", + plugin.getSpecialRewardsConfig().isVotePartyResetEachDay()); } /** @@ -270,13 +269,10 @@ public void onDayChange(DayChangeEvent event) { */ @EventHandler public void onMonthChange(MonthChangeEvent event) { - if (plugin.getSpecialRewardsConfig().isVotePartyResetMonthly()) { - runRecoverableVotePartyReset(event.getTransition(), "VotePartyMonthReset"); - } - - if (plugin.getSpecialRewardsConfig().isVotePartyResetExtraVotesMonthly()) { - runRecoverableExtraReset(event.getTransition(), "VotePartyMonthExtraVotes"); - } + runRecoverableVotePartyReset(event.getTransition(), "VotePartyMonthReset", + plugin.getSpecialRewardsConfig().isVotePartyResetMonthly()); + runRecoverableExtraReset(event.getTransition(), "VotePartyMonthExtraVotes", + plugin.getSpecialRewardsConfig().isVotePartyResetExtraVotesMonthly()); } /** @@ -286,21 +282,18 @@ public void onMonthChange(MonthChangeEvent event) { */ @EventHandler public void onWeekChange(WeekChangeEvent event) { - if (plugin.getSpecialRewardsConfig().isVotePartyResetWeekly()) { - runRecoverableVotePartyReset(event.getTransition(), "VotePartyWeekReset"); - } - - if (plugin.getSpecialRewardsConfig().isVotePartyResetExtraVotesWeekly()) { - runRecoverableExtraReset(event.getTransition(), "VotePartyWeekExtraVotes"); - } + runRecoverableVotePartyReset(event.getTransition(), "VotePartyWeekReset", + plugin.getSpecialRewardsConfig().isVotePartyResetWeekly()); + runRecoverableExtraReset(event.getTransition(), "VotePartyWeekExtraVotes", + plugin.getSpecialRewardsConfig().isVotePartyResetExtraVotesWeekly()); } - private void runRecoverableVotePartyReset(TimeChangeTransition transition, String effect) { + private void runRecoverableVotePartyReset(TimeChangeTransition transition, String effect, boolean configured) { if (transition == null) { - reset(true); + if (configured) reset(true); return; } - runRecoverableReset(transition, effect, () -> { + runRecoverableReset(transition, effect, configured, () -> { String generation = transition.getId() + ':' + effect; if (!copyRecoverableUserCountBoundary("vote-party-copy:" + generation)) { throw new IllegalStateException("Unable to durably capture VoteParty user counts"); @@ -323,16 +316,17 @@ public boolean resetRecoverableUserCounts(String generation) { return state.resetUserCounts(generation); } - private void runRecoverableExtraReset(TimeChangeTransition transition, String effect) { + private void runRecoverableExtraReset(TimeChangeTransition transition, String effect, boolean configured) { if (transition == null) { - plugin.getServerData().setVotePartyExtraRequired(0); + if (configured) plugin.getServerData().setVotePartyExtraRequired(0); return; } - runRecoverableReset(transition, effect, + runRecoverableReset(transition, effect, configured, () -> plugin.getServerData().completeTimeChangeVotePartyExtraReset(transition, effect)); } - private synchronized void runRecoverableReset(TimeChangeTransition transition, String effect, Runnable reset) { + private synchronized void runRecoverableReset(TimeChangeTransition transition, String effect, + boolean configured, Runnable reset) { if (transition == null) { reset.run(); return; @@ -343,7 +337,8 @@ private synchronized void runRecoverableReset(TimeChangeTransition transition, S throw new java.util.concurrent.CancellationException("Time transition was cancelled"); } plugin.getServerData().beginTimeChangeRecovery(transition); - if (!plugin.getServerData().hasTimeChangeEffect(transition, effect)) { + boolean required = plugin.getServerData().prepareTimeChangeEffectPolicy(transition, effect, configured); + if (required && !plugin.getServerData().hasTimeChangeEffect(transition, effect)) { reset.run(); } if (transition.isCancellationRequested()) { 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 f399a9301e..0d6e652830 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -44,6 +44,7 @@ import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardTarget; import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardState; import com.bencodez.votingplugin.data.ServerData.TimeChangeUserProgress; +import com.bencodez.votingplugin.data.ServerData.TimeChangeUserPolicy; import com.bencodez.votingplugin.user.PeriodTotalMutationFence; import com.bencodez.votingplugin.user.VotingPluginUser; import com.bencodez.votingplugin.voteshop.service.VoteShopLimitMutationFence; @@ -576,10 +577,7 @@ private void processRecoverableChange(TopVoter top, TimeChangeTransition transit try { synchronized (VotingPluginMain.plugin) { plugin.getServerData().beginTimeChangeRecovery(transition); - if (top == TopVoter.Daily) { - plugin.getServerData().prepareTimeChangeDailyStreakBoundary(transition, - plugin.getConfigFile().isUseVoteStreaks()); - } + plugin.getServerData().prepareTimeChangeUserPolicy(transition, currentTimeChangeUserPolicy()); if (!plugin.getServerData().hasTimeChangePhase(transition, COMPLETE)) { runRecoverablePeriod(top, transition); } @@ -596,6 +594,16 @@ private void processRecoverableChange(TopVoter top, TimeChangeTransition transit } } + private TimeChangeUserPolicy currentTimeChangeUserPolicy() { + return new TimeChangeUserPolicy(plugin.getConfigFile().isUseVoteStreaks(), + plugin.getConfigFile().isUseHighestTotals(), + plugin.getConfigFile().isUseMonthDateTotalsAsPrimaryTotal(), + plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage(), + plugin.getSpecialRewardsConfig().getVoteStreakRequirementDay(), + plugin.getSpecialRewardsConfig().getVoteStreakRequirementWeek(), + plugin.getSpecialRewardsConfig().getVoteStreakRequirementMonth()); + } + private void runRecoverablePeriod(TopVoter top, TimeChangeTransition transition) { // Capture the boundary before any long-running phase. The database journal // makes the later COPY_TOTALS retry a no-op, so votes accepted while reward @@ -666,7 +674,7 @@ void copyTotalBoundary(TopVoter top, TimeChangeTransition transition) { copied[0] = TimeChangeTotalReset.copyBoundary(plugin, top.getColumnName(), top.getLastColumnName(), "time-copy:" + transition.getId()); if (copied[0] && top == TopVoter.Daily - && plugin.getServerData().isTimeChangeDailyStreakBoundaryRequired(transition)) { + && plugin.getServerData().getTimeChangeUserPolicy(transition).voteStreaks()) { copied[0] = TimeChangeTotalReset.copyDailyStreakBoundary(plugin, "time-streak-copy:" + transition.getId()); } @@ -677,10 +685,8 @@ void copyTotalBoundary(TopVoter top, TimeChangeTransition transition) { } void processRecoverableUsers(TopVoter top, TimeChangeTransition transition) { - boolean processVoteStreaks = top == TopVoter.Daily - ? plugin.getServerData().isTimeChangeDailyStreakBoundaryRequired(transition) - : plugin.getConfigFile().isUseVoteStreaks(); - if (!processVoteStreaks && !plugin.getConfigFile().isUseHighestTotals()) return; + TimeChangeUserPolicy policy = plugin.getServerData().getTimeChangeUserPolicy(transition); + if (!policy.voteStreaks() && !policy.highestTotals()) return; AtomicReference cursor = new AtomicReference<>(plugin.getServerData().getTimeChangeCursor(transition)); LocalDateTime lastMonthTime = top == TopVoter.Monthly ? previousMonthTime(transition) : null; // AdvancedCore streams deterministic UUID-ordered SQL pages synchronously. @@ -695,10 +701,9 @@ void processRecoverableUsers(TopVoter top, TimeChangeTransition transition) { user.userDataFetechMode(UserDataFetchMode.TEMP_ONLY); user.updateTempCacheWithColumns(columns); try { - if (top == TopVoter.Daily) processDailyUser(user, transition, value, - processVoteStreaks); - else if (top == TopVoter.Weekly) processWeeklyUser(user, transition, value); - else processMonthlyUser(user, lastMonthTime, transition, value); + if (top == TopVoter.Daily) processDailyUser(user, transition, value, policy); + else if (top == TopVoter.Weekly) processWeeklyUser(user, transition, value, policy); + else processMonthlyUser(user, lastMonthTime, transition, value, policy); if (user.getCache() != null) user.getCache().flushChangesAndRun(() -> { }); } finally { user.clearTempCache(); @@ -717,8 +722,14 @@ void processDailyUser(VotingPluginUser user, TimeChangeTransition transition, St void processDailyUser(VotingPluginUser user, TimeChangeTransition transition, String uuid, boolean processVoteStreaks) { + processDailyUser(user, transition, uuid, new TimeChangeUserPolicy(processVoteStreaks, + plugin.getConfigFile().isUseHighestTotals(), false, false, 0, 0, 0)); + } + + void processDailyUser(VotingPluginUser user, TimeChangeTransition transition, String uuid, + TimeChangeUserPolicy policy) { int boundaryTotal = user.getLastDailyTotal(); - if (processVoteStreaks) { + if (policy.voteStreaks()) { PeriodTotalMutationFence.withReset(() -> { int boundaryStreak = user.getLastDayVoteStreak(); long boundaryUpdate = user.getLastDayVoteStreakLastUpdate(); @@ -730,25 +741,30 @@ void processDailyUser(VotingPluginUser user, TimeChangeTransition transition, St } }); } - if (plugin.getConfigFile().isUseHighestTotals() + if (policy.highestTotals() && user.getHighestDailyTotal() < boundaryTotal) { user.setHighestDailyTotal(boundaryTotal); } } void processWeeklyUser(VotingPluginUser user, TimeChangeTransition transition, String uuid) { + processWeeklyUser(user, transition, uuid, currentTimeChangeUserPolicy()); + } + + void processWeeklyUser(VotingPluginUser user, TimeChangeTransition transition, String uuid, + TimeChangeUserPolicy policy) { int boundaryTotal = user.getLastWeeklyTotal(); - if (plugin.getConfigFile().isUseVoteStreaks()) { + if (policy.voteStreaks()) { if (boundaryTotal == 0 && user.getWeekVoteStreak() != 0) { applyRecoverableStreak(user, transition, uuid, TopVoter.Weekly, 0, false); - } else if (!plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage() + } else if (!policy.streakUsesPercentage() || user.hasPercentageTotal(TopVoter.Weekly, - plugin.getSpecialRewardsConfig().getVoteStreakRequirementWeek(), null, boundaryTotal)) { + policy.weekPercentage(), null, boundaryTotal)) { applyRecoverableStreak(user, transition, uuid, TopVoter.Weekly, user.getWeekVoteStreak() + 1, true); } } - if (plugin.getConfigFile().isUseHighestTotals() + if (policy.highestTotals() && user.getHighestWeeklyTotal() < boundaryTotal) { user.setHighestWeeklyTotal(boundaryTotal); } @@ -756,20 +772,25 @@ void processWeeklyUser(VotingPluginUser user, TimeChangeTransition transition, S void processMonthlyUser(VotingPluginUser user, LocalDateTime lastMonthTime, TimeChangeTransition transition, String uuid) { - int boundaryTotal = plugin.getConfigFile().isUseMonthDateTotalsAsPrimaryTotal() + processMonthlyUser(user, lastMonthTime, transition, uuid, currentTimeChangeUserPolicy()); + } + + void processMonthlyUser(VotingPluginUser user, LocalDateTime lastMonthTime, + TimeChangeTransition transition, String uuid, TimeChangeUserPolicy policy) { + int boundaryTotal = policy.monthDateTotalsPrimary() ? user.getTotal(TopVoter.Monthly, lastMonthTime) : user.getLastMonthTotal(); - if (plugin.getConfigFile().isUseVoteStreaks()) { + if (policy.voteStreaks()) { if (boundaryTotal == 0 && user.getMonthVoteStreak() != 0) { applyRecoverableStreak(user, transition, uuid, TopVoter.Monthly, 0, false); - } else if (!plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage() + } else if (!policy.streakUsesPercentage() || user.hasPercentageTotal(TopVoter.Monthly, - plugin.getSpecialRewardsConfig().getVoteStreakRequirementMonth(), lastMonthTime, + policy.monthPercentage(), lastMonthTime, boundaryTotal)) { applyRecoverableStreak(user, transition, uuid, TopVoter.Monthly, user.getMonthVoteStreak() + 1, true); } } - if (plugin.getConfigFile().isUseHighestTotals() + if (policy.highestTotals() && user.getHighestMonthlyTotal() < boundaryTotal) { user.setHighestMonthlyTotal(boundaryTotal); } @@ -871,7 +892,8 @@ List buildTopRewardSnapshot(TopVoter top, TimeChangeTran LinkedHashMap boundaryTopVotersFor(TopVoter top, TimeChangeTransition transition) { - if (top == TopVoter.Monthly && plugin.getConfigFile().isUseMonthDateTotalsAsPrimaryTotal()) { + if (top == TopVoter.Monthly + && plugin.getServerData().getTimeChangeUserPolicy(transition).monthDateTotalsPrimary()) { return loader.getBoundaryMonthlyTopVotersAtTime(previousMonthTime(transition)); } return loader.getBoundaryTopVoters(top); @@ -1135,7 +1157,8 @@ TimeChangeArchiveSnapshot buildTopVoterArchiveSnapshot(TopVoter boundaryTop, private TopVoterLoader.BoundaryRanking boundaryRankingFor(TopVoter top, TimeChangeTransition transition) { - if (top == TopVoter.Monthly && plugin.getConfigFile().isUseMonthDateTotalsAsPrimaryTotal()) { + if (top == TopVoter.Monthly + && plugin.getServerData().getTimeChangeUserPolicy(transition).monthDateTotalsPrimary()) { return loader.getBoundaryRanking(TopVoter.Monthly, previousMonthTime(transition)); } return loader.getBoundaryRanking(top, null); 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 index 9c1a46bd46..18d1e1f7b8 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -312,21 +312,39 @@ private void commitAndConfirmRequested(Connection connection, UUID voteId, int o boolean incrementPeriodTotals(UUID voteId, String uuid, String boundaryColumn, String previousColumn, List columns, Integer maximum, boolean alreadyRequested) throws SQLException { + return incrementPeriodTotalsResolved(voteId, uuid, boundaryColumn, previousColumn, columns, maximum, + alreadyRequested).applied(); + } + + PeriodTotalResult incrementPeriodTotalsResolved(UUID voteId, String uuid, String boundaryColumn, + String previousColumn, List columns, Integer maximum, boolean alreadyRequested) + throws SQLException { + int operation = accountingOperation(boundaryColumn); + if (alreadyRequested) { + AccountingRow row = findAccountingVote(voteId); + if (row == null || (row.requested() & operation) == 0 || !java.util.Objects.equals(row.uuid(), uuid)) { + throw new SQLException("Admitted period total payload is missing or does not match"); + } + if (operation == MONTH_TOTAL) { + columns = row.monthColumn() == null ? List.of(boundaryColumn) + : List.of(boundaryColumn, row.monthColumn()); + maximum = row.monthMaximum(); + } + } if (voteId == null || uuid == null || uuid.isEmpty() || !isSafeColumn(boundaryColumn) || columns == null || columns.isEmpty() || columns.stream().anyMatch(column -> !isSafeColumn(column)) || maximum != null && (maximum.intValue() < 0 || !isSafeColumn(previousColumn))) { throw new SQLException("Invalid period total increment"); } - int operation = accountingOperation(boundaryColumn); if (!alreadyRequested) { requestAccounting(voteId, uuid, operation, null, operation == MONTH_TOTAL && columns.size() > 1 ? columns.get(1) : null, maximum, null, null); } try { applyPeriodTotals(voteId, uuid, boundaryColumn, previousColumn, columns, maximum, operation); - return true; + return new PeriodTotalResult(true, List.copyOf(columns)); } catch (SQLException deferred) { - return false; + return new PeriodTotalResult(false, List.copyOf(columns)); } } @@ -1577,6 +1595,8 @@ private record AccountingRow(String uuid, int requested, int completed, String m Integer streakAppliedValue) { } + record PeriodTotalResult(boolean applied, List columns) { } + private record DailyStreakCandidate(int dailyTotal, int streak, String lastUpdate) { } private record AccountingRecovery(UUID voteId, AccountingRow row) { 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 063e79538f..ad6bf301d1 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 @@ -626,6 +626,7 @@ public static boolean resetMysqlPeriodTotal(VotingPluginMain plugin, String tota public static boolean incrementMysqlPeriodTotals(VotingPluginMain plugin, UUID voteId, String uuid, String boundaryColumn, String previousColumn, List columns, Integer maximum) { if (!canRecoverSharedMysqlPurchases(plugin) || columns == null || columns.isEmpty()) return false; + List invalidatedColumns = columns; try { MySQL table = plugin.getMysql(); for (String column : columns) table.checkColumn(column, DataType.INTEGER); @@ -634,9 +635,11 @@ public static boolean incrementMysqlPeriodTotals(VotingPluginMain plugin, UUID v int operation = "DailyTotal".equals(boundaryColumn) ? 1 : "WeeklyTotal".equals(boundaryColumn) ? 2 : "MonthTotal".equals(boundaryColumn) ? 4 : 8; int admitted = ADMITTED_ACCOUNTING.getOrDefault(accountingId, Integer.valueOf(0)).intValue(); - boolean applied = SharedMysqlPurchaseJournal.forTable(table).incrementPeriodTotals(accountingId, uuid, - boundaryColumn, previousColumn, columns, maximum, (admitted & operation) != 0); - if (!applied) plugin.getLogger().warning( + SharedMysqlPurchaseJournal.PeriodTotalResult result = SharedMysqlPurchaseJournal.forTable(table) + .incrementPeriodTotalsResolved(accountingId, uuid, boundaryColumn, previousColumn, columns, + maximum, (admitted & operation) != 0); + invalidatedColumns = result.columns(); + if (!result.applied()) plugin.getLogger().warning( "Shared MySQL period total was retained for retry after a persistence failure"); return true; } catch (SQLException failure) { @@ -645,7 +648,7 @@ public static boolean incrementMysqlPeriodTotals(VotingPluginMain plugin, UUID v plugin.debug(failure); return false; } finally { - for (String column : columns) SharedMysqlCacheReconciler.invalidate(plugin, uuid, column); + for (String column : invalidatedColumns) SharedMysqlCacheReconciler.invalidate(plugin, uuid, column); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java index 27530ff782..39936535f2 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java @@ -142,6 +142,8 @@ public void dayResetRecordsTransitionEffectBeforeCompletingLease() { when(transition.getType()).thenReturn(com.bencodez.advancedcore.api.time.TimeType.DAY); when(transition.getId()).thenReturn("DAY:2026-09-21"); when(plugin.getSpecialRewardsConfig().isVotePartyResetEachDay()).thenReturn(true); + when(plugin.getServerData().prepareTimeChangeEffectPolicy(transition, "VotePartyDayReset", true)) + .thenReturn(true); doReturn(true).when(voteParty) .copyRecoverableUserCountBoundary("vote-party-copy:DAY:2026-09-21:VotePartyDayReset"); doReturn(true).when(voteParty) @@ -150,6 +152,7 @@ public void dayResetRecordsTransitionEffectBeforeCompletingLease() { voteParty.onDayChange(new DayChangeEvent(transition)); verify(plugin.getServerData()).beginTimeChangeRecovery(transition); + verify(plugin.getServerData()).prepareTimeChangeEffectPolicy(transition, "VotePartyDayReset", true); InOrder completionOrder = Mockito.inOrder(voteParty, plugin.getServerData(), lease); completionOrder.verify(voteParty) .copyRecoverableUserCountBoundary("vote-party-copy:DAY:2026-09-21:VotePartyDayReset"); @@ -163,6 +166,26 @@ public void dayResetRecordsTransitionEffectBeforeCompletingLease() { verify(plugin.getServerData(), never()).completeTimeChangeEffect(transition, "VotePartyDayReset"); } + @Test + public void dayResetResumesWhenConfigurationWasDisabledAfterAdmission() { + TimeChangeTransition transition = Mockito.mock(TimeChangeTransition.class); + TimeChangeTransition.Lease lease = Mockito.mock(TimeChangeTransition.Lease.class); + when(transition.retain()).thenReturn(lease); + when(transition.getId()).thenReturn("DAY:2026-09-21"); + when(plugin.getSpecialRewardsConfig().isVotePartyResetEachDay()).thenReturn(false); + when(plugin.getServerData().prepareTimeChangeEffectPolicy(transition, "VotePartyDayReset", false)) + .thenReturn(true); + doReturn(true).when(voteParty) + .copyRecoverableUserCountBoundary("vote-party-copy:DAY:2026-09-21:VotePartyDayReset"); + doReturn(true).when(voteParty) + .resetRecoverableUserCounts("vote-party-reset:DAY:2026-09-21:VotePartyDayReset"); + + voteParty.onDayChange(new DayChangeEvent(transition)); + + verify(plugin.getServerData()).completeTimeChangeVotePartyReset(transition, "VotePartyDayReset"); + verify(lease).complete(); + } + @Test public void commandVoteParty_VotePartyEnabled_SendsMessage() { CommandSender sender = Mockito.mock(CommandSender.class); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java index a851bf8b8c..e133cc1fd9 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java @@ -29,6 +29,7 @@ import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardTarget; import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardState; import com.bencodez.votingplugin.data.ServerData.TimeChangeUserProgress; +import com.bencodez.votingplugin.data.ServerData.TimeChangeUserPolicy; class ServerDataTimeChangeRecoveryTest { @TempDir @@ -117,9 +118,11 @@ void retryKeepsTheDailyStreakBoundaryDecisionFromTransitionStart() { ServerData data = new ServerData(plugin); data.beginTimeChangeRecovery(transition); - assertFalse(data.prepareTimeChangeDailyStreakBoundary(transition, false)); - assertFalse(data.prepareTimeChangeDailyStreakBoundary(transition, true)); - assertFalse(new ServerData(plugin).isTimeChangeDailyStreakBoundaryRequired(transition)); + TimeChangeUserPolicy original = new TimeChangeUserPolicy(false, true, false, true, 50, 60, 70); + TimeChangeUserPolicy changed = new TimeChangeUserPolicy(true, false, true, false, 1, 2, 3); + assertEquals(original, data.prepareTimeChangeUserPolicy(transition, original)); + assertEquals(original, data.prepareTimeChangeUserPolicy(transition, changed)); + assertEquals(original, new ServerData(plugin).getTimeChangeUserPolicy(transition)); } @Test @@ -134,12 +137,12 @@ void failedDailyStreakBoundarySaveDoesNotPublishTheDecision() { data.beginTimeChangeRecovery(transition); doThrow(new IllegalStateException("disk unavailable")).when(coreData).saveData(); - assertThrows(IllegalStateException.class, - () -> data.prepareTimeChangeDailyStreakBoundary(transition, true)); + TimeChangeUserPolicy policy = new TimeChangeUserPolicy(true, true, true, true, 50, 60, 70); + assertThrows(IllegalStateException.class, () -> data.prepareTimeChangeUserPolicy(transition, policy)); - assertFalse(data.isTimeChangeDailyStreakBoundaryRequired(transition)); + assertThrows(IllegalStateException.class, () -> data.getTimeChangeUserPolicy(transition)); doNothing().when(coreData).saveData(); - assertTrue(data.prepareTimeChangeDailyStreakBoundary(transition, true)); + assertEquals(policy, data.prepareTimeChangeUserPolicy(transition, policy)); } @Test diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java index 5af2056db0..de8895fecd 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java @@ -40,6 +40,7 @@ import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardTarget; import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardState; import com.bencodez.votingplugin.data.ServerData.TimeChangeUserProgress; +import com.bencodez.votingplugin.data.ServerData.TimeChangeUserPolicy; import com.bencodez.votingplugin.specialrewards.SpecialRewards; import com.bencodez.votingplugin.user.VotingPluginUser; import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService; @@ -52,7 +53,8 @@ void retryDoesNotCreateADailyStreakBoundaryAfterTheCapturedDecisionDisabledIt() TimeChangeTransition transition = mock(TimeChangeTransition.class); when(plugin.getServerData()).thenReturn(serverData); when(plugin.getConfigFile().isUseVoteStreaks()).thenReturn(true); - when(serverData.isTimeChangeDailyStreakBoundaryRequired(transition)).thenReturn(false); + when(serverData.getTimeChangeUserPolicy(transition)).thenReturn( + new TimeChangeUserPolicy(false, true, false, false, 0, 0, 0)); when(transition.getId()).thenReturn("DAY:2026-09-21"); try (MockedStatic reset = org.mockito.Mockito.mockStatic(TimeChangeTotalReset.class)) { reset.when(() -> TimeChangeTotalReset.copyBoundary(plugin, "DailyTotal", "LastDailyTotal", @@ -129,6 +131,8 @@ void userRecoveryProcessesRowsDuringTheBoundedStorageWalk() { when(plugin.getVotingPluginUserManager()).thenReturn(votingUsers); when(plugin.getServerData()).thenReturn(serverData); when(plugin.getConfigFile().isUseHighestTotals()).thenReturn(true); + when(serverData.getTimeChangeUserPolicy(transition)).thenReturn( + new TimeChangeUserPolicy(false, true, false, false, 0, 0, 0)); when(serverData.getTimeChangeCursor(transition)).thenReturn(""); when(votingUsers.getVotingPluginUser(uuid, false)).thenReturn(user); doAnswer(invocation -> { @@ -161,6 +165,8 @@ void userRecoveryFlushesUserChangesBeforeAdvancingTheCursor() { when(plugin.getVotingPluginUserManager()).thenReturn(votingUsers); when(plugin.getServerData()).thenReturn(serverData); when(plugin.getConfigFile().isUseHighestTotals()).thenReturn(true); + when(serverData.getTimeChangeUserPolicy(transition)).thenReturn( + new TimeChangeUserPolicy(false, true, false, false, 0, 0, 0)); when(serverData.getTimeChangeCursor(transition)).thenReturn(""); when(votingUsers.getVotingPluginUser(org.mockito.ArgumentMatchers.any(UUID.class), org.mockito.ArgumentMatchers.eq(false))).thenReturn(user); @@ -200,6 +206,8 @@ void failedUserFlushDoesNotAdvanceTheRecoveryCursor() { when(plugin.getVotingPluginUserManager()).thenReturn(votingUsers); when(plugin.getServerData()).thenReturn(serverData); when(plugin.getConfigFile().isUseHighestTotals()).thenReturn(true); + when(serverData.getTimeChangeUserPolicy(transition)).thenReturn( + new TimeChangeUserPolicy(false, true, false, false, 0, 0, 0)); when(serverData.getTimeChangeCursor(transition)).thenReturn(""); when(votingUsers.getVotingPluginUser(uuid, false)).thenReturn(user); when(user.getCache()).thenReturn(cache); @@ -243,6 +251,29 @@ void weeklyStreakUsesBoundaryTotalInsteadOfANewWeekVote() { org.mockito.ArgumentMatchers.anyInt()); } + @Test + void weeklyRecoveryUsesThePolicyCapturedBeforeConfigurationChanged() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + ServerData serverData = mock(ServerData.class); + VotingPluginUser user = mock(VotingPluginUser.class); + TimeChangeTransition transition = mock(TimeChangeTransition.class); + String uuid = "00000000-0000-0000-0000-000000000001"; + TimeChangeUserPolicy captured = new TimeChangeUserPolicy(true, true, false, false, 0, 0, 0); + when(plugin.getServerData()).thenReturn(serverData); + when(plugin.getConfigFile().isUseVoteStreaks()).thenReturn(false); + when(plugin.getConfigFile().isUseHighestTotals()).thenReturn(false); + when(user.getLastWeeklyTotal()).thenReturn(4); + when(user.getWeekVoteStreak()).thenReturn(2); + when(user.getHighestWeeklyTotal()).thenReturn(1); + when(serverData.prepareTimeChangeUserStreak(transition, uuid, 3, true)) + .thenReturn(new TimeChangeUserProgress(uuid, 3, true, true)); + + new TopVoterHandler(plugin).processWeeklyUser(user, transition, uuid, captured); + + verify(user).setWeekVoteStreak(3); + verify(user).setHighestWeeklyTotal(4); + } + @Test void delayedDailyRecoveryPreservesAStreakStartedAfterTheBoundary() { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); 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 index cd7f2facc4..791c87a255 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -15,6 +15,7 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; +import java.util.List; import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; @@ -631,6 +632,40 @@ void repeatedVoteOperationDoesNotIncrementThePeriodTwice() throws Exception { verify(fixture.work, org.mockito.Mockito.times(6)).prepareStatement(anyString()); } + @Test + void admittedMonthlyIncrementUsesItsJournaledColumnAfterConfigurationChanges() throws Exception { + Fixture fixture = fixture(); + PreparedStatement admittedSelect = mock(PreparedStatement.class); + PreparedStatement markerInsert = mock(PreparedStatement.class); + PreparedStatement markerSelect = mock(PreparedStatement.class); + PreparedStatement accountingSelect = mock(PreparedStatement.class); + PreparedStatement increment = mock(PreparedStatement.class); + PreparedStatement accountingUpdate = mock(PreparedStatement.class); + ResultSet admitted = accountingRow("player", 4, 0); + when(admitted.getString(4)).thenReturn("MonthTotal-SEPTEMBER-2026"); + when(admitted.wasNull()).thenReturn(true); + ResultSet epoch = mock(ResultSet.class); + when(epoch.next()).thenReturn(true); + ResultSet accounting = accountingRow("player", 4, 0); + when(admittedSelect.executeQuery()).thenReturn(admitted); + when(markerSelect.executeQuery()).thenReturn(epoch); + when(accountingSelect.executeQuery()).thenReturn(accounting); + when(increment.executeUpdate()).thenReturn(1); + when(accountingUpdate.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(admittedSelect, markerInsert, markerSelect, + accountingSelect, increment, accountingUpdate); + + SharedMysqlPurchaseJournal.PeriodTotalResult result = new SharedMysqlPurchaseJournal(fixture.table, false) + .incrementPeriodTotalsResolved(UUID.randomUUID(), "player", "MonthTotal", "LastMonthTotal", + List.of("MonthTotal", "MonthTotal-OCTOBER-2026"), Integer.valueOf(99), true); + + assertEquals(List.of("MonthTotal", "MonthTotal-SEPTEMBER-2026"), result.columns()); + org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class); + verify(fixture.work, org.mockito.Mockito.times(6)).prepareStatement(sql.capture()); + assertTrue(sql.getAllValues().get(4).contains("`MonthTotal-SEPTEMBER-2026`")); + assertFalse(sql.getAllValues().get(4).contains("`MonthTotal-OCTOBER-2026`")); + } + @Test void ambiguousPeriodIncrementCommitIsConfirmedByTheVoteMarker() throws Exception { Fixture fixture = fixture(); From f5489e4e870ccb29245218fdcce078376d09a3f8 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 06:19:57 -0600 Subject: [PATCH 29/61] Reuse durable vote and reset decisions --- .../core/vote/SharedVoteAccounting.java | 18 ++++++++- .../core/vote/SharedVoteProcessor.java | 11 +++-- .../votingplugin/data/ServerData.java | 40 ++++++++++++++++++- .../listeners/PlayerVoteListener.java | 11 +++-- .../topvoter/TopVoterHandler.java | 23 ++++++++--- .../service/VoteShopPurchaseService.java | 11 +++-- .../core/vote/SharedVoteProcessorTest.java | 19 ++++++++- .../ServerDataTimeChangeRecoveryTest.java | 24 +++++++++-- .../TopVoterTimeChangeRecoveryTest.java | 13 +++--- 9 files changed, 140 insertions(+), 30 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteAccounting.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteAccounting.java index 7a2e6e306a..ff6bb1979a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteAccounting.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteAccounting.java @@ -18,7 +18,23 @@ public static void apply(SharedVoteInput input, SharedVotePolicy policy, Boolean Objects.requireNonNull(addTotalWeekly, "addTotalWeekly"); Objects.requireNonNull(addPoints, "addPoints"); if (!policy.shouldAwardConfiguredPoints(input)) return; - if (policy.shouldCountTotals(input, currentlyOnline)) { + applyAdmitted(input, policy, policy.shouldCountTotals(input, currentlyOnline), addTotal, addTotalDaily, + addTotalWeekly, addPoints); + } + + /** Applies an accounting decision that was durably admitted before vote side effects began. */ + public static void applyAdmitted(SharedVoteInput input, SharedVotePolicy policy, boolean countTotals, + Runnable addTotal, Runnable addTotalDaily, Runnable addTotalWeekly, Runnable addPoints) { + Objects.requireNonNull(input, "input"); + Objects.requireNonNull(policy, "policy"); + Objects.requireNonNull(addTotal, "addTotal"); + Objects.requireNonNull(addTotalDaily, "addTotalDaily"); + Objects.requireNonNull(addTotalWeekly, "addTotalWeekly"); + Objects.requireNonNull(addPoints, "addPoints"); + // A durable totals admission came from an earlier evaluation of this gate. + // Replays must honor that decision even if live configuration has changed. + if (!countTotals && !policy.shouldAwardConfiguredPoints(input)) return; + if (countTotals) { addTotal.run(); addTotalDaily.run(); addTotalWeekly.run(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java index 98eb14c36a..583728c5f5 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java @@ -15,6 +15,7 @@ public static boolean isQueuedVoteAlreadyRecorded(boolean proxyVote, long messag public record Validation(boolean valid, String normalizedName, String source, String reason, boolean bedrock) { } public record Name(String value, String rationale) { } + public record AccountingAdmission(boolean countTotals, boolean votePartyEligible) { } public interface Operations { boolean enabled(); @@ -59,7 +60,7 @@ public interface Operations { void broadcast(UUID uuid, String name, String siteDisplayName, boolean online); boolean hasProxyTextTotals(); UUID incomingVoteId(); - boolean prepareAccounting(U user, UUID voteId, boolean countTotals); + AccountingAdmission prepareAccounting(U user, UUID voteId, boolean countTotals); void finishAccounting(UUID voteId); void cache(U user); void updateName(U user); @@ -171,8 +172,10 @@ public static void process(Operations ops) { SharedVoteInput accountingInput = new SharedVoteInput(voteId, playerName, ops.serviceSite(), ops.incomingTime(), ops.realVote(), ops.addTotals(), ops.proxyVote(), ops.forceProxyRouting(), ops.wasOnline()); - boolean countTotals = policy.shouldCountTotals(accountingInput, () -> ops.userOnline(user)); - boolean votePartyEligible = ops.prepareAccounting(user, voteId, countTotals); + boolean proposedCountTotals = policy.shouldCountTotals(accountingInput, () -> ops.userOnline(user)); + AccountingAdmission admission = ops.prepareAccounting(user, voteId, proposedCountTotals); + boolean countTotals = admission.countTotals(); + boolean votePartyEligible = admission.votePartyEligible(); try { ops.cache(user); ops.updateName(user); @@ -213,7 +216,7 @@ public static void process(Operations ops) { } SharedVoteInput input = new SharedVoteInput(voteId, playerName, ops.serviceSite(), voteTime, ops.realVote(), ops.addTotals(), ops.proxyVote(), ops.forceProxyRouting(), ops.wasOnline()); - SharedVoteAccounting.apply(input, policy, () -> countTotals, () -> ops.addTotal(user, voteId), + SharedVoteAccounting.applyAdmitted(input, policy, countTotals, () -> ops.addTotal(user, voteId), () -> ops.addTotalDaily(user, voteId), () -> ops.addTotalWeekly(user, voteId), () -> ops.addPoints(user)); ops.checkDayVoteStreak(user, ops.forceProxyRouting(), voteId); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index 23a2a7e212..3f2ff74a79 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -29,7 +29,8 @@ public record TimeChangeUserProgress(String uuid, int streakTarget, boolean rewa boolean rewardComplete) { } public record TimeChangeUserPolicy(boolean voteStreaks, boolean highestTotals, boolean monthDateTotalsPrimary, boolean streakUsesPercentage, - double dayPercentage, double weekPercentage, double monthPercentage) { } + double dayPercentage, double weekPercentage, double monthPercentage, + boolean proxyOwnsResets, boolean waitForProxy) { } public record TimeChangeRewardTarget(String uuid, String playerName, int place, String reward, int votes) { } public record TimeChangeArchiveSection(String name, List lines) { public TimeChangeArchiveSection { @@ -491,6 +492,8 @@ public synchronized TimeChangeUserPolicy prepareTimeChangeUserPolicy(TimeChangeT getData().set(policyPath + ".DayPercentage", proposed.dayPercentage()); getData().set(policyPath + ".WeekPercentage", proposed.weekPercentage()); getData().set(policyPath + ".MonthPercentage", proposed.monthPercentage()); + getData().set(policyPath + ".ProxyOwnsResets", proposed.proxyOwnsResets()); + getData().set(policyPath + ".WaitForProxy", proposed.waitForProxy()); getData().set(policyPath + ".Prepared", true); try { saveData(); @@ -516,7 +519,40 @@ public synchronized TimeChangeUserPolicy getTimeChangeUserPolicy(TimeChangeTrans getData().getBoolean(policyPath + ".StreakUsesPercentage"), getData().getDouble(policyPath + ".DayPercentage"), getData().getDouble(policyPath + ".WeekPercentage"), - getData().getDouble(policyPath + ".MonthPercentage")); + getData().getDouble(policyPath + ".MonthPercentage"), + getData().getBoolean(policyPath + ".ProxyOwnsResets"), + getData().getBoolean(policyPath + ".WaitForProxy")); + } + + /** Fixes the VoteShop identifiers selected for this transition before resets begin. */ + public synchronized List prepareTimeChangeVoteShopTargets(TimeChangeTransition transition, + List proposed) { + String path = timeChangeRecoveryPath(transition.getType()); + if (!transition.getId().equals(getData().getString(path + ".Id", ""))) { + throw new IllegalStateException("Time change recovery transition does not match"); + } + String targetsPath = path + ".VoteShopTargets"; + if (!getData().getBoolean(targetsPath + ".Prepared", false)) { + getData().set(targetsPath + ".Identifiers", List.copyOf(proposed)); + getData().set(targetsPath + ".Prepared", true); + try { + saveData(); + } catch (RuntimeException failure) { + getData().set(targetsPath, null); + throw failure; + } + } + return List.copyOf(getData().getStringList(targetsPath + ".Identifiers")); + } + + public synchronized List getTimeChangeVoteShopTargets(TimeChangeTransition transition) { + String path = timeChangeRecoveryPath(transition.getType()); + String targetsPath = path + ".VoteShopTargets"; + if (!transition.getId().equals(getData().getString(path + ".Id", "")) + || !getData().getBoolean(targetsPath + ".Prepared", false)) { + throw new IllegalStateException("Time change VoteShop targets are not prepared"); + } + return List.copyOf(getData().getStringList(targetsPath + ".Identifiers")); } /** Fixes whether one listener effect belongs to this transition. */ diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java index 0c2bdcd66a..66a5747677 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java @@ -137,15 +137,18 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { if (event.getVoteId() != null) return event.getVoteId(); return event.getBungeeTextTotals() == null ? null : event.getBungeeTextTotals().getVoteUUID(); } - @Override public boolean prepareAccounting(VotingPluginUser user, UUID voteId, boolean countTotals) { + @Override public SharedVoteProcessor.AccountingAdmission prepareAccounting( + VotingPluginUser user, UUID voteId, boolean countTotals) { boolean countVoteParty = plugin.getSpecialRewardsConfig().isVotePartyEnabled() && (plugin.getSpecialRewardsConfig().isVotePartyCountFakeVotes() || event.isRealVote()) && (plugin.getSpecialRewardsConfig().isVotePartyCountOfflineVotes() || user.isOnline()); - if (!VoteShopPurchaseService.prepareMysqlVoteAccounting(plugin, voteId, user.getUUID(), countTotals, - countVoteParty, event.isForceBungee())) { + VoteShopPurchaseService.VoteAccountingAdmission admission = VoteShopPurchaseService + .prepareMysqlVoteAccounting(plugin, voteId, user.getUUID(), countTotals, + countVoteParty, event.isForceBungee()); + if (!admission.success()) { throw new SharedVoteAdmissionException("Unable to admit shared MySQL vote accounting before processing"); } - return countVoteParty; + return new SharedVoteProcessor.AccountingAdmission(admission.countTotals(), admission.countVoteParty()); } @Override public void finishAccounting(UUID voteId) { VoteShopPurchaseService.finishMysqlVoteAccounting(voteId); 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 0d6e652830..731e4622d0 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -578,6 +578,8 @@ private void processRecoverableChange(TopVoter top, TimeChangeTransition transit synchronized (VotingPluginMain.plugin) { plugin.getServerData().beginTimeChangeRecovery(transition); plugin.getServerData().prepareTimeChangeUserPolicy(transition, currentTimeChangeUserPolicy()); + plugin.getServerData().prepareTimeChangeVoteShopTargets(transition, + currentVoteShopResetTargets(top)); if (!plugin.getServerData().hasTimeChangePhase(transition, COMPLETE)) { runRecoverablePeriod(top, transition); } @@ -595,13 +597,20 @@ private void processRecoverableChange(TopVoter top, TimeChangeTransition transit } private TimeChangeUserPolicy currentTimeChangeUserPolicy() { + boolean proxyOwnsResets = bungeeHandleResets(); return new TimeChangeUserPolicy(plugin.getConfigFile().isUseVoteStreaks(), plugin.getConfigFile().isUseHighestTotals(), plugin.getConfigFile().isUseMonthDateTotalsAsPrimaryTotal(), plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage(), plugin.getSpecialRewardsConfig().getVoteStreakRequirementDay(), plugin.getSpecialRewardsConfig().getVoteStreakRequirementWeek(), - plugin.getSpecialRewardsConfig().getVoteStreakRequirementMonth()); + plugin.getSpecialRewardsConfig().getVoteStreakRequirementMonth(), proxyOwnsResets, + plugin.getBungeeSettings().isUseBungeecoord() && !proxyOwnsResets); + } + + private List currentVoteShopResetTargets(TopVoter top) { + return plugin.getShopFile().getShopIdentifiers().stream() + .filter(identifier -> shouldResetVoteShop(top, identifier)).toList(); } private void runRecoverablePeriod(TopVoter top, TimeChangeTransition transition) { @@ -656,7 +665,9 @@ private void runRecoverablePeriod(TopVoter top, TimeChangeTransition transition) if (!plugin.getServerData().hasTimeChangePhase(transition, TOTALS_RESET)) { ensureTransitionActive(transition); - if (!bungeeHandleResets()) resetTotals(top, transition); + if (!plugin.getServerData().getTimeChangeUserPolicy(transition).proxyOwnsResets()) { + resetTotals(top, transition); + } plugin.getServerData().completeTimeChangePhase(transition, TOTALS_RESET); } @@ -723,7 +734,7 @@ void processDailyUser(VotingPluginUser user, TimeChangeTransition transition, St void processDailyUser(VotingPluginUser user, TimeChangeTransition transition, String uuid, boolean processVoteStreaks) { processDailyUser(user, transition, uuid, new TimeChangeUserPolicy(processVoteStreaks, - plugin.getConfigFile().isUseHighestTotals(), false, false, 0, 0, 0)); + plugin.getConfigFile().isUseHighestTotals(), false, false, 0, 0, 0, false, false)); } void processDailyUser(VotingPluginUser user, TimeChangeTransition transition, String uuid, @@ -800,8 +811,8 @@ void processRecoverableVoteShop(TopVoter top, TimeChangeTransition transition) { if (plugin.getServerData().hasTimeChangePhase(transition, VOTE_SHOP)) return; ensureTransitionActive(transition); String generation = VoteShopPurchaseService.limitGenerationIdForTransition(transition); - for (String shopIdent : plugin.getShopFile().getShopIdentifiers()) { - if (shouldResetVoteShop(top, shopIdent) && !resetVoteShopLimit(shopIdent, generation)) { + for (String shopIdent : plugin.getServerData().getTimeChangeVoteShopTargets(transition)) { + if (!resetVoteShopLimit(shopIdent, generation)) { throw new IllegalStateException("Unable to durably reset VoteShop limit " + shopIdent); } } @@ -954,7 +965,7 @@ private boolean shouldResetVoteShop(TopVoter top, String shopIdent) { } private void waitForBungee(TopVoter top, TimeChangeTransition transition) { - boolean wait = plugin.getBungeeSettings().isUseBungeecoord() && !bungeeHandleResets(); + boolean wait = plugin.getServerData().getTimeChangeUserPolicy(transition).waitForProxy(); if (!wait) return; ensureTransitionActive(transition); plugin.debug("Delaying time change 10 seconds for other servers to catchup"); 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 ad6bf301d1..87adccbaa3 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 @@ -53,6 +53,7 @@ public class VoteShopPurchaseService { public enum MysqlDailyStreakResult { APPLIED, ALREADY_UPDATED, NOT_REQUESTED, DEFERRED, FAILED } public record MysqlDailyStreakUpdate(MysqlDailyStreakResult result, int streak, boolean forceProxyRouting) { } + public record VoteAccountingAdmission(boolean success, boolean countTotals, boolean countVoteParty) { } private static final ConcurrentMap ADMITTED_ACCOUNTING = new ConcurrentHashMap<>(); private static final int ACCOUNTING_DAILY_STREAK = 16; private static final int PURCHASE_LOCK_STRIPES = 256; @@ -653,9 +654,11 @@ public static boolean incrementMysqlPeriodTotals(VotingPluginMain plugin, UUID v } /** Durably admits shared total mutations before vote rewards or broadcasts run. */ - public static boolean prepareMysqlVoteAccounting(VotingPluginMain plugin, UUID voteId, String uuid, + public static VoteAccountingAdmission prepareMysqlVoteAccounting(VotingPluginMain plugin, UUID voteId, String uuid, boolean countTotals, boolean countVoteParty, boolean forceProxyRouting) { - if (!canRecoverSharedMysqlPurchases(plugin) || voteId == null) return true; + if (!canRecoverSharedMysqlPurchases(plugin) || voteId == null) { + return new VoteAccountingAdmission(true, countTotals, countVoteParty); + } try { MySQL table = plugin.getMysql(); String monthColumn = plugin.getConfigFile().isStoreMonthTotalsWithDate() @@ -681,12 +684,12 @@ public static boolean prepareMysqlVoteAccounting(VotingPluginMain plugin, UUID v plugin.getVoteSiteManager().getVoteSitesEnabled().size(), forceProxyRouting, System.currentTimeMillis()); ADMITTED_ACCOUNTING.put(voteId, Integer.valueOf(bits)); - return true; + return new VoteAccountingAdmission(true, (bits & 7) != 0, (bits & 8) != 0); } catch (SQLException failure) { plugin.getLogger().severe("Unable to admit shared MySQL vote accounting: " + failure.getClass().getSimpleName()); plugin.debug(failure); - return false; + return new VoteAccountingAdmission(false, false, false); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java index 0359d267f8..80999f0718 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java @@ -39,7 +39,9 @@ private SharedVoteProcessor.Operations accepted() { when(ops.siteKey(site)).thenReturn("ExampleKey"); when(ops.userId(user)).thenReturn("user-id"); when(ops.userUuid(user)).thenReturn(UUID.randomUUID()); - when(ops.prepareAccounting(eq(user), any(UUID.class), anyBoolean())).thenReturn(true); + when(ops.prepareAccounting(eq(user), any(UUID.class), anyBoolean())) + .thenAnswer(invocation -> new SharedVoteProcessor.AccountingAdmission( + invocation.getArgument(2), true)); return ops; } @@ -99,6 +101,21 @@ void totalsUseTheEligibilityCapturedAtAdmission() { verify(ops).addTotalWeekly(eq(user), any(UUID.class)); } + @Test + void replayUsesPersistedAccountingEligibilityInsteadOfTheCurrentProposal() { + var ops = accepted(); + when(ops.addTotals()).thenReturn(false); + when(ops.prepareAccounting(eq(user), any(UUID.class), eq(false))) + .thenReturn(new SharedVoteProcessor.AccountingAdmission(true, false)); + + SharedVoteProcessor.process(ops); + + verify(ops).addTotal(eq(user), any(UUID.class)); + verify(ops).addTotalDaily(eq(user), any(UUID.class)); + verify(ops).addTotalWeekly(eq(user), any(UUID.class)); + verify(ops).voteParty(eq(user), eq(false), any(UUID.class), eq(false)); + } + @Test void proxyVoteUsesHistoricalOnlineForDeliveryButCurrentOnlineForTotals() { var ops = accepted(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java index e133cc1fd9..3b3a348ae5 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java @@ -118,8 +118,8 @@ void retryKeepsTheDailyStreakBoundaryDecisionFromTransitionStart() { ServerData data = new ServerData(plugin); data.beginTimeChangeRecovery(transition); - TimeChangeUserPolicy original = new TimeChangeUserPolicy(false, true, false, true, 50, 60, 70); - TimeChangeUserPolicy changed = new TimeChangeUserPolicy(true, false, true, false, 1, 2, 3); + TimeChangeUserPolicy original = new TimeChangeUserPolicy(false, true, false, true, 50, 60, 70, true, false); + TimeChangeUserPolicy changed = new TimeChangeUserPolicy(true, false, true, false, 1, 2, 3, false, true); assertEquals(original, data.prepareTimeChangeUserPolicy(transition, original)); assertEquals(original, data.prepareTimeChangeUserPolicy(transition, changed)); assertEquals(original, new ServerData(plugin).getTimeChangeUserPolicy(transition)); @@ -137,7 +137,7 @@ void failedDailyStreakBoundarySaveDoesNotPublishTheDecision() { data.beginTimeChangeRecovery(transition); doThrow(new IllegalStateException("disk unavailable")).when(coreData).saveData(); - TimeChangeUserPolicy policy = new TimeChangeUserPolicy(true, true, true, true, 50, 60, 70); + TimeChangeUserPolicy policy = new TimeChangeUserPolicy(true, true, true, true, 50, 60, 70, true, false); assertThrows(IllegalStateException.class, () -> data.prepareTimeChangeUserPolicy(transition, policy)); assertThrows(IllegalStateException.class, () -> data.getTimeChangeUserPolicy(transition)); @@ -145,6 +145,24 @@ void failedDailyStreakBoundarySaveDoesNotPublishTheDecision() { assertEquals(policy, data.prepareTimeChangeUserPolicy(transition, policy)); } + @Test + void retryKeepsTheVoteShopTargetsSelectedBeforeAnyReset() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); + YamlConfiguration yaml = new YamlConfiguration(); + when(plugin.getServerDataFile()).thenReturn(coreData); + when(coreData.getData()).thenReturn(yaml); + TimeChangeTransition transition = transition("WEEK:2026-W38", "2026-W38", TimeType.WEEK); + ServerData data = new ServerData(plugin); + data.beginTimeChangeRecovery(transition); + + assertEquals(List.of("weekly"), + data.prepareTimeChangeVoteShopTargets(transition, List.of("weekly"))); + assertEquals(List.of("weekly"), + data.prepareTimeChangeVoteShopTargets(transition, List.of("newly-enabled"))); + assertEquals(List.of("weekly"), new ServerData(plugin).getTimeChangeVoteShopTargets(transition)); + } + @Test void newPeriodReplacesOnlyThatTimeTypesOldCheckpoint() { VotingPluginMain plugin = mock(VotingPluginMain.class); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java index de8895fecd..f80fdff9ab 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java @@ -54,7 +54,7 @@ void retryDoesNotCreateADailyStreakBoundaryAfterTheCapturedDecisionDisabledIt() when(plugin.getServerData()).thenReturn(serverData); when(plugin.getConfigFile().isUseVoteStreaks()).thenReturn(true); when(serverData.getTimeChangeUserPolicy(transition)).thenReturn( - new TimeChangeUserPolicy(false, true, false, false, 0, 0, 0)); + new TimeChangeUserPolicy(false, true, false, false, 0, 0, 0, false, false)); when(transition.getId()).thenReturn("DAY:2026-09-21"); try (MockedStatic reset = org.mockito.Mockito.mockStatic(TimeChangeTotalReset.class)) { reset.when(() -> TimeChangeTotalReset.copyBoundary(plugin, "DailyTotal", "LastDailyTotal", @@ -132,7 +132,7 @@ void userRecoveryProcessesRowsDuringTheBoundedStorageWalk() { when(plugin.getServerData()).thenReturn(serverData); when(plugin.getConfigFile().isUseHighestTotals()).thenReturn(true); when(serverData.getTimeChangeUserPolicy(transition)).thenReturn( - new TimeChangeUserPolicy(false, true, false, false, 0, 0, 0)); + new TimeChangeUserPolicy(false, true, false, false, 0, 0, 0, false, false)); when(serverData.getTimeChangeCursor(transition)).thenReturn(""); when(votingUsers.getVotingPluginUser(uuid, false)).thenReturn(user); doAnswer(invocation -> { @@ -166,7 +166,7 @@ void userRecoveryFlushesUserChangesBeforeAdvancingTheCursor() { when(plugin.getServerData()).thenReturn(serverData); when(plugin.getConfigFile().isUseHighestTotals()).thenReturn(true); when(serverData.getTimeChangeUserPolicy(transition)).thenReturn( - new TimeChangeUserPolicy(false, true, false, false, 0, 0, 0)); + new TimeChangeUserPolicy(false, true, false, false, 0, 0, 0, false, false)); when(serverData.getTimeChangeCursor(transition)).thenReturn(""); when(votingUsers.getVotingPluginUser(org.mockito.ArgumentMatchers.any(UUID.class), org.mockito.ArgumentMatchers.eq(false))).thenReturn(user); @@ -207,7 +207,7 @@ void failedUserFlushDoesNotAdvanceTheRecoveryCursor() { when(plugin.getServerData()).thenReturn(serverData); when(plugin.getConfigFile().isUseHighestTotals()).thenReturn(true); when(serverData.getTimeChangeUserPolicy(transition)).thenReturn( - new TimeChangeUserPolicy(false, true, false, false, 0, 0, 0)); + new TimeChangeUserPolicy(false, true, false, false, 0, 0, 0, false, false)); when(serverData.getTimeChangeCursor(transition)).thenReturn(""); when(votingUsers.getVotingPluginUser(uuid, false)).thenReturn(user); when(user.getCache()).thenReturn(cache); @@ -258,7 +258,7 @@ void weeklyRecoveryUsesThePolicyCapturedBeforeConfigurationChanged() { VotingPluginUser user = mock(VotingPluginUser.class); TimeChangeTransition transition = mock(TimeChangeTransition.class); String uuid = "00000000-0000-0000-0000-000000000001"; - TimeChangeUserPolicy captured = new TimeChangeUserPolicy(true, true, false, false, 0, 0, 0); + TimeChangeUserPolicy captured = new TimeChangeUserPolicy(true, true, false, false, 0, 0, 0, false, false); when(plugin.getServerData()).thenReturn(serverData); when(plugin.getConfigFile().isUseVoteStreaks()).thenReturn(false); when(plugin.getConfigFile().isUseHighestTotals()).thenReturn(false); @@ -582,6 +582,7 @@ void failedMysqlVoteShopResetLeavesPhasePending() { when(plugin.getShopFile().getVoteShopResetDaily("daily")).thenReturn(true); when(transition.getType()).thenReturn(TimeType.DAY); when(transition.getPeriodKey()).thenReturn("2026-09-21"); + when(serverData.getTimeChangeVoteShopTargets(transition)).thenReturn(List.of("daily")); try (MockedStatic purchaseService = mockStatic(VoteShopPurchaseService.class)) { purchaseService.when(() -> VoteShopPurchaseService.limitGenerationIdForTransition(transition)) .thenReturn("time-shop:DAY:2026-09-21"); @@ -590,6 +591,8 @@ void failedMysqlVoteShopResetLeavesPhasePending() { assertThrows(IllegalStateException.class, () -> new TopVoterHandler(plugin).processRecoverableVoteShop(TopVoter.Daily, transition)); + purchaseService.verify(() -> VoteShopPurchaseService.resetMysqlLimitWithPurchaseFence(plugin, + "VoteShopLimitdaily", "time-shop:DAY:2026-09-21")); } verify(serverData, never()).completeTimeChangePhase(transition, "VOTE_SHOP"); From 81d6f55db2ba708b715031e77a5af35344a22900 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 06:39:33 -0600 Subject: [PATCH 30/61] Freeze top voter policy during recovery --- .../votingplugin/data/ServerData.java | 45 +++++++++++++++++++ .../topvoter/TopVoterHandler.java | 26 ++++++++--- .../ServerDataTimeChangeRecoveryTest.java | 19 ++++++++ .../TopVoterTimeChangeRecoveryTest.java | 9 ++-- 4 files changed, 88 insertions(+), 11 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index 3f2ff74a79..3e405c9e1b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -31,6 +31,12 @@ public record TimeChangeUserPolicy(boolean voteStreaks, boolean highestTotals, boolean monthDateTotalsPrimary, boolean streakUsesPercentage, double dayPercentage, double weekPercentage, double monthPercentage, boolean proxyOwnsResets, boolean waitForProxy) { } + public record TimeChangeTopPolicy(boolean rewardsEnabled, boolean awardTies, + boolean ignorePermission, boolean archiveRequired, List rewardPlaces) { + public TimeChangeTopPolicy { + rewardPlaces = List.copyOf(rewardPlaces); + } + } public record TimeChangeRewardTarget(String uuid, String playerName, int place, String reward, int votes) { } public record TimeChangeArchiveSection(String name, List lines) { public TimeChangeArchiveSection { @@ -555,6 +561,45 @@ public synchronized List getTimeChangeVoteShopTargets(TimeChangeTransiti return List.copyOf(getData().getStringList(targetsPath + ".Identifiers")); } + /** Fixes top reward and archive selection inputs before the period boundary is copied. */ + public synchronized TimeChangeTopPolicy prepareTimeChangeTopPolicy(TimeChangeTransition transition, + TimeChangeTopPolicy proposed) { + String path = timeChangeRecoveryPath(transition.getType()); + if (!transition.getId().equals(getData().getString(path + ".Id", ""))) { + throw new IllegalStateException("Time change recovery transition does not match"); + } + String policyPath = path + ".TopPolicy"; + if (!getData().getBoolean(policyPath + ".Prepared", false)) { + getData().set(policyPath + ".RewardsEnabled", proposed.rewardsEnabled()); + getData().set(policyPath + ".AwardTies", proposed.awardTies()); + getData().set(policyPath + ".IgnorePermission", proposed.ignorePermission()); + getData().set(policyPath + ".ArchiveRequired", proposed.archiveRequired()); + getData().set(policyPath + ".RewardPlaces", proposed.rewardPlaces()); + getData().set(policyPath + ".Prepared", true); + try { + saveData(); + } catch (RuntimeException failure) { + getData().set(policyPath, null); + throw failure; + } + } + return getTimeChangeTopPolicy(transition); + } + + public synchronized TimeChangeTopPolicy getTimeChangeTopPolicy(TimeChangeTransition transition) { + String path = timeChangeRecoveryPath(transition.getType()); + String policyPath = path + ".TopPolicy"; + if (!transition.getId().equals(getData().getString(path + ".Id", "")) + || !getData().getBoolean(policyPath + ".Prepared", false)) { + throw new IllegalStateException("Time change top policy is not prepared"); + } + return new TimeChangeTopPolicy(getData().getBoolean(policyPath + ".RewardsEnabled"), + getData().getBoolean(policyPath + ".AwardTies"), + getData().getBoolean(policyPath + ".IgnorePermission"), + getData().getBoolean(policyPath + ".ArchiveRequired"), + getData().getStringList(policyPath + ".RewardPlaces")); + } + /** Fixes whether one listener effect belongs to this transition. */ public synchronized boolean prepareTimeChangeEffectPolicy(TimeChangeTransition transition, String effect, boolean proposed) { 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 731e4622d0..7666244927 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -43,6 +43,7 @@ import com.bencodez.votingplugin.data.ServerData.TimeChangeArchiveSnapshot; import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardTarget; import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardState; +import com.bencodez.votingplugin.data.ServerData.TimeChangeTopPolicy; import com.bencodez.votingplugin.data.ServerData.TimeChangeUserProgress; import com.bencodez.votingplugin.data.ServerData.TimeChangeUserPolicy; import com.bencodez.votingplugin.user.PeriodTotalMutationFence; @@ -580,6 +581,7 @@ private void processRecoverableChange(TopVoter top, TimeChangeTransition transit plugin.getServerData().prepareTimeChangeUserPolicy(transition, currentTimeChangeUserPolicy()); plugin.getServerData().prepareTimeChangeVoteShopTargets(transition, currentVoteShopResetTargets(top)); + plugin.getServerData().prepareTimeChangeTopPolicy(transition, currentTimeChangeTopPolicy(top)); if (!plugin.getServerData().hasTimeChangePhase(transition, COMPLETE)) { runRecoverablePeriod(top, transition); } @@ -613,6 +615,16 @@ private List currentVoteShopResetTargets(TopVoter top) { .filter(identifier -> shouldResetVoteShop(top, identifier)).toList(); } + private TimeChangeTopPolicy currentTimeChangeTopPolicy(TopVoter top) { + boolean archiveRequired = (top == TopVoter.Daily && plugin.getConfigFile().isStoreTopVotersDaily()) + || (top == TopVoter.Weekly && plugin.getConfigFile().isStoreTopVotersWeekly()) + || top == TopVoter.Monthly; + return new TimeChangeTopPolicy(isTopRewardEnabled(top), + plugin.getConfigFile().isTopVoterAwardsTies(), + plugin.getConfigFile().isTopVoterIgnorePermission(), archiveRequired, + new ArrayList<>(getPossibleRewardPlaces(top))); + } + private void runRecoverablePeriod(TopVoter top, TimeChangeTransition transition) { // Capture the boundary before any long-running phase. The database journal // makes the later COPY_TOTALS retry a no-op, so votes accepted while reward @@ -621,9 +633,7 @@ private void runRecoverablePeriod(TopVoter top, TimeChangeTransition transition) if (!plugin.getServerData().hasTimeChangePhase(transition, SNAPSHOT)) { ensureTransitionActive(transition); - boolean archiveRequired = (top == TopVoter.Daily && plugin.getConfigFile().isStoreTopVotersDaily()) - || (top == TopVoter.Weekly && plugin.getConfigFile().isStoreTopVotersWeekly()) - || top == TopVoter.Monthly; + boolean archiveRequired = plugin.getServerData().getTimeChangeTopPolicy(transition).archiveRequired(); TimeChangeArchiveSnapshot proposedArchive = archiveRequired ? buildTopVoterArchiveSnapshot(top, transition) : new TimeChangeArchiveSnapshot(List.of()); plugin.getServerData().prepareTimeChangeSnapshot(transition, @@ -870,7 +880,8 @@ void processRecoverableTopRewards(TopVoter top, TimeChangeTransition transition) VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser( UUID.fromString(target.uuid()), target.playerName()); user.userDataFetechMode(UserDataFetchMode.NO_CACHE); - if (!plugin.getConfigFile().isTopVoterIgnorePermission() || !user.isTopVoterIgnore()) { + if (!plugin.getServerData().getTimeChangeTopPolicy(transition).ignorePermission() + || !user.isTopVoterIgnore()) { plugin.getServerData().claimTimeChangeReward(transition, target.uuid()); giveTopVoterAward(top, user, target.place(), target.reward(), target.votes()); plugin.getServerData().completeTimeChangeReward(transition, target.uuid()); @@ -881,14 +892,15 @@ void processRecoverableTopRewards(TopVoter top, TimeChangeTransition transition) } List buildTopRewardSnapshot(TopVoter top, TimeChangeTransition transition) { - if (!isTopRewardEnabled(top)) return List.of(); - HashMap places = handlePlaces(getPossibleRewardPlaces(top)); + TimeChangeTopPolicy policy = plugin.getServerData().getTimeChangeTopPolicy(transition); + if (!policy.rewardsEnabled()) return List.of(); + HashMap places = handlePlaces(Set.copyOf(policy.rewardPlaces())); List targets = new ArrayList<>(); int place = 0; int lastTotal = -1; for (Entry entry : boundaryTopVotersFor(top, transition).entrySet()) { ensureTransitionActive(transition); - if (plugin.getConfigFile().isTopVoterAwardsTies()) { + if (policy.awardTies()) { if (entry.getValue().intValue() != lastTotal) place++; } else place++; if (places.containsKey(place)) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java index 3b3a348ae5..9450de6471 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java @@ -27,6 +27,7 @@ import com.bencodez.votingplugin.data.ServerData.TimeChangeArchiveSection; import com.bencodez.votingplugin.data.ServerData.TimeChangeArchiveSnapshot; import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardTarget; +import com.bencodez.votingplugin.data.ServerData.TimeChangeTopPolicy; import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardState; import com.bencodez.votingplugin.data.ServerData.TimeChangeUserProgress; import com.bencodez.votingplugin.data.ServerData.TimeChangeUserPolicy; @@ -163,6 +164,24 @@ void retryKeepsTheVoteShopTargetsSelectedBeforeAnyReset() { assertEquals(List.of("weekly"), new ServerData(plugin).getTimeChangeVoteShopTargets(transition)); } + @Test + void retryKeepsTopRewardAndArchivePolicySelectedBeforeTheBoundary() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); + YamlConfiguration yaml = new YamlConfiguration(); + when(plugin.getServerDataFile()).thenReturn(coreData); + when(coreData.getData()).thenReturn(yaml); + TimeChangeTransition transition = transition("WEEK:2026-W38", "2026-W38", TimeType.WEEK); + ServerData data = new ServerData(plugin); + data.beginTimeChangeRecovery(transition); + TimeChangeTopPolicy original = new TimeChangeTopPolicy(true, true, true, true, List.of("1", "2-4")); + TimeChangeTopPolicy changed = new TimeChangeTopPolicy(false, false, false, false, List.of("5")); + + assertEquals(original, data.prepareTimeChangeTopPolicy(transition, original)); + assertEquals(original, data.prepareTimeChangeTopPolicy(transition, changed)); + assertEquals(original, new ServerData(plugin).getTimeChangeTopPolicy(transition)); + } + @Test void newPeriodReplacesOnlyThatTimeTypesOldCheckpoint() { VotingPluginMain plugin = mock(VotingPluginMain.class); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java index f80fdff9ab..9b2a16907b 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java @@ -39,6 +39,7 @@ import com.bencodez.votingplugin.data.ServerData; import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardTarget; import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardState; +import com.bencodez.votingplugin.data.ServerData.TimeChangeTopPolicy; import com.bencodez.votingplugin.data.ServerData.TimeChangeUserProgress; import com.bencodez.votingplugin.data.ServerData.TimeChangeUserPolicy; import com.bencodez.votingplugin.specialrewards.SpecialRewards; @@ -436,9 +437,9 @@ void rewardSnapshotFixesRecipientsPlacesAndVoteTotalsAtThePeriodBoundary() { UUID.fromString("00000000-0000-0000-0000-000000000002"), "second", 2L); LinkedHashMap ranking = new LinkedHashMap<>(); ranking.put(first, 20); - ranking.put(second, 10); - when(plugin.getSpecialRewardsConfig().isEnableDailyRewards()).thenReturn(true); - when(plugin.getSpecialRewardsConfig().getDailyPossibleRewardPlaces()).thenReturn(Set.of("1", "2")); + ranking.put(second, 20); + when(plugin.getServerData().getTimeChangeTopPolicy(transition)).thenReturn( + new TimeChangeTopPolicy(true, true, false, false, List.of("1"))); TopVoterHandler handler = spy(new TopVoterHandler(plugin)); doReturn(ranking).when(handler).boundaryTopVotersFor(TopVoter.Daily, transition); @@ -446,7 +447,7 @@ void rewardSnapshotFixesRecipientsPlacesAndVoteTotalsAtThePeriodBoundary() { assertEquals(List.of( new TimeChangeRewardTarget(first.getUuid().toString(), "first", 1, "1", 20), - new TimeChangeRewardTarget(second.getUuid().toString(), "second", 2, "2", 10)), targets); + new TimeChangeRewardTarget(second.getUuid().toString(), "second", 1, "1", 20)), targets); } @Test From 10268892f0a8f036a28d47b947195ac57c70197e Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 06:54:41 -0600 Subject: [PATCH 31/61] Persist vote points eligibility --- .../core/vote/SharedVoteAccounting.java | 11 ++--- .../core/vote/SharedVoteProcessor.java | 10 +++-- .../listeners/PlayerVoteListener.java | 9 ++-- .../service/SharedMysqlPurchaseJournal.java | 4 +- .../service/VoteShopPurchaseService.java | 15 ++++--- .../core/vote/SharedVoteProcessorTest.java | 25 ++++++++--- .../SharedMysqlPurchaseJournalTest.java | 41 ++++++++++++++++--- 7 files changed, 81 insertions(+), 34 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteAccounting.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteAccounting.java index ff6bb1979a..2c47735d33 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteAccounting.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteAccounting.java @@ -18,27 +18,22 @@ public static void apply(SharedVoteInput input, SharedVotePolicy policy, Boolean Objects.requireNonNull(addTotalWeekly, "addTotalWeekly"); Objects.requireNonNull(addPoints, "addPoints"); if (!policy.shouldAwardConfiguredPoints(input)) return; - applyAdmitted(input, policy, policy.shouldCountTotals(input, currentlyOnline), addTotal, addTotalDaily, + applyAdmitted(policy.shouldCountTotals(input, currentlyOnline), true, addTotal, addTotalDaily, addTotalWeekly, addPoints); } /** Applies an accounting decision that was durably admitted before vote side effects began. */ - public static void applyAdmitted(SharedVoteInput input, SharedVotePolicy policy, boolean countTotals, + public static void applyAdmitted(boolean countTotals, boolean awardPoints, Runnable addTotal, Runnable addTotalDaily, Runnable addTotalWeekly, Runnable addPoints) { - Objects.requireNonNull(input, "input"); - Objects.requireNonNull(policy, "policy"); Objects.requireNonNull(addTotal, "addTotal"); Objects.requireNonNull(addTotalDaily, "addTotalDaily"); Objects.requireNonNull(addTotalWeekly, "addTotalWeekly"); Objects.requireNonNull(addPoints, "addPoints"); - // A durable totals admission came from an earlier evaluation of this gate. - // Replays must honor that decision even if live configuration has changed. - if (!countTotals && !policy.shouldAwardConfiguredPoints(input)) return; if (countTotals) { addTotal.run(); addTotalDaily.run(); addTotalWeekly.run(); } - addPoints.run(); + if (awardPoints) addPoints.run(); } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java index 583728c5f5..aa55023cae 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java @@ -15,7 +15,7 @@ public static boolean isQueuedVoteAlreadyRecorded(boolean proxyVote, long messag public record Validation(boolean valid, String normalizedName, String source, String reason, boolean bedrock) { } public record Name(String value, String rationale) { } - public record AccountingAdmission(boolean countTotals, boolean votePartyEligible) { } + public record AccountingAdmission(boolean countTotals, boolean awardPoints, boolean votePartyEligible) { } public interface Operations { boolean enabled(); @@ -60,7 +60,7 @@ public interface Operations { void broadcast(UUID uuid, String name, String siteDisplayName, boolean online); boolean hasProxyTextTotals(); UUID incomingVoteId(); - AccountingAdmission prepareAccounting(U user, UUID voteId, boolean countTotals); + AccountingAdmission prepareAccounting(U user, UUID voteId, boolean countTotals, boolean awardPoints); void finishAccounting(UUID voteId); void cache(U user); void updateName(U user); @@ -173,8 +173,10 @@ public static void process(Operations ops) { ops.incomingTime(), ops.realVote(), ops.addTotals(), ops.proxyVote(), ops.forceProxyRouting(), ops.wasOnline()); boolean proposedCountTotals = policy.shouldCountTotals(accountingInput, () -> ops.userOnline(user)); - AccountingAdmission admission = ops.prepareAccounting(user, voteId, proposedCountTotals); + boolean proposedAwardPoints = policy.shouldAwardConfiguredPoints(accountingInput); + AccountingAdmission admission = ops.prepareAccounting(user, voteId, proposedCountTotals, proposedAwardPoints); boolean countTotals = admission.countTotals(); + boolean awardPoints = admission.awardPoints(); boolean votePartyEligible = admission.votePartyEligible(); try { ops.cache(user); @@ -216,7 +218,7 @@ public static void process(Operations ops) { } SharedVoteInput input = new SharedVoteInput(voteId, playerName, ops.serviceSite(), voteTime, ops.realVote(), ops.addTotals(), ops.proxyVote(), ops.forceProxyRouting(), ops.wasOnline()); - SharedVoteAccounting.applyAdmitted(input, policy, countTotals, () -> ops.addTotal(user, voteId), + SharedVoteAccounting.applyAdmitted(countTotals, awardPoints, () -> ops.addTotal(user, voteId), () -> ops.addTotalDaily(user, voteId), () -> ops.addTotalWeekly(user, voteId), () -> ops.addPoints(user)); ops.checkDayVoteStreak(user, ops.forceProxyRouting(), voteId); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java index 66a5747677..7d3c187a35 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java @@ -137,18 +137,19 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { if (event.getVoteId() != null) return event.getVoteId(); return event.getBungeeTextTotals() == null ? null : event.getBungeeTextTotals().getVoteUUID(); } - @Override public SharedVoteProcessor.AccountingAdmission prepareAccounting( - VotingPluginUser user, UUID voteId, boolean countTotals) { + @Override public SharedVoteProcessor.AccountingAdmission prepareAccounting( + VotingPluginUser user, UUID voteId, boolean countTotals, boolean awardPoints) { boolean countVoteParty = plugin.getSpecialRewardsConfig().isVotePartyEnabled() && (plugin.getSpecialRewardsConfig().isVotePartyCountFakeVotes() || event.isRealVote()) && (plugin.getSpecialRewardsConfig().isVotePartyCountOfflineVotes() || user.isOnline()); VoteShopPurchaseService.VoteAccountingAdmission admission = VoteShopPurchaseService - .prepareMysqlVoteAccounting(plugin, voteId, user.getUUID(), countTotals, + .prepareMysqlVoteAccounting(plugin, voteId, user.getUUID(), countTotals, awardPoints, countVoteParty, event.isForceBungee()); if (!admission.success()) { throw new SharedVoteAdmissionException("Unable to admit shared MySQL vote accounting before processing"); } - return new SharedVoteProcessor.AccountingAdmission(admission.countTotals(), admission.countVoteParty()); + return new SharedVoteProcessor.AccountingAdmission(admission.countTotals(), admission.awardPoints(), + admission.countVoteParty()); } @Override public void finishAccounting(UUID voteId) { VoteShopPurchaseService.finishMysqlVoteAccounting(voteId); 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 index 18d1e1f7b8..7c351a3be6 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -64,6 +64,7 @@ record AccountingRecoveryBatch(boolean hadRows, List pendingRewards) { } private static final int DAILY_STREAK_REWARD = 32; private static final int ACCOUNTING_DECIDED = 64; private static final int DAILY_STREAK_REWARD_CLAIMED = 128; + private static final int AWARD_POINTS = 256; private static final int RECOVERABLE_NON_REWARD_ACCOUNTING = DAILY_TOTAL | WEEKLY_TOTAL | MONTH_TOTAL | VOTE_PARTY_TOTAL | DAILY_STREAK; @@ -353,10 +354,11 @@ boolean incrementPeriodTotals(UUID voteId, String uuid, String boundaryColumn, S return incrementPeriodTotals(voteId, uuid, boundaryColumn, previousColumn, columns, maximum, false); } - int prepareVoteAccounting(UUID voteId, String uuid, boolean countTotals, boolean countVoteParty, + int prepareVoteAccounting(UUID voteId, String uuid, boolean countTotals, boolean awardPoints, boolean countVoteParty, String monthColumn, Integer monthMaximum, boolean streakUsesPercentage, double streakPercentage, int enabledSiteCount, boolean forceProxyRouting, long acceptedAt) throws SQLException { int requested = (countTotals ? DAILY_TOTAL | WEEKLY_TOTAL | MONTH_TOTAL : 0) + | (awardPoints ? AWARD_POINTS : 0) | (countVoteParty ? VOTE_PARTY_TOTAL : 0); try (Connection connection = connection()) { connection.setAutoCommit(false); 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 87adccbaa3..30ce859350 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 @@ -53,9 +53,11 @@ public class VoteShopPurchaseService { public enum MysqlDailyStreakResult { APPLIED, ALREADY_UPDATED, NOT_REQUESTED, DEFERRED, FAILED } public record MysqlDailyStreakUpdate(MysqlDailyStreakResult result, int streak, boolean forceProxyRouting) { } - public record VoteAccountingAdmission(boolean success, boolean countTotals, boolean countVoteParty) { } + public record VoteAccountingAdmission(boolean success, boolean countTotals, boolean awardPoints, + boolean countVoteParty) { } private static final ConcurrentMap ADMITTED_ACCOUNTING = new ConcurrentHashMap<>(); private static final int ACCOUNTING_DAILY_STREAK = 16; + private static final int ACCOUNTING_POINTS = 256; private static final int PURCHASE_LOCK_STRIPES = 256; private static final Object[] PURCHASE_LOCKS = createPurchaseLocks(); private static final int COMPLETION_PENDING = 0; @@ -655,9 +657,9 @@ public static boolean incrementMysqlPeriodTotals(VotingPluginMain plugin, UUID v /** Durably admits shared total mutations before vote rewards or broadcasts run. */ public static VoteAccountingAdmission prepareMysqlVoteAccounting(VotingPluginMain plugin, UUID voteId, String uuid, - boolean countTotals, boolean countVoteParty, boolean forceProxyRouting) { + boolean countTotals, boolean awardPoints, boolean countVoteParty, boolean forceProxyRouting) { if (!canRecoverSharedMysqlPurchases(plugin) || voteId == null) { - return new VoteAccountingAdmission(true, countTotals, countVoteParty); + return new VoteAccountingAdmission(true, countTotals, awardPoints, countVoteParty); } try { MySQL table = plugin.getMysql(); @@ -678,18 +680,19 @@ public static VoteAccountingAdmission prepareMysqlVoteAccounting(VotingPluginMai table.checkColumn("DayVoteStreak", DataType.INTEGER); table.checkColumn("DayVoteStreakLastUpdate", DataType.STRING); int bits = SharedMysqlPurchaseJournal.forTable(table).prepareVoteAccounting(voteId, uuid, countTotals, - countVoteParty, monthColumn, maximum, + awardPoints, countVoteParty, monthColumn, maximum, plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage(), plugin.getSpecialRewardsConfig().getVoteStreakRequirementDay(), plugin.getVoteSiteManager().getVoteSitesEnabled().size(), forceProxyRouting, System.currentTimeMillis()); ADMITTED_ACCOUNTING.put(voteId, Integer.valueOf(bits)); - return new VoteAccountingAdmission(true, (bits & 7) != 0, (bits & 8) != 0); + return new VoteAccountingAdmission(true, (bits & 7) != 0, + (bits & ACCOUNTING_POINTS) != 0, (bits & 8) != 0); } catch (SQLException failure) { plugin.getLogger().severe("Unable to admit shared MySQL vote accounting: " + failure.getClass().getSimpleName()); plugin.debug(failure); - return new VoteAccountingAdmission(false, false, false); + return new VoteAccountingAdmission(false, false, false, false); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java index 80999f0718..9656e1430e 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java @@ -39,9 +39,9 @@ private SharedVoteProcessor.Operations accepted() { when(ops.siteKey(site)).thenReturn("ExampleKey"); when(ops.userId(user)).thenReturn("user-id"); when(ops.userUuid(user)).thenReturn(UUID.randomUUID()); - when(ops.prepareAccounting(eq(user), any(UUID.class), anyBoolean())) + when(ops.prepareAccounting(eq(user), any(UUID.class), anyBoolean(), anyBoolean())) .thenAnswer(invocation -> new SharedVoteProcessor.AccountingAdmission( - invocation.getArgument(2), true)); + invocation.getArgument(2), invocation.getArgument(3), true)); return ops; } @@ -64,7 +64,7 @@ void onlineVoteKeepsProductionPhaseOrderAndCounts() { InOrder order = inOrder(ops); order.verify(ops).lastVoteTime(user, site); - order.verify(ops).prepareAccounting(eq(user), any(UUID.class), eq(true)); + order.verify(ops).prepareAccounting(eq(user), any(UUID.class), eq(true), eq(true)); order.verify(ops).cache(user); order.verify(ops).updateName(user); order.verify(ops).voteParty(eq(user), eq(false), any(UUID.class), eq(true)); @@ -95,7 +95,7 @@ void totalsUseTheEligibilityCapturedAtAdmission() { SharedVoteProcessor.process(ops); - verify(ops).prepareAccounting(eq(user), any(UUID.class), eq(true)); + verify(ops).prepareAccounting(eq(user), any(UUID.class), eq(true), eq(true)); verify(ops).addTotal(eq(user), any(UUID.class)); verify(ops).addTotalDaily(eq(user), any(UUID.class)); verify(ops).addTotalWeekly(eq(user), any(UUID.class)); @@ -105,8 +105,8 @@ void totalsUseTheEligibilityCapturedAtAdmission() { void replayUsesPersistedAccountingEligibilityInsteadOfTheCurrentProposal() { var ops = accepted(); when(ops.addTotals()).thenReturn(false); - when(ops.prepareAccounting(eq(user), any(UUID.class), eq(false))) - .thenReturn(new SharedVoteProcessor.AccountingAdmission(true, false)); + when(ops.prepareAccounting(eq(user), any(UUID.class), eq(false), eq(false))) + .thenReturn(new SharedVoteProcessor.AccountingAdmission(true, true, false)); SharedVoteProcessor.process(ops); @@ -116,6 +116,19 @@ void replayUsesPersistedAccountingEligibilityInsteadOfTheCurrentProposal() { verify(ops).voteParty(eq(user), eq(false), any(UUID.class), eq(false)); } + @Test + void replayUsesPersistedPointsEligibilityWhenTotalsWereNotAdmitted() { + var ops = accepted(); + when(ops.addTotals()).thenReturn(false); + when(ops.prepareAccounting(eq(user), any(UUID.class), eq(false), eq(false))) + .thenReturn(new SharedVoteProcessor.AccountingAdmission(false, true, false)); + + SharedVoteProcessor.process(ops); + + verify(ops).addPoints(user); + verify(ops, never()).addTotal(eq(user), any(UUID.class)); + } + @Test void proxyVoteUsesHistoricalOnlineForDeliveryButCurrentOnlineForTotals() { var ops = accepted(); 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 index 791c87a255..84f736c99e 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -82,11 +82,11 @@ void voteAdmissionDurablyIncludesEligibleDailyStreakAndReward() throws Exception accountingInsert, accountingSelect, candidateSelect, accountingUpdate); int requested = new SharedMysqlPurchaseJournal(fixture.table, false).prepareVoteAccounting( - java.util.UUID.randomUUID(), "00000000-0000-0000-0000-000000000001", false, false, + java.util.UUID.randomUUID(), "00000000-0000-0000-0000-000000000001", false, true, false, null, null, false, 0.0, 1, true, 1234L); - assertEquals(48, requested); - verify(accountingUpdate).setInt(2, 112); + assertEquals(304, requested); + verify(accountingUpdate).setInt(2, 368); verify(accountingUpdate).setInt(3, 64); verify(accountingUpdate).setInt(6, 5); verify(accountingUpdate).setLong(7, 1234L); @@ -94,6 +94,37 @@ void voteAdmissionDurablyIncludesEligibleDailyStreakAndReward() throws Exception verify(fixture.work).commit(); } + @Test + void repeatedVoteAdmissionReturnsThePersistedPointsDecision() throws Exception { + Fixture fixture = fixture(); + PreparedStatement copyInsert = mock(PreparedStatement.class); + PreparedStatement copySelect = mock(PreparedStatement.class); + PreparedStatement resetInsert = mock(PreparedStatement.class); + PreparedStatement resetSelect = mock(PreparedStatement.class); + PreparedStatement accountingInsert = mock(PreparedStatement.class); + PreparedStatement accountingSelect = mock(PreparedStatement.class); + PreparedStatement accountingUpdate = mock(PreparedStatement.class); + ResultSet copyEpoch = mock(ResultSet.class); + ResultSet resetEpoch = mock(ResultSet.class); + ResultSet accounting = accountingRow("00000000-0000-0000-0000-000000000001", 320, 64); + when(copyEpoch.next()).thenReturn(true); + when(resetEpoch.next()).thenReturn(true); + when(copySelect.executeQuery()).thenReturn(copyEpoch); + when(resetSelect.executeQuery()).thenReturn(resetEpoch); + when(accountingSelect.executeQuery()).thenReturn(accounting); + when(accountingUpdate.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(copyInsert, copySelect, resetInsert, resetSelect, + accountingInsert, accountingSelect, accountingUpdate); + + int requested = new SharedMysqlPurchaseJournal(fixture.table, false).prepareVoteAccounting( + UUID.randomUUID(), "00000000-0000-0000-0000-000000000001", false, false, false, + null, null, false, 0.0, 1, false, 1234L); + + assertEquals(256, requested); + verify(accountingUpdate).setInt(2, 320); + verify(fixture.work).commit(); + } + @Test void pendingDailyTotalsContributeToPercentageStreakAdmission() throws Exception { Fixture fixture = fixture(); @@ -142,7 +173,7 @@ void pendingDailyTotalsContributeToPercentageStreakAdmission() throws Exception dailyResetInsert, dailyResetSelect, candidateSelect, pendingTotalSelect, accountingUpdate); int requested = new SharedMysqlPurchaseJournal(fixture.table, false).prepareVoteAccounting( - UUID.randomUUID(), "00000000-0000-0000-0000-000000000001", false, false, + UUID.randomUUID(), "00000000-0000-0000-0000-000000000001", false, false, false, null, null, true, 50.0, 1, false, 1234L); assertEquals(48, requested); @@ -181,7 +212,7 @@ void firstVoteCanBeAdmittedBeforeTheUserRowIsCreated() throws Exception { accountingInsert, accountingSelect, candidateSelect, accountingUpdate); int requested = new SharedMysqlPurchaseJournal(fixture.table, false).prepareVoteAccounting( - java.util.UUID.randomUUID(), "00000000-0000-0000-0000-000000000001", false, false, + java.util.UUID.randomUUID(), "00000000-0000-0000-0000-000000000001", false, false, false, null, null, false, 0.0, 1, false, 1234L); assertEquals(48, requested); From ab1d89ee1b48b41d2be0c8cf6d2d991efdee2d65 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 07:10:29 -0600 Subject: [PATCH 32/61] Preserve eligible top voter ranking --- .../votingplugin/data/ServerData.java | 8 +++-- .../topvoter/TopVoterHandler.java | 16 ++++++--- .../votingplugin/topvoter/TopVoterLoader.java | 15 ++++++-- .../service/SharedMysqlPurchaseJournal.java | 3 +- .../ServerDataTimeChangeRecoveryTest.java | 6 ++-- .../topvoter/TopVoterLoaderTest.java | 36 +++++++++++++++++++ .../TopVoterTimeChangeRecoveryTest.java | 2 +- .../SharedMysqlPurchaseJournalTest.java | 3 +- 8 files changed, 75 insertions(+), 14 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index 3e405c9e1b..cb9ca9cbf2 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -32,9 +32,11 @@ public record TimeChangeUserPolicy(boolean voteStreaks, boolean highestTotals, double dayPercentage, double weekPercentage, double monthPercentage, boolean proxyOwnsResets, boolean waitForProxy) { } public record TimeChangeTopPolicy(boolean rewardsEnabled, boolean awardTies, - boolean ignorePermission, boolean archiveRequired, List rewardPlaces) { + boolean ignorePermission, boolean archiveRequired, List rewardPlaces, + List blacklistedPlayers) { public TimeChangeTopPolicy { rewardPlaces = List.copyOf(rewardPlaces); + blacklistedPlayers = List.copyOf(blacklistedPlayers); } } public record TimeChangeRewardTarget(String uuid, String playerName, int place, String reward, int votes) { } @@ -575,6 +577,7 @@ public synchronized TimeChangeTopPolicy prepareTimeChangeTopPolicy(TimeChangeTra getData().set(policyPath + ".IgnorePermission", proposed.ignorePermission()); getData().set(policyPath + ".ArchiveRequired", proposed.archiveRequired()); getData().set(policyPath + ".RewardPlaces", proposed.rewardPlaces()); + getData().set(policyPath + ".BlacklistedPlayers", proposed.blacklistedPlayers()); getData().set(policyPath + ".Prepared", true); try { saveData(); @@ -597,7 +600,8 @@ public synchronized TimeChangeTopPolicy getTimeChangeTopPolicy(TimeChangeTransit getData().getBoolean(policyPath + ".AwardTies"), getData().getBoolean(policyPath + ".IgnorePermission"), getData().getBoolean(policyPath + ".ArchiveRequired"), - getData().getStringList(policyPath + ".RewardPlaces")); + getData().getStringList(policyPath + ".RewardPlaces"), + getData().getStringList(policyPath + ".BlacklistedPlayers")); } /** Fixes whether one listener effect belongs to this transition. */ 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 7666244927..35a29b6d8b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -622,7 +622,8 @@ private TimeChangeTopPolicy currentTimeChangeTopPolicy(TopVoter top) { return new TimeChangeTopPolicy(isTopRewardEnabled(top), plugin.getConfigFile().isTopVoterAwardsTies(), plugin.getConfigFile().isTopVoterIgnorePermission(), archiveRequired, - new ArrayList<>(getPossibleRewardPlaces(top))); + new ArrayList<>(getPossibleRewardPlaces(top)), + new ArrayList<>(plugin.getConfigFile().getBlackList())); } private void runRecoverablePeriod(TopVoter top, TimeChangeTransition transition) { @@ -915,11 +916,14 @@ List buildTopRewardSnapshot(TopVoter top, TimeChangeTran LinkedHashMap boundaryTopVotersFor(TopVoter top, TimeChangeTransition transition) { + TimeChangeTopPolicy policy = plugin.getServerData().getTimeChangeTopPolicy(transition); if (top == TopVoter.Monthly && plugin.getServerData().getTimeChangeUserPolicy(transition).monthDateTotalsPrimary()) { - return loader.getBoundaryMonthlyTopVotersAtTime(previousMonthTime(transition)); + return loader.getBoundaryRanking(TopVoter.Monthly, previousMonthTime(transition), + policy.ignorePermission(), policy.blacklistedPlayers()).players(); } - return loader.getBoundaryTopVoters(top); + return loader.getBoundaryRanking(top, null, policy.ignorePermission(), + policy.blacklistedPlayers()).players(); } private LocalDateTime previousMonthTime(TimeChangeTransition transition) { @@ -1180,11 +1184,13 @@ TimeChangeArchiveSnapshot buildTopVoterArchiveSnapshot(TopVoter boundaryTop, private TopVoterLoader.BoundaryRanking boundaryRankingFor(TopVoter top, TimeChangeTransition transition) { + TimeChangeTopPolicy policy = plugin.getServerData().getTimeChangeTopPolicy(transition); if (top == TopVoter.Monthly && plugin.getServerData().getTimeChangeUserPolicy(transition).monthDateTotalsPrimary()) { - return loader.getBoundaryRanking(TopVoter.Monthly, previousMonthTime(transition)); + return loader.getBoundaryRanking(TopVoter.Monthly, previousMonthTime(transition), + policy.ignorePermission(), policy.blacklistedPlayers()); } - return loader.getBoundaryRanking(top, null); + return loader.getBoundaryRanking(top, null, policy.ignorePermission(), policy.blacklistedPlayers()); } void storeTopVoters(TopVoter top, TimeChangeTransition transition, TimeChangeArchiveSnapshot snapshot) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterLoader.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterLoader.java index 03b48a9cd5..a29a4f3c29 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterLoader.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterLoader.java @@ -6,6 +6,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map.Entry; import java.util.UUID; import java.util.concurrent.CountDownLatch; @@ -62,11 +63,18 @@ public LinkedHashMap getBoundaryMonthlyTopVotersAtTime( } BoundaryRanking getBoundaryRanking(TopVoter top, LocalDateTime monthlyTime) { - return loadBoundaryRanking(top, monthlyTime); + List blacklist = plugin.getConfigFile().getBlackList(); + return getBoundaryRanking(top, monthlyTime, plugin.getConfigFile().isTopVoterIgnorePermission(), + blacklist == null ? List.of() : blacklist); + } + + BoundaryRanking getBoundaryRanking(TopVoter top, LocalDateTime monthlyTime, + boolean ignorePermission, List blacklist) { + return loadBoundaryRanking(top, monthlyTime, ignorePermission, blacklist); } private BoundaryRanking loadBoundaryRanking(TopVoter top, - LocalDateTime monthlyTime) { + LocalDateTime monthlyTime, boolean ignorePermission, List blacklist) { LinkedHashMap topVoters = new LinkedHashMap<>(); int[] combinedTotal = { 0 }; int limit = plugin.getConfigFile().getMaxiumNumberOfTopVotersToLoad(); @@ -76,6 +84,9 @@ private BoundaryRanking loadBoundaryRanking(TopVoter top, user.userDataFetechMode(UserDataFetchMode.TEMP_ONLY); user.updateTempCacheWithColumns(columns); try { + String playerName = user.getPlayerName(); + if (user.isBanned() || playerName != null && blacklist.contains(playerName) + || ignorePermission && user.isTopVoterIgnore()) return; int total = monthlyTime == null ? switch (top) { case Daily -> user.getLastDailyTotal(); case Weekly -> user.getLastWeeklyTotal(); 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 index 7c351a3be6..2d44c7eef3 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -393,7 +393,8 @@ int prepareVoteAccounting(UUID voteId, String uuid, boolean countTotals, boolean if (accountingAlreadyDecided) requested = row.requested(); else if (requestStreak) requested |= DAILY_STREAK | DAILY_STREAK_REWARD; int persistedRequested = row.requested() | requested | ACCOUNTING_DECIDED; - int persistedCompleted = row.completed() | ACCOUNTING_DECIDED; + int persistedCompleted = row.completed() | ACCOUNTING_DECIDED + | (persistedRequested & AWARD_POINTS); String update = "UPDATE " + qiAccounting() + " SET " + qi("player_uuid") + " = ?, " + qi("requested") + " = ?, " + qi("completed") + " = ?, " + qi("month_column") + " = COALESCE(?, " diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java index 9450de6471..9589eb1655 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java @@ -174,8 +174,10 @@ void retryKeepsTopRewardAndArchivePolicySelectedBeforeTheBoundary() { TimeChangeTransition transition = transition("WEEK:2026-W38", "2026-W38", TimeType.WEEK); ServerData data = new ServerData(plugin); data.beginTimeChangeRecovery(transition); - TimeChangeTopPolicy original = new TimeChangeTopPolicy(true, true, true, true, List.of("1", "2-4")); - TimeChangeTopPolicy changed = new TimeChangeTopPolicy(false, false, false, false, List.of("5")); + TimeChangeTopPolicy original = new TimeChangeTopPolicy(true, true, true, true, + List.of("1", "2-4"), List.of("blocked")); + TimeChangeTopPolicy changed = new TimeChangeTopPolicy(false, false, false, false, + List.of("5"), List.of()); assertEquals(original, data.prepareTimeChangeTopPolicy(transition, original)); assertEquals(original, data.prepareTimeChangeTopPolicy(transition, changed)); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterLoaderTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterLoaderTest.java index 84575627c3..2871faaa71 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterLoaderTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterLoaderTest.java @@ -106,6 +106,42 @@ void boundaryRankingKeepsOnlyTheConfiguredBestPlayers() { assertEquals(60, boundary.combinedTotal()); } + @Test + @SuppressWarnings("unchecked") + void boundaryRankingExcludesBannedBlacklistedAndIgnoredPlayers() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + UserManager userManager = mock(UserManager.class); + com.bencodez.votingplugin.user.UserManager votingUserManager = + mock(com.bencodez.votingplugin.user.UserManager.class); + Config config = mock(Config.class); + when(plugin.getUserManager()).thenReturn(userManager); + when(plugin.getVotingPluginUserManager()).thenReturn(votingUserManager); + when(plugin.getConfigFile()).thenReturn(config); + UUID banned = UUID.fromString("00000000-0000-0000-0000-000000000001"); + UUID blacklisted = UUID.fromString("00000000-0000-0000-0000-000000000002"); + UUID ignored = UUID.fromString("00000000-0000-0000-0000-000000000003"); + VotingPluginUser bannedUser = boundaryUser(banned, "banned", 30, votingUserManager); + VotingPluginUser blacklistedUser = boundaryUser(blacklisted, "blacklisted", 20, votingUserManager); + VotingPluginUser ignoredUser = boundaryUser(ignored, "ignored", 10, votingUserManager); + when(bannedUser.isBanned()).thenReturn(true); + when(blacklistedUser.getPlayerName()).thenReturn("blacklisted"); + when(ignoredUser.isTopVoterIgnore()).thenReturn(true); + doAnswer(invocation -> { + BiConsumer> perUser = invocation.getArgument(0); + perUser.accept(banned, new ArrayList<>()); + perUser.accept(blacklisted, new ArrayList<>()); + perUser.accept(ignored, new ArrayList<>()); + return null; + }).when(userManager).forEachUserKeys(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + + TopVoterLoader.BoundaryRanking ranking = new TopVoterLoader(plugin).getBoundaryRanking( + TopVoter.Daily, null, true, java.util.List.of("blacklisted")); + + assertEquals(0, ranking.players().size()); + assertEquals(0, ranking.combinedTotal()); + } + private VotingPluginUser boundaryUser(UUID uuid, String name, int total, com.bencodez.votingplugin.user.UserManager users) { VotingPluginUser user = mock(VotingPluginUser.class); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java index 9b2a16907b..9bf1a781ad 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java @@ -439,7 +439,7 @@ void rewardSnapshotFixesRecipientsPlacesAndVoteTotalsAtThePeriodBoundary() { ranking.put(first, 20); ranking.put(second, 20); when(plugin.getServerData().getTimeChangeTopPolicy(transition)).thenReturn( - new TimeChangeTopPolicy(true, true, false, false, List.of("1"))); + new TimeChangeTopPolicy(true, true, false, false, List.of("1"), List.of())); TopVoterHandler handler = spy(new TopVoterHandler(plugin)); doReturn(ranking).when(handler).boundaryTopVotersFor(TopVoter.Daily, transition); 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 index 84f736c99e..4bfa249d21 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -87,7 +87,7 @@ void voteAdmissionDurablyIncludesEligibleDailyStreakAndReward() throws Exception assertEquals(304, requested); verify(accountingUpdate).setInt(2, 368); - verify(accountingUpdate).setInt(3, 64); + verify(accountingUpdate).setInt(3, 320); verify(accountingUpdate).setInt(6, 5); verify(accountingUpdate).setLong(7, 1234L); verify(accountingUpdate).setInt(8, 1); @@ -122,6 +122,7 @@ void repeatedVoteAdmissionReturnsThePersistedPointsDecision() throws Exception { assertEquals(256, requested); verify(accountingUpdate).setInt(2, 320); + verify(accountingUpdate).setInt(3, 320); verify(fixture.work).commit(); } From 70939166fedab6cb0184c2c378b8ff22aa0ad846 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 07:23:25 -0600 Subject: [PATCH 33/61] Preserve reset identifiers and reward routing --- .../votingplugin/data/ServerData.java | 6 +++-- .../topvoter/TimeChangeTotalReset.java | 4 +-- .../topvoter/TopVoterHandler.java | 8 +++--- .../ServerDataTimeChangeRecoveryTest.java | 6 ++--- .../TopVoterTimeChangeRecoveryTest.java | 27 +++++++++++++++---- 5 files changed, 36 insertions(+), 15 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index cb9ca9cbf2..b5e659b4b2 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -30,7 +30,7 @@ public record TimeChangeUserProgress(String uuid, int streakTarget, boolean rewa public record TimeChangeUserPolicy(boolean voteStreaks, boolean highestTotals, boolean monthDateTotalsPrimary, boolean streakUsesPercentage, double dayPercentage, double weekPercentage, double monthPercentage, - boolean proxyOwnsResets, boolean waitForProxy) { } + boolean proxyOwnsResets, boolean waitForProxy, boolean rewardForceProxy) { } public record TimeChangeTopPolicy(boolean rewardsEnabled, boolean awardTies, boolean ignorePermission, boolean archiveRequired, List rewardPlaces, List blacklistedPlayers) { @@ -502,6 +502,7 @@ public synchronized TimeChangeUserPolicy prepareTimeChangeUserPolicy(TimeChangeT getData().set(policyPath + ".MonthPercentage", proposed.monthPercentage()); getData().set(policyPath + ".ProxyOwnsResets", proposed.proxyOwnsResets()); getData().set(policyPath + ".WaitForProxy", proposed.waitForProxy()); + getData().set(policyPath + ".RewardForceProxy", proposed.rewardForceProxy()); getData().set(policyPath + ".Prepared", true); try { saveData(); @@ -529,7 +530,8 @@ public synchronized TimeChangeUserPolicy getTimeChangeUserPolicy(TimeChangeTrans getData().getDouble(policyPath + ".WeekPercentage"), getData().getDouble(policyPath + ".MonthPercentage"), getData().getBoolean(policyPath + ".ProxyOwnsResets"), - getData().getBoolean(policyPath + ".WaitForProxy")); + getData().getBoolean(policyPath + ".WaitForProxy"), + getData().getBoolean(policyPath + ".RewardForceProxy")); } /** Fixes the VoteShop identifiers selected for this transition before resets begin. */ diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TimeChangeTotalReset.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TimeChangeTotalReset.java index 00483612b9..a5708ca01c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TimeChangeTotalReset.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TimeChangeTotalReset.java @@ -197,10 +197,10 @@ private static boolean resetSqliteExpression(Connection connection, String table } private static boolean safeIdentifier(String value) { - return value != null && value.matches("[A-Za-z0-9_]+"); + return value != null && !value.isEmpty() && value.indexOf('\0') < 0; } private static String quote(String identifier) { - return '`' + identifier + '`'; + return '`' + identifier.replace("`", "``") + '`'; } } 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 35a29b6d8b..3b5624d5fe 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -607,7 +607,8 @@ private TimeChangeUserPolicy currentTimeChangeUserPolicy() { plugin.getSpecialRewardsConfig().getVoteStreakRequirementDay(), plugin.getSpecialRewardsConfig().getVoteStreakRequirementWeek(), plugin.getSpecialRewardsConfig().getVoteStreakRequirementMonth(), proxyOwnsResets, - plugin.getBungeeSettings().isUseBungeecoord() && !proxyOwnsResets); + plugin.getBungeeSettings().isUseBungeecoord() && !proxyOwnsResets, + plugin.getBungeeSettings().isUseBungeecoord()); } private List currentVoteShopResetTargets(TopVoter top) { @@ -745,7 +746,7 @@ void processDailyUser(VotingPluginUser user, TimeChangeTransition transition, St void processDailyUser(VotingPluginUser user, TimeChangeTransition transition, String uuid, boolean processVoteStreaks) { processDailyUser(user, transition, uuid, new TimeChangeUserPolicy(processVoteStreaks, - plugin.getConfigFile().isUseHighestTotals(), false, false, 0, 0, 0, false, false)); + plugin.getConfigFile().isUseHighestTotals(), false, false, 0, 0, 0, false, false, false)); } void processDailyUser(VotingPluginUser user, TimeChangeTransition transition, String uuid, @@ -863,7 +864,8 @@ void applyRecoverableStreak(VotingPluginUser user, TimeChangeTransition transiti if (rewardState == TimeChangeRewardState.COMPLETE) return; plugin.getServerData().claimTimeChangeUserStreakReward(transition, uuid); plugin.getSpecialRewards().checkVoteStreak(null, user, - top == TopVoter.Weekly ? "Week" : "Month", plugin.getBungeeSettings().isUseBungeecoord()); + top == TopVoter.Weekly ? "Week" : "Month", + plugin.getServerData().getTimeChangeUserPolicy(transition).rewardForceProxy()); plugin.getServerData().completeTimeChangeUserStreakReward(transition, uuid); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java index 9589eb1655..6c38eec5b2 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java @@ -119,8 +119,8 @@ void retryKeepsTheDailyStreakBoundaryDecisionFromTransitionStart() { ServerData data = new ServerData(plugin); data.beginTimeChangeRecovery(transition); - TimeChangeUserPolicy original = new TimeChangeUserPolicy(false, true, false, true, 50, 60, 70, true, false); - TimeChangeUserPolicy changed = new TimeChangeUserPolicy(true, false, true, false, 1, 2, 3, false, true); + TimeChangeUserPolicy original = new TimeChangeUserPolicy(false, true, false, true, 50, 60, 70, true, false, true); + TimeChangeUserPolicy changed = new TimeChangeUserPolicy(true, false, true, false, 1, 2, 3, false, true, false); assertEquals(original, data.prepareTimeChangeUserPolicy(transition, original)); assertEquals(original, data.prepareTimeChangeUserPolicy(transition, changed)); assertEquals(original, new ServerData(plugin).getTimeChangeUserPolicy(transition)); @@ -138,7 +138,7 @@ void failedDailyStreakBoundarySaveDoesNotPublishTheDecision() { data.beginTimeChangeRecovery(transition); doThrow(new IllegalStateException("disk unavailable")).when(coreData).saveData(); - TimeChangeUserPolicy policy = new TimeChangeUserPolicy(true, true, true, true, 50, 60, 70, true, false); + TimeChangeUserPolicy policy = new TimeChangeUserPolicy(true, true, true, true, 50, 60, 70, true, false, true); assertThrows(IllegalStateException.class, () -> data.prepareTimeChangeUserPolicy(transition, policy)); assertThrows(IllegalStateException.class, () -> data.getTimeChangeUserPolicy(transition)); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java index 9bf1a781ad..ff6a1dbeba 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java @@ -55,7 +55,7 @@ void retryDoesNotCreateADailyStreakBoundaryAfterTheCapturedDecisionDisabledIt() when(plugin.getServerData()).thenReturn(serverData); when(plugin.getConfigFile().isUseVoteStreaks()).thenReturn(true); when(serverData.getTimeChangeUserPolicy(transition)).thenReturn( - new TimeChangeUserPolicy(false, true, false, false, 0, 0, 0, false, false)); + new TimeChangeUserPolicy(false, true, false, false, 0, 0, 0, false, false, false)); when(transition.getId()).thenReturn("DAY:2026-09-21"); try (MockedStatic reset = org.mockito.Mockito.mockStatic(TimeChangeTotalReset.class)) { reset.when(() -> TimeChangeTotalReset.copyBoundary(plugin, "DailyTotal", "LastDailyTotal", @@ -133,7 +133,7 @@ void userRecoveryProcessesRowsDuringTheBoundedStorageWalk() { when(plugin.getServerData()).thenReturn(serverData); when(plugin.getConfigFile().isUseHighestTotals()).thenReturn(true); when(serverData.getTimeChangeUserPolicy(transition)).thenReturn( - new TimeChangeUserPolicy(false, true, false, false, 0, 0, 0, false, false)); + new TimeChangeUserPolicy(false, true, false, false, 0, 0, 0, false, false, false)); when(serverData.getTimeChangeCursor(transition)).thenReturn(""); when(votingUsers.getVotingPluginUser(uuid, false)).thenReturn(user); doAnswer(invocation -> { @@ -167,7 +167,7 @@ void userRecoveryFlushesUserChangesBeforeAdvancingTheCursor() { when(plugin.getServerData()).thenReturn(serverData); when(plugin.getConfigFile().isUseHighestTotals()).thenReturn(true); when(serverData.getTimeChangeUserPolicy(transition)).thenReturn( - new TimeChangeUserPolicy(false, true, false, false, 0, 0, 0, false, false)); + new TimeChangeUserPolicy(false, true, false, false, 0, 0, 0, false, false, false)); when(serverData.getTimeChangeCursor(transition)).thenReturn(""); when(votingUsers.getVotingPluginUser(org.mockito.ArgumentMatchers.any(UUID.class), org.mockito.ArgumentMatchers.eq(false))).thenReturn(user); @@ -208,7 +208,7 @@ void failedUserFlushDoesNotAdvanceTheRecoveryCursor() { when(plugin.getServerData()).thenReturn(serverData); when(plugin.getConfigFile().isUseHighestTotals()).thenReturn(true); when(serverData.getTimeChangeUserPolicy(transition)).thenReturn( - new TimeChangeUserPolicy(false, true, false, false, 0, 0, 0, false, false)); + new TimeChangeUserPolicy(false, true, false, false, 0, 0, 0, false, false, false)); when(serverData.getTimeChangeCursor(transition)).thenReturn(""); when(votingUsers.getVotingPluginUser(uuid, false)).thenReturn(user); when(user.getCache()).thenReturn(cache); @@ -259,7 +259,7 @@ void weeklyRecoveryUsesThePolicyCapturedBeforeConfigurationChanged() { VotingPluginUser user = mock(VotingPluginUser.class); TimeChangeTransition transition = mock(TimeChangeTransition.class); String uuid = "00000000-0000-0000-0000-000000000001"; - TimeChangeUserPolicy captured = new TimeChangeUserPolicy(true, true, false, false, 0, 0, 0, false, false); + TimeChangeUserPolicy captured = new TimeChangeUserPolicy(true, true, false, false, 0, 0, 0, false, false, false); when(plugin.getServerData()).thenReturn(serverData); when(plugin.getConfigFile().isUseVoteStreaks()).thenReturn(false); when(plugin.getConfigFile().isUseHighestTotals()).thenReturn(false); @@ -572,6 +572,23 @@ void sqliteVoteShopResetsRemainIndependentAndDoNotRepeat() throws Exception { } } + @Test + void sqliteVoteShopResetQuotesConfiguredIdentifierCharacters() throws Exception { + try (Connection connection = DriverManager.getConnection("jdbc:sqlite::memory:"); + Statement statement = connection.createStatement()) { + statement.executeUpdate("CREATE TABLE users (uuid TEXT PRIMARY KEY, `VoteShopLimitdaily-reward` INTEGER)"); + statement.executeUpdate("INSERT INTO users VALUES ('player', 4)"); + + TimeChangeTotalReset.resetSqliteToZero(connection, "users", "VoteShopLimitdaily-reward", + "time-shop:DAY:2026-09-21:VoteShopLimitdaily-reward"); + + try (ResultSet result = statement.executeQuery( + "SELECT `VoteShopLimitdaily-reward` FROM users WHERE uuid = 'player'")) { + assertEquals(0, result.getInt(1)); + } + } + } + @Test void failedMysqlVoteShopResetLeavesPhasePending() { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); From 3ffd58af01b905ffe6804df223fb8317270cdd18 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 08:26:16 -0600 Subject: [PATCH 34/61] Address remaining lifecycle review findings --- .../backendproxy/BackendProxyHandler.java | 4 +- .../votingplugin/commands/CommandLoader.java | 42 ++++++++++---- .../votingplugin/commands/gui/AdminGUI.java | 5 ++ .../gui/admin/AdminVoteVotePlayer.java | 14 ++++- .../listeners/PlayerVoteListener.java | 24 +++++++- .../votingplugin/listeners/VotiferEvent.java | 7 +-- .../listeners/VotifierVoteOverflowQueue.java | 11 ++-- .../votingplugin/test/VoteTester.java | 18 ++++-- .../topvoter/TopVoterHandler.java | 13 ++--- .../votingplugin/topvoter/TopVoterLoader.java | 6 +- .../votingplugin/util/VoteTaskAdmission.java | 30 +++++++++- .../PlayerVoteListenerAdmissionTest.java | 55 +++++++++++++++++++ .../tests/util/VoteTaskAdmissionTest.java | 29 ++++++++++ .../topvoter/TopVoterLoaderTest.java | 2 +- .../TopVoterTimeChangeRecoveryTest.java | 22 ++++++++ 15 files changed, 239 insertions(+), 43 deletions(-) create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerAdmissionTest.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java index 744efcf064..b7fb108a34 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java @@ -31,6 +31,7 @@ import com.bencodez.votingplugin.backendproxy.voteparty.BackendVotePartySync; import com.bencodez.votingplugin.proxy.BungeeMethod; import com.bencodez.votingplugin.proxy.VotingPluginWire; +import com.bencodez.votingplugin.util.VoteTaskAdmission; import lombok.Getter; @@ -317,7 +318,8 @@ private void scheduleOrderedVoteDispatchLocked() { } orderedVoteDispatchActive = true; try { - plugin.getBukkitScheduler().runTaskAsynchronously(plugin, this::runNextOrderedVoteDispatch); + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, + VoteTaskAdmission.ownedTask(this::runNextOrderedVoteDispatch)); } catch (RuntimeException schedulingFailure) { orderedVoteDispatchActive = false; orderedVoteDispatch.notifyAll(); 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 4674f5d50d..f9f1404524 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java @@ -129,6 +129,30 @@ void runForVotingUser(VotingPluginUser user, Runnable task) { BukkitCompletionScheduler.run(plugin, user.getPlayer(), task); } + private boolean callVoteAndReport(CommandSender sender, PlayerVoteEvent event) { + plugin.getServer().getPluginManager().callEvent(event); + if (event.isAccountingAdmissionFailed()) { + runForCommandSender(sender, + () -> sender.sendMessage(MessageAPI.colorize( + "&cVote could not be processed because shared storage is unavailable."))); + return false; + } + return true; + } + + private void submitVoteAndReport(CommandSender sender, PlayerVoteEvent event) { + submitVoteAndReport(sender, event, null); + } + + private void submitVoteAndReport(CommandSender sender, PlayerVoteEvent event, Runnable success) { + if (!VoteTaskAdmission.trySubmit(plugin.getVoteTimer(), () -> { + if (callVoteAndReport(sender, event) && success != null) runForCommandSender(sender, success); + })) { + sender.sendMessage(MessageAPI.colorize( + "&cCould not trigger the vote because vote processing is busy; please try again later.")); + } + } + String transferFailureMessage(PointTransferResult result) { if (result == PointTransferResult.INSUFFICIENT_POINTS) { return plugin.getConfigFile().getFormatCommandsVoteGivePointsNotEnoughPoints(); @@ -1336,7 +1360,7 @@ public void run() { + voteEvent.getServiceSite() + "?"); } } - plugin.getServer().getPluginManager().callEvent(voteEvent); + callVoteAndReport(sender, voteEvent); } })) { rejected++; @@ -1375,7 +1399,7 @@ public void execute(CommandSender sender, String[] args) { @Override public void run() { - plugin.getServer().getPluginManager().callEvent(voteEvent); + callVoteAndReport(sender, voteEvent); } })) { sendMessage(sender, "&cCould not trigger the vote because vote processing is busy; please try again later."); @@ -1413,7 +1437,7 @@ public void execute(CommandSender sender, String[] args) { @Override public void run() { - plugin.getServer().getPluginManager().callEvent(voteEvent); + callVoteAndReport(sender, voteEvent); } })) { sendMessage(sender, "&cCould not trigger the vote because vote processing is busy; please try again later."); @@ -1462,7 +1486,7 @@ public void execute(CommandSender sender, String[] args) { + voteEvent.getServiceSite() + "?"); } } - plugin.getServer().getPluginManager().callEvent(voteEvent); + submitVoteAndReport(sender, voteEvent); } if (plugin.isYmlError()) { @@ -1491,7 +1515,7 @@ public void execute(CommandSender sender, String[] args) { + voteEvent.getServiceSite() + "?"); } } - plugin.getServer().getPluginManager().callEvent(voteEvent); + submitVoteAndReport(sender, voteEvent); } }); @@ -3152,11 +3176,9 @@ public void onInput(Player player, String value) { plugin.getVoteSiteManager() .getVoteSiteServiceSite(value), false); - plugin.getServer().getPluginManager().callEvent(voteEvent); - - player.sendMessage("Forced vote for " - + UserGUI.getInstance().getCurrentPlayer(player) - + " on " + value); + String target = UserGUI.getInstance().getCurrentPlayer(player); + submitVoteAndReport(player, voteEvent, () -> player + .sendMessage("Forced vote for " + target + " on " + value)); } }); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/AdminGUI.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/AdminGUI.java index cec807d6f8..b197ef28fe 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/AdminGUI.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/AdminGUI.java @@ -330,6 +330,11 @@ public void onInput(Player player, String value) { @Override public void run() { plugin.getServer().getPluginManager().callEvent(voteEvent); + if (voteEvent.isAccountingAdmissionFailed()) { + plugin.getBukkitScheduler().runTask(plugin, + () -> player.sendMessage("Vote could not be processed because shared storage is unavailable."), + player); + } } })) { player.sendMessage( diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayer.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayer.java index ec12934bd9..c3e78e9e14 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayer.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayer.java @@ -12,6 +12,7 @@ import com.bencodez.advancedcore.api.inventory.editgui.EditGUI; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.events.PlayerVoteEvent; +import com.bencodez.votingplugin.util.VoteTaskAdmission; import com.bencodez.votingplugin.votesites.VoteSite; /** @@ -69,7 +70,18 @@ public void onClick(ClickEvent clickEvent) { + voteEvent.getServiceSite() + "?"); } } - plugin.getServer().getPluginManager().callEvent(voteEvent); + if (!VoteTaskAdmission.trySubmit(plugin.getVoteTimer(), () -> { + plugin.getServer().getPluginManager().callEvent(voteEvent); + if (voteEvent.isAccountingAdmissionFailed()) { + plugin.getBukkitScheduler().runTask(plugin, + () -> sendMessage(clickEvent.getPlayer(), + "&cVote could not be processed because shared storage is unavailable."), + clickEvent.getPlayer()); + } + })) { + sendMessage(clickEvent.getPlayer(), + "&cVote could not be triggered because vote processing is busy; please try again later."); + } if (plugin.isYmlError()) { sendMessage(clickEvent.getPlayer(), diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java index 7d3c187a35..0b76242b41 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java @@ -22,6 +22,7 @@ import com.bencodez.votingplugin.events.PlayerVoteEvent; import com.bencodez.votingplugin.topvoter.TopVoter; import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.util.VoteTaskAdmission; import com.bencodez.votingplugin.votesites.VoteSite; import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService; @@ -33,15 +34,32 @@ public PlayerVoteListener(VotingPluginMain plugin) { this.plugin = plugin; } - @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) - public void onplayerVote(PlayerVoteEvent event) { + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onplayerVote(PlayerVoteEvent event) { + if (!VoteTaskAdmission.isVoteTask() && Bukkit.isPrimaryThread()) { + if (!VoteTaskAdmission.trySubmit(plugin.getVoteTimer(), () -> processVote(event))) { + failAdmission(event, new SharedVoteAdmissionException("Vote executor rejected accounting admission")); + } + return; + } + processVote(event); + } + + private void processVote(PlayerVoteEvent event) { try { SharedVoteProcessor.process(new BukkitOperations(plugin, event)); } catch (SharedVoteAdmissionException admissionFailure) { - event.setAccountingAdmissionFailed(true); + failAdmission(event, admissionFailure); } } + private void failAdmission(PlayerVoteEvent event, SharedVoteAdmissionException failure) { + event.setAccountingAdmissionFailed(true); + plugin.getLogger().severe("Vote processing aborted because shared accounting admission failed for " + + event.getPlayer() + '/' + event.getServiceSite()); + plugin.debug(failure); + } + private static final class BukkitOperations implements SharedVoteProcessor.Operations { private final VotingPluginMain plugin; private final PlayerVoteEvent event; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java index 7a721d000c..bdb7a71956 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java @@ -1,7 +1,6 @@ package com.bencodez.votingplugin.listeners; import java.util.UUID; -import java.util.concurrent.RejectedExecutionException; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -13,6 +12,7 @@ import com.bencodez.votingplugin.proxy.BungeeMethod; import com.bencodez.votingplugin.util.MinecraftUsernameValidator; import com.bencodez.votingplugin.util.ServiceSiteValidator; +import com.bencodez.votingplugin.util.VoteTaskAdmission; import com.vexsoftware.votifier.model.Vote; import com.vexsoftware.votifier.model.VotifierEvent; @@ -165,9 +165,8 @@ public void onVotiferEvent(VotifierEvent event) { plugin.debug("IP: " + IP); UUID voteId = UUID.randomUUID(); - try { - plugin.getVoteTimer().submit(() -> processVote(voteSite, voteUsername, voteId)); - } catch (RejectedExecutionException rejected) { + if (!VoteTaskAdmission.trySubmit(plugin.getVoteTimer(), + () -> processVote(voteSite, voteUsername, voteId))) { VotifierVoteOverflowQueue overflow = plugin.getVotifierVoteOverflowQueue(); if (overflow == null || !overflow.enqueue(voteUsername, voteSite, voteId)) { plugin.getLogger().severe("Votifier vote queue is full; vote was not admitted for " diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java index 5665a35dd0..9c26dd23b6 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java @@ -29,6 +29,7 @@ import com.bencodez.votingplugin.util.DurableFiles; import com.bencodez.votingplugin.util.MinecraftUsernameValidator; import com.bencodez.votingplugin.util.ServiceSiteValidator; +import com.bencodez.votingplugin.util.VoteTaskAdmission; /** * Durable overflow for votes that cannot currently be admitted to the bounded @@ -160,14 +161,12 @@ private void drain() { return; } pending.submitted = true; - try { - // Serialize admission with enqueue so the version proven durable - // above cannot change in the gap before submit accepts this vote. - plugin.getVoteTimer().submit(() -> { + // Serialize admission with enqueue so the version proven durable + // above cannot change in the gap before submit accepts this vote. + if (!VoteTaskAdmission.trySubmit(plugin.getVoteTimer(), () -> { if (processor.accept(pending.serviceSite, pending.username, pending.voteId)) acknowledge(pending); else retry(pending); - }); - } catch (RejectedExecutionException rejected) { + })) { pending.submitted = false; drainScheduled = false; try { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/test/VoteTester.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/test/VoteTester.java index cea9280a18..de4af7249e 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/test/VoteTester.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/test/VoteTester.java @@ -104,6 +104,7 @@ public void run() { public void testSpam(int amount, String name, String site) { AtomicBoolean rejectionLogged = new AtomicBoolean(); + AtomicBoolean accountingFailureLogged = new AtomicBoolean(); for (int i = 0; i < amount; i++) { plugin.getBukkitScheduler().runTaskAsynchronously(plugin, new Runnable() { @@ -113,9 +114,14 @@ public void run() { @Override public void run() { - PlayerVoteEvent voteEvent = new PlayerVoteEvent(plugin.getVoteSiteManager().getVoteSite(site, false), name, - plugin.getVoteSiteManager().getVoteSiteServiceSite(site), false); - plugin.getServer().getPluginManager().callEvent(voteEvent); + PlayerVoteEvent voteEvent = new PlayerVoteEvent(plugin.getVoteSiteManager().getVoteSite(site, false), name, + plugin.getVoteSiteManager().getVoteSiteServiceSite(site), false); + plugin.getServer().getPluginManager().callEvent(voteEvent); + if (voteEvent.isAccountingAdmissionFailed() + && accountingFailureLogged.compareAndSet(false, true)) { + plugin.getLogger().warning( + "One or more spam-test votes failed shared accounting admission."); + } } }) && rejectionLogged.compareAndSet(false, true)) { plugin.getLogger().warning("One or more spam-test votes could not be submitted because vote processing is busy."); @@ -137,7 +143,11 @@ public void run() { long start1 = System.currentTimeMillis(); PlayerVoteEvent voteEvent = new PlayerVoteEvent(plugin.getVoteSiteManager().getVoteSite(site, false), name, plugin.getVoteSiteManager().getVoteSiteServiceSite(site), false); - plugin.getServer().getPluginManager().callEvent(voteEvent); + plugin.getServer().getPluginManager().callEvent(voteEvent); + if (voteEvent.isAccountingAdmissionFailed()) { + plugin.getLogger().warning("Vote performance test stopped after shared accounting admission failed."); + return; + } long start2 = System.currentTimeMillis(); timesPerVote.add(start2 - start1); } 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 3b5624d5fe..44be1fe596 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -883,14 +883,11 @@ void processRecoverableTopRewards(TopVoter top, TimeChangeTransition transition) VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser( UUID.fromString(target.uuid()), target.playerName()); user.userDataFetechMode(UserDataFetchMode.NO_CACHE); - if (!plugin.getServerData().getTimeChangeTopPolicy(transition).ignorePermission() - || !user.isTopVoterIgnore()) { - plugin.getServerData().claimTimeChangeReward(transition, target.uuid()); - giveTopVoterAward(top, user, target.place(), target.reward(), target.votes()); - plugin.getServerData().completeTimeChangeReward(transition, target.uuid()); - plugin.getLogger().info("Giving " + top + " top voter reward " + target.place() + " to " - + target.playerName()); - } + plugin.getServerData().claimTimeChangeReward(transition, target.uuid()); + giveTopVoterAward(top, user, target.place(), target.reward(), target.votes()); + plugin.getServerData().completeTimeChangeReward(transition, target.uuid()); + plugin.getLogger().info("Giving " + top + " top voter reward " + target.place() + " to " + + target.playerName()); } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterLoader.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterLoader.java index a29a4f3c29..b7c46c80de 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterLoader.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterLoader.java @@ -84,9 +84,6 @@ private BoundaryRanking loadBoundaryRanking(TopVoter top, user.userDataFetechMode(UserDataFetchMode.TEMP_ONLY); user.updateTempCacheWithColumns(columns); try { - String playerName = user.getPlayerName(); - if (user.isBanned() || playerName != null && blacklist.contains(playerName) - || ignorePermission && user.isTopVoterIgnore()) return; int total = monthlyTime == null ? switch (top) { case Daily -> user.getLastDailyTotal(); case Weekly -> user.getLastWeeklyTotal(); @@ -95,6 +92,9 @@ private BoundaryRanking loadBoundaryRanking(TopVoter top, } : user.getTotal(TopVoter.Monthly, monthlyTime); if (total > 0) { combinedTotal[0] += total; + String playerName = user.getPlayerName(); + if (user.isBanned() || playerName != null && blacklist.contains(playerName) + || ignorePermission && user.isTopVoterIgnore()) return; addBounded(topVoters, user.getTopVoterPlayer(), total, limit); } } finally { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/VoteTaskAdmission.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/VoteTaskAdmission.java index e749f9a99f..6578069a0c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/VoteTaskAdmission.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/VoteTaskAdmission.java @@ -6,6 +6,7 @@ /** Non-blocking admission helpers for work submitted to the bounded vote timer. */ public final class VoteTaskAdmission { + private static final ThreadLocal VOTE_TASK_DEPTH = new ThreadLocal<>(); private VoteTaskAdmission() { } @@ -19,7 +20,7 @@ private VoteTaskAdmission() { */ public static boolean trySubmit(ScheduledExecutorService executor, Runnable task) { try { - executor.submit(task); + executor.submit(wrap(task)); return true; } catch (RejectedExecutionException rejected) { return false; @@ -37,10 +38,35 @@ public static boolean trySubmit(ScheduledExecutorService executor, Runnable task */ public static boolean trySchedule(ScheduledExecutorService executor, Runnable task, long delay, TimeUnit unit) { try { - executor.schedule(task, delay, unit); + executor.schedule(wrap(task), delay, unit); return true; } catch (RejectedExecutionException rejected) { return false; } } + + /** Returns whether the current call is owned by an admitted vote-executor task. */ + public static boolean isVoteTask() { + Integer depth = VOTE_TASK_DEPTH.get(); + return depth != null && depth.intValue() > 0; + } + + /** Marks an asynchronously scheduled producer as owning vote processing. */ + public static Runnable ownedTask(Runnable task) { + return wrap(task); + } + + private static Runnable wrap(Runnable task) { + return () -> { + Integer depth = VOTE_TASK_DEPTH.get(); + int previous = depth == null ? 0 : depth.intValue(); + VOTE_TASK_DEPTH.set(Integer.valueOf(previous + 1)); + try { + task.run(); + } finally { + if (previous == 0) VOTE_TASK_DEPTH.remove(); + else VOTE_TASK_DEPTH.set(Integer.valueOf(previous)); + } + }; + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerAdmissionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerAdmissionTest.java new file mode 100644 index 0000000000..5a299dac23 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerAdmissionTest.java @@ -0,0 +1,55 @@ +package com.bencodez.votingplugin.listeners; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.events.PlayerVoteEvent; + +class PlayerVoteListenerAdmissionTest { + @Test + void primaryThreadEventDefersAccountingToTheVoteExecutor() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + ScheduledExecutorService voteExecutor = mock(ScheduledExecutorService.class); + PlayerVoteEvent event = new PlayerVoteEvent(null, "player", "site", false); + when(plugin.getVoteTimer()).thenReturn(voteExecutor); + + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + new PlayerVoteListener(plugin).onplayerVote(event); + } + + verify(voteExecutor).submit(any(Runnable.class)); + assertFalse(event.isAccountingAdmissionFailed()); + } + + @Test + void rejectedDeferralSurfacesAccountingFailure() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + ScheduledExecutorService voteExecutor = mock(ScheduledExecutorService.class); + PlayerVoteEvent event = new PlayerVoteEvent(null, "player", "site", false); + when(plugin.getVoteTimer()).thenReturn(voteExecutor); + when(plugin.getLogger()).thenReturn(Logger.getLogger("PlayerVoteListenerAdmissionTest")); + doThrow(new RejectedExecutionException("full")).when(voteExecutor).submit(any(Runnable.class)); + + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + new PlayerVoteListener(plugin).onplayerVote(event); + } + + assertTrue(event.isAccountingAdmissionFailed()); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/util/VoteTaskAdmissionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/util/VoteTaskAdmissionTest.java index 1991dfedd1..b415ef3de1 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/util/VoteTaskAdmissionTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/util/VoteTaskAdmissionTest.java @@ -8,8 +8,10 @@ import static org.mockito.Mockito.mock; import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import org.junit.jupiter.api.Test; @@ -41,4 +43,31 @@ void admittedTasksAreReported() { assertTrue(VoteTaskAdmission.trySubmit(executor, () -> { })); assertTrue(VoteTaskAdmission.trySchedule(executor, () -> { }, 5, TimeUnit.SECONDS)); } + + @Test + void admittedTaskOwnsVoteLaneOnlyWhileItRuns() throws Exception { + ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + AtomicBoolean ownedInside = new AtomicBoolean(); + try { + assertFalse(VoteTaskAdmission.isVoteTask()); + assertTrue(VoteTaskAdmission.trySubmit(executor, + () -> ownedInside.set(VoteTaskAdmission.isVoteTask()))); + executor.shutdown(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + assertTrue(ownedInside.get()); + assertFalse(VoteTaskAdmission.isVoteTask()); + } finally { + executor.shutdownNow(); + } + } + + @Test + void externallyScheduledOwnedTaskUsesTheSameScopedMarker() { + AtomicBoolean ownedInside = new AtomicBoolean(); + + VoteTaskAdmission.ownedTask(() -> ownedInside.set(VoteTaskAdmission.isVoteTask())).run(); + + assertTrue(ownedInside.get()); + assertFalse(VoteTaskAdmission.isVoteTask()); + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterLoaderTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterLoaderTest.java index 2871faaa71..87c403f7af 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterLoaderTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterLoaderTest.java @@ -139,7 +139,7 @@ void boundaryRankingExcludesBannedBlacklistedAndIgnoredPlayers() { TopVoter.Daily, null, true, java.util.List.of("blacklisted")); assertEquals(0, ranking.players().size()); - assertEquals(0, ranking.combinedTotal()); + assertEquals(60, ranking.combinedTotal()); } private VotingPluginUser boundaryUser(UUID uuid, String name, int total, diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java index ff6a1dbeba..03d8ddcb8b 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java @@ -468,6 +468,28 @@ void ambiguousTopRewardClaimStopsAutomaticReplay() { org.mockito.ArgumentMatchers.any(UUID.class), org.mockito.ArgumentMatchers.anyString()); } + @Test + void persistedTopRewardTargetIsNotReevaluatedAgainstCurrentIgnoreState() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + ServerData serverData = mock(ServerData.class); + VotingPluginUser user = mock(VotingPluginUser.class); + TimeChangeTransition transition = mock(TimeChangeTransition.class); + String uuid = "00000000-0000-0000-0000-000000000001"; + TimeChangeRewardTarget target = new TimeChangeRewardTarget(uuid, "first", 1, "1", 20); + when(plugin.getServerData()).thenReturn(serverData); + when(serverData.getTimeChangeRewardTargets(transition)).thenReturn(List.of(target)); + when(serverData.getTimeChangeRewardState(transition, uuid)).thenReturn(TimeChangeRewardState.UNCLAIMED); + when(plugin.getVotingPluginUserManager().getVotingPluginUser(UUID.fromString(uuid), "first")) + .thenReturn(user); + when(user.isTopVoterIgnore()).thenReturn(true); + + new TopVoterHandler(plugin).processRecoverableTopRewards(TopVoter.Daily, transition); + + verify(serverData).claimTimeChangeReward(transition, uuid); + verify(user).giveDailyTopVoterAward(1, "1", 20); + verify(serverData).completeTimeChangeReward(transition, uuid); + } + @Test void archiveRetryUsesOneStableTransitionFile() { VotingPluginMain plugin = mock(VotingPluginMain.class); From 6d5fb91ed5f7f2375bd637f873eabad7a98ad798 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:01:14 -0600 Subject: [PATCH 35/61] Make vote accounting replay idempotent --- .../core/vote/SharedVoteProcessor.java | 4 +- .../votingplugin/data/ServerData.java | 53 ++++++++++++- .../listeners/PlayerVoteListener.java | 4 +- .../specialrewards/voteparty/VoteParty.java | 4 +- .../votingplugin/user/VotingPluginUser.java | 74 ++++++++++++++++--- .../service/SharedMysqlPurchaseJournal.java | 11 ++- .../service/VoteShopPurchaseService.java | 13 +++- .../core/vote/SharedVoteProcessorTest.java | 12 +-- .../votingplugin/tests/VotePartyTest.java | 6 +- .../ServerDataTimeChangeRecoveryTest.java | 20 +++++ .../user/VotingPluginUserVotePointsTest.java | 66 +++++++++++++++++ .../VotingPluginUserVoteShopLimitTest.java | 19 +++++ .../SharedMysqlPurchaseJournalTest.java | 28 +++++++ 13 files changed, 282 insertions(+), 32 deletions(-) create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVotePointsTest.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java index aa55023cae..e3b164b354 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java @@ -82,7 +82,7 @@ public interface Operations { void addTotal(U user, UUID voteId); void addTotalDaily(U user, UUID voteId); void addTotalWeekly(U user, UUID voteId); - void addPoints(U user); + void addPoints(U user, UUID voteId); void checkDayVoteStreak(U user, boolean forceProxyRouting, UUID voteId); boolean limitMonthlyVotes(); int proxyMonthTotal(); @@ -220,7 +220,7 @@ public static void process(Operations ops) { ops.realVote(), ops.addTotals(), ops.proxyVote(), ops.forceProxyRouting(), ops.wasOnline()); SharedVoteAccounting.applyAdmitted(countTotals, awardPoints, () -> ops.addTotal(user, voteId), () -> ops.addTotalDaily(user, voteId), () -> ops.addTotalWeekly(user, voteId), - () -> ops.addPoints(user)); + () -> ops.addPoints(user, voteId)); ops.checkDayVoteStreak(user, ops.forceProxyRouting(), voteId); if (ops.limitMonthlyVotes() && (!ops.proxyVote() || ops.hasProxyTextTotals())) { int value = ops.proxyVote() ? ops.proxyMonthTotal() : ops.userMonthTotal(user); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index b5e659b4b2..8e1890cc76 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -1,10 +1,12 @@ package com.bencodez.votingplugin.data; import java.time.temporal.WeekFields; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.Set; import java.util.UUID; @@ -52,6 +54,9 @@ public record TimeChangeArchiveSnapshot(List sections) } private static final String TIME_CHANGE_RECOVERY = "TimeChangeRecovery"; + private static final String VOTE_PARTY_ACCOUNTING = "VoteParty.Accounting"; + private static final String VOTE_PARTY_ACCOUNTING_CLEANUP = "VoteParty.AccountingCleanup"; + private static final long VOTE_PARTY_ACCOUNTING_RETENTION_MILLIS = 30L * 24L * 60L * 60L * 1000L; private static final List TIME_CHANGE_PHASES = List.of("START", "SNAPSHOT", "COPY_TOTALS", "USER_UPDATES", "TOP_REWARDS", "VOTE_SHOP", "BUNGEE_WAIT", "TOTALS_RESET", "CACHE_CLEAR", "POST_DATE", "COMPLETE"); @@ -446,6 +451,46 @@ public synchronized void saveData() { plugin.getServerDataFile().saveData(); } + /** Atomically records and applies one local VoteParty total increment. */ + public synchronized boolean incrementVotePartyTotal(UUID voteId) { + if (voteId == null) { + getData().set("VoteParty.Total", getData().getInt("VoteParty.Total") + 1); + saveData(); + return true; + } + String receiptPath = VOTE_PARTY_ACCOUNTING + "." + voteId; + if (getData().contains(receiptPath)) return false; + int previousTotal = getData().getInt("VoteParty.Total"); + long previousCleanup = getData().getLong(VOTE_PARTY_ACCOUNTING_CLEANUP); + long now = System.currentTimeMillis(); + Map removedReceipts = new HashMap<>(); + try { + if (now - previousCleanup >= 24L * 60L * 60L * 1000L) { + ConfigurationSection receipts = getData().getConfigurationSection(VOTE_PARTY_ACCOUNTING); + if (receipts != null) { + for (String key : receipts.getKeys(false)) { + String path = VOTE_PARTY_ACCOUNTING + "." + key; + if (getData().getLong(path) < now - VOTE_PARTY_ACCOUNTING_RETENTION_MILLIS) { + removedReceipts.put(path, getData().get(path)); + getData().set(path, null); + } + } + } + getData().set(VOTE_PARTY_ACCOUNTING_CLEANUP, now); + } + getData().set("VoteParty.Total", previousTotal + 1); + getData().set(receiptPath, now); + saveData(); + return true; + } catch (RuntimeException | Error failure) { + getData().set("VoteParty.Total", previousTotal); + getData().set(receiptPath, null); + getData().set(VOTE_PARTY_ACCOUNTING_CLEANUP, previousCleanup == 0L ? null : previousCleanup); + removedReceipts.forEach((path, value) -> getData().set(path, value)); + throw failure; + } + } + /** * Starts or resumes the compact local checkpoint for a durable core time * transition. Only the current transition for each time type is retained; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java index 0b76242b41..04994d76e6 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java @@ -199,7 +199,7 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { @Override public void addTotal(VotingPluginUser user, UUID voteId) { user.addTotal(voteId); } @Override public void addTotalDaily(VotingPluginUser user, UUID voteId) { user.addTotalDaily(voteId); } @Override public void addTotalWeekly(VotingPluginUser user, UUID voteId) { user.addTotalWeekly(voteId); } - @Override public void addPoints(VotingPluginUser user) { user.addPoints(); } + @Override public void addPoints(VotingPluginUser user, UUID voteId) { user.addVotePoints(voteId); } @Override public void checkDayVoteStreak(VotingPluginUser user, boolean forceProxyRouting, UUID voteId) { user.checkDayVoteStreak(forceProxyRouting, voteId); } @@ -211,7 +211,7 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { @Override public void setMonthTotal(VotingPluginUser user, int total) { // Shared MySQL increments apply the same cap inside the boundary-serialized // transaction, so an absolute cache write here could only reintroduce a stale value. - if (!UserStorage.MYSQL.equals(plugin.getStorageType())) user.setTotal(TopVoter.Monthly, total); + if (!UserStorage.MYSQL.equals(plugin.getStorageType())) user.capMonthTotal(total); } @Override public void milestones(VotingPluginUser user, UUID voteId, boolean forceProxyRouting) { plugin.getVoteMilestonesManager().handleVote(user, event.getBungeeTextTotals(), forceProxyRouting, diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java index 87b44ac9fc..1dfdd82715 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java @@ -52,9 +52,9 @@ public void addTotal(VotingPluginUser user) { } public void addTotal(VotingPluginUser user, UUID voteId) { - setTotalVotes(getTotalVotes() + 1); + boolean totalAdded = plugin.getServerData().incrementVotePartyTotal(voteId); user.addVotePartyVote(voteId); - plugin.getPlaceholders().onVotePartyUpdate(); + if (totalAdded) plugin.getPlaceholders().onVotePartyUpdate(); } /** 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 b06bc6f387..a3e5836fc5 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -151,9 +151,20 @@ public VotingPluginUser(VotingPluginMain plugin, UUID uuid, String playerName) { /** * Adds one to the all-time total votes. */ - public void addAllTimeTotal() { - setAllTimeTotal(getAllTimeTotal() + 1); - } + public void addAllTimeTotal() { + setAllTimeTotal(getAllTimeTotal() + 1); + } + + public void addAllTimeTotal(UUID voteId) { + if (plugin != null && UserStorage.MYSQL.equals(plugin.getStorageType())) { + if (!VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, getUUID(), "AllTimeTotal", + "AllTimeTotal", List.of("AllTimeTotal"), null)) { + throw new IllegalStateException("Unable to retain shared MySQL all-time vote total"); + } + return; + } + addAllTimeTotal(); + } /** * Adds one to the daily vote streak. @@ -173,8 +184,7 @@ public void addMonthTotal() { public void addMonthTotal(UUID voteId) { PeriodTotalMutationFence.withMutation(() -> { int fallbackTotal = getMonthTotal() + 1; - if (plugin != null && UserStorage.MYSQL.equals(plugin.getStorageType()) - && plugin.getConfigFile().isLimitMonthlyVotes()) { + if (plugin != null && plugin.getConfigFile().isLimitMonthlyVotes()) { int maximum = plugin.getTimeChecker().getTime().getDayOfMonth() * plugin.getVoteSiteManager().getVoteSitesEnabled().size(); fallbackTotal = Math.min(maximum, fallbackTotal); @@ -272,6 +282,43 @@ public synchronized int addPoints(int value, boolean async) { return newTotal; } + /** + * Adds the configured vote points under the accepted vote's durable identity. + * Shared MySQL retries confirm the journalled credit before dispatching the + * receive hook again, and the vote producer is not acknowledged until the + * credit has committed. + * + * @param voteId stable identity of the accepted vote + */ + public void addVotePoints(UUID voteId) { + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + if (voteId == null || !sharedPoints.usesMysqlPointMutations()) { + addPoints(); + return; + } + + int points = plugin.getConfigFile().getPointsOnVote(); + int limit = plugin.getConfigFile().getLimitVotePoints(); + String pointsColumn = getPointsPath(); + String operationId = "vote-points:" + voteId; + Integer completedTotal = sharedPoints.completedPointAdditionTotal(operationId, getUUID(), pointsColumn); + if (completedTotal == null && points != 0) { + PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, points); + Bukkit.getPluginManager().callEvent(event); + if (!event.isCancelled()) { + SharedMysqlPointMutator.AddResult result = sharedPoints.addCommittedToColumn(this, + event.getPoints(), operationId, pointsColumn); + if (!result.success()) { + throw new IllegalStateException("Unable to persist vote points for " + getUUID()); + } + sharedPoints.acknowledgePointAdditionNow(operationId); + } + } else if (completedTotal != null) { + sharedPoints.acknowledgePointAdditionNow(operationId); + } + if (limit > 0) sharedPoints.cap(this, limit, false); + } + /** * Keeps ordinary storage writes synchronous while moving shared-MySQL atomic * arithmetic onto the persistence executor. @@ -891,7 +938,7 @@ public void addTotal() { public void addTotal(UUID voteId) { addMonthTotal(voteId); - addAllTimeTotal(); + addAllTimeTotal(voteId); } /** @@ -2442,10 +2489,17 @@ public void setLastVotes(HashMap lastVotes) { * @param total the total votes for the month * @deprecated Use setTotal(TopVoter.Monthly, total) instead */ - @Deprecated - public void setMonthTotal(int total) { - setTotal(TopVoter.Monthly, total); - } + @Deprecated + public void setMonthTotal(int total) { + setTotal(TopVoter.Monthly, total); + } + + /** Applies a monthly ceiling against the value read inside the period fence. */ + public void capMonthTotal(int maximum) { + PeriodTotalMutationFence.withMutation(() -> { + if (getMonthTotal() > maximum) setTotalWithinFence(TopVoter.Monthly, maximum); + }); + } /** * Sets the month vote streak. 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 index 2d44c7eef3..00cadf695d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -65,8 +65,9 @@ record AccountingRecoveryBatch(boolean hadRows, List pendingRewards) { } private static final int ACCOUNTING_DECIDED = 64; private static final int DAILY_STREAK_REWARD_CLAIMED = 128; private static final int AWARD_POINTS = 256; + private static final int ALL_TIME_TOTAL = 512; private static final int RECOVERABLE_NON_REWARD_ACCOUNTING = DAILY_TOTAL | WEEKLY_TOTAL | MONTH_TOTAL - | VOTE_PARTY_TOTAL | DAILY_STREAK; + | VOTE_PARTY_TOTAL | DAILY_STREAK | ALL_TIME_TOTAL; private static final ReferenceQueue INITIALIZED_QUEUE = new ReferenceQueue<>(); private static final Set INITIALIZED = new HashSet<>(); @@ -357,7 +358,7 @@ boolean incrementPeriodTotals(UUID voteId, String uuid, String boundaryColumn, S int prepareVoteAccounting(UUID voteId, String uuid, boolean countTotals, boolean awardPoints, boolean countVoteParty, String monthColumn, Integer monthMaximum, boolean streakUsesPercentage, double streakPercentage, int enabledSiteCount, boolean forceProxyRouting, long acceptedAt) throws SQLException { - int requested = (countTotals ? DAILY_TOTAL | WEEKLY_TOTAL | MONTH_TOTAL : 0) + int requested = (countTotals ? DAILY_TOTAL | WEEKLY_TOTAL | MONTH_TOTAL | ALL_TIME_TOTAL : 0) | (awardPoints ? AWARD_POINTS : 0) | (countVoteParty ? VOTE_PARTY_TOTAL : 0); try (Connection connection = connection()) { @@ -513,6 +514,7 @@ private static int accountingOperation(String boundaryColumn) throws SQLExceptio case "WeeklyTotal" -> WEEKLY_TOTAL; case "MonthTotal" -> MONTH_TOTAL; case "VotePartyVotes" -> VOTE_PARTY_TOTAL; + case "AllTimeTotal" -> ALL_TIME_TOTAL; default -> throw new SQLException("Unsupported period total boundary"); }; } @@ -1011,7 +1013,8 @@ private void applyPendingAccounting(Connection connection, UUID voteId, Accounti } String boundary = operation == DAILY_TOTAL ? "DailyTotal" : operation == WEEKLY_TOTAL ? "WeeklyTotal" - : operation == MONTH_TOTAL ? "MonthTotal" : "VotePartyVotes"; + : operation == MONTH_TOTAL ? "MonthTotal" + : operation == VOTE_PARTY_TOTAL ? "VotePartyVotes" : "AllTimeTotal"; List columns = operation == MONTH_TOTAL && row.monthColumn() != null ? List.of(boundary, row.monthColumn()) : List.of(boundary); StringBuilder sql = new StringBuilder("UPDATE ").append(qi(table.getTableName())).append(" SET "); @@ -1332,6 +1335,8 @@ AccountingRecoveryBatch recoverAccounting(long now) throws SQLException { } if ((missing & VOTE_PARTY_TOTAL) != 0) applyPeriodTotals(recovery.voteId(), row.uuid(), "VotePartyVotes", "LastVotePartyVotes", List.of("VotePartyVotes"), null, VOTE_PARTY_TOTAL); + if ((missing & ALL_TIME_TOTAL) != 0) applyPeriodTotals(recovery.voteId(), row.uuid(), + "AllTimeTotal", "AllTimeTotal", List.of("AllTimeTotal"), null, ALL_TIME_TOTAL); if ((missing & DAILY_STREAK) != 0) { if (row.streakValue() == null || row.streakUpdatedAt() == null) { throw new SQLException("Pending daily streak accounting payload is incomplete"); 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 30ce859350..acc498d369 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 @@ -58,6 +58,7 @@ public record VoteAccountingAdmission(boolean success, boolean countTotals, bool private static final ConcurrentMap ADMITTED_ACCOUNTING = new ConcurrentHashMap<>(); private static final int ACCOUNTING_DAILY_STREAK = 16; private static final int ACCOUNTING_POINTS = 256; + private static final int ACCOUNTING_ALL_TIME = 512; private static final int PURCHASE_LOCK_STRIPES = 256; private static final Object[] PURCHASE_LOCKS = createPurchaseLocks(); private static final int COMPLETION_PENDING = 0; @@ -635,8 +636,15 @@ public static boolean incrementMysqlPeriodTotals(VotingPluginMain plugin, UUID v for (String column : columns) table.checkColumn(column, DataType.INTEGER); if (maximum != null) table.checkColumn(previousColumn, DataType.INTEGER); UUID accountingId = voteId == null ? UUID.randomUUID() : voteId; - int operation = "DailyTotal".equals(boundaryColumn) ? 1 : "WeeklyTotal".equals(boundaryColumn) ? 2 - : "MonthTotal".equals(boundaryColumn) ? 4 : 8; + int operation = switch (boundaryColumn) { + case "DailyTotal" -> 1; + case "WeeklyTotal" -> 2; + case "MonthTotal" -> 4; + case "VotePartyVotes" -> 8; + case "AllTimeTotal" -> ACCOUNTING_ALL_TIME; + default -> 0; + }; + if (operation == 0) return false; int admitted = ADMITTED_ACCOUNTING.getOrDefault(accountingId, Integer.valueOf(0)).intValue(); SharedMysqlPurchaseJournal.PeriodTotalResult result = SharedMysqlPurchaseJournal.forTable(table) .incrementPeriodTotalsResolved(accountingId, uuid, boundaryColumn, previousColumn, columns, @@ -672,6 +680,7 @@ public static VoteAccountingAdmission prepareMysqlVoteAccounting(VotingPluginMai table.checkColumn("DailyTotal", DataType.INTEGER); table.checkColumn("WeeklyTotal", DataType.INTEGER); table.checkColumn("MonthTotal", DataType.INTEGER); + table.checkColumn("AllTimeTotal", DataType.INTEGER); if (monthColumn != null) table.checkColumn(monthColumn, DataType.INTEGER); if (maximum != null) table.checkColumn("LastMonthTotal", DataType.INTEGER); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java index 9656e1430e..d8d775132d 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java @@ -48,6 +48,8 @@ private SharedVoteProcessor.Operations accepted() { @Test void onlineVoteKeepsProductionPhaseOrderAndCounts() { var ops = accepted(); + UUID voteId = UUID.randomUUID(); + when(ops.incomingVoteId()).thenReturn(voteId); when(ops.userOnline(user)).thenReturn(true); when(ops.processRewards()).thenReturn(true); when(ops.broadcastEnabled()).thenReturn(true); @@ -76,7 +78,7 @@ void onlineVoteKeepsProductionPhaseOrderAndCounts() { order.verify(ops).addTotal(eq(user), any(UUID.class)); order.verify(ops).addTotalDaily(eq(user), any(UUID.class)); order.verify(ops).addTotalWeekly(eq(user), any(UUID.class)); - order.verify(ops).addPoints(user); + order.verify(ops).addPoints(user, voteId); order.verify(ops).checkDayVoteStreak(eq(user), eq(false), any(UUID.class)); order.verify(ops).setMonthTotal(user, 2); order.verify(ops).milestones(eq(user), any(UUID.class), eq(false)); @@ -125,7 +127,7 @@ void replayUsesPersistedPointsEligibilityWhenTotalsWereNotAdmitted() { SharedVoteProcessor.process(ops); - verify(ops).addPoints(user); + verify(ops).addPoints(eq(user), any(UUID.class)); verify(ops, never()).addTotal(eq(user), any(UUID.class)); } @@ -150,7 +152,7 @@ void proxyVoteUsesHistoricalOnlineForDeliveryButCurrentOnlineForTotals() { verify(ops).playerVote(user, site, true, false); verify(ops, never()).addOfflineVote(any(), any()); verify(ops, never()).addTotal(eq(user), any(UUID.class)); - verify(ops).addPoints(user); + verify(ops).addPoints(eq(user), any(UUID.class)); ArgumentCaptor id = ArgumentCaptor.forClass(UUID.class); verify(ops).postVote(eq(site), eq(user), eq("Ben"), eq(321L), id.capture(), eq(false)); assertEquals(proxyId, id.getValue()); @@ -209,7 +211,7 @@ void offlineVoteQueuesBeforeAccountingAndMarksPostEventCached() { InOrder order = inOrder(ops); order.verify(ops).addOfflineVote(user, "ExampleKey"); - order.verify(ops).addPoints(user); + order.verify(ops).addPoints(eq(user), any(UUID.class)); order.verify(ops).postVote(eq(site), eq(user), eq("Ben"), eq(456L), any(UUID.class), eq(true)); verify(ops, never()).playerVote(any(), any(), eq(false), eq(false)); verify(ops, never()).addTotal(eq(user), any(UUID.class)); @@ -253,6 +255,6 @@ void offlineQueueLimitPreservesUncachedPostEvent() { verify(ops, never()).addOfflineVote(any(), any()); verify(ops).postVote(eq(site), eq(user), eq("Ben"), eq(457L), any(UUID.class), eq(false)); - verify(ops).addPoints(user); + verify(ops).addPoints(eq(user), any(UUID.class)); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java index 39936535f2..31a0006ec2 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java @@ -91,9 +91,11 @@ public void tearDown() { @Test public void addTotal_IncrementsTotalVotes() { - when(plugin.getServerData().getData().getInt("VoteParty.Total")).thenReturn(5); + when(plugin.getServerData().incrementVotePartyTotal(null)).thenReturn(true); voteParty.addTotal(user); - verify(plugin.getServerData().getData()).set("VoteParty.Total", 6); + verify(plugin.getServerData()).incrementVotePartyTotal(null); + verify(user).addVotePartyVote(null); + verify(plugin.getPlaceholders()).onVotePartyUpdate(); } @Test diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java index 6c38eec5b2..f9c8d00fe4 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java @@ -14,6 +14,7 @@ import static org.mockito.Mockito.when; import java.util.List; +import java.util.UUID; import java.nio.file.Path; import org.bukkit.configuration.file.YamlConfiguration; @@ -36,6 +37,25 @@ class ServerDataTimeChangeRecoveryTest { @TempDir Path temporaryDirectory; + @Test + void repeatedVotePartyIdentityIncrementsTheGlobalTotalOnlyOnce() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); + YamlConfiguration yaml = new YamlConfiguration(); + when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile()); + when(plugin.getServerDataFile()).thenReturn(coreData); + when(coreData.getData()).thenReturn(yaml); + ServerData data = new ServerData(plugin); + UUID voteId = UUID.randomUUID(); + + assertTrue(data.incrementVotePartyTotal(voteId)); + assertFalse(data.incrementVotePartyTotal(voteId)); + + assertEquals(1, yaml.getInt("VotingPlugin.VoteParty.Total")); + assertTrue(yaml.contains("VotingPlugin.VoteParty.Accounting." + voteId)); + verify(coreData).saveData(); + } + @Test void perUserCursorUsesTheCompactDurableCheckpointWithoutRewritingServerData() { VotingPluginMain plugin = mock(VotingPluginMain.class); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVotePointsTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVotePointsTest.java new file mode 100644 index 0000000000..3c8e8d7006 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVotePointsTest.java @@ -0,0 +1,66 @@ +package com.bencodez.votingplugin.user; + +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.UserData; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.events.PlayerReceivePointsEvent; + +class VotingPluginUserVotePointsTest { + @Test + void repeatedVoteIdentityCommitsPointsAndDispatchesTheReceiveHookOnlyOnce() throws Exception { + 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.getConfigFile().getPointsOnVote()).thenReturn(5); + MySQL table = mock(MySQL.class); + when(plugin.getMysql()).thenReturn(table); + AdvancedCoreUser base = mock(AdvancedCoreUser.class); + String uuid = "00000000-0000-0000-0000-000000000001"; + when(base.getUserData()).thenReturn(mock(UserData.class)); + when(base.getUUID()).thenReturn(uuid); + VotingPluginUser user = org.mockito.Mockito.spy(new VotingPluginUser(plugin, base)); + doReturn(false).when(user).isCached(); + SharedPointAdditionJournal journal = mock(SharedPointAdditionJournal.class); + UUID voteId = UUID.randomUUID(); + String operationId = "vote-points:" + voteId; + when(journal.findCompleted(operationId, uuid, "Points")) + .thenReturn(null, new SharedPointAdditionJournal.AdditionResult(5)); + when(journal.add(org.mockito.ArgumentMatchers.eq(operationId), org.mockito.ArgumentMatchers.eq(uuid), + org.mockito.ArgumentMatchers.eq("Points"), org.mockito.ArgumentMatchers.eq(5), anyLong())) + .thenReturn(new SharedPointAdditionJournal.AdditionResult(5)); + PluginManager pluginManager = mock(PluginManager.class); + + try (MockedStatic journals = mockStatic(SharedPointAdditionJournal.class); + MockedStatic bukkit = mockStatic(Bukkit.class)) { + journals.when(() -> SharedPointAdditionJournal.forTable(table)).thenReturn(journal); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + + user.addVotePoints(voteId); + user.addVotePoints(voteId); + } + + verify(journal).add(org.mockito.ArgumentMatchers.eq(operationId), + org.mockito.ArgumentMatchers.eq(uuid), org.mockito.ArgumentMatchers.eq("Points"), + org.mockito.ArgumentMatchers.eq(5), anyLong()); + verify(journal, times(2)).acknowledge(org.mockito.ArgumentMatchers.eq(operationId), anyLong()); + verify(pluginManager).callEvent(isA(PlayerReceivePointsEvent.class)); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java index 7c344e123b..c5519cb2ce 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java @@ -154,6 +154,25 @@ void unretainedSharedMysqlMonthlyIncrementDoesNotWriteAnUnfencedCappedValue() { } } + @Test + void sqliteMonthlyIncrementAppliesTheCapInsideTheMutationFence() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getConfigFile().isLimitMonthlyVotes()).thenReturn(true); + when(plugin.getTimeChecker().getTime().getDayOfMonth()).thenReturn(2); + when(plugin.getVoteSiteManager().getVoteSitesEnabled().size()).thenReturn(3); + AdvancedCoreUser base = mock(AdvancedCoreUser.class); + when(base.getUserData()).thenReturn(mock(UserData.class)); + when(base.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + VotingPluginUser user = spy(new VotingPluginUser(plugin, base)); + doReturn(6).when(user).getMonthTotal(); + doNothing().when(user).setMonthTotal(org.mockito.ArgumentMatchers.anyInt()); + + user.addMonthTotal(UUID.randomUUID()); + + verify(user).setMonthTotal(6); + } + @Test void sharedMysqlLimitsUseNonBlockingUserCacheWhenAvailable() { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); 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 index 4bfa249d21..9ff7d606d5 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -664,6 +664,34 @@ void repeatedVoteOperationDoesNotIncrementThePeriodTwice() throws Exception { verify(fixture.work, org.mockito.Mockito.times(6)).prepareStatement(anyString()); } + @Test + void repeatedVoteOperationDoesNotIncrementAllTimeTotalTwice() throws Exception { + Fixture fixture = fixture(); + PreparedStatement requestInsert = mock(PreparedStatement.class); + PreparedStatement requestSelect = mock(PreparedStatement.class); + PreparedStatement requestUpdate = mock(PreparedStatement.class); + PreparedStatement markerInsert = mock(PreparedStatement.class); + PreparedStatement markerSelect = mock(PreparedStatement.class); + PreparedStatement accountingSelect = mock(PreparedStatement.class); + ResultSet epoch = mock(ResultSet.class); + ResultSet requested = accountingRow("player", 512, 512); + ResultSet accounting = accountingRow("player", 512, 512); + when(epoch.next()).thenReturn(true); + when(markerSelect.executeQuery()).thenReturn(epoch); + when(requestSelect.executeQuery()).thenReturn(requested); + when(accountingSelect.executeQuery()).thenReturn(accounting); + when(requestUpdate.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(requestInsert, requestSelect, requestUpdate, + markerInsert, markerSelect, accountingSelect); + + new SharedMysqlPurchaseJournal(fixture.table, false).incrementPeriodTotals(UUID.randomUUID(), + "player", "AllTimeTotal", "AllTimeTotal", List.of("AllTimeTotal"), null); + + verify(fixture.work).rollback(); + verify(fixture.work).commit(); + verify(fixture.work, org.mockito.Mockito.times(6)).prepareStatement(anyString()); + } + @Test void admittedMonthlyIncrementUsesItsJournaledColumnAfterConfigurationChanges() throws Exception { Fixture fixture = fixture(); From 5c5e5a043676c80faa28e30b194f417561299a6a Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:16:52 -0600 Subject: [PATCH 36/61] Require a confirmed proxy time boundary --- .../global/BackendGlobalDataSync.java | 10 ++++++++- .../votingplugin/proxy/VotingPluginProxy.java | 22 ++++++++++++++++++- .../votingplugin/proxy/VotingPluginWire.java | 4 ++++ .../global/BackendGlobalDataSyncTest.java | 8 +++++-- .../tests/VotingPluginProxyTest.java | 19 ++++++++++++++++ 5 files changed, 59 insertions(+), 4 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSync.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSync.java index febe99c28c..f40aea6033 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSync.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSync.java @@ -134,7 +134,11 @@ public boolean checkGlobalDataTime(TimeType type, HashMap dat private void finishTimeChange(TimeType type, String serverName) { boolean completed = false; try { - globalDataHandler.setBoolean(serverName, type.toString(), false); + HashMap completion = new HashMap<>(); + completion.put(type.toString(), new DataValueBoolean(false)); + completion.put(VotingPluginWire.timeChangeBoundaryCapturedKey(type.toString()), + new DataValueBoolean(true)); + globalDataHandler.setData(serverName, completion); JsonEnvelope.Builder builder = JsonEnvelope.builder("TimeChangeFinished") .schema(VotingPluginWire.SCHEMA_VERSION); builder.put("server", serverName); @@ -214,6 +218,10 @@ public void load() { "ForceUpdate", "VARCHAR(5)").entrySet()) { globalDataHandler.getGlobalMysql().alterColumnType(column.getKey(), column.getValue()); } + for (TimeType type : TimeType.values()) { + globalDataHandler.getGlobalMysql().alterColumnType( + VotingPluginWire.timeChangeBoundaryCapturedKey(type.toString()), "VARCHAR(5)"); + } plugin.getTimeChecker().setProcessingEnabled(false); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java index 9ba456734a..2ca38e3167 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java @@ -427,6 +427,8 @@ public void timeChanged(TimeType type, boolean fake, boolean pre, boolean post) int delay = 1; for (String s : getAllAvailableServers()) { if (getGlobalDataHandler().getGlobalMysql().containsKey(s)) { + getGlobalDataHandler().setBoolean(s, + VotingPluginWire.timeChangeBoundaryCapturedKey(type.toString()), false); String lastOnlineStr = getGlobalDataHandler().getString(s, "LastOnline"); long lastOnline = 0; try { @@ -470,7 +472,21 @@ public void onTimeChangedFailed(String srv, TimeType type) { } public void onTimeChangedFinished(TimeType type) { - getProxyMySQL().wipeColumnData(TopVoter.of(type).getColumnName(), DataType.INTEGER); + boolean boundaryCaptured = false; + String boundaryCapturedKey = VotingPluginWire.timeChangeBoundaryCapturedKey(type.toString()); + for (String server : getAllAvailableServers()) { + if (getGlobalDataHandler().getGlobalMysql().containsKey(server) + && getGlobalDataHandler().getBoolean(server, boundaryCapturedKey)) { + boundaryCaptured = true; + break; + } + } + if (boundaryCaptured) { + getProxyMySQL().wipeColumnData(TopVoter.of(type).getColumnName(), DataType.INTEGER); + } else { + warn("Retaining " + TopVoter.of(type).getColumnName() + + " because no backend confirmed the time-change boundary"); + } if (!getConfig().getGlobalDataEnabled()) { return; @@ -623,6 +639,10 @@ public void onTimeChangedFinished(TimeType type) { getGlobalDataHandler().getGlobalMysql().alterColumnType("Processing", "VARCHAR(5)"); getGlobalDataHandler().getGlobalMysql().alterColumnType("ForceUpdate", "VARCHAR(5)"); getGlobalDataHandler().getGlobalMysql().alterColumnType("LastUpdated", "MEDIUMTEXT"); + for (TimeType type : TimeType.values()) { + getGlobalDataHandler().getGlobalMysql().alterColumnType( + VotingPluginWire.timeChangeBoundaryCapturedKey(type.toString()), "VARCHAR(5)"); + } } // column types (unchanged from original) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java index 598edac8a8..120bcab495 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java @@ -33,6 +33,10 @@ public final class VotingPluginWire { private VotingPluginWire() { } + public static String timeChangeBoundaryCapturedKey(String type) { + return "BoundaryCaptured" + type; + } + public static final int SCHEMA_VERSION = 1; // ========================= diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSyncTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSyncTest.java index 800b14c109..6f9816fcb9 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSyncTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSyncTest.java @@ -140,8 +140,12 @@ void bungeeTimeChangeForceChangedRunsOnTheBukkitScheduler() { verify(globalDataHandler, never()).setData(eq("lobby"), any()); assertNotNull(asyncCompletion.get()); asyncCompletion.get().run(); - verify(globalDataHandler).setBoolean("lobby", TimeType.DAY.toString(), false); - verify(globalDataHandler).setData(eq("lobby"), any()); + verify(globalDataHandler).setData(eq("lobby"), org.mockito.ArgumentMatchers.argThat(values -> + values.containsKey(TimeType.DAY.toString()) && values.containsKey("BoundaryCapturedDAY") + && !values.get(TimeType.DAY.toString()).getBoolean() + && values.get("BoundaryCapturedDAY").getBoolean())); + verify(globalDataHandler).setData(eq("lobby"), org.mockito.ArgumentMatchers.argThat(values -> + values.containsKey("FinishedProcessing"))); } private static void setField(Object target, String fieldName, Object value) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java index 17555b7413..a879ea1958 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java @@ -19,6 +19,7 @@ import com.bencodez.advancedcore.api.time.TimeType; import com.bencodez.advancedcore.bungeeapi.globaldata.GlobalDataHandlerProxy; +import com.bencodez.advancedcore.bungeeapi.globaldata.GlobalMySQL; import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; import com.bencodez.simpleapi.servercomm.http.HttpProxyTransportServer; import com.bencodez.simpleapi.servercomm.mysql.MySqlMessenger; @@ -63,12 +64,30 @@ void setUp() { @Test void monthlyCompletionOnlyClearsTheBoundaryAlreadyCapturedByTheBackend() { + GlobalMySQL globalMysql = Mockito.mock(GlobalMySQL.class); + Mockito.when(globalDataHandler.getGlobalMysql()).thenReturn(globalMysql); + Mockito.when(globalMysql.containsKey("Server1")).thenReturn(true); + Mockito.when(globalDataHandler.getBoolean("Server1", "BoundaryCapturedMONTH")).thenReturn(true); + votingPluginProxy.onTimeChangedFinished(TimeType.MONTH); verify(proxyMySQL).wipeColumnData("MonthTotal", com.bencodez.simpleapi.sql.DataType.INTEGER); verify(proxyMySQL, never()).copyColumnData(Mockito.anyString(), Mockito.anyString()); } + @Test + void monthlyCompletionRetainsTotalsWhenEveryBackendFailedBeforeBoundaryCapture() { + GlobalMySQL globalMysql = Mockito.mock(GlobalMySQL.class); + Mockito.when(globalDataHandler.getGlobalMysql()).thenReturn(globalMysql); + Mockito.when(globalMysql.containsKey(Mockito.anyString())).thenReturn(true); + + votingPluginProxy.onTimeChangedFinished(TimeType.MONTH); + + verify(proxyMySQL, never()).wipeColumnData(Mockito.anyString(), Mockito.any()); + assertTrue(votingPluginProxy.getWarnings().stream() + .anyMatch(message -> message.contains("no backend confirmed the time-change boundary"))); + } + @Test void httpMethodPreparationRejectsAnOccupiedPortBeforeConfigurationPublication() throws Exception { try (java.net.ServerSocket occupied = new java.net.ServerSocket(0, 1, From a1d4b4708093cee3ad0f5d6f0e0994876b46ccff Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:36:58 -0600 Subject: [PATCH 37/61] Close remaining vote recovery gaps --- .../global/BackendGlobalDataSync.java | 18 +++++++++++++---- .../votingplugin/events/PlayerVoteEvent.java | 5 +++++ .../listeners/PlayerVoteListener.java | 7 ++++++- .../votingplugin/proxy/VotingPluginProxy.java | 20 ++++++++++++++----- .../votingplugin/proxy/VotingPluginWire.java | 4 ++++ .../timequeue/TimeQueueHandler.java | 2 +- .../votingplugin/user/VotingPluginUser.java | 5 ++--- .../global/BackendGlobalDataSyncTest.java | 5 ++++- .../PlayerVoteListenerAdmissionTest.java | 18 +++++++++++++++++ .../tests/VotingPluginProxyTest.java | 18 ++++++++++++++++- .../TimeQueueHandlerRejectionTest.java | 19 ++++++++++++++++++ .../user/VotingPluginUserVotePointsTest.java | 3 ++- 12 files changed, 107 insertions(+), 17 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSync.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSync.java index f40aea6033..2775730962 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSync.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSync.java @@ -19,6 +19,7 @@ import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; import com.bencodez.simpleapi.sql.data.DataValue; import com.bencodez.simpleapi.sql.data.DataValueBoolean; +import com.bencodez.simpleapi.sql.data.DataValueString; import com.bencodez.simpleapi.sql.mysql.config.MysqlConfigSpigot; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.proxy.VotingPluginWire; @@ -102,6 +103,13 @@ public boolean checkGlobalDataTime(TimeType type, HashMap dat if (!timeChangesInProgress.add(type)) { return false; } + String transitionId = data.containsKey(VotingPluginWire.timeChangeTransitionKey(type.toString())) + ? data.get(VotingPluginWire.timeChangeTransitionKey(type.toString())).getString() : ""; + if (transitionId == null || transitionId.isBlank()) { + timeChangesInProgress.remove(type); + plugin.getLogger().warning("Ignoring bungee time change without a transition identity: " + type); + return false; + } String serverName = plugin.getBungeeSettings().getServer(); globalDataHandler.setBoolean(serverName, "Processing", true); @@ -117,7 +125,7 @@ public boolean checkGlobalDataTime(TimeType type, HashMap dat } try { plugin.getBukkitScheduler().runTaskAsynchronously(plugin, - () -> finishTimeChange(type, serverName)); + () -> finishTimeChange(type, serverName, transitionId)); } catch (RuntimeException failure) { timeChangesInProgress.remove(type); plugin.debug(failure); @@ -131,13 +139,13 @@ public boolean checkGlobalDataTime(TimeType type, HashMap dat return true; } - private void finishTimeChange(TimeType type, String serverName) { + private void finishTimeChange(TimeType type, String serverName, String transitionId) { boolean completed = false; try { HashMap completion = new HashMap<>(); completion.put(type.toString(), new DataValueBoolean(false)); completion.put(VotingPluginWire.timeChangeBoundaryCapturedKey(type.toString()), - new DataValueBoolean(true)); + new DataValueString(transitionId)); globalDataHandler.setData(serverName, completion); JsonEnvelope.Builder builder = JsonEnvelope.builder("TimeChangeFinished") .schema(VotingPluginWire.SCHEMA_VERSION); @@ -220,7 +228,9 @@ public void load() { } for (TimeType type : TimeType.values()) { globalDataHandler.getGlobalMysql().alterColumnType( - VotingPluginWire.timeChangeBoundaryCapturedKey(type.toString()), "VARCHAR(5)"); + VotingPluginWire.timeChangeBoundaryCapturedKey(type.toString()), "VARCHAR(36)"); + globalDataHandler.getGlobalMysql().alterColumnType( + VotingPluginWire.timeChangeTransitionKey(type.toString()), "VARCHAR(36)"); } plugin.getTimeChecker().setProcessingEnabled(false); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java index 39351426a6..ddba806267 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java @@ -90,6 +90,11 @@ public static HandlerList getHandlerList() { @Getter @Setter private boolean accountingAdmissionFailed; + + /** Processing began but did not reach the post-vote completion boundary. */ + @Getter + @Setter + private boolean processingFailed; /** * Constructs a new PlayerVoteEvent. diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java index 04994d76e6..7e1c4ae23b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java @@ -50,6 +50,11 @@ private void processVote(PlayerVoteEvent event) { SharedVoteProcessor.process(new BukkitOperations(plugin, event)); } catch (SharedVoteAdmissionException admissionFailure) { failAdmission(event, admissionFailure); + } catch (RuntimeException processingFailure) { + event.setProcessingFailed(true); + plugin.getLogger().severe("Vote processing did not complete for " + event.getPlayer() + '/' + + event.getServiceSite() + "; a durable producer may retry it"); + plugin.debug(processingFailure); } } @@ -169,7 +174,7 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { return new SharedVoteProcessor.AccountingAdmission(admission.countTotals(), admission.awardPoints(), admission.countVoteParty()); } - @Override public void finishAccounting(UUID voteId) { + @Override public void finishAccounting(UUID voteId) { VoteShopPurchaseService.finishMysqlVoteAccounting(voteId); } @Override public void cache(VotingPluginUser user) { user.cache(); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java index 2ca38e3167..dc87f39c21 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java @@ -424,11 +424,16 @@ public void timeChanged(TimeType type, boolean fake, boolean pre, boolean post) warn("Global data not enabled, ignoring time change event"); return; } + String transitionId = UUID.randomUUID().toString(); int delay = 1; for (String s : getAllAvailableServers()) { if (getGlobalDataHandler().getGlobalMysql().containsKey(s)) { - getGlobalDataHandler().setBoolean(s, - VotingPluginWire.timeChangeBoundaryCapturedKey(type.toString()), false); + HashMap boundary = new HashMap<>(); + boundary.put(VotingPluginWire.timeChangeTransitionKey(type.toString()), + new DataValueString(transitionId)); + boundary.put(VotingPluginWire.timeChangeBoundaryCapturedKey(type.toString()), + new DataValueString("")); + getGlobalDataHandler().setData(s, boundary); String lastOnlineStr = getGlobalDataHandler().getString(s, "LastOnline"); long lastOnline = 0; try { @@ -474,9 +479,12 @@ public void onTimeChangedFailed(String srv, TimeType type) { public void onTimeChangedFinished(TimeType type) { boolean boundaryCaptured = false; String boundaryCapturedKey = VotingPluginWire.timeChangeBoundaryCapturedKey(type.toString()); + String transitionKey = VotingPluginWire.timeChangeTransitionKey(type.toString()); for (String server : getAllAvailableServers()) { - if (getGlobalDataHandler().getGlobalMysql().containsKey(server) - && getGlobalDataHandler().getBoolean(server, boundaryCapturedKey)) { + if (!getGlobalDataHandler().getGlobalMysql().containsKey(server)) continue; + String expected = getGlobalDataHandler().getString(server, transitionKey); + String captured = getGlobalDataHandler().getString(server, boundaryCapturedKey); + if (expected != null && !expected.isBlank() && expected.equals(captured)) { boundaryCaptured = true; break; } @@ -641,7 +649,9 @@ public void onTimeChangedFinished(TimeType type) { getGlobalDataHandler().getGlobalMysql().alterColumnType("LastUpdated", "MEDIUMTEXT"); for (TimeType type : TimeType.values()) { getGlobalDataHandler().getGlobalMysql().alterColumnType( - VotingPluginWire.timeChangeBoundaryCapturedKey(type.toString()), "VARCHAR(5)"); + VotingPluginWire.timeChangeBoundaryCapturedKey(type.toString()), "VARCHAR(36)"); + getGlobalDataHandler().getGlobalMysql().alterColumnType( + VotingPluginWire.timeChangeTransitionKey(type.toString()), "VARCHAR(36)"); } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java index 120bcab495..efc64f284f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java @@ -37,6 +37,10 @@ public static String timeChangeBoundaryCapturedKey(String type) { return "BoundaryCaptured" + type; } + public static String timeChangeTransitionKey(String type) { + return "BoundaryTransition" + type; + } + public static final int SCHEMA_VERSION = 1; // ========================= diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java index 382d0c0129..ab93521ab1 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java @@ -192,7 +192,7 @@ public void processQueue() { voteEvent.setTime(vote.getTime()); voteEvent.setVoteId(vote.getVoteId() == null ? vote.legacyTimedVoteId() : vote.getVoteId()); plugin.getServer().getPluginManager().callEvent(voteEvent); - if (voteEvent.isAccountingAdmissionFailed()) { + if (voteEvent.isAccountingAdmissionFailed() || voteEvent.isProcessingFailed()) { timeChangeQueue.addFirst(vote); scheduleRetry(!persistQueueSnapshot()); return; 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 a3e5836fc5..63a4d2e0c6 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -311,11 +311,10 @@ public void addVotePoints(UUID voteId) { if (!result.success()) { throw new IllegalStateException("Unable to persist vote points for " + getUUID()); } - sharedPoints.acknowledgePointAdditionNow(operationId); } - } else if (completedTotal != null) { - sharedPoints.acknowledgePointAdditionNow(operationId); } + // A durable vote queue has no age limit. Keep vote-point receipts in + // COMPLETED so a delayed replay cannot credit the same vote after cleanup. if (limit > 0) sharedPoints.cap(this, limit, false); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSyncTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSyncTest.java index 6f9816fcb9..ff85c5d450 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSyncTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSyncTest.java @@ -29,6 +29,7 @@ import com.bencodez.simpleapi.sql.data.DataValueString; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.config.BungeeSettings; +import com.bencodez.votingplugin.proxy.VotingPluginWire; class BackendGlobalDataSyncTest { @@ -128,6 +129,8 @@ void bungeeTimeChangeForceChangedRunsOnTheBukkitScheduler() { data.put("LastUpdated", new DataValueString( "" + LocalDateTime.now().atZone(ZoneOffset.UTC).toInstant().toEpochMilli())); data.put(TimeType.DAY.toString(), new DataValueBoolean(true)); + data.put(VotingPluginWire.timeChangeTransitionKey(TimeType.DAY.toString()), + new DataValueString("transition-1")); assertTrue(sync.checkGlobalDataTime(TimeType.DAY, data)); @@ -143,7 +146,7 @@ void bungeeTimeChangeForceChangedRunsOnTheBukkitScheduler() { verify(globalDataHandler).setData(eq("lobby"), org.mockito.ArgumentMatchers.argThat(values -> values.containsKey(TimeType.DAY.toString()) && values.containsKey("BoundaryCapturedDAY") && !values.get(TimeType.DAY.toString()).getBoolean() - && values.get("BoundaryCapturedDAY").getBoolean())); + && "transition-1".equals(values.get("BoundaryCapturedDAY").getString()))); verify(globalDataHandler).setData(eq("lobby"), org.mockito.ArgumentMatchers.argThat(values -> values.containsKey("FinishedProcessing"))); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerAdmissionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerAdmissionTest.java index 5a299dac23..f0452b1bdd 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerAdmissionTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerAdmissionTest.java @@ -17,6 +17,7 @@ import org.mockito.MockedStatic; import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.core.vote.SharedVoteProcessor; import com.bencodez.votingplugin.events.PlayerVoteEvent; class PlayerVoteListenerAdmissionTest { @@ -52,4 +53,21 @@ void rejectedDeferralSurfacesAccountingFailure() { assertTrue(event.isAccountingAdmissionFailed()); } + + @Test + void postAdmissionFailureIsVisibleToDurableProducers() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + PlayerVoteEvent event = new PlayerVoteEvent(null, "player", "site", false); + when(plugin.getLogger()).thenReturn(Logger.getLogger("PlayerVoteListenerAdmissionTest")); + + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class); + MockedStatic processor = org.mockito.Mockito.mockStatic(SharedVoteProcessor.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); + processor.when(() -> SharedVoteProcessor.process(any())) + .thenThrow(new IllegalStateException("storage failed")); + new PlayerVoteListener(plugin).onplayerVote(event); + } + + assertTrue(event.isProcessingFailed()); + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java index a879ea1958..457454d088 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java @@ -67,7 +67,8 @@ void monthlyCompletionOnlyClearsTheBoundaryAlreadyCapturedByTheBackend() { GlobalMySQL globalMysql = Mockito.mock(GlobalMySQL.class); Mockito.when(globalDataHandler.getGlobalMysql()).thenReturn(globalMysql); Mockito.when(globalMysql.containsKey("Server1")).thenReturn(true); - Mockito.when(globalDataHandler.getBoolean("Server1", "BoundaryCapturedMONTH")).thenReturn(true); + Mockito.when(globalDataHandler.getString("Server1", "BoundaryTransitionMONTH")).thenReturn("transition-1"); + Mockito.when(globalDataHandler.getString("Server1", "BoundaryCapturedMONTH")).thenReturn("transition-1"); votingPluginProxy.onTimeChangedFinished(TimeType.MONTH); @@ -88,6 +89,21 @@ void monthlyCompletionRetainsTotalsWhenEveryBackendFailedBeforeBoundaryCapture() .anyMatch(message -> message.contains("no backend confirmed the time-change boundary"))); } + @Test + void monthlyCompletionRejectsALateConfirmationFromAnOlderTransition() { + GlobalMySQL globalMysql = Mockito.mock(GlobalMySQL.class); + Mockito.when(globalDataHandler.getGlobalMysql()).thenReturn(globalMysql); + Mockito.when(globalMysql.containsKey("Server1")).thenReturn(true); + Mockito.when(globalDataHandler.getString("Server1", "BoundaryTransitionMONTH")) + .thenReturn("transition-2"); + Mockito.when(globalDataHandler.getString("Server1", "BoundaryCapturedMONTH")) + .thenReturn("transition-1"); + + votingPluginProxy.onTimeChangedFinished(TimeType.MONTH); + + verify(proxyMySQL, never()).wipeColumnData(Mockito.anyString(), Mockito.any()); + } + @Test void httpMethodPreparationRejectsAnOccupiedPortBeforeConfigurationPublication() throws Exception { try (java.net.ServerSocket occupied = new java.net.ServerSocket(0, 1, diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java index d68a6f8fd2..9b5a6d88d1 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java @@ -132,6 +132,25 @@ void accountingAdmissionFailureRetainsTheSameQueuedVote() { assertEquals(List.of(vote, following), persisted.getValue()); } + @Test + void postAdmissionProcessingFailureRetainsTheSameQueuedVote() { + when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of()); + TimeQueueHandler handler = new TimeQueueHandler(plugin); + VoteTimeQueue vote = new VoteTimeQueue(UUID.randomUUID(), "Alex", "example.org", 123L); + handler.getTimeChangeQueue().add(vote); + org.bukkit.plugin.PluginManager pluginManager = plugin.getServer().getPluginManager(); + doAnswer(invocation -> { + PlayerVoteEvent event = invocation.getArgument(0); + event.setProcessingFailed(true); + return null; + }).when(pluginManager).callEvent(any(PlayerVoteEvent.class)); + + handler.processQueue(); + + assertEquals(vote, handler.getTimeChangeQueue().peek()); + verify(serverData).replaceTimedVoteCache(List.of(vote)); + } + @Test void timedVoteSnapshotPersistsStableIdsWithOneSave() { VotingPluginMain snapshotPlugin = mock(VotingPluginMain.class); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVotePointsTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVotePointsTest.java index 3c8e8d7006..8d712129a8 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVotePointsTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVotePointsTest.java @@ -60,7 +60,8 @@ void repeatedVoteIdentityCommitsPointsAndDispatchesTheReceiveHookOnlyOnce() thro verify(journal).add(org.mockito.ArgumentMatchers.eq(operationId), org.mockito.ArgumentMatchers.eq(uuid), org.mockito.ArgumentMatchers.eq("Points"), org.mockito.ArgumentMatchers.eq(5), anyLong()); - verify(journal, times(2)).acknowledge(org.mockito.ArgumentMatchers.eq(operationId), anyLong()); + verify(journal, org.mockito.Mockito.never()).acknowledge( + org.mockito.ArgumentMatchers.eq(operationId), anyLong()); verify(pluginManager).callEvent(isA(PlayerReceivePointsEvent.class)); } } From 9f792606cc932125b2d2614ff8cb4249ff261111 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:50:48 -0600 Subject: [PATCH 38/61] Propagate incomplete vote processing --- .../votingplugin/commands/CommandLoader.java | 2 +- .../votingplugin/commands/gui/AdminGUI.java | 2 +- .../gui/admin/AdminVoteVotePlayer.java | 2 +- .../votingplugin/events/PlayerVoteEvent.java | 4 ++ .../votingplugin/listeners/VotiferEvent.java | 2 +- .../votingplugin/test/VoteTester.java | 4 +- .../timequeue/TimeQueueHandler.java | 2 +- .../votingplugin/user/VotingPluginUser.java | 2 +- .../service/SharedMysqlPurchaseJournal.java | 13 +---- .../VotiferEventDisabledVoteSiteTest.java | 14 ++++++ .../user/VotingPluginUserProxyVoteTest.java | 49 +++++++++++++++++++ .../SharedMysqlPurchaseJournalTest.java | 17 +++---- 12 files changed, 84 insertions(+), 29 deletions(-) create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserProxyVoteTest.java 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 f9f1404524..e0fbeed237 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java @@ -131,7 +131,7 @@ void runForVotingUser(VotingPluginUser user, Runnable task) { private boolean callVoteAndReport(CommandSender sender, PlayerVoteEvent event) { plugin.getServer().getPluginManager().callEvent(event); - if (event.isAccountingAdmissionFailed()) { + if (event.isProcessingIncomplete()) { runForCommandSender(sender, () -> sender.sendMessage(MessageAPI.colorize( "&cVote could not be processed because shared storage is unavailable."))); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/AdminGUI.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/AdminGUI.java index b197ef28fe..28a0aa27ae 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/AdminGUI.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/AdminGUI.java @@ -330,7 +330,7 @@ public void onInput(Player player, String value) { @Override public void run() { plugin.getServer().getPluginManager().callEvent(voteEvent); - if (voteEvent.isAccountingAdmissionFailed()) { + if (voteEvent.isProcessingIncomplete()) { plugin.getBukkitScheduler().runTask(plugin, () -> player.sendMessage("Vote could not be processed because shared storage is unavailable."), player); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayer.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayer.java index c3e78e9e14..e156a2454a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayer.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayer.java @@ -72,7 +72,7 @@ public void onClick(ClickEvent clickEvent) { } if (!VoteTaskAdmission.trySubmit(plugin.getVoteTimer(), () -> { plugin.getServer().getPluginManager().callEvent(voteEvent); - if (voteEvent.isAccountingAdmissionFailed()) { + if (voteEvent.isProcessingIncomplete()) { plugin.getBukkitScheduler().runTask(plugin, () -> sendMessage(clickEvent.getPlayer(), "&cVote could not be processed because shared storage is unavailable."), diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java index ddba806267..93d775e71f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java @@ -95,6 +95,10 @@ public static HandlerList getHandlerList() { @Getter @Setter private boolean processingFailed; + + public boolean isProcessingIncomplete() { + return accountingAdmissionFailed || processingFailed; + } /** * Constructs a new PlayerVoteEvent. diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java index bdb7a71956..a758f4e4b9 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java @@ -102,7 +102,7 @@ private boolean processVoteAttempt(String voteSite, String voteUsername, UUID vo plugin.getVoteSiteManager().getVoteSite(voteSiteName, true), voteUsername, voteSite, true); voteEvent.setVoteId(voteId); plugin.getServer().getPluginManager().callEvent(voteEvent); - if (voteEvent.isAccountingAdmissionFailed()) { + if (voteEvent.isProcessingIncomplete()) { return false; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/test/VoteTester.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/test/VoteTester.java index de4af7249e..0ecd6d597d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/test/VoteTester.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/test/VoteTester.java @@ -117,7 +117,7 @@ public void run() { PlayerVoteEvent voteEvent = new PlayerVoteEvent(plugin.getVoteSiteManager().getVoteSite(site, false), name, plugin.getVoteSiteManager().getVoteSiteServiceSite(site), false); plugin.getServer().getPluginManager().callEvent(voteEvent); - if (voteEvent.isAccountingAdmissionFailed() + if (voteEvent.isProcessingIncomplete() && accountingFailureLogged.compareAndSet(false, true)) { plugin.getLogger().warning( "One or more spam-test votes failed shared accounting admission."); @@ -144,7 +144,7 @@ public void run() { PlayerVoteEvent voteEvent = new PlayerVoteEvent(plugin.getVoteSiteManager().getVoteSite(site, false), name, plugin.getVoteSiteManager().getVoteSiteServiceSite(site), false); plugin.getServer().getPluginManager().callEvent(voteEvent); - if (voteEvent.isAccountingAdmissionFailed()) { + if (voteEvent.isProcessingIncomplete()) { plugin.getLogger().warning("Vote performance test stopped after shared accounting admission failed."); return; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java index ab93521ab1..de69316e6b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java @@ -192,7 +192,7 @@ public void processQueue() { voteEvent.setTime(vote.getTime()); voteEvent.setVoteId(vote.getVoteId() == null ? vote.legacyTimedVoteId() : vote.getVoteId()); plugin.getServer().getPluginManager().callEvent(voteEvent); - if (voteEvent.isAccountingAdmissionFailed() || voteEvent.isProcessingFailed()) { + if (voteEvent.isProcessingIncomplete()) { timeChangeQueue.addFirst(vote); scheduleRetry(!persistQueueSnapshot()); return; 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 63a4d2e0c6..9c300b3b38 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -1049,7 +1049,7 @@ public boolean bungeeVotePluginMessagingAccepted(String service, long time, Vote voteEvent.setVoteNumber(num); voteEvent.setVoteId(voteId); plugin.getServer().getPluginManager().callEvent(voteEvent); - return !voteEvent.isAccountingAdmissionFailed(); + return !voteEvent.isProcessingIncomplete(); } return false; } 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 index 00cadf695d..a0b059669a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -44,7 +44,6 @@ record AccountingRecoveryBatch(boolean hadRows, List pendingRewards) { } 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); - static final long ACCOUNTING_RETENTION_MILLIS = TimeUnit.DAYS.toMillis(30); 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. */ @@ -1350,7 +1349,8 @@ AccountingRecoveryBatch recoverAccounting(long now) throws SQLException { break; } } - cleanupAccounting(now - ACCOUNTING_RETENTION_MILLIS); + // Durable Votifier and proxy queues have no age limit. Completed vote + // accounting receipts must therefore outlive every possible same-ID replay. return new AccountingRecoveryBatch(!pending.isEmpty(), List.copyOf(pendingRewards)); } @@ -1377,15 +1377,6 @@ private List findPendingAccounting(int limit) throws SQLExce return pending; } - private void cleanupAccounting(long cutoff) throws SQLException { - String delete = "DELETE FROM " + qiAccounting() + " WHERE " + qi("requested") + " = " - + qi("completed") + " AND " + qi("created_at") + " < ?"; - try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(delete)) { - statement.setLong(1, cutoff); - statement.executeUpdate(); - } - } - 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 ?"; diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java index 6a8f21febc..fd0533a257 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java @@ -1,5 +1,6 @@ package com.bencodez.votingplugin.tests.listeners; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.RETURNS_DEEP_STUBS; @@ -173,4 +174,17 @@ void timeChangeQueueReceivesTheOriginalVoteId() { verify(timeQueue).addVote(voteId, "Steve", SERVICE_SITE); verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class)); } + + @Test + void queuedVoteIsNotAcknowledgedWhenPostAdmissionProcessingFails() { + when(voteSiteManager.getVoteSiteName(false, SERVICE_SITE, "")).thenReturn(SERVICE_SITE); + when(voteSiteManager.getVoteSiteName(true, SERVICE_SITE, "")).thenReturn(SERVICE_SITE); + doAnswer(invocation -> { + PlayerVoteEvent event = invocation.getArgument(0); + event.setProcessingFailed(true); + return null; + }).when(pluginManager).callEvent(any(PlayerVoteEvent.class)); + + assertFalse(listener.processQueuedVote(SERVICE_SITE, "Steve", UUID.randomUUID())); + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserProxyVoteTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserProxyVoteTest.java new file mode 100644 index 0000000000..e373d49a4a --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserProxyVoteTest.java @@ -0,0 +1,49 @@ +package com.bencodez.votingplugin.user; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.UUID; + +import org.bukkit.Server; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.UserData; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.config.BungeeSettings; +import com.bencodez.votingplugin.events.PlayerVoteEvent; +import com.bencodez.votingplugin.votesites.VoteSiteManager; + +class VotingPluginUserProxyVoteTest { + @Test + void backendDeliveryIsNotAcknowledgedWhenPostAdmissionProcessingFails() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + BungeeSettings bungee = mock(BungeeSettings.class); + VoteSiteManager sites = mock(VoteSiteManager.class); + Server server = mock(Server.class); + PluginManager pluginManager = mock(PluginManager.class); + AdvancedCoreUser base = mock(AdvancedCoreUser.class); + when(plugin.getBungeeSettings()).thenReturn(bungee); + when(bungee.isUseBungeecoord()).thenReturn(true); + when(plugin.getVoteSiteManager()).thenReturn(sites); + when(plugin.getServer()).thenReturn(server); + when(server.getPluginManager()).thenReturn(pluginManager); + when(base.getPlayerName()).thenReturn("Player"); + when(base.getUserData()).thenReturn(mock(UserData.class)); + doAnswer(invocation -> { + PlayerVoteEvent event = invocation.getArgument(0); + event.setProcessingFailed(true); + return null; + }).when(pluginManager).callEvent(any(PlayerVoteEvent.class)); + + VotingPluginUser user = new VotingPluginUser(plugin, base); + + assertFalse(user.bungeeVotePluginMessagingAccepted("service", 100L, null, true, true, + true, 1, UUID.randomUUID())); + } +} 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 index 9ff7d606d5..8cf444cf1c 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -903,26 +903,23 @@ void failedApplyLeavesTheCommittedAccountingRequestForRecovery() throws Exceptio } @Test - void completedAccountingRowsAreDeletedAfterTheReplayWindow() throws Exception { + void completedAccountingRowsRemainForTheUnboundedReplayWindow() throws Exception { Fixture fixture = fixture(); PreparedStatement pending = mock(PreparedStatement.class); - PreparedStatement cleanup = mock(PreparedStatement.class); ResultSet none = ids(); when(pending.executeQuery()).thenReturn(none); - when(fixture.work.prepareStatement(anyString())).thenReturn(pending, cleanup); + when(fixture.work.prepareStatement(anyString())).thenReturn(pending); new SharedMysqlPurchaseJournal(fixture.table, false) - .recoverAccounting(SharedMysqlPurchaseJournal.ACCOUNTING_RETENTION_MILLIS + 5L); + .recoverAccounting(java.util.concurrent.TimeUnit.DAYS.toMillis(90)); - verify(cleanup).setLong(1, 5L); - verify(cleanup).executeUpdate(); + verify(fixture.work).prepareStatement(org.mockito.ArgumentMatchers.contains("requested")); } @Test void claimedRewardIsSkippedWithoutStarvingLaterRecoverableReward() throws Exception { Fixture fixture = fixture(); PreparedStatement pending = mock(PreparedStatement.class); - PreparedStatement cleanup = mock(PreparedStatement.class); ResultSet rows = mock(ResultSet.class); UUID ambiguous = UUID.randomUUID(); UUID recoverable = UUID.randomUUID(); @@ -932,15 +929,15 @@ void claimedRewardIsSkippedWithoutStarvingLaterRecoverableReward() throws Except when(rows.getInt(3)).thenReturn(48, 48); when(rows.getInt(4)).thenReturn(144, 16); when(pending.executeQuery()).thenReturn(rows); - when(fixture.work.prepareStatement(anyString())).thenReturn(pending, cleanup); + when(fixture.work.prepareStatement(anyString())).thenReturn(pending); SharedMysqlPurchaseJournal.AccountingRecoveryBatch batch = new SharedMysqlPurchaseJournal(fixture.table, false).recoverAccounting(1L); assertEquals(java.util.List.of(recoverable), batch.pendingRewards()); org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class); - verify(fixture.work, org.mockito.Mockito.times(2)).prepareStatement(sql.capture()); - assertTrue(sql.getAllValues().get(0).contains("& 128"), + verify(fixture.work).prepareStatement(sql.capture()); + assertTrue(sql.getValue().contains("& 128"), "claimed reward rows must be excluded from the bounded recovery page"); } From b60b72885e95b835e5188fb7b02162c176705168 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:09:24 -0600 Subject: [PATCH 39/61] Quarantine ambiguous vote retries --- .../votingplugin/VotingPluginMain.java | 2 +- .../core/vote/SharedVoteProcessor.java | 4 ++ .../votingplugin/data/ServerData.java | 30 +++++------- .../votingplugin/events/PlayerVoteEvent.java | 5 ++ .../listeners/PlayerVoteListener.java | 1 + .../votingplugin/listeners/VotiferEvent.java | 27 ++++++++--- .../listeners/VotifierVoteOverflowQueue.java | 48 +++++++++++++++++-- .../timequeue/TimeQueueHandler.java | 6 +++ .../votingplugin/user/VotingPluginUser.java | 3 ++ .../core/vote/SharedVoteProcessorTest.java | 1 + .../ServerDataTimeChangeRecoveryTest.java | 35 ++++++++++++++ .../VotiferEventDisabledVoteSiteTest.java | 4 ++ .../VotifierVoteOverflowQueueTest.java | 40 +++++++++++++++- .../TimeQueueHandlerRejectionTest.java | 23 +++++++++ .../user/VotingPluginUserProxyVoteTest.java | 9 ++-- 15 files changed, 201 insertions(+), 37 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 5a3e84c02c..1e0924af15 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -1883,7 +1883,7 @@ private void registerEvents() { if (isVotifierLoaded()) { VotiferEvent votifierEvent = new VotiferEvent(this); votifierVoteOverflowQueue = new VotifierVoteOverflowQueue(this, - (serviceSite, username, voteId) -> votifierEvent.processQueuedVote(serviceSite, username, voteId)); + (serviceSite, username, voteId) -> votifierEvent.processQueuedVoteOutcome(serviceSite, username, voteId)); pm.registerEvents(votifierEvent, this); } pm.registerEvents(new PlayerVoteListener(this), this); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java index e3b164b354..fe113320a4 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java @@ -65,6 +65,7 @@ public interface Operations { void cache(U user); void updateName(U user); void voteParty(U user, boolean forceProxyRouting, UUID voteId, boolean eligible); + void markReplayUnsafe(); long incomingTime(); void setTime(U user, S site, long time); void setTimeNow(U user, S site); @@ -182,6 +183,9 @@ public static void process(Operations ops) { ops.cache(user); ops.updateName(user); ops.voteParty(user, ops.forceProxyRouting(), voteId, votePartyEligible); + // Everything before this point is protected by the vote accounting and + // VoteParty receipts. Later platform effects do not all have replay receipts. + ops.markReplayUnsafe(); if (ops.broadcastEnabled() && ops.hasBroadcastHandler()) { boolean currentOnline = ops.userOnline(user); boolean online = currentOnline; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index 8e1890cc76..3b408cd13e 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -55,8 +55,6 @@ public record TimeChangeArchiveSnapshot(List sections) private static final String TIME_CHANGE_RECOVERY = "TimeChangeRecovery"; private static final String VOTE_PARTY_ACCOUNTING = "VoteParty.Accounting"; - private static final String VOTE_PARTY_ACCOUNTING_CLEANUP = "VoteParty.AccountingCleanup"; - private static final long VOTE_PARTY_ACCOUNTING_RETENTION_MILLIS = 30L * 24L * 60L * 60L * 1000L; private static final List TIME_CHANGE_PHASES = List.of("START", "SNAPSHOT", "COPY_TOTALS", "USER_UPDATES", "TOP_REWARDS", "VOTE_SHOP", "BUNGEE_WAIT", "TOTALS_RESET", "CACHE_CLEAR", "POST_DATE", "COMPLETE"); @@ -152,6 +150,17 @@ public synchronized void replaceTimedVoteCache(List votes) { } saveData(); } + + /** Retains an ambiguously processed timed vote without admitting it to replay. */ + public synchronized void quarantineTimedVote(VoteTimeQueue vote) { + String key = vote.getVoteId() == null ? vote.legacyTimedVoteId().toString() : vote.getVoteId().toString(); + String path = "TimedVoteQuarantine." + key; + getData().set(path + ".Name", vote.getName()); + getData().set(path + ".Service", vote.getService()); + getData().set(path + ".Time", vote.getTime()); + getData().set(path + ".VoteId", key); + saveData(); + } /** * Adds a vote shop purchase for the specified identifier. @@ -461,23 +470,8 @@ public synchronized boolean incrementVotePartyTotal(UUID voteId) { String receiptPath = VOTE_PARTY_ACCOUNTING + "." + voteId; if (getData().contains(receiptPath)) return false; int previousTotal = getData().getInt("VoteParty.Total"); - long previousCleanup = getData().getLong(VOTE_PARTY_ACCOUNTING_CLEANUP); long now = System.currentTimeMillis(); - Map removedReceipts = new HashMap<>(); try { - if (now - previousCleanup >= 24L * 60L * 60L * 1000L) { - ConfigurationSection receipts = getData().getConfigurationSection(VOTE_PARTY_ACCOUNTING); - if (receipts != null) { - for (String key : receipts.getKeys(false)) { - String path = VOTE_PARTY_ACCOUNTING + "." + key; - if (getData().getLong(path) < now - VOTE_PARTY_ACCOUNTING_RETENTION_MILLIS) { - removedReceipts.put(path, getData().get(path)); - getData().set(path, null); - } - } - } - getData().set(VOTE_PARTY_ACCOUNTING_CLEANUP, now); - } getData().set("VoteParty.Total", previousTotal + 1); getData().set(receiptPath, now); saveData(); @@ -485,8 +479,6 @@ public synchronized boolean incrementVotePartyTotal(UUID voteId) { } catch (RuntimeException | Error failure) { getData().set("VoteParty.Total", previousTotal); getData().set(receiptPath, null); - getData().set(VOTE_PARTY_ACCOUNTING_CLEANUP, previousCleanup == 0L ? null : previousCleanup); - removedReceipts.forEach((path, value) -> getData().set(path, value)); throw failure; } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java index 93d775e71f..1109cb6d93 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java @@ -96,6 +96,11 @@ public static HandlerList getHandlerList() { @Setter private boolean processingFailed; + /** A non-idempotent effect may already have run, so automatic replay is unsafe. */ + @Getter + @Setter + private boolean replayUnsafe; + public boolean isProcessingIncomplete() { return accountingAdmissionFailed || processingFailed; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java index 7e1c4ae23b..a4b6275e8a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java @@ -182,6 +182,7 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { @Override public void voteParty(VotingPluginUser user, boolean forceProxyRouting, UUID voteId, boolean eligible) { plugin.getVoteParty().voteAdmitted(user, forceProxyRouting, voteId, eligible); } + @Override public void markReplayUnsafe() { event.setReplayUnsafe(true); } @Override public long incomingTime() { return event.getTime(); } @Override public void setTime(VotingPluginUser user, VoteSite site, long time) { user.setTime(site, time); } @Override public void setTimeNow(VotingPluginUser user, VoteSite site) { user.setTime(site); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java index a758f4e4b9..0a26714b63 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java @@ -10,6 +10,7 @@ import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.events.PlayerVoteEvent; import com.bencodez.votingplugin.proxy.BungeeMethod; +import com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue.VoteOutcome; import com.bencodez.votingplugin.util.MinecraftUsernameValidator; import com.bencodez.votingplugin.util.ServiceSiteValidator; import com.bencodez.votingplugin.util.VoteTaskAdmission; @@ -41,16 +42,27 @@ public void processVote(String voteSite, String voteUsername) { } public void processVote(String voteSite, String voteUsername, UUID voteId) { - if (!processVoteAttempt(voteSite, voteUsername, voteId)) { + VoteOutcome outcome = processVoteAttempt(voteSite, voteUsername, voteId); + if (outcome == VoteOutcome.RETRY) { retainForAccountingRetry(voteSite, voteUsername, voteId); + } else if (outcome == VoteOutcome.QUARANTINE + && !plugin.getVotifierVoteOverflowQueue().quarantine(voteUsername, voteSite, voteId)) { + plugin.getLogger().severe("Unable to retain ambiguous Votifier vote " + voteId + " for manual review"); + } else if (outcome == VoteOutcome.QUARANTINE) { + plugin.getLogger().severe("Votifier vote " + voteId + + " reached an ambiguous post-effect failure and was retained for manual review"); } } public boolean processQueuedVote(String voteSite, String voteUsername, UUID voteId) { + return processVoteAttempt(voteSite, voteUsername, voteId) == VoteOutcome.COMPLETE; + } + + public VoteOutcome processQueuedVoteOutcome(String voteSite, String voteUsername, UUID voteId) { return processVoteAttempt(voteSite, voteUsername, voteId); } - private boolean processVoteAttempt(String voteSite, String voteUsername, UUID voteId) { + private VoteOutcome processVoteAttempt(String voteSite, String voteUsername, UUID voteId) { try { plugin.getServerData().addServiceSite(voteSite); if (plugin.getBungeeSettings().isUseBungeecoord() && !plugin.getBungeeSettings().isVotifierBypass() @@ -61,7 +73,7 @@ private boolean processVoteAttempt(String voteSite, String voteUsername, UUID vo || plugin.getBackendProxyHandler().getMethod().equals(BungeeMethod.REDIS))) { plugin.getLogger().severe( "Ignoring vote from votifier since a proxy vote transport is enabled; receive votes on the proxy or enable VotifierBypass, then check: https://github.com/BenCodez/VotingPlugin/wiki/Bungeecord-Setups"); - return true; + return VoteOutcome.COMPLETE; } String matchSite = ""; @@ -93,7 +105,7 @@ private boolean processVoteAttempt(String voteSite, String voteUsername, UUID vo && plugin.getConfigFile().isQueueVotesDuringTimeChange()) { plugin.debug("Adding vote to time queue " + voteUsername + "/" + voteSite); plugin.getTimeQueueHandler().addVote(voteId, voteUsername, voteSite); - return true; + return VoteOutcome.COMPLETE; } String voteSiteName = plugin.getVoteSiteManager().getVoteSiteName(true, serviceSite, matchSite); @@ -103,7 +115,7 @@ private boolean processVoteAttempt(String voteSite, String voteUsername, UUID vo voteEvent.setVoteId(voteId); plugin.getServer().getPluginManager().callEvent(voteEvent); if (voteEvent.isProcessingIncomplete()) { - return false; + return voteEvent.isReplayUnsafe() ? VoteOutcome.QUARANTINE : VoteOutcome.RETRY; } if (voteEvent.isCancelled()) { @@ -111,9 +123,10 @@ private boolean processVoteAttempt(String voteSite, String voteUsername, UUID vo } } catch (Exception e) { plugin.getLogger().severe("Error occured during vote processing"); - e.printStackTrace(); + plugin.debug(e); + return VoteOutcome.RETRY; } - return true; + return VoteOutcome.COMPLETE; } private void retainForAccountingRetry(String voteSite, String voteUsername, UUID voteId) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java index 9c26dd23b6..9da16b20fd 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java @@ -80,7 +80,7 @@ public VotifierVoteOverflowQueue(VotingPluginMain plugin, VoteProcessor processo public VotifierVoteOverflowQueue(VotingPluginMain plugin, BiConsumer processor) { this(plugin, (serviceSite, username, voteId) -> { processor.accept(serviceSite, username); - return true; + return VoteOutcome.COMPLETE; }); } @@ -121,6 +121,20 @@ public boolean enqueue(String username, String serviceSite, UUID voteId) { } } + /** Durably retains an ambiguous vote without automatically replaying it. */ + public boolean quarantine(String username, String serviceSite, UUID voteId) { + if (username == null || serviceSite == null || voteId == null) return false; + synchronized (lock) { + if (closed || entries.size() >= MAX_ENTRIES) return false; + PendingVote pending = new PendingVote(username, serviceSite, System.currentTimeMillis(), voteId); + pending.quarantined = true; + entries.addLast(pending); + stateVersion++; + requestPersistenceLocked(); + return true; + } + } + /** * Returns the number of votes waiting for admission or completion. * @@ -164,7 +178,9 @@ private void drain() { // Serialize admission with enqueue so the version proven durable // above cannot change in the gap before submit accepts this vote. if (!VoteTaskAdmission.trySubmit(plugin.getVoteTimer(), () -> { - if (processor.accept(pending.serviceSite, pending.username, pending.voteId)) acknowledge(pending); + VoteOutcome outcome = processor.accept(pending.serviceSite, pending.username, pending.voteId); + if (outcome == VoteOutcome.COMPLETE) acknowledge(pending); + else if (outcome == VoteOutcome.QUARANTINE) quarantine(pending); else retry(pending); })) { pending.submitted = false; @@ -194,9 +210,23 @@ private void retry(PendingVote pending) { } } + private void quarantine(PendingVote pending) { + synchronized (lock) { + if (closed || !entries.contains(pending)) return; + pending.submitted = false; + pending.quarantined = true; + stateVersion++; + requestPersistenceLocked(); + drainScheduled = false; + scheduleDrainLocked(); + } + plugin.getLogger().severe("Queued Votifier vote " + pending.voteId + + " reached an ambiguous post-effect failure and was retained for manual review"); + } + private PendingVote nextUnsubmittedLocked() { for (PendingVote pending : entries) { - if (!pending.submitted) return pending; + if (!pending.submitted && !pending.quarantined) return pending; } return null; } @@ -295,7 +325,9 @@ private void load() { skipped = true; continue; } - entries.addLast(new PendingVote(name, site, timestamp.longValue(), voteId)); + PendingVote pending = new PendingVote(name, site, timestamp.longValue(), voteId); + pending.quarantined = Boolean.TRUE.equals(map.get("Quarantined")); + entries.addLast(pending); } if (skipped) { synchronized (lock) { @@ -336,6 +368,7 @@ private void writeSnapshotLocked(List snapshot) throws IOException value.put("ServiceSite", pending.serviceSite); value.put("Time", pending.time); value.put("VoteId", pending.voteId.toString()); + if (pending.quarantined) value.put("Quarantined", true); values.add(value); } yaml.set("Votes", values); @@ -392,6 +425,7 @@ private static final class PendingVote { private final long time; private final UUID voteId; private boolean submitted; + private boolean quarantined; private PendingVote(String username, String serviceSite, long time, UUID voteId) { this.username = username; @@ -403,6 +437,10 @@ private PendingVote(String username, String serviceSite, long time, UUID voteId) @FunctionalInterface public interface VoteProcessor { - boolean accept(String serviceSite, String username, UUID voteId); + VoteOutcome accept(String serviceSite, String username, UUID voteId); + } + + public enum VoteOutcome { + COMPLETE, RETRY, QUARANTINE } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java index de69316e6b..d6f6676d58 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java @@ -193,6 +193,12 @@ public void processQueue() { voteEvent.setVoteId(vote.getVoteId() == null ? vote.legacyTimedVoteId() : vote.getVoteId()); plugin.getServer().getPluginManager().callEvent(voteEvent); if (voteEvent.isProcessingIncomplete()) { + if (voteEvent.isReplayUnsafe()) { + plugin.getServerData().quarantineTimedVote(vote); + plugin.getLogger().severe("Timed vote " + voteEvent.getVoteId() + + " reached an ambiguous post-effect failure and was retained for manual review"); + continue; + } timeChangeQueue.addFirst(vote); scheduleRetry(!persistQueueSnapshot()); return; 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 9c300b3b38..cffd521cc6 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -1049,6 +1049,9 @@ public boolean bungeeVotePluginMessagingAccepted(String service, long time, Vote voteEvent.setVoteNumber(num); voteEvent.setVoteId(voteId); plugin.getServer().getPluginManager().callEvent(voteEvent); + if (voteEvent.isProcessingFailed() && voteEvent.isReplayUnsafe()) { + throw new IllegalStateException("Proxy vote reached an ambiguous post-effect failure: " + voteId); + } return !voteEvent.isProcessingIncomplete(); } return false; diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java index d8d775132d..d17c4723e7 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java @@ -70,6 +70,7 @@ void onlineVoteKeepsProductionPhaseOrderAndCounts() { order.verify(ops).cache(user); order.verify(ops).updateName(user); order.verify(ops).voteParty(eq(user), eq(false), any(UUID.class), eq(true)); + order.verify(ops).markReplayUnsafe(); order.verify(ops).broadcast(any(UUID.class), eq("Ben"), any(), eq(true)); order.verify(ops).setTime(user, site, 123L); order.verify(ops).playerVote(user, site, true, false); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java index f9c8d00fe4..cf7e311086 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java @@ -32,6 +32,7 @@ import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardState; import com.bencodez.votingplugin.data.ServerData.TimeChangeUserProgress; import com.bencodez.votingplugin.data.ServerData.TimeChangeUserPolicy; +import com.bencodez.votingplugin.timequeue.VoteTimeQueue; class ServerDataTimeChangeRecoveryTest { @TempDir @@ -56,6 +57,40 @@ void repeatedVotePartyIdentityIncrementsTheGlobalTotalOnlyOnce() { verify(coreData).saveData(); } + @Test + void oldVotePartyReceiptsRemainForUnboundedQueueReplay() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); + YamlConfiguration yaml = new YamlConfiguration(); + when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile()); + when(plugin.getServerDataFile()).thenReturn(coreData); + when(coreData.getData()).thenReturn(yaml); + ServerData data = new ServerData(plugin); + UUID oldVote = UUID.randomUUID(); + yaml.set("VotingPlugin.VoteParty.Accounting." + oldVote, 1L); + + assertTrue(data.incrementVotePartyTotal(UUID.randomUUID())); + assertTrue(yaml.contains("VotingPlugin.VoteParty.Accounting." + oldVote)); + } + + @Test + void ambiguousTimedVoteIsStoredOutsideTheAutomaticReplayQueue() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); + YamlConfiguration yaml = new YamlConfiguration(); + when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile()); + when(plugin.getServerDataFile()).thenReturn(coreData); + when(coreData.getData()).thenReturn(yaml); + ServerData data = new ServerData(plugin); + VoteTimeQueue vote = new VoteTimeQueue(UUID.randomUUID(), "Alex", "example.org", 123L); + + data.quarantineTimedVote(vote); + + assertEquals("Alex", yaml.getString("VotingPlugin.TimedVoteQuarantine." + vote.getVoteId() + ".Name")); + assertFalse(yaml.contains("VotingPlugin.TimedVoteCache")); + verify(coreData).saveData(); + } + @Test void perUserCursorUsesTheCompactDurableCheckpointWithoutRewritingServerData() { VotingPluginMain plugin = mock(VotingPluginMain.class); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java index fd0533a257..bb57b1f4b4 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java @@ -1,6 +1,7 @@ package com.bencodez.votingplugin.tests.listeners; 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.anyString; import static org.mockito.Mockito.RETURNS_DEEP_STUBS; @@ -182,9 +183,12 @@ void queuedVoteIsNotAcknowledgedWhenPostAdmissionProcessingFails() { doAnswer(invocation -> { PlayerVoteEvent event = invocation.getArgument(0); event.setProcessingFailed(true); + event.setReplayUnsafe(true); return null; }).when(pluginManager).callEvent(any(PlayerVoteEvent.class)); assertFalse(listener.processQueuedVote(SERVICE_SITE, "Steve", UUID.randomUUID())); + assertEquals(VotifierVoteOverflowQueue.VoteOutcome.QUARANTINE, + listener.processQueuedVoteOutcome(SERVICE_SITE, "Steve", UUID.randomUUID())); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java index b0d157207b..79075b1620 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java @@ -38,7 +38,7 @@ void failedQueuedAttemptKeepsTheExistingEntry(@TempDir Path dataFolder) throws E when(plugin.getVoteTimer()).thenReturn(voteTimer); VotifierVoteOverflowQueue queue = new VotifierVoteOverflowQueue(plugin, (site, user, voteId) -> { attempted.countDown(); - return false; + return VotifierVoteOverflowQueue.VoteOutcome.RETRY; }); try { assertTrue(queue.enqueue("Steve", "example.org", java.util.UUID.randomUUID())); @@ -51,6 +51,44 @@ void failedQueuedAttemptKeepsTheExistingEntry(@TempDir Path dataFolder) throws E } } + @Test + void ambiguousAttemptIsDurablyQuarantinedAcrossRestart(@TempDir Path dataFolder) throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + ScheduledExecutorService voteTimer = Executors.newSingleThreadScheduledExecutor(); + CountDownLatch attempted = new CountDownLatch(1); + when(plugin.getDataFolder()).thenReturn(dataFolder.toFile()); + when(plugin.getVoteTimer()).thenReturn(voteTimer); + VotifierVoteOverflowQueue queue = new VotifierVoteOverflowQueue(plugin, (site, user, voteId) -> { + attempted.countDown(); + return VotifierVoteOverflowQueue.VoteOutcome.QUARANTINE; + }); + try { + assertTrue(queue.enqueue("Steve", "example.org", java.util.UUID.randomUUID())); + queue.start(); + assertTrue(attempted.await(2, TimeUnit.SECONDS)); + Path file = dataFolder.resolve("VotifierVoteQueue.yml"); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while ((!Files.exists(file) || !Files.readString(file).contains("Quarantined: true")) + && System.nanoTime() < deadline) Thread.sleep(10L); + assertTrue(Files.readString(file).contains("Quarantined: true")); + } finally { + queue.close(); + voteTimer.shutdownNow(); + } + + ScheduledExecutorService restartedTimer = mock(ScheduledExecutorService.class); + when(plugin.getVoteTimer()).thenReturn(restartedTimer); + VotifierVoteOverflowQueue restarted = new VotifierVoteOverflowQueue(plugin, (site, user) -> { }); + try { + restarted.start(); + Thread.sleep(100L); + assertEquals(1, restarted.size()); + verify(restartedTimer, org.mockito.Mockito.never()).submit(any(Runnable.class)); + } finally { + restarted.close(); + } + } + @Test void enqueueCannotChangeVersionDuringDurableAdmission(@TempDir Path dataFolder) throws Exception { VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java index 9b5a6d88d1..7dea8a9f99 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; @@ -151,6 +152,28 @@ void postAdmissionProcessingFailureRetainsTheSameQueuedVote() { verify(serverData).replaceTimedVoteCache(List.of(vote)); } + @Test + void ambiguousPostEffectFailureIsQuarantinedInsteadOfReplayed() { + when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of()); + TimeQueueHandler handler = new TimeQueueHandler(plugin); + VoteTimeQueue vote = new VoteTimeQueue(UUID.randomUUID(), "Alex", "example.org", 123L); + handler.getTimeChangeQueue().add(vote); + org.bukkit.plugin.PluginManager pluginManager = plugin.getServer().getPluginManager(); + doAnswer(invocation -> { + PlayerVoteEvent event = invocation.getArgument(0); + event.setProcessingFailed(true); + event.setReplayUnsafe(true); + return null; + }).when(pluginManager).callEvent(any(PlayerVoteEvent.class)); + + handler.processQueue(); + + assertTrue(handler.getTimeChangeQueue().isEmpty()); + verify(serverData).quarantineTimedVote(vote); + verify(plugin.getBukkitScheduler(), never()).runTaskLaterAsynchronously( + org.mockito.ArgumentMatchers.eq(plugin), any(Runnable.class), anyLong()); + } + @Test void timedVoteSnapshotPersistsStableIdsWithOneSave() { VotingPluginMain snapshotPlugin = mock(VotingPluginMain.class); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserProxyVoteTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserProxyVoteTest.java index e373d49a4a..ca992277d8 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserProxyVoteTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserProxyVoteTest.java @@ -1,6 +1,6 @@ package com.bencodez.votingplugin.user; -import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; @@ -35,15 +35,16 @@ void backendDeliveryIsNotAcknowledgedWhenPostAdmissionProcessingFails() { when(server.getPluginManager()).thenReturn(pluginManager); when(base.getPlayerName()).thenReturn("Player"); when(base.getUserData()).thenReturn(mock(UserData.class)); - doAnswer(invocation -> { + doAnswer(invocation -> { PlayerVoteEvent event = invocation.getArgument(0); event.setProcessingFailed(true); + event.setReplayUnsafe(true); return null; }).when(pluginManager).callEvent(any(PlayerVoteEvent.class)); VotingPluginUser user = new VotingPluginUser(plugin, base); - assertFalse(user.bungeeVotePluginMessagingAccepted("service", 100L, null, true, true, - true, 1, UUID.randomUUID())); + assertThrows(IllegalStateException.class, () -> user.bungeeVotePluginMessagingAccepted( + "service", 100L, null, true, true, true, 1, UUID.randomUUID())); } } From a9230ff080fb9f0ef926b7ec9f7690f75fb99177 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:13:12 -0600 Subject: [PATCH 40/61] Persist vote effect admission state --- .../core/vote/SharedVoteProcessor.java | 24 ++++-- .../vote/SharedVoteReplayUnsafeException.java | 10 +++ .../votingplugin/data/ServerData.java | 11 +++ .../listeners/PlayerVoteListener.java | 22 +++-- .../votingplugin/user/VotingPluginUser.java | 15 +++- .../service/SharedMysqlPurchaseJournal.java | 80 ++++++++++++++++--- .../service/VoteShopPurchaseService.java | 37 +++++++-- .../core/vote/SharedVoteProcessorTest.java | 48 +++++++---- .../SharedMysqlPurchaseJournalTest.java | 20 ++++- 9 files changed, 216 insertions(+), 51 deletions(-) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteReplayUnsafeException.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java index fe113320a4..7c431f9853 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java @@ -15,7 +15,8 @@ public static boolean isQueuedVoteAlreadyRecorded(boolean proxyVote, long messag public record Validation(boolean valid, String normalizedName, String source, String reason, boolean bedrock) { } public record Name(String value, String rationale) { } - public record AccountingAdmission(boolean countTotals, boolean awardPoints, boolean votePartyEligible) { } + public record AccountingAdmission(boolean countTotals, boolean awardPoints, boolean votePartyEligible, + int pointAmount, int pointCap, boolean replayUnsafe) { } public interface Operations { boolean enabled(); @@ -60,12 +61,16 @@ public interface Operations { void broadcast(UUID uuid, String name, String siteDisplayName, boolean online); boolean hasProxyTextTotals(); UUID incomingVoteId(); - AccountingAdmission prepareAccounting(U user, UUID voteId, boolean countTotals, boolean awardPoints); + int configuredPointAmount(); + int configuredPointCap(); + AccountingAdmission prepareAccounting(U user, UUID voteId, boolean countTotals, boolean awardPoints, + int pointAmount, int pointCap); void finishAccounting(UUID voteId); void cache(U user); void updateName(U user); void voteParty(U user, boolean forceProxyRouting, UUID voteId, boolean eligible); - void markReplayUnsafe(); + void markReplayUnsafe(UUID voteId); + void restoreReplayUnsafe(); long incomingTime(); void setTime(U user, S site, long time); void setTimeNow(U user, S site); @@ -83,7 +88,7 @@ public interface Operations { void addTotal(U user, UUID voteId); void addTotalDaily(U user, UUID voteId); void addTotalWeekly(U user, UUID voteId); - void addPoints(U user, UUID voteId); + void addPoints(U user, UUID voteId, int amount, int cap); void checkDayVoteStreak(U user, boolean forceProxyRouting, UUID voteId); boolean limitMonthlyVotes(); int proxyMonthTotal(); @@ -175,17 +180,22 @@ public static void process(Operations ops) { ops.forceProxyRouting(), ops.wasOnline()); boolean proposedCountTotals = policy.shouldCountTotals(accountingInput, () -> ops.userOnline(user)); boolean proposedAwardPoints = policy.shouldAwardConfiguredPoints(accountingInput); - AccountingAdmission admission = ops.prepareAccounting(user, voteId, proposedCountTotals, proposedAwardPoints); + AccountingAdmission admission = ops.prepareAccounting(user, voteId, proposedCountTotals, proposedAwardPoints, + ops.configuredPointAmount(), ops.configuredPointCap()); boolean countTotals = admission.countTotals(); boolean awardPoints = admission.awardPoints(); boolean votePartyEligible = admission.votePartyEligible(); + if (admission.replayUnsafe()) { + ops.restoreReplayUnsafe(); + throw new SharedVoteReplayUnsafeException("Vote delivery already crossed its durable effect boundary"); + } try { ops.cache(user); ops.updateName(user); ops.voteParty(user, ops.forceProxyRouting(), voteId, votePartyEligible); // Everything before this point is protected by the vote accounting and // VoteParty receipts. Later platform effects do not all have replay receipts. - ops.markReplayUnsafe(); + ops.markReplayUnsafe(voteId); if (ops.broadcastEnabled() && ops.hasBroadcastHandler()) { boolean currentOnline = ops.userOnline(user); boolean online = currentOnline; @@ -224,7 +234,7 @@ public static void process(Operations ops) { ops.realVote(), ops.addTotals(), ops.proxyVote(), ops.forceProxyRouting(), ops.wasOnline()); SharedVoteAccounting.applyAdmitted(countTotals, awardPoints, () -> ops.addTotal(user, voteId), () -> ops.addTotalDaily(user, voteId), () -> ops.addTotalWeekly(user, voteId), - () -> ops.addPoints(user, voteId)); + () -> ops.addPoints(user, voteId, admission.pointAmount(), admission.pointCap())); ops.checkDayVoteStreak(user, ops.forceProxyRouting(), voteId); if (ops.limitMonthlyVotes() && (!ops.proxyVote() || ops.hasProxyTextTotals())) { int value = ops.proxyVote() ? ops.proxyMonthTotal() : ops.userMonthTotal(user); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteReplayUnsafeException.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteReplayUnsafeException.java new file mode 100644 index 0000000000..8efdab5bdc --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteReplayUnsafeException.java @@ -0,0 +1,10 @@ +package com.bencodez.votingplugin.core.vote; + +/** Signals that a durable delivery may already have executed platform effects. */ +public final class SharedVoteReplayUnsafeException extends RuntimeException { + private static final long serialVersionUID = 1L; + + public SharedVoteReplayUnsafeException(String message) { + super(message); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index 3b408cd13e..5ac208b5c7 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -55,6 +55,7 @@ public record TimeChangeArchiveSnapshot(List sections) private static final String TIME_CHANGE_RECOVERY = "TimeChangeRecovery"; private static final String VOTE_PARTY_ACCOUNTING = "VoteParty.Accounting"; + private static final String VOTE_REPLAY_UNSAFE = "VoteDelivery.ReplayUnsafe"; private static final List TIME_CHANGE_PHASES = List.of("START", "SNAPSHOT", "COPY_TOTALS", "USER_UPDATES", "TOP_REWARDS", "VOTE_SHOP", "BUNGEE_WAIT", "TOTALS_RESET", "CACHE_CLEAR", "POST_DATE", "COMPLETE"); @@ -161,6 +162,16 @@ public synchronized void quarantineTimedVote(VoteTimeQueue vote) { getData().set(path + ".VoteId", key); saveData(); } + + /** Persists that a delivery crossed into effects which cannot safely be replayed. */ + public synchronized void markVoteReplayUnsafe(UUID voteId) { + getData().set(VOTE_REPLAY_UNSAFE + "." + voteId, true); + saveData(); + } + + public synchronized boolean isVoteReplayUnsafe(UUID voteId) { + return voteId != null && getData().getBoolean(VOTE_REPLAY_UNSAFE + "." + voteId); + } /** * Adds a vote shop purchase for the specified identifier. diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java index a4b6275e8a..3cf1c7028c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java @@ -161,19 +161,23 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { return event.getBungeeTextTotals() == null ? null : event.getBungeeTextTotals().getVoteUUID(); } @Override public SharedVoteProcessor.AccountingAdmission prepareAccounting( - VotingPluginUser user, UUID voteId, boolean countTotals, boolean awardPoints) { + VotingPluginUser user, UUID voteId, boolean countTotals, boolean awardPoints, + int pointAmount, int pointCap) { boolean countVoteParty = plugin.getSpecialRewardsConfig().isVotePartyEnabled() && (plugin.getSpecialRewardsConfig().isVotePartyCountFakeVotes() || event.isRealVote()) && (plugin.getSpecialRewardsConfig().isVotePartyCountOfflineVotes() || user.isOnline()); VoteShopPurchaseService.VoteAccountingAdmission admission = VoteShopPurchaseService .prepareMysqlVoteAccounting(plugin, voteId, user.getUUID(), countTotals, awardPoints, - countVoteParty, event.isForceBungee()); + countVoteParty, event.isForceBungee(), pointAmount, pointCap); if (!admission.success()) { throw new SharedVoteAdmissionException("Unable to admit shared MySQL vote accounting before processing"); } return new SharedVoteProcessor.AccountingAdmission(admission.countTotals(), admission.awardPoints(), - admission.countVoteParty()); + admission.countVoteParty(), admission.pointAmount(), admission.pointCap(), + admission.replayUnsafe()); } + @Override public int configuredPointAmount() { return plugin.getConfigFile().getPointsOnVote(); } + @Override public int configuredPointCap() { return plugin.getConfigFile().getLimitVotePoints(); } @Override public void finishAccounting(UUID voteId) { VoteShopPurchaseService.finishMysqlVoteAccounting(voteId); } @@ -182,7 +186,13 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { @Override public void voteParty(VotingPluginUser user, boolean forceProxyRouting, UUID voteId, boolean eligible) { plugin.getVoteParty().voteAdmitted(user, forceProxyRouting, voteId, eligible); } - @Override public void markReplayUnsafe() { event.setReplayUnsafe(true); } + @Override public void markReplayUnsafe(UUID voteId) { + if (!VoteShopPurchaseService.markVoteReplayUnsafe(plugin, voteId)) { + throw new SharedVoteAdmissionException("Unable to persist the vote effect boundary"); + } + event.setReplayUnsafe(true); + } + @Override public void restoreReplayUnsafe() { event.setReplayUnsafe(true); } @Override public long incomingTime() { return event.getTime(); } @Override public void setTime(VotingPluginUser user, VoteSite site, long time) { user.setTime(site, time); } @Override public void setTimeNow(VotingPluginUser user, VoteSite site) { user.setTime(site); } @@ -205,7 +215,9 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { @Override public void addTotal(VotingPluginUser user, UUID voteId) { user.addTotal(voteId); } @Override public void addTotalDaily(VotingPluginUser user, UUID voteId) { user.addTotalDaily(voteId); } @Override public void addTotalWeekly(VotingPluginUser user, UUID voteId) { user.addTotalWeekly(voteId); } - @Override public void addPoints(VotingPluginUser user, UUID voteId) { user.addVotePoints(voteId); } + @Override public void addPoints(VotingPluginUser user, UUID voteId, int amount, int cap) { + user.addVotePoints(voteId, amount, cap); + } @Override public void checkDayVoteStreak(VotingPluginUser user, boolean forceProxyRouting, UUID voteId) { user.checkDayVoteStreak(forceProxyRouting, voteId); } 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 cffd521cc6..96b3d94776 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -291,14 +291,16 @@ public synchronized int addPoints(int value, boolean async) { * @param voteId stable identity of the accepted vote */ public void addVotePoints(UUID voteId) { + addVotePoints(voteId, plugin.getConfigFile().getPointsOnVote(), plugin.getConfigFile().getLimitVotePoints()); + } + + /** Applies the point policy captured when the durable vote was admitted. */ + public void addVotePoints(UUID voteId, int points, int limit) { SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); if (voteId == null || !sharedPoints.usesMysqlPointMutations()) { - addPoints(); + addVotePointsWithPolicy(points, limit); return; } - - int points = plugin.getConfigFile().getPointsOnVote(); - int limit = plugin.getConfigFile().getLimitVotePoints(); String pointsColumn = getPointsPath(); String operationId = "vote-points:" + voteId; Integer completedTotal = sharedPoints.completedPointAdditionTotal(operationId, getUUID(), pointsColumn); @@ -318,6 +320,11 @@ public void addVotePoints(UUID voteId) { if (limit > 0) sharedPoints.cap(this, limit, false); } + private void addVotePointsWithPolicy(int points, int limit) { + if (points != 0) addPoints(points, false); + if (limit > 0 && getPoints() > limit) setPoints(limit); + } + /** * Keeps ordinary storage writes synchronous while moving shared-MySQL atomic * arithmetic onto the persistence executor. 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 index a0b059669a..b5b32086d7 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -36,6 +36,7 @@ enum DailyStreakOutcome { APPLIED, ALREADY_UPDATED, DEFERRED } record DailyStreakResult(DailyStreakOutcome outcome, int streak) { } record RecoveredDailyStreak(UUID voteId, String uuid, int streak, boolean forceProxyRouting) { } record AccountingRecoveryBatch(boolean hadRows, List pendingRewards) { } + record VoteAccountingDecision(int bits, int pointAmount, int pointCap, boolean replayUnsafe) { } private static final String PENDING = "PENDING"; private static final String HOOK_STARTED = "HOOK_STARTED"; private static final String COMPENSATING = "COMPENSATING"; @@ -354,9 +355,11 @@ boolean incrementPeriodTotals(UUID voteId, String uuid, String boundaryColumn, S return incrementPeriodTotals(voteId, uuid, boundaryColumn, previousColumn, columns, maximum, false); } - int prepareVoteAccounting(UUID voteId, String uuid, boolean countTotals, boolean awardPoints, boolean countVoteParty, + VoteAccountingDecision prepareVoteAccounting(UUID voteId, String uuid, boolean countTotals, boolean awardPoints, + boolean countVoteParty, String monthColumn, Integer monthMaximum, boolean streakUsesPercentage, double streakPercentage, - int enabledSiteCount, boolean forceProxyRouting, long acceptedAt) throws SQLException { + int enabledSiteCount, boolean forceProxyRouting, long acceptedAt, int pointAmount, int pointCap) + throws SQLException { int requested = (countTotals ? DAILY_TOTAL | WEEKLY_TOTAL | MONTH_TOTAL | ALL_TIME_TOTAL : 0) | (awardPoints ? AWARD_POINTS : 0) | (countVoteParty ? VOTE_PARTY_TOTAL : 0); @@ -378,6 +381,10 @@ int prepareVoteAccounting(UUID voteId, String uuid, boolean countTotals, boolean throw new SQLException("Vote accounting identity does not match"); } boolean accountingAlreadyDecided = (row.requested() & ACCOUNTING_DECIDED) != 0; + int admittedPointAmount = accountingAlreadyDecided && row.pointAmount() != null + ? row.pointAmount().intValue() : pointAmount; + int admittedPointCap = accountingAlreadyDecided && row.pointCap() != null + ? row.pointCap().intValue() : pointCap; boolean dailyResetPending = !accountingAlreadyDecided && streakUsesPercentage && resetPending(connection, "DailyTotal", dailyCopyMarker); DailyStreakCandidate streak = accountingAlreadyDecided ? null @@ -402,7 +409,9 @@ int prepareVoteAccounting(UUID voteId, String uuid, boolean countTotals, boolean + qi("month_maximum") + "), " + qi("streak_value") + " = COALESCE(?, " + qi("streak_value") + "), " + qi("streak_updated_at") + " = COALESCE(?, " + qi("streak_updated_at") + "), " + qi("streak_force_proxy") + " = COALESCE(?, " - + qi("streak_force_proxy") + ") WHERE " + qi("vote_id") + " = ?"; + + qi("streak_force_proxy") + "), " + qi("point_amount") + " = COALESCE(" + + qi("point_amount") + ", ?), " + qi("point_cap") + " = COALESCE(" + + qi("point_cap") + ", ?) WHERE " + qi("vote_id") + " = ?"; try (PreparedStatement statement = connection.prepareStatement(update)) { statement.setString(1, uuid); statement.setInt(2, persistedRequested); @@ -416,11 +425,53 @@ int prepareVoteAccounting(UUID voteId, String uuid, boolean countTotals, boolean else statement.setNull(7, java.sql.Types.BIGINT); if (requestStreak) statement.setInt(8, forceProxyRouting ? 1 : 0); else statement.setNull(8, java.sql.Types.INTEGER); - statement.setString(9, voteId.toString()); + statement.setInt(9, admittedPointAmount); + statement.setInt(10, admittedPointCap); + statement.setString(11, voteId.toString()); if (statement.executeUpdate() != 1) throw new SQLException("Vote accounting marker is missing"); } commitAndConfirmRequested(connection, voteId, persistedRequested); - return persistedRequested & ~ACCOUNTING_DECIDED; + return new VoteAccountingDecision(persistedRequested & ~ACCOUNTING_DECIDED, + admittedPointAmount, admittedPointCap, row.replayUnsafe()); + } catch (SQLException failure) { + rollback(connection); + throw failure; + } + } + } + + int prepareVoteAccounting(UUID voteId, String uuid, boolean countTotals, boolean awardPoints, + boolean countVoteParty, String monthColumn, Integer monthMaximum, boolean streakUsesPercentage, + double streakPercentage, int enabledSiteCount, boolean forceProxyRouting, long acceptedAt) + throws SQLException { + return prepareVoteAccounting(voteId, uuid, countTotals, awardPoints, countVoteParty, monthColumn, + monthMaximum, streakUsesPercentage, streakPercentage, enabledSiteCount, forceProxyRouting, + acceptedAt, 0, -1).bits(); + } + + void markVoteReplayUnsafe(UUID voteId) throws SQLException { + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try { + AccountingRow row = findAndLockAccountingVote(connection, voteId); + if (row == null) throw new SQLException("Vote accounting marker is missing"); + if (row.replayUnsafe()) { + rollback(connection); + return; + } + try (PreparedStatement statement = connection.prepareStatement("UPDATE " + qiAccounting() + + " SET " + qi("replay_unsafe") + " = 1 WHERE " + qi("vote_id") + " = ?")) { + statement.setString(1, voteId.toString()); + if (statement.executeUpdate() != 1) throw new SQLException("Vote accounting marker is missing"); + } + try { + connection.commit(); + } catch (SQLException ambiguousCommit) { + closeQuietly(connection); + AccountingRow confirmed = findAccountingVote(voteId); + if (confirmed != null && confirmed.replayUnsafe()) return; + throw ambiguousCommit; + } } catch (SQLException failure) { rollback(connection); throw failure; @@ -685,13 +736,15 @@ private String accountingColumns() { return qi("player_uuid") + ", " + qi("requested") + ", " + qi("completed") + ", " + qi("month_column") + ", " + qi("month_maximum") + ", " + qi("streak_value") + ", " + qi("streak_updated_at") + ", " + qi("streak_force_proxy") + ", " - + qi("streak_applied_value"); + + qi("streak_applied_value") + ", " + qi("point_amount") + ", " + qi("point_cap") + ", " + + qi("replay_unsafe"); } private static AccountingRow accountingRow(ResultSet result) throws SQLException { return new AccountingRow(result.getString(1), result.getInt(2), result.getInt(3), result.getString(4), nullableInteger(result, 5), nullableInteger(result, 6), nullableLong(result, 7), - nullableInteger(result, 8), nullableInteger(result, 9)); + nullableInteger(result, 8), nullableInteger(result, 9), nullableInteger(result, 10), + nullableInteger(result, 11), result.getInt(12) != 0); } private boolean resetPending(Connection connection, String boundaryColumn, EpochRow copyMarker) @@ -981,7 +1034,8 @@ private void drainPendingAccounting(Connection connection, int operation) throws UUID voteId = UUID.fromString(result.getString(1)); AccountingRow row = new AccountingRow(result.getString(2), result.getInt(3), result.getInt(4), result.getString(5), nullableInteger(result, 6), nullableInteger(result, 7), - nullableLong(result, 8), nullableInteger(result, 9), nullableInteger(result, 10)); + nullableLong(result, 8), nullableInteger(result, 9), nullableInteger(result, 10), + nullableInteger(result, 11), nullableInteger(result, 12), result.getInt(13) != 0); applyPendingAccounting(connection, voteId, row, operation); } } @@ -1371,7 +1425,8 @@ private List findPendingAccounting(int limit) throws SQLExce while (result.next()) pending.add(new AccountingRecovery(UUID.fromString(result.getString(1)), new AccountingRow(result.getString(2), result.getInt(3), result.getInt(4), result.getString(5), nullableInteger(result, 6), nullableInteger(result, 7), nullableLong(result, 8), - nullableInteger(result, 9), nullableInteger(result, 10)))); + nullableInteger(result, 9), nullableInteger(result, 10), nullableInteger(result, 11), + nullableInteger(result, 12), result.getInt(13) != 0))); } } return pending; @@ -1450,6 +1505,8 @@ private void ensureAccountingSchema(Connection connection) throws SQLException { + qi("month_column") + " VARCHAR(128) NULL, " + qi("month_maximum") + " INT NULL, " + qi("streak_value") + " INT NULL, " + qi("streak_updated_at") + " BIGINT NULL, " + qi("streak_force_proxy") + " INT NULL, " + qi("streak_applied_value") + " INT NULL, " + + qi("point_amount") + " INT NULL, " + qi("point_cap") + " INT NULL, " + + qi("replay_unsafe") + " INT NOT NULL DEFAULT 0, " + qi("created_at") + " BIGINT NOT NULL, PRIMARY KEY (" + qi("vote_id") + "));"; try (PreparedStatement statement = connection.prepareStatement(create)) { @@ -1463,6 +1520,9 @@ private void ensureAccountingSchema(Connection connection) throws SQLException { ensureAccountingColumn(connection, "streak_updated_at", "BIGINT NULL"); ensureAccountingColumn(connection, "streak_force_proxy", "INT NULL"); ensureAccountingColumn(connection, "streak_applied_value", "INT NULL"); + ensureAccountingColumn(connection, "point_amount", "INT NULL"); + ensureAccountingColumn(connection, "point_cap", "INT NULL"); + ensureAccountingColumn(connection, "replay_unsafe", "INT NOT NULL DEFAULT 0"); try (PreparedStatement migrate = connection.prepareStatement("UPDATE " + qiAccounting() + " SET " + qi("requested") + " = " + qi("completed") + " WHERE " + qi("requested") + " = 0 AND " + qi("completed") + " <> 0")) { @@ -1591,7 +1651,7 @@ private record EpochRow(long epoch, String lastResetGeneration) { private record AccountingRow(String uuid, int requested, int completed, String monthColumn, Integer monthMaximum, Integer streakValue, Long streakUpdatedAt, Integer streakForceProxy, - Integer streakAppliedValue) { + Integer streakAppliedValue, Integer pointAmount, Integer pointCap, boolean replayUnsafe) { } record PeriodTotalResult(boolean applied, List columns) { } 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 acc498d369..1969aa2441 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 @@ -54,7 +54,7 @@ public class VoteShopPurchaseService { public enum MysqlDailyStreakResult { APPLIED, ALREADY_UPDATED, NOT_REQUESTED, DEFERRED, FAILED } public record MysqlDailyStreakUpdate(MysqlDailyStreakResult result, int streak, boolean forceProxyRouting) { } public record VoteAccountingAdmission(boolean success, boolean countTotals, boolean awardPoints, - boolean countVoteParty) { } + boolean countVoteParty, int pointAmount, int pointCap, boolean replayUnsafe) { } private static final ConcurrentMap ADMITTED_ACCOUNTING = new ConcurrentHashMap<>(); private static final int ACCOUNTING_DAILY_STREAK = 16; private static final int ACCOUNTING_POINTS = 256; @@ -665,9 +665,11 @@ public static boolean incrementMysqlPeriodTotals(VotingPluginMain plugin, UUID v /** Durably admits shared total mutations before vote rewards or broadcasts run. */ public static VoteAccountingAdmission prepareMysqlVoteAccounting(VotingPluginMain plugin, UUID voteId, String uuid, - boolean countTotals, boolean awardPoints, boolean countVoteParty, boolean forceProxyRouting) { + boolean countTotals, boolean awardPoints, boolean countVoteParty, boolean forceProxyRouting, + int pointAmount, int pointCap) { if (!canRecoverSharedMysqlPurchases(plugin) || voteId == null) { - return new VoteAccountingAdmission(true, countTotals, awardPoints, countVoteParty); + return new VoteAccountingAdmission(true, countTotals, awardPoints, countVoteParty, pointAmount, pointCap, + voteId != null && plugin.getServerData().isVoteReplayUnsafe(voteId)); } try { MySQL table = plugin.getMysql(); @@ -688,20 +690,41 @@ public static VoteAccountingAdmission prepareMysqlVoteAccounting(VotingPluginMai table.checkColumn("DailyTotal", DataType.INTEGER); table.checkColumn("DayVoteStreak", DataType.INTEGER); table.checkColumn("DayVoteStreakLastUpdate", DataType.STRING); - int bits = SharedMysqlPurchaseJournal.forTable(table).prepareVoteAccounting(voteId, uuid, countTotals, + SharedMysqlPurchaseJournal.VoteAccountingDecision decision = SharedMysqlPurchaseJournal.forTable(table) + .prepareVoteAccounting(voteId, uuid, countTotals, awardPoints, countVoteParty, monthColumn, maximum, plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage(), plugin.getSpecialRewardsConfig().getVoteStreakRequirementDay(), plugin.getVoteSiteManager().getVoteSitesEnabled().size(), forceProxyRouting, - System.currentTimeMillis()); + System.currentTimeMillis(), pointAmount, pointCap); + int bits = decision.bits(); ADMITTED_ACCOUNTING.put(voteId, Integer.valueOf(bits)); return new VoteAccountingAdmission(true, (bits & 7) != 0, - (bits & ACCOUNTING_POINTS) != 0, (bits & 8) != 0); + (bits & ACCOUNTING_POINTS) != 0, (bits & 8) != 0, decision.pointAmount(), + decision.pointCap(), decision.replayUnsafe()); } catch (SQLException failure) { plugin.getLogger().severe("Unable to admit shared MySQL vote accounting: " + failure.getClass().getSimpleName()); plugin.debug(failure); - return new VoteAccountingAdmission(false, false, false, false); + return new VoteAccountingAdmission(false, false, false, false, pointAmount, pointCap, false); + } + } + + /** Commits the boundary before any uncheckpointed vote effect can execute. */ + public static boolean markVoteReplayUnsafe(VotingPluginMain plugin, UUID voteId) { + if (voteId == null) return false; + if (!canRecoverSharedMysqlPurchases(plugin)) { + plugin.getServerData().markVoteReplayUnsafe(voteId); + return true; + } + try { + SharedMysqlPurchaseJournal.forTable(plugin.getMysql()).markVoteReplayUnsafe(voteId); + return true; + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to persist shared MySQL vote effect boundary: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + return false; } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java index d17c4723e7..aa7021104b 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java @@ -1,6 +1,7 @@ package com.bencodez.votingplugin.core.vote; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; @@ -39,9 +40,12 @@ private SharedVoteProcessor.Operations accepted() { when(ops.siteKey(site)).thenReturn("ExampleKey"); when(ops.userId(user)).thenReturn("user-id"); when(ops.userUuid(user)).thenReturn(UUID.randomUUID()); - when(ops.prepareAccounting(eq(user), any(UUID.class), anyBoolean(), anyBoolean())) + when(ops.configuredPointAmount()).thenReturn(3); + when(ops.configuredPointCap()).thenReturn(100); + when(ops.prepareAccounting(eq(user), any(UUID.class), anyBoolean(), anyBoolean(), anyInt(), anyInt())) .thenAnswer(invocation -> new SharedVoteProcessor.AccountingAdmission( - invocation.getArgument(2), invocation.getArgument(3), true)); + invocation.getArgument(2), invocation.getArgument(3), true, + invocation.getArgument(4), invocation.getArgument(5), false)); return ops; } @@ -66,11 +70,11 @@ void onlineVoteKeepsProductionPhaseOrderAndCounts() { InOrder order = inOrder(ops); order.verify(ops).lastVoteTime(user, site); - order.verify(ops).prepareAccounting(eq(user), any(UUID.class), eq(true), eq(true)); + order.verify(ops).prepareAccounting(eq(user), any(UUID.class), eq(true), eq(true), eq(3), eq(100)); order.verify(ops).cache(user); order.verify(ops).updateName(user); order.verify(ops).voteParty(eq(user), eq(false), any(UUID.class), eq(true)); - order.verify(ops).markReplayUnsafe(); + order.verify(ops).markReplayUnsafe(voteId); order.verify(ops).broadcast(any(UUID.class), eq("Ben"), any(), eq(true)); order.verify(ops).setTime(user, site, 123L); order.verify(ops).playerVote(user, site, true, false); @@ -79,7 +83,7 @@ void onlineVoteKeepsProductionPhaseOrderAndCounts() { order.verify(ops).addTotal(eq(user), any(UUID.class)); order.verify(ops).addTotalDaily(eq(user), any(UUID.class)); order.verify(ops).addTotalWeekly(eq(user), any(UUID.class)); - order.verify(ops).addPoints(user, voteId); + order.verify(ops).addPoints(user, voteId, 3, 100); order.verify(ops).checkDayVoteStreak(eq(user), eq(false), any(UUID.class)); order.verify(ops).setMonthTotal(user, 2); order.verify(ops).milestones(eq(user), any(UUID.class), eq(false)); @@ -98,7 +102,7 @@ void totalsUseTheEligibilityCapturedAtAdmission() { SharedVoteProcessor.process(ops); - verify(ops).prepareAccounting(eq(user), any(UUID.class), eq(true), eq(true)); + verify(ops).prepareAccounting(eq(user), any(UUID.class), eq(true), eq(true), eq(3), eq(100)); verify(ops).addTotal(eq(user), any(UUID.class)); verify(ops).addTotalDaily(eq(user), any(UUID.class)); verify(ops).addTotalWeekly(eq(user), any(UUID.class)); @@ -108,8 +112,8 @@ void totalsUseTheEligibilityCapturedAtAdmission() { void replayUsesPersistedAccountingEligibilityInsteadOfTheCurrentProposal() { var ops = accepted(); when(ops.addTotals()).thenReturn(false); - when(ops.prepareAccounting(eq(user), any(UUID.class), eq(false), eq(false))) - .thenReturn(new SharedVoteProcessor.AccountingAdmission(true, true, false)); + when(ops.prepareAccounting(eq(user), any(UUID.class), eq(false), eq(false), eq(3), eq(100))) + .thenReturn(new SharedVoteProcessor.AccountingAdmission(true, true, false, 7, 50, false)); SharedVoteProcessor.process(ops); @@ -123,15 +127,31 @@ void replayUsesPersistedAccountingEligibilityInsteadOfTheCurrentProposal() { void replayUsesPersistedPointsEligibilityWhenTotalsWereNotAdmitted() { var ops = accepted(); when(ops.addTotals()).thenReturn(false); - when(ops.prepareAccounting(eq(user), any(UUID.class), eq(false), eq(false))) - .thenReturn(new SharedVoteProcessor.AccountingAdmission(false, true, false)); + when(ops.prepareAccounting(eq(user), any(UUID.class), eq(false), eq(false), eq(3), eq(100))) + .thenReturn(new SharedVoteProcessor.AccountingAdmission(false, true, false, 7, 50, false)); SharedVoteProcessor.process(ops); - verify(ops).addPoints(eq(user), any(UUID.class)); + verify(ops).addPoints(eq(user), any(UUID.class), eq(7), eq(50)); verify(ops, never()).addTotal(eq(user), any(UUID.class)); } + @Test + void recoveredReplayUnsafeVoteStopsBeforeAnyEffect() { + var ops = accepted(); + UUID voteId = UUID.randomUUID(); + when(ops.incomingVoteId()).thenReturn(voteId); + when(ops.prepareAccounting(eq(user), eq(voteId), anyBoolean(), anyBoolean(), anyInt(), anyInt())) + .thenReturn(new SharedVoteProcessor.AccountingAdmission(true, true, true, 3, 100, true)); + + assertThrows(SharedVoteReplayUnsafeException.class, () -> SharedVoteProcessor.process(ops)); + + verify(ops).restoreReplayUnsafe(); + verify(ops, never()).cache(user); + verify(ops, never()).markReplayUnsafe(any(UUID.class)); + verify(ops, never()).broadcast(any(), any(), any(), anyBoolean()); + } + @Test void proxyVoteUsesHistoricalOnlineForDeliveryButCurrentOnlineForTotals() { var ops = accepted(); @@ -153,7 +173,7 @@ void proxyVoteUsesHistoricalOnlineForDeliveryButCurrentOnlineForTotals() { verify(ops).playerVote(user, site, true, false); verify(ops, never()).addOfflineVote(any(), any()); verify(ops, never()).addTotal(eq(user), any(UUID.class)); - verify(ops).addPoints(eq(user), any(UUID.class)); + verify(ops).addPoints(eq(user), any(UUID.class), anyInt(), anyInt()); ArgumentCaptor id = ArgumentCaptor.forClass(UUID.class); verify(ops).postVote(eq(site), eq(user), eq("Ben"), eq(321L), id.capture(), eq(false)); assertEquals(proxyId, id.getValue()); @@ -212,7 +232,7 @@ void offlineVoteQueuesBeforeAccountingAndMarksPostEventCached() { InOrder order = inOrder(ops); order.verify(ops).addOfflineVote(user, "ExampleKey"); - order.verify(ops).addPoints(eq(user), any(UUID.class)); + order.verify(ops).addPoints(eq(user), any(UUID.class), anyInt(), anyInt()); order.verify(ops).postVote(eq(site), eq(user), eq("Ben"), eq(456L), any(UUID.class), eq(true)); verify(ops, never()).playerVote(any(), any(), eq(false), eq(false)); verify(ops, never()).addTotal(eq(user), any(UUID.class)); @@ -256,6 +276,6 @@ void offlineQueueLimitPreservesUncachedPostEvent() { verify(ops, never()).addOfflineVote(any(), any()); verify(ops).postVote(eq(site), eq(user), eq("Ben"), eq(457L), any(UUID.class), eq(false)); - verify(ops).addPoints(eq(user), any(UUID.class)); + verify(ops).addPoints(eq(user), any(UUID.class), anyInt(), anyInt()); } } 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 index 8cf444cf1c..75f4e912bc 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -106,7 +106,7 @@ void repeatedVoteAdmissionReturnsThePersistedPointsDecision() throws Exception { PreparedStatement accountingUpdate = mock(PreparedStatement.class); ResultSet copyEpoch = mock(ResultSet.class); ResultSet resetEpoch = mock(ResultSet.class); - ResultSet accounting = accountingRow("00000000-0000-0000-0000-000000000001", 320, 64); + ResultSet accounting = accountingRow("00000000-0000-0000-0000-000000000001", 320, 64, 9, 40, true); when(copyEpoch.next()).thenReturn(true); when(resetEpoch.next()).thenReturn(true); when(copySelect.executeQuery()).thenReturn(copyEpoch); @@ -116,11 +116,15 @@ void repeatedVoteAdmissionReturnsThePersistedPointsDecision() throws Exception { when(fixture.work.prepareStatement(anyString())).thenReturn(copyInsert, copySelect, resetInsert, resetSelect, accountingInsert, accountingSelect, accountingUpdate); - int requested = new SharedMysqlPurchaseJournal(fixture.table, false).prepareVoteAccounting( + SharedMysqlPurchaseJournal.VoteAccountingDecision decision = new SharedMysqlPurchaseJournal(fixture.table, false) + .prepareVoteAccounting( UUID.randomUUID(), "00000000-0000-0000-0000-000000000001", false, false, false, - null, null, false, 0.0, 1, false, 1234L); + null, null, false, 0.0, 1, false, 1234L, 2, 10); - assertEquals(256, requested); + assertEquals(256, decision.bits()); + assertEquals(9, decision.pointAmount()); + assertEquals(40, decision.pointCap()); + assertEquals(true, decision.replayUnsafe()); verify(accountingUpdate).setInt(2, 320); verify(accountingUpdate).setInt(3, 320); verify(fixture.work).commit(); @@ -1248,11 +1252,19 @@ private static ResultSet pendingRow() throws Exception { } private static ResultSet accountingRow(String uuid, int requested, int completed) throws Exception { + return accountingRow(uuid, requested, completed, null, null, false); + } + + private static ResultSet accountingRow(String uuid, int requested, int completed, Integer points, Integer cap, + boolean replayUnsafe) throws Exception { ResultSet row = mock(ResultSet.class); when(row.next()).thenReturn(true); when(row.getString(1)).thenReturn(uuid); when(row.getInt(2)).thenReturn(requested); when(row.getInt(3)).thenReturn(completed); + when(row.getObject(10)).thenReturn(points); + when(row.getObject(11)).thenReturn(cap); + when(row.getInt(12)).thenReturn(replayUnsafe ? 1 : 0); return row; } From 7c6015555b041c51f4f1f44eda038682b5bbd458 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:21:11 -0600 Subject: [PATCH 41/61] Retire acknowledged vote replay fences --- .../backendproxy/BackendProxyHandler.java | 16 +++++++- .../core/vote/SharedVoteProcessor.java | 9 +++-- .../votingplugin/data/ServerData.java | 10 +++++ .../votingplugin/events/PlayerVoteEvent.java | 5 +++ .../listeners/PlayerVoteListener.java | 4 ++ .../votingplugin/listeners/VotiferEvent.java | 10 +++-- .../listeners/VotifierVoteOverflowQueue.java | 40 +++++++++++++++++-- .../timequeue/TimeQueueHandler.java | 3 ++ .../votingplugin/user/VotingPluginUser.java | 1 + .../service/VoteShopPurchaseService.java | 7 ++++ .../core/vote/SharedVoteProcessorTest.java | 34 +++++++++++++++- .../ServerDataTimeChangeRecoveryTest.java | 21 ++++++++++ .../VotifierVoteOverflowQueueTest.java | 40 +++++++++++++++++++ 13 files changed, 187 insertions(+), 13 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java index b7fb108a34..ee5018c221 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java @@ -32,6 +32,7 @@ import com.bencodez.votingplugin.proxy.BungeeMethod; import com.bencodez.votingplugin.proxy.VotingPluginWire; import com.bencodez.votingplugin.util.VoteTaskAdmission; +import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService; import lombok.Getter; @@ -419,13 +420,15 @@ private void finishOrderedVoteDispatch(BackendOrderedVoteOverflowQueue.PendingEn boolean successful = outcome == OrderedVoteOutcome.COMPLETE; if (successful && overflowEntry != null && orderedVoteOverflow != null) { orderedVoteOverflow.acknowledgeAsync(overflowEntry, - stored -> completeOrderedVoteAcknowledgement(stored)); + stored -> completeOrderedVoteAcknowledgement(stored, envelope)); return; } + boolean completedInMemory = false; synchronized (orderedVoteDispatch) { if (successful && overflowEntry == null && orderedVoteDispatchQueue.peekFirst() == orderedVoteDispatchInFlight) { orderedVoteDispatchQueue.removeFirst(); + completedInMemory = true; } orderedVoteDispatchInFlight = null; orderedVoteOverflowInFlight = null; @@ -434,9 +437,10 @@ private void finishOrderedVoteDispatch(BackendOrderedVoteOverflowQueue.PendingEn if (successful) scheduleOrderedVoteDispatchLocked(); else retryOrderedVoteDispatchLocked(); } + if (completedInMemory) completeVoteDelivery(envelope); } - private void completeOrderedVoteAcknowledgement(boolean stored) { + private void completeOrderedVoteAcknowledgement(boolean stored, JsonEnvelope envelope) { synchronized (orderedVoteDispatch) { if (stored) { orderedVoteDispatchInFlight = null; @@ -452,6 +456,14 @@ private void completeOrderedVoteAcknowledgement(boolean stored) { orderedVoteDispatch.notifyAll(); if (stored) scheduleOrderedVoteDispatchLocked(); } + if (stored) completeVoteDelivery(envelope); + } + + private void completeVoteDelivery(JsonEnvelope envelope) { + String subChannel = envelope == null ? null : envelope.getSubChannel(); + if (!VotingPluginWire.SUB_VOTE.equals(subChannel) + && !VotingPluginWire.SUB_VOTE_ONLINE.equals(subChannel)) return; + VoteShopPurchaseService.completeVoteDelivery(plugin, VotingPluginWire.readVote(envelope).voteId); } private void completeOrderedVoteQuarantine(BackendOrderedVoteOverflowQueue.PendingEnvelope overflowEntry, diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java index 7c431f9853..d18c7c02dc 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java @@ -70,6 +70,8 @@ AccountingAdmission prepareAccounting(U user, UUID voteId, boolean countTotals, void updateName(U user); void voteParty(U user, boolean forceProxyRouting, UUID voteId, boolean eligible); void markReplayUnsafe(UUID voteId); + default boolean deferDeliveryCompletion() { return false; } + default void completeDelivery(UUID voteId) { } void restoreReplayUnsafe(); long incomingTime(); void setTime(U user, S site, long time); @@ -192,10 +194,10 @@ public static void process(Operations ops) { try { ops.cache(user); ops.updateName(user); + // VoteParty can execute rewards and commands. Commit the replay fence + // before it, along with every later effect that lacks its own receipt. + ops.markReplayUnsafe(voteId); ops.voteParty(user, ops.forceProxyRouting(), voteId, votePartyEligible); - // Everything before this point is protected by the vote accounting and - // VoteParty receipts. Later platform effects do not all have replay receipts. - ops.markReplayUnsafe(voteId); if (ops.broadcastEnabled() && ops.hasBroadcastHandler()) { boolean currentOnline = ops.userOnline(user); boolean online = currentOnline; @@ -254,6 +256,7 @@ public static void process(Operations ops) { if (!ops.userOnline(user)) ops.clearCache(user); ops.setUpdate(); ops.extraDebug("Finished vote processing: " + playerName + "/" + userId); + if (!ops.deferDeliveryCompletion()) ops.completeDelivery(voteId); } finally { ops.finishAccounting(voteId); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index 5ac208b5c7..72166a381b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -172,6 +172,16 @@ public synchronized void markVoteReplayUnsafe(UUID voteId) { public synchronized boolean isVoteReplayUnsafe(UUID voteId) { return voteId != null && getData().getBoolean(VOTE_REPLAY_UNSAFE + "." + voteId); } + + /** Removes a replay fence after the delivery owner has durably retired the vote. */ + public synchronized void clearVoteReplayUnsafe(UUID voteId) { + if (voteId == null) return; + if (!getData().contains(VOTE_REPLAY_UNSAFE + "." + voteId)) return; + getData().set(VOTE_REPLAY_UNSAFE + "." + voteId, null); + ConfigurationSection remaining = getData().getConfigurationSection(VOTE_REPLAY_UNSAFE); + if (remaining != null && remaining.getKeys(false).isEmpty()) getData().set(VOTE_REPLAY_UNSAFE, null); + saveData(); + } /** * Adds a vote shop purchase for the specified identifier. diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java index 1109cb6d93..8adaf68d8d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java @@ -101,6 +101,11 @@ public static HandlerList getHandlerList() { @Setter private boolean replayUnsafe; + /** The durable producer will retire the replay fence after its own acknowledgement. */ + @Getter + @Setter + private boolean deferredDeliveryCompletion; + public boolean isProcessingIncomplete() { return accountingAdmissionFailed || processingFailed; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java index 3cf1c7028c..6b8060ec29 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java @@ -192,6 +192,10 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { } event.setReplayUnsafe(true); } + @Override public boolean deferDeliveryCompletion() { return event.isDeferredDeliveryCompletion(); } + @Override public void completeDelivery(UUID voteId) { + VoteShopPurchaseService.completeVoteDelivery(plugin, voteId); + } @Override public void restoreReplayUnsafe() { event.setReplayUnsafe(true); } @Override public long incomingTime() { return event.getTime(); } @Override public void setTime(VotingPluginUser user, VoteSite site, long time) { user.setTime(site, time); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java index 0a26714b63..01999ea9c7 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java @@ -42,7 +42,7 @@ public void processVote(String voteSite, String voteUsername) { } public void processVote(String voteSite, String voteUsername, UUID voteId) { - VoteOutcome outcome = processVoteAttempt(voteSite, voteUsername, voteId); + VoteOutcome outcome = processVoteAttempt(voteSite, voteUsername, voteId, false); if (outcome == VoteOutcome.RETRY) { retainForAccountingRetry(voteSite, voteUsername, voteId); } else if (outcome == VoteOutcome.QUARANTINE @@ -55,14 +55,15 @@ public void processVote(String voteSite, String voteUsername, UUID voteId) { } public boolean processQueuedVote(String voteSite, String voteUsername, UUID voteId) { - return processVoteAttempt(voteSite, voteUsername, voteId) == VoteOutcome.COMPLETE; + return processVoteAttempt(voteSite, voteUsername, voteId, true) == VoteOutcome.COMPLETE; } public VoteOutcome processQueuedVoteOutcome(String voteSite, String voteUsername, UUID voteId) { - return processVoteAttempt(voteSite, voteUsername, voteId); + return processVoteAttempt(voteSite, voteUsername, voteId, true); } - private VoteOutcome processVoteAttempt(String voteSite, String voteUsername, UUID voteId) { + private VoteOutcome processVoteAttempt(String voteSite, String voteUsername, UUID voteId, + boolean deferredDeliveryCompletion) { try { plugin.getServerData().addServiceSite(voteSite); if (plugin.getBungeeSettings().isUseBungeecoord() && !plugin.getBungeeSettings().isVotifierBypass() @@ -113,6 +114,7 @@ private VoteOutcome processVoteAttempt(String voteSite, String voteUsername, UUI PlayerVoteEvent voteEvent = new PlayerVoteEvent( plugin.getVoteSiteManager().getVoteSite(voteSiteName, true), voteUsername, voteSite, true); voteEvent.setVoteId(voteId); + voteEvent.setDeferredDeliveryCompletion(deferredDeliveryCompletion); plugin.getServer().getPluginManager().callEvent(voteEvent); if (voteEvent.isProcessingIncomplete()) { return voteEvent.isReplayUnsafe() ? VoteOutcome.QUARANTINE : VoteOutcome.RETRY; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java index 9da16b20fd..446af21774 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java @@ -30,6 +30,7 @@ import com.bencodez.votingplugin.util.MinecraftUsernameValidator; import com.bencodez.votingplugin.util.ServiceSiteValidator; import com.bencodez.votingplugin.util.VoteTaskAdmission; +import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService; /** * Durable overflow for votes that cannot currently be admitted to the bounded @@ -50,6 +51,7 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable { private final Object lock = new Object(); private final Object persistenceWriteLock = new Object(); private final ArrayDeque entries = new ArrayDeque<>(); + private final ArrayDeque retiredVotes = new ArrayDeque<>(); private boolean drainScheduled; private boolean persistenceScheduled; private boolean persistenceDirty; @@ -233,8 +235,9 @@ private PendingVote nextUnsubmittedLocked() { private void acknowledge(PendingVote pending) { synchronized (lock) { - entries.remove(pending); - stateVersion++; + if (closed || !entries.remove(pending)) return; + long retirementVersion = ++stateVersion; + retiredVotes.addLast(new RetiredVote(pending.voteId, retirementVersion)); requestPersistenceLocked(); scheduleDrainLocked(); } @@ -278,14 +281,26 @@ private void persistLoop() { } return; } + List completedRetirements = new ArrayList<>(); synchronized (lock) { + // closeBlocking owns the final snapshot and marker retirement once + // closed; writeSnapshot deliberately becomes a no-op in that race. + if (closed) return; durableVersion = Math.max(durableVersion, snapshotVersion); + while (!retiredVotes.isEmpty() && retiredVotes.peekFirst().version <= durableVersion) { + completedRetirements.add(retiredVotes.removeFirst().voteId); + } if (!persistenceDirty) { persistenceScheduled = false; scheduleDrainLocked(); - return; } } + for (UUID voteId : completedRetirements) { + completeDelivery(voteId); + } + synchronized (lock) { + if (!persistenceDirty) return; + } } } @@ -413,12 +428,29 @@ public void close() { synchronized (persistenceWriteLock) { writeSnapshotLocked(snapshot); } + List completedRetirements = new ArrayList<>(); + synchronized (lock) { + while (!retiredVotes.isEmpty()) completedRetirements.add(retiredVotes.removeFirst().voteId); + } + for (UUID voteId : completedRetirements) { + completeDelivery(voteId); + } } catch (IOException failure) { plugin.getLogger().warning("Unable to persist queued Votifier votes during shutdown: " + failure.getClass().getSimpleName()); } } + private void completeDelivery(UUID voteId) { + try { + VoteShopPurchaseService.completeVoteDelivery(plugin, voteId); + } catch (RuntimeException failure) { + plugin.getLogger().warning("Unable to retire acknowledged Votifier vote replay fence: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + } + } + private static final class PendingVote { private final String username; private final String serviceSite; @@ -435,6 +467,8 @@ private PendingVote(String username, String serviceSite, long time, UUID voteId) } } + private record RetiredVote(UUID voteId, long version) { } + @FunctionalInterface public interface VoteProcessor { VoteOutcome accept(String serviceSite, String username, UUID voteId); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java index d6f6676d58..02c7494b05 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java @@ -24,6 +24,7 @@ import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.events.PlayerVoteEvent; import com.bencodez.votingplugin.util.VoteTaskAdmission; +import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService; /** * The TimeQueueHandler class manages time-based vote queue processing. @@ -191,6 +192,7 @@ public void processQueue() { vote.getService(), true); voteEvent.setTime(vote.getTime()); voteEvent.setVoteId(vote.getVoteId() == null ? vote.legacyTimedVoteId() : vote.getVoteId()); + voteEvent.setDeferredDeliveryCompletion(true); plugin.getServer().getPluginManager().callEvent(voteEvent); if (voteEvent.isProcessingIncomplete()) { if (voteEvent.isReplayUnsafe()) { @@ -204,6 +206,7 @@ public void processQueue() { return; } + VoteShopPurchaseService.completeVoteDelivery(plugin, voteEvent.getVoteId()); if (voteEvent.isCancelled()) { plugin.debug("Vote cancelled"); return; 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 40bb2b2c6c..dc4a00b813 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -1086,6 +1086,7 @@ public boolean bungeeVotePluginMessagingAccepted(String service, long time, Vote voteEvent.setBroadcast(broadcast); voteEvent.setVoteNumber(num); voteEvent.setVoteId(voteId); + voteEvent.setDeferredDeliveryCompletion(true); plugin.getServer().getPluginManager().callEvent(voteEvent); if (voteEvent.isProcessingFailed() && voteEvent.isReplayUnsafe()) { throw new IllegalStateException("Proxy vote reached an ambiguous post-effect failure: " + voteId); 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 2ad348dc57..06572c51f3 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 @@ -728,6 +728,13 @@ public static boolean markVoteReplayUnsafe(VotingPluginMain plugin, UUID voteId) } } + /** Retires the YAML replay fence once the delivery owner has durably acknowledged success. */ + public static void completeVoteDelivery(VotingPluginMain plugin, UUID voteId) { + if (voteId != null && !canRecoverSharedMysqlPurchases(plugin)) { + plugin.getServerData().clearVoteReplayUnsafe(voteId); + } + } + public static void finishMysqlVoteAccounting(UUID voteId) { if (voteId != null) ADMITTED_ACCOUNTING.remove(voteId); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java index aa7021104b..4e74d0fc9a 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java @@ -7,6 +7,7 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -73,8 +74,8 @@ void onlineVoteKeepsProductionPhaseOrderAndCounts() { order.verify(ops).prepareAccounting(eq(user), any(UUID.class), eq(true), eq(true), eq(3), eq(100)); order.verify(ops).cache(user); order.verify(ops).updateName(user); - order.verify(ops).voteParty(eq(user), eq(false), any(UUID.class), eq(true)); order.verify(ops).markReplayUnsafe(voteId); + order.verify(ops).voteParty(eq(user), eq(false), any(UUID.class), eq(true)); order.verify(ops).broadcast(any(UUID.class), eq("Ben"), any(), eq(true)); order.verify(ops).setTime(user, site, 123L); order.verify(ops).playerVote(user, site, true, false); @@ -92,9 +93,40 @@ void onlineVoteKeepsProductionPhaseOrderAndCounts() { order.verify(ops).postVote(eq(site), eq(user), eq("Ben"), eq(123L), any(UUID.class), eq(false)); order.verify(ops).updatePlaceholders(user); order.verify(ops).setUpdate(); + order.verify(ops).completeDelivery(voteId); order.verify(ops).finishAccounting(any(UUID.class)); } + @Test + void durableProducerRetainsReplayFenceUntilItsOwnAcknowledgement() { + var ops = accepted(); + UUID voteId = UUID.randomUUID(); + when(ops.incomingVoteId()).thenReturn(voteId); + when(ops.deferDeliveryCompletion()).thenReturn(true); + + SharedVoteProcessor.process(ops); + + verify(ops).markReplayUnsafe(voteId); + verify(ops, never()).completeDelivery(voteId); + } + + @Test + void votePartyFailureKeepsTheFenceForQuarantine() { + var ops = accepted(); + UUID voteId = UUID.randomUUID(); + when(ops.incomingVoteId()).thenReturn(voteId); + doThrow(new IllegalStateException("reward failed")) + .when(ops).voteParty(user, false, voteId, true); + + assertThrows(IllegalStateException.class, () -> SharedVoteProcessor.process(ops)); + + InOrder order = inOrder(ops); + order.verify(ops).markReplayUnsafe(voteId); + order.verify(ops).voteParty(user, false, voteId, true); + verify(ops, never()).completeDelivery(voteId); + verify(ops).finishAccounting(voteId); + } + @Test void totalsUseTheEligibilityCapturedAtAdmission() { var ops = accepted(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java index cf7e311086..dd38cebed6 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java @@ -73,6 +73,27 @@ void oldVotePartyReceiptsRemainForUnboundedQueueReplay() { assertTrue(yaml.contains("VotingPlugin.VoteParty.Accounting." + oldVote)); } + @Test + void acknowledgedVoteRemovesOnlyItsReplayFence() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); + YamlConfiguration yaml = new YamlConfiguration(); + when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile()); + when(plugin.getServerDataFile()).thenReturn(coreData); + when(coreData.getData()).thenReturn(yaml); + ServerData data = new ServerData(plugin); + UUID completed = UUID.randomUUID(); + UUID pending = UUID.randomUUID(); + + data.markVoteReplayUnsafe(completed); + data.markVoteReplayUnsafe(pending); + data.clearVoteReplayUnsafe(completed); + + assertFalse(data.isVoteReplayUnsafe(completed)); + assertTrue(data.isVoteReplayUnsafe(pending)); + verify(coreData, org.mockito.Mockito.times(3)).saveData(); + } + @Test void ambiguousTimedVoteIsStoredOutsideTheAutomaticReplayQueue() { VotingPluginMain plugin = mock(VotingPluginMain.class); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java index 79075b1620..5c1c4552da 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java @@ -26,9 +26,49 @@ import org.junit.jupiter.api.io.TempDir; import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.votingplugin.data.ServerData; import com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue; class VotifierVoteOverflowQueueTest { + @Test + void successfulVoteRetiresReplayFenceAfterQueueSnapshot(@TempDir Path dataFolder) throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + ServerData serverData = mock(ServerData.class); + ScheduledExecutorService voteTimer = Executors.newSingleThreadScheduledExecutor(); + CountDownLatch processed = new CountDownLatch(1); + java.util.UUID voteId = java.util.UUID.randomUUID(); + when(plugin.getDataFolder()).thenReturn(dataFolder.toFile()); + when(plugin.getVoteTimer()).thenReturn(voteTimer); + when(plugin.getServerData()).thenReturn(serverData); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getLogger()).thenReturn(Logger.getLogger("VotifierVoteOverflowQueueTest")); + VotifierVoteOverflowQueue queue = new VotifierVoteOverflowQueue(plugin, (site, user, id) -> { + processed.countDown(); + return VotifierVoteOverflowQueue.VoteOutcome.COMPLETE; + }); + try { + assertTrue(queue.enqueue("Steve", "example.org", voteId)); + queue.start(); + + assertTrue(processed.await(2, TimeUnit.SECONDS)); + Path queueFile = dataFolder.resolve("VotifierVoteQueue.yml"); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while ((!Files.exists(queueFile) || Files.readString(queueFile).contains(voteId.toString())) + && System.nanoTime() < deadline) Thread.sleep(10L); + assertTrue(Files.exists(queueFile)); + assertTrue(!Files.readString(queueFile).contains(voteId.toString())); + deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (org.mockito.Mockito.mockingDetails(serverData).getInvocations().isEmpty() + && System.nanoTime() < deadline) Thread.sleep(10L); + verify(serverData).clearVoteReplayUnsafe(voteId); + assertEquals(0, queue.size()); + } finally { + queue.close(); + voteTimer.shutdownNow(); + } + } + @Test void failedQueuedAttemptKeepsTheExistingEntry(@TempDir Path dataFolder) throws Exception { VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); From b80d9ba8b9cba1e2f87d9085b0d68bd6a62224b8 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:43:40 -0600 Subject: [PATCH 42/61] Preserve failed vote recovery records --- .../backendproxy/BackendProxyHandler.java | 3 +- .../messaging/BackendProxyMessageRouter.java | 3 +- .../votingplugin/proxy/VotingPluginWire.java | 7 ++++ .../timequeue/TimeQueueHandler.java | 11 +++++- .../BackendProxyHandlerLifecycleTest.java | 37 ++++++++++++++++++- .../tests/VotingPluginWireTest.java | 10 +++++ .../TimeQueueHandlerRejectionTest.java | 27 +++++++++++++- 7 files changed, 91 insertions(+), 7 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java index ee5018c221..2fd78bdc46 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java @@ -463,7 +463,8 @@ private void completeVoteDelivery(JsonEnvelope envelope) { String subChannel = envelope == null ? null : envelope.getSubChannel(); if (!VotingPluginWire.SUB_VOTE.equals(subChannel) && !VotingPluginWire.SUB_VOTE_ONLINE.equals(subChannel)) return; - VoteShopPurchaseService.completeVoteDelivery(plugin, VotingPluginWire.readVote(envelope).voteId); + VoteShopPurchaseService.completeVoteDelivery(plugin, + VotingPluginWire.resolveVoteId(VotingPluginWire.readVote(envelope))); } private void completeOrderedVoteQuarantine(BackendOrderedVoteOverflowQueue.PendingEnvelope overflowEntry, diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java index 66af49f7b6..e10122e454 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java @@ -366,8 +366,7 @@ private void handleWireVote(JsonEnvelope msg) { + ServiceSiteValidator.sanitizeForLog(vote.uuid) + " on " + ServiceSiteValidator.sanitizeForLog(vote.service)); VoteTotalsSnapshot totals = VoteTotalsSnapshot.parseStorage(vote.totals == null ? "" : vote.totals); - @SuppressWarnings("deprecation") - UUID voteId = vote.voteId != null ? vote.voteId : totals.getVoteUUID(); + UUID voteId = VotingPluginWire.resolveVoteId(vote); if (!processedVoteCache.reserve(voteId)) { plugin.debug("Ignoring duplicate wire vote " + voteId + " for " + ServiceSiteValidator.sanitizeForLog(vote.player) + " on " diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java index c7d55502ad..8759916737 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java @@ -534,6 +534,13 @@ public static Vote readVote(JsonEnvelope env) { broadcast, num, numberOfVotes); } + /** Resolves the current vote ID or the legacy ID carried in the totals snapshot. */ + @SuppressWarnings("deprecation") + public static UUID resolveVoteId(Vote vote) { + if (vote == null || vote.voteId != null) return vote == null ? null : vote.voteId; + return VoteTotalsSnapshot.parseStorage(vote.totals).getVoteUUID(); + } + public static final class VoteDelayRejected { public final String player; public final String uuid; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java index 02c7494b05..0ff32b98bb 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java @@ -196,7 +196,16 @@ public void processQueue() { plugin.getServer().getPluginManager().callEvent(voteEvent); if (voteEvent.isProcessingIncomplete()) { if (voteEvent.isReplayUnsafe()) { - plugin.getServerData().quarantineTimedVote(vote); + try { + plugin.getServerData().quarantineTimedVote(vote); + } catch (RuntimeException persistenceFailure) { + timeChangeQueue.addFirst(vote); + plugin.getLogger().severe("Unable to quarantine ambiguous timed vote " + + voteEvent.getVoteId() + "; retained it in the durable retry queue"); + plugin.debug(persistenceFailure); + scheduleRetry(!persistQueueSnapshot()); + return; + } plugin.getLogger().severe("Timed vote " + voteEvent.getVoteId() + " reached an ambiguous post-effect failure and was retained for manual review"); continue; diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandlerLifecycleTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandlerLifecycleTest.java index 2c62d70b09..a15daddc28 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandlerLifecycleTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandlerLifecycleTest.java @@ -24,9 +24,10 @@ import java.lang.reflect.Field; import java.net.ServerSocket; -import java.util.ArrayDeque; -import java.nio.file.Path; import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.LinkedBlockingQueue; @@ -61,6 +62,7 @@ import com.bencodez.votingplugin.backendproxy.messaging.BackendProxyMessageRouter.OrderedVoteOutcome; import com.bencodez.votingplugin.backendproxy.presence.BackendPresenceManager; import com.bencodez.votingplugin.config.BungeeSettings; +import com.bencodez.votingplugin.data.ServerData; import com.bencodez.votingplugin.proxy.VotingPluginWire; import com.bencodez.votingplugin.backendproxy.transport.MqttBackendProxyTransport; import com.bencodez.votingplugin.backendproxy.transport.MysqlBackendProxyTransport; @@ -73,6 +75,37 @@ import com.bencodez.votingplugin.proxy.BungeeMethod; class BackendProxyHandlerLifecycleTest { + @Test + void completedLegacyVoteRetiresTheTotalsReplayFence() throws Exception { + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + ServerData serverData = mock(ServerData.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.getServerData()).thenReturn(serverData); + ArrayDeque asyncTasks = new ArrayDeque<>(); + doAnswer(invocation -> { + asyncTasks.addLast(invocation.getArgument(1)); + return null; + }).when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + BackendProxyHandler handler = new BackendProxyHandler(plugin); + BackendProxyMessageRouter router = mock(BackendProxyMessageRouter.class); + setField(handler, "messageRouter", router); + doAnswer(invocation -> { + invocation.>getArgument(1) + .accept(OrderedVoteOutcome.COMPLETE); + return null; + }).when(router).handleOrderedVote(any(JsonEnvelope.class), any()); + handler.activateInboundMessages(); + UUID voteId = UUID.randomUUID(); + String legacyTotals = "1//2//3//4//5//0//6//7//8//" + voteId; + + handler.dispatchIncomingAfterPublication(VotingPluginWire.vote("Player", UUID.randomUUID().toString(), + "Service", 100L, true, true, legacyTotals, null, true, false, 1, 1), mock(Runnable.class)); + asyncTasks.removeFirst().run(); + + verify(serverData).clearVoteReplayUnsafe(voteId); + } + @Test void globalDataWakeupMovesOffTheCallingAndPrimaryThreads() { com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java index ff591c5e63..d06a326a1b 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java @@ -44,6 +44,16 @@ public void voteOnlineRoundTripAllowsMissingVoteId() { assertNull(vote.voteId); } + @Test + public void legacyTotalsSupplyTheVoteIdWhenTheTopLevelFieldIsMissing() { + UUID voteId = UUID.randomUUID(); + String legacyTotals = "1//2//3//4//5//0//6//7//8//" + voteId; + JsonEnvelope envelope = VotingPluginWire.vote("Player", UUID.randomUUID().toString(), "Service", 100L, + true, true, legacyTotals, null, true, false, 1, 1); + + assertEquals(voteId, VotingPluginWire.resolveVoteId(VotingPluginWire.readVote(envelope))); + } + @Test public void voteDelayRejectedRoundTripPreservesContext() { String uuid = UUID.randomUUID().toString(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java index 7dea8a9f99..8d6ced4c02 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java @@ -7,13 +7,14 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import static org.mockito.Mockito.reset; import java.util.List; import java.util.Set; @@ -174,6 +175,30 @@ void ambiguousPostEffectFailureIsQuarantinedInsteadOfReplayed() { org.mockito.ArgumentMatchers.eq(plugin), any(Runnable.class), anyLong()); } + @Test + void failedAmbiguousQuarantineReturnsVoteToDurableRetryQueue() { + when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of()); + TimeQueueHandler handler = new TimeQueueHandler(plugin); + clearInvocations(plugin.getBukkitScheduler()); + VoteTimeQueue vote = new VoteTimeQueue(UUID.randomUUID(), "Alex", "example.org", 123L); + handler.getTimeChangeQueue().add(vote); + doThrow(new IllegalStateException("disk unavailable")).when(serverData).quarantineTimedVote(vote); + org.bukkit.plugin.PluginManager pluginManager = plugin.getServer().getPluginManager(); + doAnswer(invocation -> { + PlayerVoteEvent event = invocation.getArgument(0); + event.setProcessingFailed(true); + event.setReplayUnsafe(true); + return null; + }).when(pluginManager).callEvent(any(PlayerVoteEvent.class)); + + assertDoesNotThrow(handler::processQueue); + + assertEquals(vote, handler.getTimeChangeQueue().peek()); + verify(serverData).replaceTimedVoteCache(List.of(vote)); + verify(plugin.getBukkitScheduler()).runTaskLaterAsynchronously( + org.mockito.ArgumentMatchers.eq(plugin), any(Runnable.class), anyLong()); + } + @Test void timedVoteSnapshotPersistsStableIdsWithOneSave() { VotingPluginMain snapshotPlugin = mock(VotingPluginMain.class); From e9db716d5bbfae4e55e1a4eaec69004abecfad9e Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:01:11 -0600 Subject: [PATCH 43/61] Make vote point accounting replay stable --- .../core/vote/SharedVoteProcessor.java | 7 +-- .../listeners/PlayerVoteListener.java | 10 ++-- .../user/SharedMysqlPointMutator.java | 12 ++++- .../user/SharedPointAdditionJournal.java | 24 ++++++--- .../votingplugin/user/VotingPluginUser.java | 31 ++++++----- .../service/SharedMysqlPurchaseJournal.java | 53 +++++++++++++------ .../service/VoteShopPurchaseService.java | 13 ++--- .../core/vote/SharedVoteProcessorTest.java | 19 +++---- .../user/SharedPointAdditionJournalTest.java | 19 +++++++ .../user/VotingPluginUserVotePointsTest.java | 10 ++-- .../SharedMysqlPurchaseJournalTest.java | 14 +++-- 11 files changed, 146 insertions(+), 66 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java index d18c7c02dc..ab5d1e4900 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java @@ -16,7 +16,7 @@ public static boolean isQueuedVoteAlreadyRecorded(boolean proxyVote, long messag public record Validation(boolean valid, String normalizedName, String source, String reason, boolean bedrock) { } public record Name(String value, String rationale) { } public record AccountingAdmission(boolean countTotals, boolean awardPoints, boolean votePartyEligible, - int pointAmount, int pointCap, boolean replayUnsafe) { } + int pointAmount, int pointCap, String pointColumn, boolean replayUnsafe) { } public interface Operations { boolean enabled(); @@ -90,7 +90,7 @@ default void completeDelivery(UUID voteId) { } void addTotal(U user, UUID voteId); void addTotalDaily(U user, UUID voteId); void addTotalWeekly(U user, UUID voteId); - void addPoints(U user, UUID voteId, int amount, int cap); + void addPoints(U user, UUID voteId, int amount, int cap, String pointColumn); void checkDayVoteStreak(U user, boolean forceProxyRouting, UUID voteId); boolean limitMonthlyVotes(); int proxyMonthTotal(); @@ -236,7 +236,8 @@ public static void process(Operations ops) { ops.realVote(), ops.addTotals(), ops.proxyVote(), ops.forceProxyRouting(), ops.wasOnline()); SharedVoteAccounting.applyAdmitted(countTotals, awardPoints, () -> ops.addTotal(user, voteId), () -> ops.addTotalDaily(user, voteId), () -> ops.addTotalWeekly(user, voteId), - () -> ops.addPoints(user, voteId, admission.pointAmount(), admission.pointCap())); + () -> ops.addPoints(user, voteId, admission.pointAmount(), admission.pointCap(), + admission.pointColumn())); ops.checkDayVoteStreak(user, ops.forceProxyRouting(), voteId); if (ops.limitMonthlyVotes() && (!ops.proxyVote() || ops.hasProxyTextTotals())) { int value = ops.proxyVote() ? ops.proxyMonthTotal() : ops.userMonthTotal(user); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java index 6b8060ec29..bed289ed66 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java @@ -166,15 +166,15 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { boolean countVoteParty = plugin.getSpecialRewardsConfig().isVotePartyEnabled() && (plugin.getSpecialRewardsConfig().isVotePartyCountFakeVotes() || event.isRealVote()) && (plugin.getSpecialRewardsConfig().isVotePartyCountOfflineVotes() || user.isOnline()); - VoteShopPurchaseService.VoteAccountingAdmission admission = VoteShopPurchaseService + VoteShopPurchaseService.VoteAccountingAdmission admission = VoteShopPurchaseService .prepareMysqlVoteAccounting(plugin, voteId, user.getUUID(), countTotals, awardPoints, - countVoteParty, event.isForceBungee(), pointAmount, pointCap); + countVoteParty, event.isForceBungee(), pointAmount, pointCap, user.getPointsPath()); if (!admission.success()) { throw new SharedVoteAdmissionException("Unable to admit shared MySQL vote accounting before processing"); } return new SharedVoteProcessor.AccountingAdmission(admission.countTotals(), admission.awardPoints(), admission.countVoteParty(), admission.pointAmount(), admission.pointCap(), - admission.replayUnsafe()); + admission.pointColumn(), admission.replayUnsafe()); } @Override public int configuredPointAmount() { return plugin.getConfigFile().getPointsOnVote(); } @Override public int configuredPointCap() { return plugin.getConfigFile().getLimitVotePoints(); } @@ -219,8 +219,8 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { @Override public void addTotal(VotingPluginUser user, UUID voteId) { user.addTotal(voteId); } @Override public void addTotalDaily(VotingPluginUser user, UUID voteId) { user.addTotalDaily(voteId); } @Override public void addTotalWeekly(VotingPluginUser user, UUID voteId) { user.addTotalWeekly(voteId); } - @Override public void addPoints(VotingPluginUser user, UUID voteId, int amount, int cap) { - user.addVotePoints(voteId, amount, cap); + @Override public void addPoints(VotingPluginUser user, UUID voteId, int amount, int cap, String pointColumn) { + user.addVotePoints(voteId, amount, cap, pointColumn); } @Override public void checkDayVoteStreak(VotingPluginUser user, boolean forceProxyRouting, UUID voteId) { user.checkDayVoteStreak(forceProxyRouting, voteId); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index c2680332d7..b33ec3f82b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -180,11 +180,19 @@ AddResult addCommitted(VotingPluginUser user, int amount, String operationId) { } AddResult addCommittedToColumn(VotingPluginUser user, int amount, String operationId, String pointsColumn) { + return addCommittedToColumn(user, amount, operationId, pointsColumn, -1); + } + + AddResult addCommittedToColumn(VotingPluginUser user, int amount, String operationId, String pointsColumn, + int maximum) { if (operationId == null || operationId.isEmpty()) return addCommittedToColumn(user, amount, pointsColumn); drainCache(user, pointsColumn); try { - SharedPointAdditionJournal.AdditionResult result = SharedPointAdditionJournal.forTable(plugin.getMysql()) - .add(operationId, user.getUUID(), pointsColumn, amount, System.currentTimeMillis()); + SharedPointAdditionJournal journal = SharedPointAdditionJournal.forTable(plugin.getMysql()); + SharedPointAdditionJournal.AdditionResult result = maximum > 0 + ? journal.addCapped(operationId, user.getUUID(), pointsColumn, amount, maximum, + System.currentTimeMillis()) + : journal.add(operationId, user.getUUID(), pointsColumn, amount, System.currentTimeMillis()); return new AddResult(true, result.total()); } catch (SQLException failure) { logFailure(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 index 4b6f462c30..e560a52b87 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java @@ -77,7 +77,13 @@ static SharedPointAdditionJournal forTable(MySQL table) throws SQLException { /** 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); + return mutate(operationId, uuid, pointsColumn, amount, null, now, false); + } + + /** Applies one idempotent credit and upper bound in the same transaction. */ + AdditionResult addCapped(String operationId, String uuid, String pointsColumn, int amount, int maximum, long now) + throws SQLException { + return mutate(operationId, uuid, pointsColumn, amount, Integer.valueOf(maximum), now, false); } /** Applies a durable conditional debit. A retry confirms the journal row and @@ -85,10 +91,10 @@ AdditionResult add(String operationId, String uuid, String pointsColumn, int amo * 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); + return mutate(operationId, uuid, pointsColumn, -amount, null, now, true); } - private AdditionResult mutate(String operationId, String uuid, String pointsColumn, int amount, long now, + private AdditionResult mutate(String operationId, String uuid, String pointsColumn, int amount, Integer maximum, long now, boolean requireNonnegative) throws SQLException { if (!isSafeColumn(pointsColumn)) throw new SQLException("Unsafe shared point column"); AdditionRow existing = find(operationId); @@ -99,8 +105,10 @@ private AdditionResult mutate(String operationId, String uuid, String pointsColu + qi("state") + ", " + qi("total_points") + ", " + qi("created_at") + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; String points = qi(pointsColumn); String coalescedPoints = "COALESCE(" + points + ", 0)"; - String update = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + coalescedPoints - + " + ? WHERE " + qi("uuid") + uuidCast() + String updatedPoints = coalescedPoints + " + ?"; + if (maximum != null && maximum.intValue() > 0) updatedPoints = "LEAST(" + updatedPoints + ", ?)"; + String update = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + updatedPoints + + " WHERE " + qi("uuid") + uuidCast() + (requireNonnegative ? " AND " + coalescedPoints + " >= ?" : ""); String read = "SELECT " + points + " FROM " + qi(table.getTableName()) + " WHERE " + qi("uuid") + uuidCast(); String complete = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ?, " + qi("total_points") @@ -122,8 +130,10 @@ private AdditionResult mutate(String operationId, String uuid, String pointsColu insertStatement.executeUpdate(); updateStatement.setInt(1, amount); - updateStatement.setString(2, uuid); - if (requireNonnegative) updateStatement.setInt(3, -amount); + int parameter = 2; + if (maximum != null && maximum.intValue() > 0) updateStatement.setInt(parameter++, maximum.intValue()); + updateStatement.setString(parameter++, uuid); + if (requireNonnegative) updateStatement.setInt(parameter, -amount); if (updateStatement.executeUpdate() != 1) { rollback(connection); if (requireNonnegative) throw new DebitRejectedException(); 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 dc4a00b813..aa31279641 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -291,33 +291,40 @@ public synchronized int addPoints(int value, boolean async) { * @param voteId stable identity of the accepted vote */ public void addVotePoints(UUID voteId) { - addVotePoints(voteId, plugin.getConfigFile().getPointsOnVote(), plugin.getConfigFile().getLimitVotePoints()); + addVotePoints(voteId, plugin.getConfigFile().getPointsOnVote(), plugin.getConfigFile().getLimitVotePoints(), + getPointsPath()); } /** Applies the point policy captured when the durable vote was admitted. */ public void addVotePoints(UUID voteId, int points, int limit) { + addVotePoints(voteId, points, limit, getPointsPath()); + } + + /** Applies the point policy and destination captured by durable vote admission. */ + public void addVotePoints(UUID voteId, int points, int limit, String admittedPointsColumn) { SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); if (voteId == null || !sharedPoints.usesMysqlPointMutations()) { addVotePointsWithPolicy(points, limit); return; } - String pointsColumn = getPointsPath(); + String pointsColumn = admittedPointsColumn; String operationId = "vote-points:" + voteId; Integer completedTotal = sharedPoints.completedPointAdditionTotal(operationId, getUUID(), pointsColumn); - if (completedTotal == null && points != 0) { - PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, points); - Bukkit.getPluginManager().callEvent(event); - if (!event.isCancelled()) { - SharedMysqlPointMutator.AddResult result = sharedPoints.addCommittedToColumn(this, - event.getPoints(), operationId, pointsColumn); - if (!result.success()) { - throw new IllegalStateException("Unable to persist vote points for " + getUUID()); - } + if (completedTotal == null && (points != 0 || limit > 0)) { + int admittedAmount = 0; + if (points != 0) { + PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, points); + Bukkit.getPluginManager().callEvent(event); + if (!event.isCancelled()) admittedAmount = event.getPoints(); + } + SharedMysqlPointMutator.AddResult result = sharedPoints.addCommittedToColumn(this, + admittedAmount, operationId, pointsColumn, limit); + if (!result.success()) { + throw new IllegalStateException("Unable to persist vote points for " + getUUID()); } } // A durable vote queue has no age limit. Keep vote-point receipts in // COMPLETED so a delayed replay cannot credit the same vote after cleanup. - if (limit > 0) sharedPoints.cap(this, limit, false); } private void addVotePointsWithPolicy(int points, int limit) { 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 index b5b32086d7..f2357c0483 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -36,7 +36,8 @@ enum DailyStreakOutcome { APPLIED, ALREADY_UPDATED, DEFERRED } record DailyStreakResult(DailyStreakOutcome outcome, int streak) { } record RecoveredDailyStreak(UUID voteId, String uuid, int streak, boolean forceProxyRouting) { } record AccountingRecoveryBatch(boolean hadRows, List pendingRewards) { } - record VoteAccountingDecision(int bits, int pointAmount, int pointCap, boolean replayUnsafe) { } + record VoteAccountingDecision(int bits, int pointAmount, int pointCap, String pointColumn, + boolean replayUnsafe) { } private static final String PENDING = "PENDING"; private static final String HOOK_STARTED = "HOOK_STARTED"; private static final String COMPENSATING = "COMPENSATING"; @@ -292,14 +293,16 @@ void resetPeriodTotal(String totalColumn, String previousColumn, String resetGen } } - private void commitAndConfirmRequested(Connection connection, UUID voteId, int operation) throws SQLException { + private AccountingRow commitAndConfirmRequested(Connection connection, UUID voteId, int operation) + throws SQLException { try { connection.commit(); + return null; } catch (SQLException ambiguousCommit) { closeQuietly(connection); try { AccountingRow row = findAccountingVote(voteId); - if (row != null && (row.requested() & operation) == operation) return; + if (row != null && (row.requested() & operation) == operation) return row; } catch (SQLException confirmationFailure) { ambiguousCommit.addSuppressed(confirmationFailure); } @@ -358,8 +361,10 @@ boolean incrementPeriodTotals(UUID voteId, String uuid, String boundaryColumn, S VoteAccountingDecision prepareVoteAccounting(UUID voteId, String uuid, boolean countTotals, boolean awardPoints, boolean countVoteParty, String monthColumn, Integer monthMaximum, boolean streakUsesPercentage, double streakPercentage, - int enabledSiteCount, boolean forceProxyRouting, long acceptedAt, int pointAmount, int pointCap) + int enabledSiteCount, boolean forceProxyRouting, long acceptedAt, int pointAmount, int pointCap, + String pointColumn) throws SQLException { + if (!isSafeColumn(pointColumn)) throw new SQLException("Unsafe shared point column"); int requested = (countTotals ? DAILY_TOTAL | WEEKLY_TOTAL | MONTH_TOTAL | ALL_TIME_TOTAL : 0) | (awardPoints ? AWARD_POINTS : 0) | (countVoteParty ? VOTE_PARTY_TOTAL : 0); @@ -385,6 +390,8 @@ VoteAccountingDecision prepareVoteAccounting(UUID voteId, String uuid, boolean c ? row.pointAmount().intValue() : pointAmount; int admittedPointCap = accountingAlreadyDecided && row.pointCap() != null ? row.pointCap().intValue() : pointCap; + String admittedPointColumn = accountingAlreadyDecided && row.pointColumn() != null + && !row.pointColumn().isEmpty() ? row.pointColumn() : pointColumn; boolean dailyResetPending = !accountingAlreadyDecided && streakUsesPercentage && resetPending(connection, "DailyTotal", dailyCopyMarker); DailyStreakCandidate streak = accountingAlreadyDecided ? null @@ -411,7 +418,8 @@ VoteAccountingDecision prepareVoteAccounting(UUID voteId, String uuid, boolean c + qi("streak_updated_at") + "), " + qi("streak_force_proxy") + " = COALESCE(?, " + qi("streak_force_proxy") + "), " + qi("point_amount") + " = COALESCE(" + qi("point_amount") + ", ?), " + qi("point_cap") + " = COALESCE(" - + qi("point_cap") + ", ?) WHERE " + qi("vote_id") + " = ?"; + + qi("point_cap") + ", ?), " + qi("point_column") + " = COALESCE(" + + qi("point_column") + ", ?) WHERE " + qi("vote_id") + " = ?"; try (PreparedStatement statement = connection.prepareStatement(update)) { statement.setString(1, uuid); statement.setInt(2, persistedRequested); @@ -427,12 +435,22 @@ VoteAccountingDecision prepareVoteAccounting(UUID voteId, String uuid, boolean c else statement.setNull(8, java.sql.Types.INTEGER); statement.setInt(9, admittedPointAmount); statement.setInt(10, admittedPointCap); - statement.setString(11, voteId.toString()); + statement.setString(11, admittedPointColumn); + statement.setString(12, voteId.toString()); if (statement.executeUpdate() != 1) throw new SQLException("Vote accounting marker is missing"); } - commitAndConfirmRequested(connection, voteId, persistedRequested); + AccountingRow confirmed = commitAndConfirmRequested(connection, voteId, persistedRequested); + if (confirmed != null) { + if (!java.util.Objects.equals(confirmed.uuid(), uuid) || confirmed.pointAmount() == null + || confirmed.pointCap() == null || confirmed.pointColumn() == null) { + throw new SQLException("Confirmed vote accounting payload is incomplete or does not match"); + } + return new VoteAccountingDecision(confirmed.requested() & ~ACCOUNTING_DECIDED, + confirmed.pointAmount().intValue(), confirmed.pointCap().intValue(), + confirmed.pointColumn(), confirmed.replayUnsafe()); + } return new VoteAccountingDecision(persistedRequested & ~ACCOUNTING_DECIDED, - admittedPointAmount, admittedPointCap, row.replayUnsafe()); + admittedPointAmount, admittedPointCap, admittedPointColumn, row.replayUnsafe()); } catch (SQLException failure) { rollback(connection); throw failure; @@ -446,7 +464,7 @@ int prepareVoteAccounting(UUID voteId, String uuid, boolean countTotals, boolean throws SQLException { return prepareVoteAccounting(voteId, uuid, countTotals, awardPoints, countVoteParty, monthColumn, monthMaximum, streakUsesPercentage, streakPercentage, enabledSiteCount, forceProxyRouting, - acceptedAt, 0, -1).bits(); + acceptedAt, 0, -1, "Points").bits(); } void markVoteReplayUnsafe(UUID voteId) throws SQLException { @@ -737,6 +755,7 @@ private String accountingColumns() { + qi("month_column") + ", " + qi("month_maximum") + ", " + qi("streak_value") + ", " + qi("streak_updated_at") + ", " + qi("streak_force_proxy") + ", " + qi("streak_applied_value") + ", " + qi("point_amount") + ", " + qi("point_cap") + ", " + + qi("point_column") + ", " + qi("replay_unsafe"); } @@ -744,7 +763,7 @@ private static AccountingRow accountingRow(ResultSet result) throws SQLException return new AccountingRow(result.getString(1), result.getInt(2), result.getInt(3), result.getString(4), nullableInteger(result, 5), nullableInteger(result, 6), nullableLong(result, 7), nullableInteger(result, 8), nullableInteger(result, 9), nullableInteger(result, 10), - nullableInteger(result, 11), result.getInt(12) != 0); + nullableInteger(result, 11), result.getString(12), result.getInt(13) != 0); } private boolean resetPending(Connection connection, String boundaryColumn, EpochRow copyMarker) @@ -1033,9 +1052,10 @@ private void drainPendingAccounting(Connection connection, int operation) throws while (result.next()) { UUID voteId = UUID.fromString(result.getString(1)); AccountingRow row = new AccountingRow(result.getString(2), result.getInt(3), result.getInt(4), - result.getString(5), nullableInteger(result, 6), nullableInteger(result, 7), - nullableLong(result, 8), nullableInteger(result, 9), nullableInteger(result, 10), - nullableInteger(result, 11), nullableInteger(result, 12), result.getInt(13) != 0); + result.getString(5), nullableInteger(result, 6), nullableInteger(result, 7), + nullableLong(result, 8), nullableInteger(result, 9), nullableInteger(result, 10), + nullableInteger(result, 11), nullableInteger(result, 12), result.getString(13), + result.getInt(14) != 0); applyPendingAccounting(connection, voteId, row, operation); } } @@ -1426,7 +1446,7 @@ private List findPendingAccounting(int limit) throws SQLExce new AccountingRow(result.getString(2), result.getInt(3), result.getInt(4), result.getString(5), nullableInteger(result, 6), nullableInteger(result, 7), nullableLong(result, 8), nullableInteger(result, 9), nullableInteger(result, 10), nullableInteger(result, 11), - nullableInteger(result, 12), result.getInt(13) != 0))); + nullableInteger(result, 12), result.getString(13), result.getInt(14) != 0))); } } return pending; @@ -1506,6 +1526,7 @@ private void ensureAccountingSchema(Connection connection) throws SQLException { + qi("streak_value") + " INT NULL, " + qi("streak_updated_at") + " BIGINT NULL, " + qi("streak_force_proxy") + " INT NULL, " + qi("streak_applied_value") + " INT NULL, " + qi("point_amount") + " INT NULL, " + qi("point_cap") + " INT NULL, " + + qi("point_column") + " VARCHAR(128) NULL, " + qi("replay_unsafe") + " INT NOT NULL DEFAULT 0, " + qi("created_at") + " BIGINT NOT NULL, PRIMARY KEY (" + qi("vote_id") + "));"; @@ -1522,6 +1543,7 @@ private void ensureAccountingSchema(Connection connection) throws SQLException { ensureAccountingColumn(connection, "streak_applied_value", "INT NULL"); ensureAccountingColumn(connection, "point_amount", "INT NULL"); ensureAccountingColumn(connection, "point_cap", "INT NULL"); + ensureAccountingColumn(connection, "point_column", "VARCHAR(128) NULL"); ensureAccountingColumn(connection, "replay_unsafe", "INT NOT NULL DEFAULT 0"); try (PreparedStatement migrate = connection.prepareStatement("UPDATE " + qiAccounting() + " SET " + qi("requested") + " = " + qi("completed") + " WHERE " + qi("requested") + " = 0 AND " @@ -1651,7 +1673,8 @@ private record EpochRow(long epoch, String lastResetGeneration) { private record AccountingRow(String uuid, int requested, int completed, String monthColumn, Integer monthMaximum, Integer streakValue, Long streakUpdatedAt, Integer streakForceProxy, - Integer streakAppliedValue, Integer pointAmount, Integer pointCap, boolean replayUnsafe) { + Integer streakAppliedValue, Integer pointAmount, Integer pointCap, String pointColumn, + boolean replayUnsafe) { } record PeriodTotalResult(boolean applied, List columns) { } 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 06572c51f3..ebd279af25 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 @@ -54,7 +54,7 @@ public class VoteShopPurchaseService { public enum MysqlDailyStreakResult { APPLIED, ALREADY_UPDATED, NOT_REQUESTED, DEFERRED, FAILED } public record MysqlDailyStreakUpdate(MysqlDailyStreakResult result, int streak, boolean forceProxyRouting) { } public record VoteAccountingAdmission(boolean success, boolean countTotals, boolean awardPoints, - boolean countVoteParty, int pointAmount, int pointCap, boolean replayUnsafe) { } + boolean countVoteParty, int pointAmount, int pointCap, String pointColumn, boolean replayUnsafe) { } private static final ConcurrentMap ADMITTED_ACCOUNTING = new ConcurrentHashMap<>(); private static final int ACCOUNTING_DAILY_STREAK = 16; private static final int ACCOUNTING_POINTS = 256; @@ -666,10 +666,10 @@ public static boolean incrementMysqlPeriodTotals(VotingPluginMain plugin, UUID v /** Durably admits shared total mutations before vote rewards or broadcasts run. */ public static VoteAccountingAdmission prepareMysqlVoteAccounting(VotingPluginMain plugin, UUID voteId, String uuid, boolean countTotals, boolean awardPoints, boolean countVoteParty, boolean forceProxyRouting, - int pointAmount, int pointCap) { + int pointAmount, int pointCap, String pointColumn) { if (!canRecoverSharedMysqlPurchases(plugin) || voteId == null) { return new VoteAccountingAdmission(true, countTotals, awardPoints, countVoteParty, pointAmount, pointCap, - voteId != null && plugin.getServerData().isVoteReplayUnsafe(voteId)); + pointColumn, voteId != null && plugin.getServerData().isVoteReplayUnsafe(voteId)); } try { MySQL table = plugin.getMysql(); @@ -696,17 +696,18 @@ public static VoteAccountingAdmission prepareMysqlVoteAccounting(VotingPluginMai plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage(), plugin.getSpecialRewardsConfig().getVoteStreakRequirementDay(), plugin.getVoteSiteManager().getVoteSitesEnabled().size(), forceProxyRouting, - System.currentTimeMillis(), pointAmount, pointCap); + System.currentTimeMillis(), pointAmount, pointCap, pointColumn); int bits = decision.bits(); + if ((bits & ACCOUNTING_POINTS) != 0) table.checkColumn(decision.pointColumn(), DataType.INTEGER); ADMITTED_ACCOUNTING.put(voteId, Integer.valueOf(bits)); return new VoteAccountingAdmission(true, (bits & 7) != 0, (bits & ACCOUNTING_POINTS) != 0, (bits & 8) != 0, decision.pointAmount(), - decision.pointCap(), decision.replayUnsafe()); + decision.pointCap(), decision.pointColumn(), decision.replayUnsafe()); } catch (SQLException failure) { plugin.getLogger().severe("Unable to admit shared MySQL vote accounting: " + failure.getClass().getSimpleName()); plugin.debug(failure); - return new VoteAccountingAdmission(false, false, false, false, pointAmount, pointCap, false); + return new VoteAccountingAdmission(false, false, false, false, pointAmount, pointCap, pointColumn, false); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java index 4e74d0fc9a..02ed0cba90 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java @@ -7,6 +7,7 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; @@ -46,7 +47,7 @@ private SharedVoteProcessor.Operations accepted() { when(ops.prepareAccounting(eq(user), any(UUID.class), anyBoolean(), anyBoolean(), anyInt(), anyInt())) .thenAnswer(invocation -> new SharedVoteProcessor.AccountingAdmission( invocation.getArgument(2), invocation.getArgument(3), true, - invocation.getArgument(4), invocation.getArgument(5), false)); + invocation.getArgument(4), invocation.getArgument(5), "Points", false)); return ops; } @@ -84,7 +85,7 @@ void onlineVoteKeepsProductionPhaseOrderAndCounts() { order.verify(ops).addTotal(eq(user), any(UUID.class)); order.verify(ops).addTotalDaily(eq(user), any(UUID.class)); order.verify(ops).addTotalWeekly(eq(user), any(UUID.class)); - order.verify(ops).addPoints(user, voteId, 3, 100); + order.verify(ops).addPoints(user, voteId, 3, 100, "Points"); order.verify(ops).checkDayVoteStreak(eq(user), eq(false), any(UUID.class)); order.verify(ops).setMonthTotal(user, 2); order.verify(ops).milestones(eq(user), any(UUID.class), eq(false)); @@ -145,7 +146,7 @@ void replayUsesPersistedAccountingEligibilityInsteadOfTheCurrentProposal() { var ops = accepted(); when(ops.addTotals()).thenReturn(false); when(ops.prepareAccounting(eq(user), any(UUID.class), eq(false), eq(false), eq(3), eq(100))) - .thenReturn(new SharedVoteProcessor.AccountingAdmission(true, true, false, 7, 50, false)); + .thenReturn(new SharedVoteProcessor.AccountingAdmission(true, true, false, 7, 50, "Points", false)); SharedVoteProcessor.process(ops); @@ -160,11 +161,11 @@ void replayUsesPersistedPointsEligibilityWhenTotalsWereNotAdmitted() { var ops = accepted(); when(ops.addTotals()).thenReturn(false); when(ops.prepareAccounting(eq(user), any(UUID.class), eq(false), eq(false), eq(3), eq(100))) - .thenReturn(new SharedVoteProcessor.AccountingAdmission(false, true, false, 7, 50, false)); + .thenReturn(new SharedVoteProcessor.AccountingAdmission(false, true, false, 7, 50, "Points", false)); SharedVoteProcessor.process(ops); - verify(ops).addPoints(eq(user), any(UUID.class), eq(7), eq(50)); + verify(ops).addPoints(eq(user), any(UUID.class), eq(7), eq(50), eq("Points")); verify(ops, never()).addTotal(eq(user), any(UUID.class)); } @@ -174,7 +175,7 @@ void recoveredReplayUnsafeVoteStopsBeforeAnyEffect() { UUID voteId = UUID.randomUUID(); when(ops.incomingVoteId()).thenReturn(voteId); when(ops.prepareAccounting(eq(user), eq(voteId), anyBoolean(), anyBoolean(), anyInt(), anyInt())) - .thenReturn(new SharedVoteProcessor.AccountingAdmission(true, true, true, 3, 100, true)); + .thenReturn(new SharedVoteProcessor.AccountingAdmission(true, true, true, 3, 100, "Points", true)); assertThrows(SharedVoteReplayUnsafeException.class, () -> SharedVoteProcessor.process(ops)); @@ -205,7 +206,7 @@ void proxyVoteUsesHistoricalOnlineForDeliveryButCurrentOnlineForTotals() { verify(ops).playerVote(user, site, true, false); verify(ops, never()).addOfflineVote(any(), any()); verify(ops, never()).addTotal(eq(user), any(UUID.class)); - verify(ops).addPoints(eq(user), any(UUID.class), anyInt(), anyInt()); + verify(ops).addPoints(eq(user), any(UUID.class), anyInt(), anyInt(), anyString()); ArgumentCaptor id = ArgumentCaptor.forClass(UUID.class); verify(ops).postVote(eq(site), eq(user), eq("Ben"), eq(321L), id.capture(), eq(false)); assertEquals(proxyId, id.getValue()); @@ -264,7 +265,7 @@ void offlineVoteQueuesBeforeAccountingAndMarksPostEventCached() { InOrder order = inOrder(ops); order.verify(ops).addOfflineVote(user, "ExampleKey"); - order.verify(ops).addPoints(eq(user), any(UUID.class), anyInt(), anyInt()); + order.verify(ops).addPoints(eq(user), any(UUID.class), anyInt(), anyInt(), anyString()); order.verify(ops).postVote(eq(site), eq(user), eq("Ben"), eq(456L), any(UUID.class), eq(true)); verify(ops, never()).playerVote(any(), any(), eq(false), eq(false)); verify(ops, never()).addTotal(eq(user), any(UUID.class)); @@ -308,6 +309,6 @@ void offlineQueueLimitPreservesUncachedPostEvent() { verify(ops, never()).addOfflineVote(any(), any()); verify(ops).postVote(eq(site), eq(user), eq("Ben"), eq(457L), any(UUID.class), eq(false)); - verify(ops).addPoints(eq(user), any(UUID.class), anyInt(), anyInt()); + verify(ops).addPoints(eq(user), any(UUID.class), anyInt(), anyInt(), anyString()); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java index 86156a8584..eab7809e9b 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java @@ -117,6 +117,25 @@ void firstPerServerColumnCreditTreatsNullAsZero() throws Exception { .contains("SET `hub_Points` = COALESCE(`hub_Points`, 0) + ?")); } + @Test + void cappedVoteCreditAppliesTheAdditionAndLimitInOneUpdate() throws Exception { + Fixture fixture = fixture(); + Connection missing = missingLookup(); + Attempt credit = successfulAttempt(10); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(missing, credit.connection()); + + assertEquals(10, new SharedPointAdditionJournal(fixture.table, false) + .addCapped("vote-points", "player", "hub_Points", 5, 10, 100L).total()); + + org.mockito.ArgumentCaptor statements = org.mockito.ArgumentCaptor.forClass(String.class); + verify(credit.connection(), times(4)).prepareStatement(statements.capture()); + assertTrue(statements.getAllValues().get(1) + .contains("SET `hub_Points` = LEAST(COALESCE(`hub_Points`, 0) + ?, ?)")); + verify(credit.credit()).setInt(1, 5); + verify(credit.credit()).setInt(2, 10); + verify(credit.credit()).setString(3, "player"); + } + @Test void completedOperationRejectsAConflictingRetryInsteadOfChangingPoints() throws Exception { Fixture fixture = fixture(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVotePointsTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVotePointsTest.java index 8d712129a8..e8ddc2f49a 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVotePointsTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVotePointsTest.java @@ -30,6 +30,7 @@ void repeatedVoteIdentityCommitsPointsAndDispatchesTheReceiveHookOnlyOnce() thro when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(false); when(plugin.getConfigFile().getPointsOnVote()).thenReturn(5); + when(plugin.getConfigFile().getLimitVotePoints()).thenReturn(10); MySQL table = mock(MySQL.class); when(plugin.getMysql()).thenReturn(table); AdvancedCoreUser base = mock(AdvancedCoreUser.class); @@ -43,8 +44,9 @@ void repeatedVoteIdentityCommitsPointsAndDispatchesTheReceiveHookOnlyOnce() thro String operationId = "vote-points:" + voteId; when(journal.findCompleted(operationId, uuid, "Points")) .thenReturn(null, new SharedPointAdditionJournal.AdditionResult(5)); - when(journal.add(org.mockito.ArgumentMatchers.eq(operationId), org.mockito.ArgumentMatchers.eq(uuid), - org.mockito.ArgumentMatchers.eq("Points"), org.mockito.ArgumentMatchers.eq(5), anyLong())) + when(journal.addCapped(org.mockito.ArgumentMatchers.eq(operationId), org.mockito.ArgumentMatchers.eq(uuid), + org.mockito.ArgumentMatchers.eq("Points"), org.mockito.ArgumentMatchers.eq(5), + org.mockito.ArgumentMatchers.eq(10), anyLong())) .thenReturn(new SharedPointAdditionJournal.AdditionResult(5)); PluginManager pluginManager = mock(PluginManager.class); @@ -57,9 +59,9 @@ void repeatedVoteIdentityCommitsPointsAndDispatchesTheReceiveHookOnlyOnce() thro user.addVotePoints(voteId); } - verify(journal).add(org.mockito.ArgumentMatchers.eq(operationId), + verify(journal).addCapped(org.mockito.ArgumentMatchers.eq(operationId), org.mockito.ArgumentMatchers.eq(uuid), org.mockito.ArgumentMatchers.eq("Points"), - org.mockito.ArgumentMatchers.eq(5), anyLong()); + org.mockito.ArgumentMatchers.eq(5), org.mockito.ArgumentMatchers.eq(10), anyLong()); verify(journal, org.mockito.Mockito.never()).acknowledge( org.mockito.ArgumentMatchers.eq(operationId), anyLong()); verify(pluginManager).callEvent(isA(PlayerReceivePointsEvent.class)); 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 index 75f4e912bc..1f3759e423 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -119,14 +119,16 @@ void repeatedVoteAdmissionReturnsThePersistedPointsDecision() throws Exception { SharedMysqlPurchaseJournal.VoteAccountingDecision decision = new SharedMysqlPurchaseJournal(fixture.table, false) .prepareVoteAccounting( UUID.randomUUID(), "00000000-0000-0000-0000-000000000001", false, false, false, - null, null, false, 0.0, 1, false, 1234L, 2, 10); + null, null, false, 0.0, 1, false, 1234L, 2, 10, "current_Points"); assertEquals(256, decision.bits()); assertEquals(9, decision.pointAmount()); assertEquals(40, decision.pointCap()); + assertEquals("persisted_Points", decision.pointColumn()); assertEquals(true, decision.replayUnsafe()); verify(accountingUpdate).setInt(2, 320); verify(accountingUpdate).setInt(3, 320); + verify(accountingUpdate).setString(11, "persisted_Points"); verify(fixture.work).commit(); } @@ -1252,11 +1254,16 @@ private static ResultSet pendingRow() throws Exception { } private static ResultSet accountingRow(String uuid, int requested, int completed) throws Exception { - return accountingRow(uuid, requested, completed, null, null, false); + return accountingRow(uuid, requested, completed, null, null, null, false); } private static ResultSet accountingRow(String uuid, int requested, int completed, Integer points, Integer cap, boolean replayUnsafe) throws Exception { + return accountingRow(uuid, requested, completed, points, cap, "persisted_Points", replayUnsafe); + } + + private static ResultSet accountingRow(String uuid, int requested, int completed, Integer points, Integer cap, + String pointColumn, boolean replayUnsafe) throws Exception { ResultSet row = mock(ResultSet.class); when(row.next()).thenReturn(true); when(row.getString(1)).thenReturn(uuid); @@ -1264,7 +1271,8 @@ private static ResultSet accountingRow(String uuid, int requested, int completed when(row.getInt(3)).thenReturn(completed); when(row.getObject(10)).thenReturn(points); when(row.getObject(11)).thenReturn(cap); - when(row.getInt(12)).thenReturn(replayUnsafe ? 1 : 0); + when(row.getString(12)).thenReturn(pointColumn); + when(row.getInt(13)).thenReturn(replayUnsafe ? 1 : 0); return row; } From de0fd9b4aab6ea81d965a90f3b712b9a59ef9d0e Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:13:42 -0600 Subject: [PATCH 44/61] Persist ambiguous vote recovery safely --- .../votingplugin/VotingPluginMain.java | 2 +- .../listeners/VotifierVoteOverflowQueue.java | 57 ++++++++++++++++--- .../service/VoteShopPurchaseService.java | 7 +++ .../VotifierVoteOverflowQueueTest.java | 31 ++++++++++ .../service/VoteShopPurchaseServiceTest.java | 19 +++++++ 5 files changed, 108 insertions(+), 8 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 4ebdad5fc4..69beddb333 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -724,12 +724,12 @@ public void onPostLoad() { loadVoteBroadcast(); + specialRewards = new SpecialRewards(this); loadVoteShopManager(); loadDirectlyDefined(); checkUpdate = new CheckUpdate(this); checkUpdate.startUp(); - specialRewards = new SpecialRewards(this); signs = new Signs(this); coolDownCheck.checkEnabled(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java index 446af21774..28b79b3b49 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java @@ -59,6 +59,7 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable { private boolean closed; private long stateVersion; private long durableVersion; + private long persistedSnapshotVersion; /** * Creates and loads the overflow queue. @@ -123,18 +124,55 @@ public boolean enqueue(String username, String serviceSite, UUID voteId) { } } - /** Durably retains an ambiguous vote without automatically replaying it. */ + /** + * Durably retains an ambiguous vote without automatically replaying it. + * Success is reported only after the quarantine snapshot has been forced and + * replaced on disk. + */ public boolean quarantine(String username, String serviceSite, UUID voteId) { if (username == null || serviceSite == null || voteId == null) return false; + PendingVote pending = new PendingVote(username, serviceSite, System.currentTimeMillis(), voteId); + pending.quarantined = true; synchronized (lock) { if (closed || entries.size() >= MAX_ENTRIES) return false; - PendingVote pending = new PendingVote(username, serviceSite, System.currentTimeMillis(), voteId); - pending.quarantined = true; entries.addLast(pending); stateVersion++; - requestPersistenceLocked(); - return true; } + + List snapshot; + long snapshotVersion; + try { + synchronized (persistenceWriteLock) { + // Snapshot after obtaining the write lock so an older asynchronous + // snapshot cannot overtake this confirmed quarantine write. + synchronized (lock) { + if (closed) return false; + snapshot = new ArrayList<>(entries); + snapshotVersion = stateVersion; + } + writeSnapshotLocked(snapshot); + persistedSnapshotVersion = Math.max(persistedSnapshotVersion, snapshotVersion); + } + } catch (IOException failure) { + plugin.getLogger().warning("Unable to persist quarantined Votifier vote: " + + failure.getClass().getSimpleName()); + synchronized (lock) { + if (!closed) requestPersistenceLocked(); + } + return false; + } + + List completedRetirements = new ArrayList<>(); + synchronized (lock) { + durableVersion = Math.max(durableVersion, snapshotVersion); + while (!retiredVotes.isEmpty() && retiredVotes.peekFirst().version <= durableVersion) { + completedRetirements.add(retiredVotes.removeFirst().voteId); + } + if (stateVersion != snapshotVersion) requestPersistenceLocked(); + scheduleDrainLocked(); + } + for (UUID completedVoteId : completedRetirements) completeDelivery(completedVoteId); + return true; } /** @@ -268,7 +306,7 @@ private void persistLoop() { snapshotVersion = stateVersion; } try { - writeSnapshot(snapshot); + writeSnapshot(snapshot, snapshotVersion); } catch (IOException failure) { plugin.getLogger().warning("Unable to persist queued Votifier votes: " + failure.getClass().getSimpleName()); synchronized (lock) { @@ -367,10 +405,15 @@ private String readQueueFile() throws IOException { } } - private void writeSnapshot(List snapshot) throws IOException { + private void writeSnapshot(List snapshot, long snapshotVersion) throws IOException { synchronized (persistenceWriteLock) { if (closed) return; + // A confirmed synchronous quarantine snapshot may overtake a worker + // snapshot that was captured earlier. Never let that stale worker write + // replace the newer durable state afterward. + if (snapshotVersion < persistedSnapshotVersion) return; writeSnapshotLocked(snapshot); + persistedSnapshotVersion = snapshotVersion; } } 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 ebd279af25..8a8ba08d04 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 @@ -913,6 +913,13 @@ static void recoverSharedMysqlPurchases(VotingPluginMain plugin, SharedMysqlPurc accounting = journal.recoverAccounting(System.currentTimeMillis()); boolean deferredReward = false; for (UUID voteId : accounting.pendingRewards()) { + // Startup recovery begins asynchronously. Do not claim a durable reward + // until the service that executes it is ready, because claimed rows are + // intentionally excluded from later recovery scans. + if (plugin.getSpecialRewards() == null) { + deferredReward = true; + break; + } SharedMysqlPurchaseJournal.RecoveredDailyStreak streak = journal.claimDailyStreakReward(voteId); if (streak == null) { deferredReward = true; diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java index 5c1c4552da..2a8245619c 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java @@ -129,6 +129,37 @@ void ambiguousAttemptIsDurablyQuarantinedAcrossRestart(@TempDir Path dataFolder) } } + @Test + void directQuarantineReturnsOnlyAfterItsSnapshotIsDurable(@TempDir Path dataFolder) throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + when(plugin.getDataFolder()).thenReturn(dataFolder.toFile()); + when(plugin.getVoteTimer()).thenReturn(mock(ScheduledExecutorService.class)); + VotifierVoteOverflowQueue queue = new VotifierVoteOverflowQueue(plugin, (site, user) -> { }); + java.lang.reflect.Field writeLockField = VotifierVoteOverflowQueue.class + .getDeclaredField("persistenceWriteLock"); + writeLockField.setAccessible(true); + Object writeLock = writeLockField.get(queue); + java.util.concurrent.ExecutorService caller = Executors.newSingleThreadExecutor(); + java.util.UUID voteId = java.util.UUID.randomUUID(); + java.util.concurrent.Future stored; + try { + synchronized (writeLock) { + stored = caller.submit(() -> queue.quarantine("Steve", "example.org", voteId)); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (queue.size() == 0 && System.nanoTime() < deadline) Thread.onSpinWait(); + assertEquals(1, queue.size()); + assertThrows(TimeoutException.class, () -> stored.get(100, TimeUnit.MILLISECONDS)); + } + assertTrue(stored.get(2, TimeUnit.SECONDS)); + String persisted = Files.readString(dataFolder.resolve("VotifierVoteQueue.yml")); + assertTrue(persisted.contains(voteId.toString())); + assertTrue(persisted.contains("Quarantined: true")); + } finally { + caller.shutdownNow(); + queue.close(); + } + } + @Test void enqueueCannotChangeVersionDuringDurableAdmission(@TempDir Path dataFolder) throws Exception { VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); 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 index 8f13af6e56..6b49353efd 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -988,6 +988,7 @@ void failedCompensationMarkerIsRetriedByPeriodicRecovery(@TempDir Path temporary @Test void recoveredDailyStreakRunsRewardOnlyAfterThePersistedIncrement(@TempDir Path temporaryDirectory) throws Exception { VotingPluginMain plugin = mockPluginForCompensation(temporaryDirectory); + when(plugin.getSpecialRewards()).thenReturn(mock(com.bencodez.votingplugin.specialrewards.SpecialRewards.class)); SharedMysqlPurchaseJournal journal = mock(SharedMysqlPurchaseJournal.class); VotingPluginUser user = mock(VotingPluginUser.class); java.util.UUID voteId = java.util.UUID.randomUUID(); @@ -1011,6 +1012,7 @@ void recoveredDailyStreakRunsRewardOnlyAfterThePersistedIncrement(@TempDir Path @Test void deferredDailyStreakRecoveryYieldsUntilTheNextRecoveryPass(@TempDir Path temporaryDirectory) throws Exception { VotingPluginMain plugin = mockPluginForCompensation(temporaryDirectory); + when(plugin.getSpecialRewards()).thenReturn(mock(com.bencodez.votingplugin.specialrewards.SpecialRewards.class)); SharedMysqlPurchaseJournal journal = mock(SharedMysqlPurchaseJournal.class); java.util.UUID voteId = java.util.UUID.randomUUID(); when(journal.recoverAccounting(anyLong())).thenReturn( @@ -1025,6 +1027,23 @@ void deferredDailyStreakRecoveryYieldsUntilTheNextRecoveryPass(@TempDir Path tem verify(journal).recoverAndCleanup(anyLong()); } + @Test + void dailyStreakRecoveryDoesNotClaimRewardBeforeRewardServiceIsReady(@TempDir Path temporaryDirectory) + throws Exception { + VotingPluginMain plugin = mockPluginForCompensation(temporaryDirectory); + when(plugin.getSpecialRewards()).thenReturn(null); + SharedMysqlPurchaseJournal journal = mock(SharedMysqlPurchaseJournal.class); + java.util.UUID voteId = java.util.UUID.randomUUID(); + when(journal.recoverAccounting(anyLong())).thenReturn( + new SharedMysqlPurchaseJournal.AccountingRecoveryBatch(true, java.util.List.of(voteId))); + when(journal.recoverAndCleanup(anyLong())).thenReturn(java.util.List.of()); + + VoteShopPurchaseService.recoverSharedMysqlPurchases(plugin, journal); + + verify(journal, never()).claimDailyStreakReward(any()); + verify(journal).recoverAndCleanup(anyLong()); + } + private static VotingPluginMain mockPluginForCompensation(Path dataDirectory) { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); when(plugin.getDataFolder()).thenReturn(dataDirectory.toFile()); From a0dd95a3472bfeefe80eda2c6cdcc69c0c39544f Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:29:59 -0600 Subject: [PATCH 45/61] Preserve durable vote and rolling period handoffs --- .../global/BackendGlobalDataSync.java | 32 ++++++---- .../votingplugin/listeners/VotiferEvent.java | 4 +- .../votingplugin/proxy/VotingPluginProxy.java | 60 +++++++++++++------ .../votingplugin/proxy/VotingPluginWire.java | 13 ++++ .../timequeue/TimeQueueHandler.java | 43 +++++++++---- .../global/BackendGlobalDataSyncTest.java | 38 ++++++++++++ .../tests/VotingPluginProxyTest.java | 26 ++++++++ .../tests/VotingPluginWireTest.java | 8 +++ .../VotiferEventDisabledVoteSiteTest.java | 18 +++++- .../TimeQueueHandlerRejectionTest.java | 26 ++++++++ 10 files changed, 227 insertions(+), 41 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSync.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSync.java index 07812406f7..27f127a4d1 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSync.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSync.java @@ -116,11 +116,8 @@ public boolean checkGlobalDataTime(TimeType type, HashMap dat } String transitionId = data.containsKey(VotingPluginWire.timeChangeTransitionKey(type.toString())) ? data.get(VotingPluginWire.timeChangeTransitionKey(type.toString())).getString() : ""; - if (transitionId == null || transitionId.isBlank()) { - timeChangesInProgress.remove(type); - plugin.getLogger().warning("Ignoring bungee time change without a transition identity: " + type); - return false; - } + boolean confirmsBoundary = transitionId != null && !transitionId.isBlank() + && !VotingPluginWire.LEGACY_TIME_CHANGE_TRANSITION.equals(transitionId); String serverName = plugin.getBungeeSettings().getServer(); globalDataHandler.setBoolean(serverName, "Processing", true); @@ -136,7 +133,7 @@ public boolean checkGlobalDataTime(TimeType type, HashMap dat } try { plugin.getBukkitScheduler().runTaskAsynchronously(plugin, - () -> finishTimeChange(type, serverName, transitionId)); + () -> finishTimeChange(type, serverName, transitionId, confirmsBoundary)); } catch (RuntimeException failure) { timeChangesInProgress.remove(type); plugin.debug(failure); @@ -150,13 +147,15 @@ public boolean checkGlobalDataTime(TimeType type, HashMap dat return true; } - private void finishTimeChange(TimeType type, String serverName, String transitionId) { + private void finishTimeChange(TimeType type, String serverName, String transitionId, boolean confirmsBoundary) { boolean completed = false; try { HashMap completion = new HashMap<>(); completion.put(type.toString(), new DataValueBoolean(false)); - completion.put(VotingPluginWire.timeChangeBoundaryCapturedKey(type.toString()), - new DataValueString(transitionId)); + if (confirmsBoundary) { + completion.put(VotingPluginWire.timeChangeBoundaryCapturedKey(type.toString()), + new DataValueString(transitionId)); + } globalDataHandler.setData(serverName, completion); JsonEnvelope.Builder builder = JsonEnvelope.builder("TimeChangeFinished") .schema(VotingPluginWire.SCHEMA_VERSION); @@ -199,8 +198,7 @@ public void load() { timer.scheduleWithFixedDelay(this::checkGlobalData, 60, 10, TimeUnit.SECONDS); timer.scheduleWithFixedDelay(() -> { if (globalDataHandler != null) { - globalDataHandler.setString(plugin.getBungeeSettings().getServer(), "LastOnline", - "" + LocalDateTime.now().atZone(ZoneOffset.UTC).toInstant().toEpochMilli()); + publishBoundaryProtocolHeartbeat(); } }, 1, 60, TimeUnit.MINUTES); @@ -243,9 +241,21 @@ public void load() { globalDataHandler.getGlobalMysql().alterColumnType( VotingPluginWire.timeChangeTransitionKey(type.toString()), "VARCHAR(36)"); } + globalDataHandler.getGlobalMysql().alterColumnType( + VotingPluginWire.TIME_CHANGE_BOUNDARY_PROTOCOL_KEY, "VARCHAR(32)"); + publishBoundaryProtocolHeartbeat(); plugin.getTimeChecker().setProcessingEnabled(false); } + private void publishBoundaryProtocolHeartbeat() { + String heartbeat = "" + LocalDateTime.now().atZone(ZoneOffset.UTC).toInstant().toEpochMilli(); + HashMap state = new HashMap<>(); + state.put("LastOnline", new DataValueString(heartbeat)); + state.put(VotingPluginWire.TIME_CHANGE_BOUNDARY_PROTOCOL_KEY, + new DataValueString(VotingPluginWire.timeChangeBoundaryProtocolHeartbeat(heartbeat))); + globalDataHandler.setData(plugin.getBungeeSettings().getServer(), state); + } + public void close() { shutdownTimer(); closeGlobalMysql(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java index 01999ea9c7..4c091f92e0 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java @@ -105,8 +105,8 @@ private VoteOutcome processVoteAttempt(String voteSite, String voteUsername, UUI if (plugin.getTimeChecker().isActiveProcessing() && plugin.getConfigFile().isQueueVotesDuringTimeChange()) { plugin.debug("Adding vote to time queue " + voteUsername + "/" + voteSite); - plugin.getTimeQueueHandler().addVote(voteId, voteUsername, voteSite); - return VoteOutcome.COMPLETE; + return plugin.getTimeQueueHandler().addVoteDurably(voteId, voteUsername, voteSite) + ? VoteOutcome.COMPLETE : VoteOutcome.RETRY; } String voteSiteName = plugin.getVoteSiteManager().getVoteSiteName(true, serviceSite, matchSite); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java index 64724f47db..b7a1da6eca 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java @@ -429,12 +429,12 @@ public void timeChanged(TimeType type, boolean fake, boolean pre, boolean post) return; } String transitionId = UUID.randomUUID().toString(); - int delay = 1; + List activeServers = new ArrayList<>(); + boolean allSupportBoundaryProtocol = true; for (String s : getAllAvailableServers()) { if (getGlobalDataHandler().getGlobalMysql().containsKey(s)) { HashMap boundary = new HashMap<>(); - boundary.put(VotingPluginWire.timeChangeTransitionKey(type.toString()), - new DataValueString(transitionId)); + boundary.put(VotingPluginWire.timeChangeTransitionKey(type.toString()), new DataValueString("")); boundary.put(VotingPluginWire.timeChangeBoundaryCapturedKey(type.toString()), new DataValueString("")); getGlobalDataHandler().setData(s, boundary); @@ -448,15 +448,10 @@ public void timeChanged(TimeType type, boolean fake, boolean pre, boolean post) if (LocalDateTime.now().atZone(ZoneOffset.UTC).toInstant().toEpochMilli() - lastOnline < 1000 * 60 * 60 * 12) { - HashMap dataToSet = new HashMap<>(); - dataToSet.put("LastUpdated", new DataValueString( - "" + LocalDateTime.now().atZone(ZoneOffset.UTC).toInstant().toEpochMilli())); - dataToSet.put("FinishedProcessing", new DataValueBoolean(false)); - dataToSet.put(type.toString(), new DataValueBoolean(true)); - getGlobalDataHandler().setData(s, dataToSet); - - globalMessageProxyHandler.sendMessage(s, delay, VotingPluginWire.bungeeTimeChange()); - delay++; + activeServers.add(s); + allSupportBoundaryProtocol &= VotingPluginWire.supportsTimeChangeBoundaryProtocol( + lastOnlineStr, getGlobalDataHandler().getString(s, + VotingPluginWire.TIME_CHANGE_BOUNDARY_PROTOCOL_KEY)); } else { warn("Server " + s + " hasn't been online recently"); } @@ -464,6 +459,20 @@ public void timeChanged(TimeType type, boolean fake, boolean pre, boolean post) warn("Server " + s + " global data handler disabled?"); } } + String publishedTransition = allSupportBoundaryProtocol ? transitionId + : VotingPluginWire.LEGACY_TIME_CHANGE_TRANSITION; + int delay = 1; + for (String server : activeServers) { + HashMap dataToSet = new HashMap<>(); + dataToSet.put(VotingPluginWire.timeChangeTransitionKey(type.toString()), + new DataValueString(publishedTransition)); + dataToSet.put("LastUpdated", new DataValueString( + "" + LocalDateTime.now().atZone(ZoneOffset.UTC).toInstant().toEpochMilli())); + dataToSet.put("FinishedProcessing", new DataValueBoolean(false)); + dataToSet.put(type.toString(), new DataValueBoolean(true)); + getGlobalDataHandler().setData(server, dataToSet); + globalMessageProxyHandler.sendMessage(server, delay++, VotingPluginWire.bungeeTimeChange()); + } globalDataHandler.onTimeChange(type); } @@ -475,25 +484,40 @@ public void warning(String text) { } public void onTimeChangedFailed(String srv, TimeType type) { - getGlobalDataHandler().setBoolean(srv, type.toString(), false); - getGlobalDataHandler().setBoolean(srv, "FinishedProcessing", true); - getGlobalDataHandler().setBoolean(srv, "Processing", false); + HashMap failed = new HashMap<>(); + failed.put(type.toString(), new DataValueBoolean(false)); + failed.put("FinishedProcessing", new DataValueBoolean(true)); + failed.put("Processing", new DataValueBoolean(false)); + // A participant that failed did not prove either the negotiated boundary + // or the legacy reset path. Remove its marker before aggregate completion. + failed.put(VotingPluginWire.timeChangeTransitionKey(type.toString()), new DataValueString("")); + failed.put(VotingPluginWire.timeChangeBoundaryCapturedKey(type.toString()), new DataValueString("")); + getGlobalDataHandler().setData(srv, failed); } public void onTimeChangedFinished(TimeType type) { boolean boundaryCaptured = false; + boolean legacyBoundary = false; String boundaryCapturedKey = VotingPluginWire.timeChangeBoundaryCapturedKey(type.toString()); String transitionKey = VotingPluginWire.timeChangeTransitionKey(type.toString()); for (String server : getAllAvailableServers()) { if (!getGlobalDataHandler().getGlobalMysql().containsKey(server)) continue; String expected = getGlobalDataHandler().getString(server, transitionKey); String captured = getGlobalDataHandler().getString(server, boundaryCapturedKey); + if (VotingPluginWire.LEGACY_TIME_CHANGE_TRANSITION.equals(expected)) { + legacyBoundary = true; + continue; + } if (expected != null && !expected.isBlank() && expected.equals(captured)) { boundaryCaptured = true; - break; } } - if (boundaryCaptured) { + if (legacyBoundary) { + if (type.equals(TimeType.MONTH)) { + getProxyMySQL().copyColumnData(TopVoter.Monthly.getColumnName(), "LastMonthTotal"); + } + getProxyMySQL().wipeColumnData(TopVoter.of(type).getColumnName(), DataType.INTEGER); + } else if (boundaryCaptured) { getProxyMySQL().wipeColumnData(TopVoter.of(type).getColumnName(), DataType.INTEGER); } else { warn("Retaining " + TopVoter.of(type).getColumnName() @@ -657,6 +681,8 @@ public void onTimeChangedFinished(TimeType type) { getGlobalDataHandler().getGlobalMysql().alterColumnType( VotingPluginWire.timeChangeTransitionKey(type.toString()), "VARCHAR(36)"); } + getGlobalDataHandler().getGlobalMysql().alterColumnType( + VotingPluginWire.TIME_CHANGE_BOUNDARY_PROTOCOL_KEY, "VARCHAR(32)"); } // column types (unchanged from original) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java index 8759916737..2b8edcee48 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java @@ -41,6 +41,19 @@ public static String timeChangeTransitionKey(String type) { return "BoundaryTransition" + type; } + public static final String TIME_CHANGE_BOUNDARY_PROTOCOL_KEY = "BoundaryProtocol"; + public static final String TIME_CHANGE_BOUNDARY_PROTOCOL_VERSION = "1"; + public static final String LEGACY_TIME_CHANGE_TRANSITION = "LEGACY"; + + public static String timeChangeBoundaryProtocolHeartbeat(String lastOnline) { + return TIME_CHANGE_BOUNDARY_PROTOCOL_VERSION + ":" + lastOnline; + } + + public static boolean supportsTimeChangeBoundaryProtocol(String lastOnline, String advertisedProtocol) { + return lastOnline != null && !lastOnline.isBlank() + && timeChangeBoundaryProtocolHeartbeat(lastOnline).equals(advertisedProtocol); + } + public static final int SCHEMA_VERSION = 1; // ========================= diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java index 0ff32b98bb..3602ddab41 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java @@ -32,6 +32,7 @@ public class TimeQueueHandler implements Listener { private static final long TICKS_PER_SECOND = 20L; private final Deque timeChangeQueue = new ConcurrentLinkedDeque<>(); + private final Object queuePersistenceLock = new Object(); private VotingPluginMain plugin; private final AtomicBoolean retryPending = new AtomicBoolean(); @@ -68,6 +69,24 @@ public void addVote(UUID voteId, String voteUsername, String voteSiteName) { LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); } + /** Adds and persists a vote before its previous durable owner may acknowledge it. */ + public boolean addVoteDurably(UUID voteId, String voteUsername, String voteSiteName) { + VoteTimeQueue vote = new VoteTimeQueue(voteId, voteUsername, voteSiteName, + LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); + synchronized (queuePersistenceLock) { + timeChangeQueue.add(vote); + try { + plugin.getServerData().replaceTimedVoteCache(new ArrayList<>(timeChangeQueue)); + return true; + } catch (RuntimeException persistenceFailure) { + timeChangeQueue.remove(vote); + plugin.getLogger().severe("Unable to persist a vote handed to the time-change queue"); + plugin.debug(persistenceFailure); + return false; + } + } + } + /** * Loads cached votes from server data and schedules queue processing. */ @@ -165,13 +184,15 @@ private void scheduleRetry(boolean persistenceRequired) { } private boolean persistQueueSnapshot() { - try { - plugin.getServerData().replaceTimedVoteCache(new ArrayList<>(timeChangeQueue)); - return true; - } catch (RuntimeException persistenceFailure) { - plugin.getLogger().severe("Unable to persist the time-queue retry; the vote remains in memory"); - plugin.debug(persistenceFailure); - return false; + synchronized (queuePersistenceLock) { + try { + plugin.getServerData().replaceTimedVoteCache(new ArrayList<>(timeChangeQueue)); + return true; + } catch (RuntimeException persistenceFailure) { + plugin.getLogger().severe("Unable to persist the time-queue retry; the vote remains in memory"); + plugin.debug(persistenceFailure); + return false; + } } } @@ -227,9 +248,11 @@ public void processQueue() { * Saves pending votes to server data. */ public void save() { - if (!timeChangeQueue.isEmpty()) { - plugin.getServerData().replaceTimedVoteCache(new ArrayList<>(timeChangeQueue)); + synchronized (queuePersistenceLock) { + if (!timeChangeQueue.isEmpty()) { + plugin.getServerData().replaceTimedVoteCache(new ArrayList<>(timeChangeQueue)); + } + timeChangeQueue.clear(); } - timeChangeQueue.clear(); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSyncTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSyncTest.java index 78b95ae7b4..c44d689560 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSyncTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSyncTest.java @@ -160,6 +160,44 @@ void bungeeTimeChangeForceChangedRunsOnTheBukkitScheduler() { values.containsKey("FinishedProcessing"))); } + @Test + void legacyProxyTimeChangeStillRunsWithoutBoundaryConfirmation() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + BungeeSettings bungeeSettings = mock(BungeeSettings.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + TimeChecker timeChecker = mock(TimeChecker.class); + GlobalDataHandler globalDataHandler = mock(GlobalDataHandler.class); + AtomicReference scheduled = new AtomicReference<>(); + AtomicReference asyncCompletion = new AtomicReference<>(); + when(plugin.getBungeeSettings()).thenReturn(bungeeSettings); + when(bungeeSettings.getServer()).thenReturn("lobby"); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.getTimeChecker()).thenReturn(timeChecker); + org.mockito.Mockito.doAnswer(invocation -> { + scheduled.set(invocation.getArgument(1)); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + org.mockito.Mockito.doAnswer(invocation -> { + asyncCompletion.set(invocation.getArgument(1)); + return null; + }).when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + BackendGlobalDataSync sync = new BackendGlobalDataSync(plugin, ignored -> { }); + setField(sync, "globalDataHandler", globalDataHandler); + HashMap data = new HashMap<>(); + data.put("LastUpdated", new DataValueString( + "" + LocalDateTime.now().atZone(ZoneOffset.UTC).toInstant().toEpochMilli())); + data.put(TimeType.DAY.toString(), new DataValueBoolean(true)); + + assertTrue(sync.checkGlobalDataTime(TimeType.DAY, data)); + scheduled.get().run(); + asyncCompletion.get().run(); + + verify(timeChecker).forceChanged(TimeType.DAY, false, true, true); + verify(globalDataHandler).setData(eq("lobby"), org.mockito.ArgumentMatchers.argThat(values -> + values.size() == 1 && values.containsKey(TimeType.DAY.toString()) + && !values.get(TimeType.DAY.toString()).getBoolean())); + } + private static void setField(Object target, String fieldName, Object value) { try { Field field = BackendGlobalDataSync.class.getDeclaredField(fieldName); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java index cf840942fa..78009682b8 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java @@ -192,6 +192,32 @@ void monthlyCompletionOnlyClearsTheBoundaryAlreadyCapturedByTheBackend() { verify(proxyMySQL, never()).copyColumnData(Mockito.anyString(), Mockito.anyString()); } + @Test + void monthlyCompletionUsesLegacyBoundaryWhenAnyBackendLacksConfirmationSupport() { + GlobalMySQL globalMysql = Mockito.mock(GlobalMySQL.class); + Mockito.when(globalDataHandler.getGlobalMysql()).thenReturn(globalMysql); + Mockito.when(globalMysql.containsKey("Server1")).thenReturn(true); + Mockito.when(globalDataHandler.getString("Server1", "BoundaryTransitionMONTH")) + .thenReturn(VotingPluginWire.LEGACY_TIME_CHANGE_TRANSITION); + + votingPluginProxy.onTimeChangedFinished(TimeType.MONTH); + + org.mockito.InOrder boundaryThenReset = Mockito.inOrder(proxyMySQL); + boundaryThenReset.verify(proxyMySQL).copyColumnData("MonthTotal", "LastMonthTotal"); + boundaryThenReset.verify(proxyMySQL).wipeColumnData("MonthTotal", + com.bencodez.simpleapi.sql.DataType.INTEGER); + } + + @Test + void failedLegacyBackendCannotAuthorizeTheProxyBoundaryReset() { + votingPluginProxy.onTimeChangedFailed("Server1", TimeType.MONTH); + + verify(globalDataHandler).setData(Mockito.eq("Server1"), Mockito.argThat(values -> + !values.get("MONTH").getBoolean() + && values.get("BoundaryTransitionMONTH").getString().isEmpty() + && values.get("BoundaryCapturedMONTH").getString().isEmpty())); + } + @Test void monthlyCompletionRetainsTotalsWhenEveryBackendFailedBeforeBoundaryCapture() { GlobalMySQL globalMysql = Mockito.mock(GlobalMySQL.class); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java index d06a326a1b..49ac8e66c2 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java @@ -19,6 +19,14 @@ * Tests proxy vote wire encoding and decoding. */ public class VotingPluginWireTest { + @Test + void boundaryProtocolCapabilityMustMatchTheCurrentBackendHeartbeat() { + assertTrue(VotingPluginWire.supportsTimeChangeBoundaryProtocol("200", + VotingPluginWire.timeChangeBoundaryProtocolHeartbeat("200"))); + assertFalse(VotingPluginWire.supportsTimeChangeBoundaryProtocol("201", + VotingPluginWire.timeChangeBoundaryProtocolHeartbeat("200"))); + assertFalse(VotingPluginWire.supportsTimeChangeBoundaryProtocol("200", "")); + } @Test public void voteRoundTripPreservesVoteId() { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java index bb57b1f4b4..852bf3d1f7 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java @@ -169,10 +169,26 @@ void timeChangeQueueReceivesTheOriginalVoteId() { when(plugin.getTimeQueueHandler()).thenReturn(timeQueue); when(plugin.getTimeChecker().isActiveProcessing()).thenReturn(true); when(plugin.getConfigFile().isQueueVotesDuringTimeChange()).thenReturn(true); + when(timeQueue.addVoteDurably(voteId, "Steve", SERVICE_SITE)).thenReturn(true); listener.processVote(SERVICE_SITE, "Steve", voteId); - verify(timeQueue).addVote(voteId, "Steve", SERVICE_SITE); + verify(timeQueue).addVoteDurably(voteId, "Steve", SERVICE_SITE); + verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class)); + } + + @Test + void queuedVoteIsRetriedWhenTimeQueueHandoffIsNotDurable() { + TimeQueueHandler timeQueue = mock(TimeQueueHandler.class); + UUID voteId = UUID.randomUUID(); + when(plugin.getTimeQueueHandler()).thenReturn(timeQueue); + when(plugin.getTimeChecker().isActiveProcessing()).thenReturn(true); + when(plugin.getConfigFile().isQueueVotesDuringTimeChange()).thenReturn(true); + + assertEquals(VotifierVoteOverflowQueue.VoteOutcome.RETRY, + listener.processQueuedVoteOutcome(SERVICE_SITE, "Steve", voteId)); + + verify(timeQueue).addVoteDurably(voteId, "Steve", SERVICE_SITE); verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class)); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java index 8d6ced4c02..c74848beb5 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java @@ -94,6 +94,32 @@ void queueAdmissionPreservesTheReceptionVoteId() { assertEquals(voteId, handler.getTimeChangeQueue().peek().getVoteId()); } + @Test + void durableAdmissionPersistsBeforeReportingSuccess() { + when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of()); + TimeQueueHandler handler = new TimeQueueHandler(plugin); + UUID voteId = UUID.randomUUID(); + + assertTrue(handler.addVoteDurably(voteId, "Alex", "example.org")); + + @SuppressWarnings("unchecked") + org.mockito.ArgumentCaptor> persisted = org.mockito.ArgumentCaptor.forClass(List.class); + verify(serverData).replaceTimedVoteCache(persisted.capture()); + assertEquals(voteId, persisted.getValue().getFirst().getVoteId()); + } + + @Test + void failedDurableAdmissionLeavesTheVoteWithItsSourceOwner() { + when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of()); + doThrow(new IllegalStateException("disk unavailable")).when(serverData).replaceTimedVoteCache(any()); + TimeQueueHandler handler = new TimeQueueHandler(plugin); + + org.junit.jupiter.api.Assertions.assertFalse( + handler.addVoteDurably(UUID.randomUUID(), "Alex", "example.org")); + + assertTrue(handler.getTimeChangeQueue().isEmpty()); + } + @Test void rejectedProcessingSchedulesOneBoundedRetry() { TimeQueueHandler handler = new TimeQueueHandler(plugin); From f77141f428c197a3792e43aecd26b2d493253c57 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:12:17 -0600 Subject: [PATCH 46/61] Preserve capped totals and Bukkit vote ownership --- .../listeners/PlayerVoteListener.java | 75 ++++++++++++++++--- .../service/SharedMysqlPurchaseJournal.java | 9 ++- .../PlayerVoteListenerAdmissionTest.java | 47 ++++++++++++ .../SharedMysqlPurchaseJournalTest.java | 9 ++- 4 files changed, 121 insertions(+), 19 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java index bed289ed66..d2fcb26895 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java @@ -37,17 +37,18 @@ public PlayerVoteListener(VotingPluginMain plugin) { @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onplayerVote(PlayerVoteEvent event) { if (!VoteTaskAdmission.isVoteTask() && Bukkit.isPrimaryThread()) { - if (!VoteTaskAdmission.trySubmit(plugin.getVoteTimer(), () -> processVote(event))) { + PlatformVoteState platformState = PlatformVoteState.capture(plugin, event.getPlayer()); + if (!VoteTaskAdmission.trySubmit(plugin.getVoteTimer(), () -> processVote(event, platformState))) { failAdmission(event, new SharedVoteAdmissionException("Vote executor rejected accounting admission")); } return; } - processVote(event); + processVote(event, PlatformVoteState.uncaptured()); } - private void processVote(PlayerVoteEvent event) { + private void processVote(PlayerVoteEvent event, PlatformVoteState platformState) { try { - SharedVoteProcessor.process(new BukkitOperations(plugin, event)); + SharedVoteProcessor.process(new BukkitOperations(plugin, event, platformState)); } catch (SharedVoteAdmissionException admissionFailure) { failAdmission(event, admissionFailure); } catch (RuntimeException processingFailure) { @@ -65,13 +66,15 @@ private void failAdmission(PlayerVoteEvent event, SharedVoteAdmissionException f plugin.debug(failure); } - private static final class BukkitOperations implements SharedVoteProcessor.Operations { + static final class BukkitOperations implements SharedVoteProcessor.Operations { private final VotingPluginMain plugin; private final PlayerVoteEvent event; + private final PlatformVoteState platformState; - private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { + BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event, PlatformVoteState platformState) { this.plugin = plugin; this.event = event; + this.platformState = platformState; } @Override public boolean enabled() { return plugin.isEnabled(); } @@ -127,7 +130,10 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { @Override public String siteKey(VoteSite site) { return site.getKey(); } @Override public String siteDisplayName(VoteSite site) { return site.getDisplayName(); } @Override public VotingPluginUser resolveUser(String name) { - if (event.getVotingPluginUser() != null) return event.getVotingPluginUser(); + if (event.getVotingPluginUser() != null) return event.getVotingPluginUser(); + if (platformState.matchesName(name)) return plugin.getVotingPluginUserManager() + .getVotingPluginUser(platformState.uuid(), platformState.playerName()); + if (platformState.captured()) return plugin.getVotingPluginUserManager().getVotingPluginUser(name); Player player = Bukkit.getPlayerExact(name); if (player != null) return plugin.getVotingPluginUserManager().getVotingPluginUser(player); return plugin.getVotingPluginUserManager().getVotingPluginUser(name); @@ -135,12 +141,20 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { @Override public String userName(VotingPluginUser user) { return user.getPlayerName(); } @Override public String userId(VotingPluginUser user) { return user.getUUID(); } @Override public UUID userUuid(VotingPluginUser user) { return user.getJavaUUID(); } - @Override public boolean userOnline(VotingPluginUser user) { return user.isOnline(); } - @Override public boolean userVanished(VotingPluginUser user) { return user.isVanished(); } + @Override public boolean userOnline(VotingPluginUser user) { + return platformState.captured() ? platformState.matches(user) && platformState.online() : user.isOnline(); + } + @Override public boolean userVanished(VotingPluginUser user) { + return platformState.captured() ? platformState.matches(user) && platformState.vanished() : user.isVanished(); + } @Override public long lastVoteTime(VotingPluginUser user, VoteSite site) { return user.getTime(site); } @Override public boolean waitUntilVoteDelay(VoteSite site) { return site.isWaitUntilVoteDelay(); } @Override public boolean canVoteSite(VotingPluginUser user, VoteSite site) { return user.canVoteSite(site); } - @Override public boolean bypassWaitPermission(VotingPluginUser user) { return user.hasPermission("VotingPlugin.BypassWaitUntilVoteDelay"); } + @Override public boolean bypassWaitPermission(VotingPluginUser user) { + return platformState.captured() + ? platformState.matches(user) && platformState.bypassWaitPermission() + : user.hasPermission("VotingPlugin.BypassWaitUntilVoteDelay"); + } @Override public boolean processRewards() { return plugin.getOptions().isProcessRewards(); } @Override public void giveWaitRewards(VoteSite site, VotingPluginUser user, boolean online, boolean proxyVote) { site.giveWaitUntilVoteDelayRewards(user, online, proxyVote); @@ -153,7 +167,14 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { @Override public boolean broadcastEnabled() { return event.isBroadcast(); } @Override public boolean hasBroadcastHandler() { return plugin.getBroadcastHandler() != null; } @Override public void broadcast(UUID uuid, String name, String siteDisplayName, boolean online) { - plugin.getBroadcastHandler().broadcastVote(uuid, name, siteDisplayName, online); + Runnable broadcast = () -> plugin.getBroadcastHandler().broadcastVote(uuid, name, siteDisplayName, online); + if (!platformState.captured()) { + broadcast.run(); + } else if (platformState.owner() != null) { + plugin.getBukkitScheduler().runTask(plugin, broadcast, platformState.owner()); + } else { + plugin.getBukkitScheduler().runTask(plugin, broadcast); + } } @Override public boolean hasProxyTextTotals() { return event.getBungeeTextTotals() != null; } @Override public UUID incomingVoteId() { @@ -165,7 +186,7 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { int pointAmount, int pointCap) { boolean countVoteParty = plugin.getSpecialRewardsConfig().isVotePartyEnabled() && (plugin.getSpecialRewardsConfig().isVotePartyCountFakeVotes() || event.isRealVote()) - && (plugin.getSpecialRewardsConfig().isVotePartyCountOfflineVotes() || user.isOnline()); + && (plugin.getSpecialRewardsConfig().isVotePartyCountOfflineVotes() || userOnline(user)); VoteShopPurchaseService.VoteAccountingAdmission admission = VoteShopPurchaseService .prepareMysqlVoteAccounting(plugin, voteId, user.getUUID(), countTotals, awardPoints, countVoteParty, event.isForceBungee(), pointAmount, pointCap, user.getPointsPath()); @@ -254,4 +275,34 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { @Override public void clearCache(VotingPluginUser user) { user.clearCache(); } @Override public void setUpdate() { plugin.setUpdate(true); } } + + /** Bukkit-owned state captured before a primary-thread event enters the storage lane. */ + static record PlatformVoteState(boolean captured, Player owner, UUID uuid, String playerName, boolean online, + boolean vanished, boolean bypassWaitPermission) { + static PlatformVoteState uncaptured() { + return new PlatformVoteState(false, null, null, null, false, false, false); + } + + static PlatformVoteState capture(VotingPluginMain plugin, String requestedName) { + if (requestedName == null || requestedName.isEmpty()) { + return new PlatformVoteState(true, null, null, null, false, false, false); + } + Player player = Bukkit.getPlayerExact(requestedName); + if (player == null) return new PlatformVoteState(true, null, null, null, false, false, false); + VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(player); + boolean vanished = user.isVanished(); + boolean online = player.isOnline() + && (!plugin.getOptions().isTreatVanishAsOffline() || !vanished); + return new PlatformVoteState(true, player, player.getUniqueId(), player.getName(), online, vanished, + user.hasPermission("VotingPlugin.BypassWaitUntilVoteDelay")); + } + + boolean matchesName(String name) { + return uuid != null && playerName != null && name != null && playerName.equalsIgnoreCase(name); + } + + boolean matches(VotingPluginUser user) { + return uuid != null && user != null && uuid.equals(user.getJavaUUID()); + } + } } 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 index f2357c0483..594fa31522 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -551,9 +551,12 @@ private void applyPeriodTotals(UUID voteId, String uuid, String boundaryColumn, String increment = "COALESCE(" + quoted + ", 0) + 1"; sql.append(quoted).append(" = "); if (maximum != null && resetPending && columns.get(index).equals(boundaryColumn)) { - sql.append("LEAST(COALESCE(").append(qi(previousColumn)).append(", 0) + ?, ") - .append(increment).append(')'); - } else if (maximum != null) sql.append("LEAST(?, ").append(increment).append(')'); + sql.append("GREATEST(COALESCE(").append(quoted).append(", 0), LEAST(COALESCE(") + .append(qi(previousColumn)).append(", 0) + ?, ").append(increment).append("))"); + } else if (maximum != null) { + sql.append("GREATEST(COALESCE(").append(quoted).append(", 0), LEAST(?, ") + .append(increment).append("))"); + } else sql.append(increment); } sql.append(" WHERE ").append(qi("uuid")).append(uuidCast()); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerAdmissionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerAdmissionTest.java index f0452b1bdd..181d11fb69 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerAdmissionTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerAdmissionTest.java @@ -5,20 +5,26 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.UUID; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.logging.Logger; import org.bukkit.Bukkit; +import org.bukkit.entity.Player; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.core.vote.SharedVoteProcessor; import com.bencodez.votingplugin.events.PlayerVoteEvent; +import com.bencodez.votingplugin.user.UserManager; +import com.bencodez.votingplugin.user.VotingPluginUser; class PlayerVoteListenerAdmissionTest { @Test @@ -70,4 +76,45 @@ void postAdmissionFailureIsVisibleToDurableProducers() { assertTrue(event.isProcessingFailed()); } + + @Test + void capturedPlatformStateAvoidsWorkerPlayerReads() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + PlayerVoteEvent event = new PlayerVoteEvent(null, "player", "site", false); + VotingPluginUser user = mock(VotingPluginUser.class); + UserManager users = mock(UserManager.class); + UUID uuid = UUID.randomUUID(); + when(plugin.getVotingPluginUserManager()).thenReturn(users); + when(users.getVotingPluginUser(uuid, "Player")).thenReturn(user); + when(user.getJavaUUID()).thenReturn(uuid); + PlayerVoteListener.PlatformVoteState state = new PlayerVoteListener.PlatformVoteState(true, mock(Player.class), + uuid, "Player", true, true, true); + PlayerVoteListener.BukkitOperations operations = new PlayerVoteListener.BukkitOperations(plugin, event, state); + + assertTrue(operations.userOnline(user)); + assertTrue(operations.userVanished(user)); + assertTrue(operations.bypassWaitPermission(user)); + assertTrue(operations.resolveUser("player") == user); + verify(user, never()).isOnline(); + verify(user, never()).isVanished(); + verify(user, never()).hasPermission(any()); + } + + @Test + void capturedPlatformStateSchedulesBroadcastForThePlayerOwner() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + Player owner = mock(Player.class); + UUID uuid = UUID.randomUUID(); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + PlayerVoteListener.PlatformVoteState state = new PlayerVoteListener.PlatformVoteState(true, owner, uuid, + "Player", true, false, false); + PlayerVoteListener.BukkitOperations operations = new PlayerVoteListener.BukkitOperations(plugin, + new PlayerVoteEvent(null, "player", "site", false), state); + + operations.broadcast(uuid, "Player", "Site", true); + + verify(scheduler).runTask(org.mockito.ArgumentMatchers.same(plugin), any(Runnable.class), + org.mockito.ArgumentMatchers.same(owner)); + } } 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 index 1f3759e423..e6e4d9b47b 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -630,10 +630,11 @@ void periodIncrementLocksTheSameBoundaryRowBeforeUpdatingTheUser() throws Except org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class); verify(fixture.work, org.mockito.Mockito.times(10)).prepareStatement(sql.capture()); assertTrue(sql.getAllValues().get(4).contains("FOR UPDATE")); - assertTrue(sql.getAllValues().get(8).contains( - "`MonthTotal` = LEAST(COALESCE(`LastMonthTotal`, 0) + ?, COALESCE(`MonthTotal`, 0) + 1)")); - assertTrue(sql.getAllValues().get(8).contains( - "`MonthTotal-SEPTEMBER-2026` = LEAST(?, COALESCE(`MonthTotal-SEPTEMBER-2026`, 0) + 1)")); + assertTrue(sql.getAllValues().get(8).contains("`MonthTotal` = GREATEST(COALESCE(`MonthTotal`, 0), " + + "LEAST(COALESCE(`LastMonthTotal`, 0) + ?, COALESCE(`MonthTotal`, 0) + 1))")); + assertTrue(sql.getAllValues().get(8).contains("`MonthTotal-SEPTEMBER-2026` = " + + "GREATEST(COALESCE(`MonthTotal-SEPTEMBER-2026`, 0), " + + "LEAST(?, COALESCE(`MonthTotal-SEPTEMBER-2026`, 0) + 1))")); verify(markerSelect).setString(1, "period-copy:MonthTotal"); verify(increment).setInt(1, 42); verify(increment).setInt(2, 42); From 81fe60159bfa1bf46c5e1b880137f504a34c6bb2 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:28:43 -0600 Subject: [PATCH 47/61] Reserve capped monthly vote increments --- .../service/SharedMysqlPurchaseJournal.java | 53 +++++++++++++++---- .../service/VoteShopPurchaseService.java | 4 +- .../SharedMysqlPurchaseJournalTest.java | 48 +++++++++++++++-- 3 files changed, 90 insertions(+), 15 deletions(-) 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 index 594fa31522..bad6629cee 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -67,6 +67,8 @@ record VoteAccountingDecision(int bits, int pointAmount, int pointCap, String po private static final int DAILY_STREAK_REWARD_CLAIMED = 128; private static final int AWARD_POINTS = 256; private static final int ALL_TIME_TOTAL = 512; + private static final int MONTH_TOTAL_RESERVED = 1024; + private static final int ACCOUNTING_METADATA = ACCOUNTING_DECIDED | MONTH_TOTAL_RESERVED; private static final int RECOVERABLE_NON_REWARD_ACCOUNTING = DAILY_TOTAL | WEEKLY_TOTAL | MONTH_TOTAL | VOTE_PARTY_TOTAL | DAILY_STREAK | ALL_TIME_TOTAL; @@ -333,7 +335,9 @@ PeriodTotalResult incrementPeriodTotalsResolved(UUID voteId, String uuid, String if (operation == MONTH_TOTAL) { columns = row.monthColumn() == null ? List.of(boundaryColumn) : List.of(boundaryColumn, row.monthColumn()); - maximum = row.monthMaximum(); + // Capped monthly increments marked as reserved during durable admission + // must not re-evaluate an older cap after later votes advance the total. + maximum = (row.requested() & MONTH_TOTAL_RESERVED) != 0 ? null : row.monthMaximum(); } } if (voteId == null || uuid == null || uuid.isEmpty() || !isSafeColumn(boundaryColumn) || columns == null @@ -375,7 +379,10 @@ VoteAccountingDecision prepareVoteAccounting(UUID voteId, String uuid, boolean c if ((requested & DAILY_TOTAL) != 0 || streakUsesPercentage) { dailyCopyMarker = lockLimitEpochRow(connection, "period-copy:DailyTotal"); } - if ((requested & MONTH_TOTAL) != 0) lockLimitEpochRow(connection, "period-copy:MonthTotal"); + EpochRow monthCopyMarker = null; + if ((requested & MONTH_TOTAL) != 0) { + monthCopyMarker = lockLimitEpochRow(connection, "period-copy:MonthTotal"); + } if ((requested & VOTE_PARTY_TOTAL) != 0) lockLimitEpochRow(connection, "period-copy:VotePartyVotes"); if ((requested & WEEKLY_TOTAL) != 0) lockLimitEpochRow(connection, "period-copy:WeeklyTotal"); lockLimitEpochRow(connection, "streak-copy:DayVoteStreak"); @@ -386,6 +393,14 @@ VoteAccountingDecision prepareVoteAccounting(UUID voteId, String uuid, boolean c throw new SQLException("Vote accounting identity does not match"); } boolean accountingAlreadyDecided = (row.requested() & ACCOUNTING_DECIDED) != 0; + int accountingMetadata = 0; + if (!accountingAlreadyDecided && (requested & MONTH_TOTAL) != 0 && monthMaximum != null) { + boolean monthResetPending = resetPending(connection, "MonthTotal", monthCopyMarker); + int admittedMonthTotal = findPeriodTotal(connection, uuid, "MonthTotal", "LastMonthTotal", + monthResetPending) + countPendingPeriodTotals(connection, voteId, uuid, MONTH_TOTAL); + if (admittedMonthTotal >= monthMaximum.intValue()) requested &= ~MONTH_TOTAL; + else accountingMetadata |= MONTH_TOTAL_RESERVED; + } int admittedPointAmount = accountingAlreadyDecided && row.pointAmount() != null ? row.pointAmount().intValue() : pointAmount; int admittedPointCap = accountingAlreadyDecided && row.pointCap() != null @@ -398,7 +413,7 @@ VoteAccountingDecision prepareVoteAccounting(UUID voteId, String uuid, boolean c : findDailyStreakCandidate(connection, uuid, dailyResetPending); boolean dailyAlreadyApplied = (row.completed() & DAILY_TOTAL) != 0; int projectedDailyTotal = accountingAlreadyDecided || !streakUsesPercentage ? 0 - : streak.dailyTotal() + countPendingDailyTotals(connection, voteId, uuid) + : streak.dailyTotal() + countPendingPeriodTotals(connection, voteId, uuid, DAILY_TOTAL) + (countTotals && !dailyAlreadyApplied ? 1 : 0); boolean percentageMet = accountingAlreadyDecided || !streakUsesPercentage || enabledSiteCount > 0 && (double) projectedDailyTotal / (double) enabledSiteCount * 100 > streakPercentage; @@ -406,9 +421,9 @@ VoteAccountingDecision prepareVoteAccounting(UUID voteId, String uuid, boolean c && !sameLocalDay(streak.lastUpdate(), acceptedAt) && percentageMet; if (accountingAlreadyDecided) requested = row.requested(); else if (requestStreak) requested |= DAILY_STREAK | DAILY_STREAK_REWARD; - int persistedRequested = row.requested() | requested | ACCOUNTING_DECIDED; + int persistedRequested = row.requested() | requested | ACCOUNTING_DECIDED | accountingMetadata; int persistedCompleted = row.completed() | ACCOUNTING_DECIDED - | (persistedRequested & AWARD_POINTS); + | accountingMetadata | (persistedRequested & AWARD_POINTS); String update = "UPDATE " + qiAccounting() + " SET " + qi("player_uuid") + " = ?, " + qi("requested") + " = ?, " + qi("completed") + " = ?, " + qi("month_column") + " = COALESCE(?, " @@ -445,11 +460,11 @@ VoteAccountingDecision prepareVoteAccounting(UUID voteId, String uuid, boolean c || confirmed.pointCap() == null || confirmed.pointColumn() == null) { throw new SQLException("Confirmed vote accounting payload is incomplete or does not match"); } - return new VoteAccountingDecision(confirmed.requested() & ~ACCOUNTING_DECIDED, + return new VoteAccountingDecision(confirmed.requested() & ~ACCOUNTING_METADATA, confirmed.pointAmount().intValue(), confirmed.pointCap().intValue(), confirmed.pointColumn(), confirmed.replayUnsafe()); } - return new VoteAccountingDecision(persistedRequested & ~ACCOUNTING_DECIDED, + return new VoteAccountingDecision(persistedRequested & ~ACCOUNTING_METADATA, admittedPointAmount, admittedPointCap, admittedPointColumn, row.replayUnsafe()); } catch (SQLException failure) { rollback(connection); @@ -497,15 +512,16 @@ void markVoteReplayUnsafe(UUID voteId) throws SQLException { } } - private int countPendingDailyTotals(Connection connection, UUID voteId, String uuid) throws SQLException { + private int countPendingPeriodTotals(Connection connection, UUID voteId, String uuid, int operation) + throws SQLException { String select = "SELECT COUNT(*) FROM " + qiAccounting() + " WHERE " + qi("player_uuid") + " = ? AND " + qi("vote_id") + " <> ? AND (" + qi("requested") + " & ?) <> 0 AND (" + qi("completed") + " & ?) = 0 AND (" + qi("completed") + " & ?) <> 0"; try (PreparedStatement statement = connection.prepareStatement(select)) { statement.setString(1, uuid); statement.setString(2, voteId.toString()); - statement.setInt(3, DAILY_TOTAL); - statement.setInt(4, DAILY_TOTAL); + statement.setInt(3, operation); + statement.setInt(4, operation); statement.setInt(5, ACCOUNTING_DECIDED); try (ResultSet result = statement.executeQuery()) { return result.next() ? result.getInt(1) : 0; @@ -513,6 +529,21 @@ private int countPendingDailyTotals(Connection connection, UUID voteId, String u } } + private int findPeriodTotal(Connection connection, String uuid, String totalColumn, String previousColumn, + boolean resetPending) throws SQLException { + String total = resetPending + ? "GREATEST(0, COALESCE(" + qi(totalColumn) + ", 0) - COALESCE(" + qi(previousColumn) + ", 0))" + : "COALESCE(" + qi(totalColumn) + ", 0)"; + String select = "SELECT " + total + " FROM " + qi(table.getTableName()) + " WHERE " + qi("uuid") + + uuidCast(); + try (PreparedStatement statement = connection.prepareStatement(select)) { + statement.setString(1, uuid); + try (ResultSet result = statement.executeQuery()) { + return result.next() ? result.getInt(1) : 0; + } + } + } + private DailyStreakCandidate findDailyStreakCandidate(Connection connection, String uuid, boolean resetPending) throws SQLException { String dailyTotal = resetPending @@ -1407,7 +1438,7 @@ AccountingRecoveryBatch recoverAccounting(long now) throws SQLException { List columns = row.monthColumn() == null ? List.of("MonthTotal") : List.of("MonthTotal", row.monthColumn()); applyPeriodTotals(recovery.voteId(), row.uuid(), "MonthTotal", "LastMonthTotal", columns, - row.monthMaximum(), MONTH_TOTAL); + (row.requested() & MONTH_TOTAL_RESERVED) != 0 ? null : row.monthMaximum(), MONTH_TOTAL); } if ((missing & VOTE_PARTY_TOTAL) != 0) applyPeriodTotals(recovery.voteId(), row.uuid(), "VotePartyVotes", "LastVotePartyVotes", List.of("VotePartyVotes"), null, VOTE_PARTY_TOTAL); 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 8a8ba08d04..b025a7135c 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 @@ -645,7 +645,9 @@ public static boolean incrementMysqlPeriodTotals(VotingPluginMain plugin, UUID v default -> 0; }; if (operation == 0) return false; - int admitted = ADMITTED_ACCOUNTING.getOrDefault(accountingId, Integer.valueOf(0)).intValue(); + Integer admittedDecision = ADMITTED_ACCOUNTING.get(accountingId); + int admitted = admittedDecision == null ? 0 : admittedDecision.intValue(); + if (admittedDecision != null && (admitted & operation) == 0) return true; SharedMysqlPurchaseJournal.PeriodTotalResult result = SharedMysqlPurchaseJournal.forTable(table) .incrementPeriodTotalsResolved(accountingId, uuid, boundaryColumn, previousColumn, columns, maximum, (admitted & operation) != 0); 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 index e6e4d9b47b..123d95c852 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -699,6 +699,46 @@ void repeatedVoteOperationDoesNotIncrementAllTimeTotalTwice() throws Exception { verify(fixture.work, org.mockito.Mockito.times(6)).prepareStatement(anyString()); } + @Test + void cappedMonthlyAdmissionReservesAfterPreviouslyAdmittedVotes() throws Exception { + Fixture fixture = fixture(); + PreparedStatement[] statements = new PreparedStatement[16]; + for (int index = 0; index < statements.length; index++) statements[index] = mock(PreparedStatement.class); + ResultSet marker = mock(ResultSet.class); + ResultSet accounting = accountingRow("player", 0, 0); + when(marker.next()).thenReturn(true); + when(statements[1].executeQuery()).thenReturn(marker); + when(statements[3].executeQuery()).thenReturn(marker); + when(statements[5].executeQuery()).thenReturn(marker); + when(statements[7].executeQuery()).thenReturn(marker); + when(statements[9].executeQuery()).thenReturn(marker); + when(statements[11].executeQuery()).thenReturn(accounting); + ResultSet currentTotal = mock(ResultSet.class); + when(currentTotal.next()).thenReturn(true); + when(currentTotal.getInt(1)).thenReturn(1); + when(statements[12].executeQuery()).thenReturn(currentTotal); + ResultSet pendingTotal = mock(ResultSet.class); + when(pendingTotal.next()).thenReturn(true); + when(pendingTotal.getInt(1)).thenReturn(1); + when(statements[13].executeQuery()).thenReturn(pendingTotal); + ResultSet streak = mock(ResultSet.class); + when(streak.next()).thenReturn(true); + when(statements[14].executeQuery()).thenReturn(streak); + when(statements[15].executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(statements[0], + java.util.Arrays.copyOfRange(statements, 1, statements.length)); + + int requested = new SharedMysqlPurchaseJournal(fixture.table, false).prepareVoteAccounting( + UUID.randomUUID(), "player", true, false, false, "MonthTotal-SEPTEMBER-2026", + Integer.valueOf(3), false, 0.0, 1, false, 1234L); + + assertEquals(567, requested, "the vote must retain its available monthly slot"); + verify(statements[13]).setInt(3, 4); + verify(statements[15]).setInt(2, 1655); + verify(statements[15]).setInt(3, 1088); + verify(fixture.work).commit(); + } + @Test void admittedMonthlyIncrementUsesItsJournaledColumnAfterConfigurationChanges() throws Exception { Fixture fixture = fixture(); @@ -708,12 +748,12 @@ void admittedMonthlyIncrementUsesItsJournaledColumnAfterConfigurationChanges() t PreparedStatement accountingSelect = mock(PreparedStatement.class); PreparedStatement increment = mock(PreparedStatement.class); PreparedStatement accountingUpdate = mock(PreparedStatement.class); - ResultSet admitted = accountingRow("player", 4, 0); + ResultSet admitted = accountingRow("player", 1028, 1024); when(admitted.getString(4)).thenReturn("MonthTotal-SEPTEMBER-2026"); - when(admitted.wasNull()).thenReturn(true); + when(admitted.getObject(5)).thenReturn(Integer.valueOf(2)); ResultSet epoch = mock(ResultSet.class); when(epoch.next()).thenReturn(true); - ResultSet accounting = accountingRow("player", 4, 0); + ResultSet accounting = accountingRow("player", 1028, 1024); when(admittedSelect.executeQuery()).thenReturn(admitted); when(markerSelect.executeQuery()).thenReturn(epoch); when(accountingSelect.executeQuery()).thenReturn(accounting); @@ -731,6 +771,8 @@ void admittedMonthlyIncrementUsesItsJournaledColumnAfterConfigurationChanges() t verify(fixture.work, org.mockito.Mockito.times(6)).prepareStatement(sql.capture()); assertTrue(sql.getAllValues().get(4).contains("`MonthTotal-SEPTEMBER-2026`")); assertFalse(sql.getAllValues().get(4).contains("`MonthTotal-OCTOBER-2026`")); + assertFalse(sql.getAllValues().get(4).contains("LEAST("), + "an increment reserved under an older cap must still advance after newer votes"); } @Test From 0095979cc01c5cbf20eb5890249efefc032f5543 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:15:50 -0600 Subject: [PATCH 48/61] Close durable vote handoff gaps --- .../votingplugin/data/ServerData.java | 11 +++++++ .../votingplugin/listeners/VotiferEvent.java | 4 +-- .../listeners/VotifierVoteOverflowQueue.java | 10 ++++++ .../timequeue/TimeQueueHandler.java | 33 +++++++++++++------ .../service/VoteShopPurchaseService.java | 6 ++-- .../ServerDataTimeChangeRecoveryTest.java | 20 +++++++++++ .../VotiferEventDisabledVoteSiteTest.java | 4 +-- .../VotifierVoteOverflowQueueTest.java | 16 +++++++++ .../TimeQueueHandlerRejectionTest.java | 19 +++++++++++ 9 files changed, 106 insertions(+), 17 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index 72166a381b..0b18caca8c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -504,6 +504,17 @@ public synchronized boolean incrementVotePartyTotal(UUID voteId) { } } + /** Retires a VoteParty replay receipt after its durable delivery owner completes. */ + public synchronized void clearVotePartyAccounting(UUID voteId) { + if (voteId == null) return; + String receiptPath = VOTE_PARTY_ACCOUNTING + "." + voteId; + if (!getData().contains(receiptPath)) return; + getData().set(receiptPath, null); + ConfigurationSection remaining = getData().getConfigurationSection(VOTE_PARTY_ACCOUNTING); + if (remaining != null && remaining.getKeys(false).isEmpty()) getData().set(VOTE_PARTY_ACCOUNTING, null); + saveData(); + } + /** * Starts or resumes the compact local checkpoint for a durable core time * transition. Only the current transition for each time type is retained; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java index 4c091f92e0..8aa6abd472 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java @@ -133,7 +133,7 @@ private VoteOutcome processVoteAttempt(String voteSite, String voteUsername, UUI private void retainForAccountingRetry(String voteSite, String voteUsername, UUID voteId) { VotifierVoteOverflowQueue overflow = plugin.getVotifierVoteOverflowQueue(); - if (overflow == null || !overflow.enqueue(voteUsername, voteSite, voteId)) { + if (overflow == null || !overflow.enqueueDurably(voteUsername, voteSite, voteId)) { plugin.getLogger().severe("Unable to retain vote after shared MySQL accounting admission failed"); } else { plugin.getLogger().warning("Shared MySQL accounting is unavailable; retained Votifier vote for retry"); @@ -183,7 +183,7 @@ public void onVotiferEvent(VotifierEvent event) { if (!VoteTaskAdmission.trySubmit(plugin.getVoteTimer(), () -> processVote(voteSite, voteUsername, voteId))) { VotifierVoteOverflowQueue overflow = plugin.getVotifierVoteOverflowQueue(); - if (overflow == null || !overflow.enqueue(voteUsername, voteSite, voteId)) { + if (overflow == null || !overflow.enqueueDurably(voteUsername, voteSite, voteId)) { plugin.getLogger().severe("Votifier vote queue is full; vote was not admitted for " + MinecraftUsernameValidator.sanitizeForLog(voteUsername)); } else { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java index 28b79b3b49..500a66b543 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java @@ -124,6 +124,12 @@ public boolean enqueue(String username, String serviceSite, UUID voteId) { } } + /** Adds a vote and reports success only after its queue snapshot is durable. */ + public boolean enqueueDurably(String username, String serviceSite, UUID voteId) { + if (username == null || serviceSite == null || voteId == null) return false; + return admitDurably(new PendingVote(username, serviceSite, System.currentTimeMillis(), voteId)); + } + /** * Durably retains an ambiguous vote without automatically replaying it. * Success is reported only after the quarantine snapshot has been forced and @@ -133,6 +139,10 @@ public boolean quarantine(String username, String serviceSite, UUID voteId) { if (username == null || serviceSite == null || voteId == null) return false; PendingVote pending = new PendingVote(username, serviceSite, System.currentTimeMillis(), voteId); pending.quarantined = true; + return admitDurably(pending); + } + + private boolean admitDurably(PendingVote pending) { synchronized (lock) { if (closed || entries.size() >= MAX_ENTRIES) return false; entries.addLast(pending); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java index 3602ddab41..335c17f6bb 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java @@ -2,6 +2,7 @@ import java.time.LocalDateTime; import java.time.ZoneId; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Comparator; import java.util.Deque; @@ -32,6 +33,7 @@ public class TimeQueueHandler implements Listener { private static final long TICKS_PER_SECOND = 20L; private final Deque timeChangeQueue = new ConcurrentLinkedDeque<>(); + private final Deque completedDeliveries = new ArrayDeque<>(); private final Object queuePersistenceLock = new Object(); private VotingPluginMain plugin; @@ -143,12 +145,7 @@ public void postTimeChange(DateChangedEvent event) { } private void scheduleQueueProcessing(long delay, TimeUnit unit) { - boolean admitted = VoteTaskAdmission.trySchedule(plugin.getVoteTimer(), () -> { - // Clear only after the bounded executor has admitted the task. If it is - // rejected, shutdown persistence can still recover the in-memory queue. - plugin.getServerData().clearTimedVoteCache(); - processQueue(); - }, delay, unit); + boolean admitted = VoteTaskAdmission.trySchedule(plugin.getVoteTimer(), this::processQueue, delay, unit); if (!admitted) { plugin.getLogger().warning("Unable to schedule time-queue processing because vote processing is busy; queued votes were retained."); scheduleRetry(false); @@ -159,7 +156,7 @@ private void scheduleQueueProcessing(long delay, TimeUnit unit) { private void scheduleRetry(boolean persistenceRequired) { if (persistenceRequired) retryPersistenceRequired.set(true); - if (timeChangeQueue.isEmpty() || !retryPending.compareAndSet(false, true)) return; + if ((!persistenceRequired && timeChangeQueue.isEmpty()) || !retryPending.compareAndSet(false, true)) return; if (!plugin.isEnabled()) { retryPending.set(false); return; @@ -170,11 +167,11 @@ private void scheduleRetry(boolean persistenceRequired) { try { plugin.getBukkitScheduler().runTaskLaterAsynchronously(plugin, () -> { retryPending.set(false); - if (timeChangeQueue.isEmpty()) return; if (retryPersistenceRequired.getAndSet(false) && !persistQueueSnapshot()) { scheduleRetry(true); return; } + if (timeChangeQueue.isEmpty()) return; scheduleQueueProcessing(0, TimeUnit.SECONDS); }, delayTicks); } catch (RuntimeException rejected) { @@ -184,16 +181,25 @@ private void scheduleRetry(boolean persistenceRequired) { } private boolean persistQueueSnapshot() { + return persistQueueSnapshot(null); + } + + private boolean persistQueueSnapshot(UUID completedVoteId) { + List durableCompletions; synchronized (queuePersistenceLock) { + if (completedVoteId != null) completedDeliveries.addLast(completedVoteId); try { plugin.getServerData().replaceTimedVoteCache(new ArrayList<>(timeChangeQueue)); - return true; + durableCompletions = new ArrayList<>(completedDeliveries); + completedDeliveries.clear(); } catch (RuntimeException persistenceFailure) { plugin.getLogger().severe("Unable to persist the time-queue retry; the vote remains in memory"); plugin.debug(persistenceFailure); return false; } } + for (UUID voteId : durableCompletions) VoteShopPurchaseService.completeVoteDelivery(plugin, voteId); + return true; } private void ensureTransitionActive(TimeChangeTransition transition) { @@ -229,6 +235,10 @@ public void processQueue() { } plugin.getLogger().severe("Timed vote " + voteEvent.getVoteId() + " reached an ambiguous post-effect failure and was retained for manual review"); + if (!persistQueueSnapshot()) { + scheduleRetry(true); + return; + } continue; } timeChangeQueue.addFirst(vote); @@ -236,7 +246,10 @@ public void processQueue() { return; } - VoteShopPurchaseService.completeVoteDelivery(plugin, voteEvent.getVoteId()); + if (!persistQueueSnapshot(voteEvent.getVoteId())) { + scheduleRetry(true); + return; + } if (voteEvent.isCancelled()) { plugin.debug("Vote cancelled"); return; 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 65f8b76940..49652abc72 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 @@ -797,9 +797,9 @@ public static boolean markVoteReplayUnsafe(VotingPluginMain plugin, UUID voteId) /** Retires the YAML replay fence once the delivery owner has durably acknowledged success. */ public static void completeVoteDelivery(VotingPluginMain plugin, UUID voteId) { - if (voteId != null && !canRecoverSharedMysqlPurchases(plugin)) { - plugin.getServerData().clearVoteReplayUnsafe(voteId); - } + if (voteId == null) return; + plugin.getServerData().clearVotePartyAccounting(voteId); + if (!canRecoverSharedMysqlPurchases(plugin)) plugin.getServerData().clearVoteReplayUnsafe(voteId); } public static void finishMysqlVoteAccounting(UUID voteId) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java index dd38cebed6..0363d7a8fd 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java @@ -73,6 +73,26 @@ void oldVotePartyReceiptsRemainForUnboundedQueueReplay() { assertTrue(yaml.contains("VotingPlugin.VoteParty.Accounting." + oldVote)); } + @Test + void completedDeliveryRetiresOnlyItsVotePartyReceipt() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); + YamlConfiguration yaml = new YamlConfiguration(); + when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile()); + when(plugin.getServerDataFile()).thenReturn(coreData); + when(coreData.getData()).thenReturn(yaml); + ServerData data = new ServerData(plugin); + UUID completed = UUID.randomUUID(); + UUID pending = UUID.randomUUID(); + assertTrue(data.incrementVotePartyTotal(completed)); + assertTrue(data.incrementVotePartyTotal(pending)); + + data.clearVotePartyAccounting(completed); + + assertFalse(yaml.contains("VotingPlugin.VoteParty.Accounting." + completed)); + assertTrue(yaml.contains("VotingPlugin.VoteParty.Accounting." + pending)); + } + @Test void acknowledgedVoteRemovesOnlyItsReplayFence() { VotingPluginMain plugin = mock(VotingPluginMain.class); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java index 852bf3d1f7..481e379462 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java @@ -152,12 +152,12 @@ public void testUnknownSiteStillAttemptsGenerationByVotifierPath() { public void testVoteIsQueuedWhenBoundedExecutorRejectsIt() { doThrow(new RejectedExecutionException("capacity exhausted")) .when(voteTimer).submit(any(Runnable.class)); - when(overflowQueue.enqueue(org.mockito.ArgumentMatchers.eq("Steve"), + when(overflowQueue.enqueueDurably(org.mockito.ArgumentMatchers.eq("Steve"), org.mockito.ArgumentMatchers.eq(SERVICE_SITE), any())).thenReturn(true); listener.onVotiferEvent(createVoteEvent(SERVICE_SITE)); - verify(overflowQueue).enqueue(org.mockito.ArgumentMatchers.eq("Steve"), + verify(overflowQueue).enqueueDurably(org.mockito.ArgumentMatchers.eq("Steve"), org.mockito.ArgumentMatchers.eq(SERVICE_SITE), any()); verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class)); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java index 2a8245619c..410270d467 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java @@ -31,6 +31,22 @@ import com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue; class VotifierVoteOverflowQueueTest { + @Test + void durableEnqueuePublishesSnapshotBeforeReportingSuccess(@TempDir Path dataFolder) throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + java.util.UUID voteId = java.util.UUID.randomUUID(); + when(plugin.getDataFolder()).thenReturn(dataFolder.toFile()); + when(plugin.getLogger()).thenReturn(Logger.getLogger("VotifierVoteOverflowQueueTest")); + VotifierVoteOverflowQueue queue = new VotifierVoteOverflowQueue(plugin, (site, user) -> { }); + try { + assertTrue(queue.enqueueDurably("Steve", "example.org", voteId)); + + assertTrue(Files.readString(dataFolder.resolve("VotifierVoteQueue.yml")).contains(voteId.toString())); + } finally { + queue.close(); + } + } + @Test void successfulVoteRetiresReplayFenceAfterQueueSnapshot(@TempDir Path dataFolder) throws Exception { VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java index c74848beb5..2bd5663236 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java @@ -108,6 +108,25 @@ void durableAdmissionPersistsBeforeReportingSuccess() { assertEquals(voteId, persisted.getValue().getFirst().getVoteId()); } + @Test + void processingRetiresEachTimedVoteFromTheDurableSnapshot() { + when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of()); + TimeQueueHandler handler = new TimeQueueHandler(plugin); + VoteTimeQueue first = new VoteTimeQueue(UUID.randomUUID(), "Alex", "first.example", 123L); + VoteTimeQueue second = new VoteTimeQueue(UUID.randomUUID(), "Steve", "second.example", 124L); + handler.getTimeChangeQueue().add(first); + handler.getTimeChangeQueue().add(second); + + handler.processQueue(); + + org.mockito.InOrder retirement = org.mockito.Mockito.inOrder(serverData); + retirement.verify(serverData).replaceTimedVoteCache(List.of(second)); + retirement.verify(serverData).clearVotePartyAccounting(first.getVoteId()); + retirement.verify(serverData).replaceTimedVoteCache(List.of()); + retirement.verify(serverData).clearVotePartyAccounting(second.getVoteId()); + verify(serverData, never()).clearTimedVoteCache(); + } + @Test void failedDurableAdmissionLeavesTheVoteWithItsSourceOwner() { when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of()); From 41cde6aa64ca1576c78fd9f7b72feb19ae313732 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:24:25 -0600 Subject: [PATCH 49/61] Preserve quarantined duplicate vote fences --- .../backendproxy/BackendProxyHandler.java | 12 ++++++----- .../messaging/BackendProxyMessageRouter.java | 20 ++++++++++--------- .../BackendProxyMessageRouterTest.java | 18 +++++++++++++++++ 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java index 8671479ec0..c95e95b220 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java @@ -420,10 +420,12 @@ private void finishOrderedVoteDispatch(BackendOrderedVoteOverflowQueue.PendingEn } return; } - boolean successful = outcome == OrderedVoteOutcome.COMPLETE; + boolean successful = outcome == OrderedVoteOutcome.COMPLETE + || outcome == OrderedVoteOutcome.COMPLETE_WITHOUT_RETIREMENT; + boolean retireDelivery = outcome == OrderedVoteOutcome.COMPLETE; if (successful && overflowEntry != null && orderedVoteOverflow != null) { orderedVoteOverflow.acknowledgeAsync(overflowEntry, - stored -> completeOrderedVoteAcknowledgement(stored, envelope)); + stored -> completeOrderedVoteAcknowledgement(stored, envelope, retireDelivery)); return; } boolean completedInMemory = false; @@ -440,10 +442,10 @@ private void finishOrderedVoteDispatch(BackendOrderedVoteOverflowQueue.PendingEn if (successful) scheduleOrderedVoteDispatchLocked(); else retryOrderedVoteDispatchLocked(); } - if (completedInMemory) completeVoteDelivery(envelope); + if (completedInMemory && retireDelivery) completeVoteDelivery(envelope); } - private void completeOrderedVoteAcknowledgement(boolean stored, JsonEnvelope envelope) { + private void completeOrderedVoteAcknowledgement(boolean stored, JsonEnvelope envelope, boolean retireDelivery) { synchronized (orderedVoteDispatch) { if (stored) { orderedVoteDispatchInFlight = null; @@ -459,7 +461,7 @@ private void completeOrderedVoteAcknowledgement(boolean stored, JsonEnvelope env orderedVoteDispatch.notifyAll(); if (stored) scheduleOrderedVoteDispatchLocked(); } - if (stored) completeVoteDelivery(envelope); + if (stored && retireDelivery) completeVoteDelivery(envelope); } private void completeVoteDelivery(JsonEnvelope envelope) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java index e10122e454..0c09e8565a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java @@ -29,7 +29,7 @@ * Registers and handles backend-side proxy message routes. */ public class BackendProxyMessageRouter { - public enum OrderedVoteOutcome { COMPLETE, RETRY, QUARANTINE } + public enum OrderedVoteOutcome { COMPLETE, COMPLETE_WITHOUT_RETIREMENT, RETRY, QUARANTINE } private final VotingPluginMain plugin; private final BackendPresenceManager presenceManager; @@ -126,7 +126,9 @@ public void handleOrderedVote(JsonEnvelope msg, Consumer com } if (VotingPluginWire.SUB_VOTE.equals(subChannel) || VotingPluginWire.SUB_VOTE_ONLINE.equals(subChannel)) { try { - handleWireVote(msg); + boolean processed = handleWireVote(msg); + completion.accept(processed ? OrderedVoteOutcome.COMPLETE + : OrderedVoteOutcome.COMPLETE_WITHOUT_RETIREMENT); } catch (SharedVoteAdmissionException retryableAdmission) { completion.accept(OrderedVoteOutcome.RETRY); return; @@ -134,7 +136,6 @@ public void handleOrderedVote(JsonEnvelope msg, Consumer com completion.accept(OrderedVoteOutcome.QUARANTINE); throw failure; } - completion.accept(OrderedVoteOutcome.COMPLETE); return; } completion.accept(OrderedVoteOutcome.QUARANTINE); @@ -348,18 +349,18 @@ private void handleWireVoteDelayRejected(JsonEnvelope msg) { voteSite.giveWaitUntilVoteDelayRewards(user, rejected.wasOnline && user.isOnline(), true); } - private void handleWireVote(JsonEnvelope msg) { + private boolean handleWireVote(JsonEnvelope msg) { if (!validSchema(msg)) { - return; + return false; } VotingPluginWire.Vote vote = VotingPluginWire.readVote(msg); if (vote.uuid == null || vote.uuid.isEmpty()) { - return; + return false; } if (!ServiceSiteValidator.isValid(vote.service)) { plugin.getLogger().warning("Rejected proxy vote with invalid service site '" + ServiceSiteValidator.sanitizeForLog(vote.service) + "'"); - return; + return false; } plugin.debug("wire vote received from " + ServiceSiteValidator.sanitizeForLog(vote.player) + "/" @@ -371,7 +372,7 @@ private void handleWireVote(JsonEnvelope msg) { plugin.debug("Ignoring duplicate wire vote " + voteId + " for " + ServiceSiteValidator.sanitizeForLog(vote.player) + " on " + ServiceSiteValidator.sanitizeForLog(vote.service)); - return; + return false; } UUID javaUuid; @@ -380,7 +381,7 @@ private void handleWireVote(JsonEnvelope msg) { } catch (IllegalArgumentException e) { plugin.getLogger().warning("Invalid UUID in proxy vote: " + ServiceSiteValidator.sanitizeForLog(vote.uuid)); - return; + return false; } VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(javaUuid, vote.player); votePartySync.replace(totals.getVotePartyCurrent(), totals.getVotePartyRequired()); @@ -396,6 +397,7 @@ private void handleWireVote(JsonEnvelope msg) { if (vote.service != null && !vote.service.isEmpty()) { plugin.getServerData().addServiceSite(vote.service); } + return true; } private boolean validSchema(JsonEnvelope msg) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java index 9aaa4f86fc..3fba4527e9 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java @@ -213,4 +213,22 @@ void accountingAdmissionFailureReleasesVoteIdAndRetriesWithoutQuarantine() { verify(cache).release(voteId); } + @Test + void duplicateVoteCompletesLaneWithoutRetiringOriginalDeliveryFence() { + UUID voteId = UUID.randomUUID(); + ProcessedVoteCache cache = mock(ProcessedVoteCache.class); + when(cache.reserve(voteId)).thenReturn(false); + BackendProxyMessageRouter voteRouter = new BackendProxyMessageRouter(plugin, + mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class), + mock(BackendVotePartySync.class), cache); + AtomicReference outcome = new AtomicReference<>(); + + voteRouter.handleOrderedVote(VotingPluginWire.vote("Player", PLAYER_UUID.toString(), "known.example", + LAST_VOTE_TIME, true, true, "", voteId, false, false, 1, 1), outcome::set); + + assertEquals(OrderedVoteOutcome.COMPLETE_WITHOUT_RETIREMENT, outcome.get()); + verify(user, never()).bungeeVotePluginMessagingAccepted(any(), anyLong(), any(), anyBoolean(), + anyBoolean(), anyBoolean(), anyInt(), any()); + } + } From 83954a6f218c68ed651ce2e04b6336d62fb317a8 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:29:46 -0600 Subject: [PATCH 50/61] Freeze streak recovery site counts --- .../votingplugin/data/ServerData.java | 16 +++++++++++-- .../topvoter/TopVoterHandler.java | 7 +++--- .../votingplugin/user/VotingPluginUser.java | 24 ++++++++++++------- .../ServerDataTimeChangeRecoveryTest.java | 4 ++-- 4 files changed, 36 insertions(+), 15 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index 0b18caca8c..2c100f9c4e 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -32,7 +32,17 @@ public record TimeChangeUserProgress(String uuid, int streakTarget, boolean rewa public record TimeChangeUserPolicy(boolean voteStreaks, boolean highestTotals, boolean monthDateTotalsPrimary, boolean streakUsesPercentage, double dayPercentage, double weekPercentage, double monthPercentage, - boolean proxyOwnsResets, boolean waitForProxy, boolean rewardForceProxy) { } + boolean proxyOwnsResets, boolean waitForProxy, boolean rewardForceProxy, + int enabledSiteCount) { + public TimeChangeUserPolicy(boolean voteStreaks, boolean highestTotals, + boolean monthDateTotalsPrimary, boolean streakUsesPercentage, + double dayPercentage, double weekPercentage, double monthPercentage, + boolean proxyOwnsResets, boolean waitForProxy, boolean rewardForceProxy) { + this(voteStreaks, highestTotals, monthDateTotalsPrimary, streakUsesPercentage, + dayPercentage, weekPercentage, monthPercentage, proxyOwnsResets, + waitForProxy, rewardForceProxy, 0); + } + } public record TimeChangeTopPolicy(boolean rewardsEnabled, boolean awardTies, boolean ignorePermission, boolean archiveRequired, List rewardPlaces, List blacklistedPlayers) { @@ -569,6 +579,7 @@ public synchronized TimeChangeUserPolicy prepareTimeChangeUserPolicy(TimeChangeT getData().set(policyPath + ".DayPercentage", proposed.dayPercentage()); getData().set(policyPath + ".WeekPercentage", proposed.weekPercentage()); getData().set(policyPath + ".MonthPercentage", proposed.monthPercentage()); + getData().set(policyPath + ".EnabledSiteCount", proposed.enabledSiteCount()); getData().set(policyPath + ".ProxyOwnsResets", proposed.proxyOwnsResets()); getData().set(policyPath + ".WaitForProxy", proposed.waitForProxy()); getData().set(policyPath + ".RewardForceProxy", proposed.rewardForceProxy()); @@ -600,7 +611,8 @@ public synchronized TimeChangeUserPolicy getTimeChangeUserPolicy(TimeChangeTrans getData().getDouble(policyPath + ".MonthPercentage"), getData().getBoolean(policyPath + ".ProxyOwnsResets"), getData().getBoolean(policyPath + ".WaitForProxy"), - getData().getBoolean(policyPath + ".RewardForceProxy")); + getData().getBoolean(policyPath + ".RewardForceProxy"), + getData().getInt(policyPath + ".EnabledSiteCount")); } /** Fixes the VoteShop identifiers selected for this transition before resets begin. */ 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 44be1fe596..1b28fd0830 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -608,7 +608,8 @@ private TimeChangeUserPolicy currentTimeChangeUserPolicy() { plugin.getSpecialRewardsConfig().getVoteStreakRequirementWeek(), plugin.getSpecialRewardsConfig().getVoteStreakRequirementMonth(), proxyOwnsResets, plugin.getBungeeSettings().isUseBungeecoord() && !proxyOwnsResets, - plugin.getBungeeSettings().isUseBungeecoord()); + plugin.getBungeeSettings().isUseBungeecoord(), + plugin.getVoteSiteManager().getVoteSitesEnabled().size()); } private List currentVoteShopResetTargets(TopVoter top) { @@ -782,7 +783,7 @@ void processWeeklyUser(VotingPluginUser user, TimeChangeTransition transition, S applyRecoverableStreak(user, transition, uuid, TopVoter.Weekly, 0, false); } else if (!policy.streakUsesPercentage() || user.hasPercentageTotal(TopVoter.Weekly, - policy.weekPercentage(), null, boundaryTotal)) { + policy.weekPercentage(), null, boundaryTotal, policy.enabledSiteCount())) { applyRecoverableStreak(user, transition, uuid, TopVoter.Weekly, user.getWeekVoteStreak() + 1, true); } @@ -808,7 +809,7 @@ void processMonthlyUser(VotingPluginUser user, LocalDateTime lastMonthTime, } else if (!policy.streakUsesPercentage() || user.hasPercentageTotal(TopVoter.Monthly, policy.monthPercentage(), lastMonthTime, - boundaryTotal)) { + boundaryTotal, policy.enabledSiteCount())) { applyRecoverableStreak(user, transition, uuid, TopVoter.Monthly, user.getMonthVoteStreak() + 1, true); } 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 aa31279641..425e47648a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -2106,15 +2106,23 @@ public boolean hasPercentageTotal(TopVoter top, double percentage, LocalDateTime /** Checks a percentage requirement against an explicitly captured total. */ public boolean hasPercentageTotal(TopVoter top, double percentage, LocalDateTime time, int total) { + return hasPercentageTotal(top, percentage, time, total, + plugin.getVoteSiteManager().getVoteSitesEnabled().size()); + } + + /** Checks a percentage requirement against captured total and site-count boundaries. */ + public boolean hasPercentageTotal(TopVoter top, double percentage, LocalDateTime time, int total, + int enabledSiteCount) { + if (enabledSiteCount <= 0) return false; switch (top) { - case Daily: - return (double) total / (double) plugin.getVoteSiteManager().getVoteSitesEnabled().size() - * 100 > percentage; - case Monthly: - return total / ((double) plugin.getVoteSiteManager().getVoteSitesEnabled().size() - * time.getMonth().length(false)) * 100 > percentage; - case Weekly: - return total / ((double) plugin.getVoteSiteManager().getVoteSitesEnabled().size() * 7) * 100 > percentage; + case Daily: + return (double) total / (double) enabledSiteCount + * 100 > percentage; + case Monthly: + return total / ((double) enabledSiteCount + * time.getMonth().length(false)) * 100 > percentage; + case Weekly: + return total / ((double) enabledSiteCount * 7) * 100 > percentage; default: return false; } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java index 0363d7a8fd..d79895adb9 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java @@ -215,8 +215,8 @@ void retryKeepsTheDailyStreakBoundaryDecisionFromTransitionStart() { ServerData data = new ServerData(plugin); data.beginTimeChangeRecovery(transition); - TimeChangeUserPolicy original = new TimeChangeUserPolicy(false, true, false, true, 50, 60, 70, true, false, true); - TimeChangeUserPolicy changed = new TimeChangeUserPolicy(true, false, true, false, 1, 2, 3, false, true, false); + TimeChangeUserPolicy original = new TimeChangeUserPolicy(false, true, false, true, 50, 60, 70, true, false, true, 4); + TimeChangeUserPolicy changed = new TimeChangeUserPolicy(true, false, true, false, 1, 2, 3, false, true, false, 8); assertEquals(original, data.prepareTimeChangeUserPolicy(transition, original)); assertEquals(original, data.prepareTimeChangeUserPolicy(transition, changed)); assertEquals(original, new ServerData(plugin).getTimeChangeUserPolicy(transition)); From 1f64e75afb0cbb8572c21ddf3ee39c9b8b341372 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:38:41 -0600 Subject: [PATCH 51/61] Honor reserved pending accounting --- .../service/SharedMysqlPurchaseJournal.java | 13 +++-- .../SharedMysqlPurchaseJournalTest.java | 47 +++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) 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 index 9269448811..39558bf962 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -1124,21 +1124,22 @@ private void applyPendingAccounting(Connection connection, UUID voteId, Accounti : operation == VOTE_PARTY_TOTAL ? "VotePartyVotes" : "AllTimeTotal"; List columns = operation == MONTH_TOTAL && row.monthColumn() != null ? List.of(boundary, row.monthColumn()) : List.of(boundary); + Integer maximum = pendingAccountingMaximum(operation, row.requested(), row.monthMaximum()); StringBuilder sql = new StringBuilder("UPDATE ").append(qi(table.getTableName())).append(" SET "); for (int index = 0; index < columns.size(); index++) { if (index > 0) sql.append(", "); String quoted = qi(columns.get(index)); sql.append(quoted).append(" = "); - if (row.monthMaximum() != null) sql.append("LEAST(?, "); + if (maximum != null) sql.append("LEAST(?, "); sql.append("COALESCE(").append(quoted).append(", 0) + 1"); - if (row.monthMaximum() != null) sql.append(')'); + if (maximum != null) sql.append(')'); } sql.append(" WHERE ").append(qi("uuid")).append(uuidCast()); try (PreparedStatement update = connection.prepareStatement(sql.toString())) { int parameter = 1; - if (row.monthMaximum() != null) { + if (maximum != null) { for (int ignored = 0; ignored < columns.size(); ignored++) { - update.setInt(parameter++, row.monthMaximum().intValue()); + update.setInt(parameter++, maximum.intValue()); } } update.setString(parameter, row.uuid()); @@ -1147,6 +1148,10 @@ private void applyPendingAccounting(Connection connection, UUID voteId, Accounti markAccountingComplete(connection, voteId, row.completed() | operation); } + static Integer pendingAccountingMaximum(int operation, int requested, Integer monthMaximum) { + return operation == MONTH_TOTAL && (requested & MONTH_TOTAL_RESERVED) == 0 ? monthMaximum : null; + } + /** * A JDBC commit error does not prove that the database discarded the * transaction. Close the possibly-broken handle before looking up the same 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 index 123d95c852..2d1b61a1b4 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -590,6 +590,53 @@ void periodBoundaryCopyAndGenerationAdvanceShareOneTransaction() throws Exceptio verify(fixture.work).commit(); } + @Test + void reservedMonthIncrementDrainsWithoutReapplyingItsOldCap() throws Exception { + Fixture fixture = fixture(); + PreparedStatement markerInsert = mock(PreparedStatement.class); + PreparedStatement markerSelect = mock(PreparedStatement.class); + PreparedStatement pending = mock(PreparedStatement.class); + PreparedStatement increment = mock(PreparedStatement.class); + PreparedStatement complete = mock(PreparedStatement.class); + PreparedStatement copy = mock(PreparedStatement.class); + PreparedStatement advance = mock(PreparedStatement.class); + ResultSet epoch = mock(ResultSet.class); + ResultSet accounting = mock(ResultSet.class); + when(epoch.next()).thenReturn(true); + when(epoch.getLong(1)).thenReturn(8L); + when(markerSelect.executeQuery()).thenReturn(epoch); + when(accounting.next()).thenReturn(true, false); + when(accounting.getString(1)).thenReturn(UUID.randomUUID().toString()); + when(accounting.getString(2)).thenReturn("player"); + when(accounting.getInt(3)).thenReturn(4 | 1024); + when(accounting.getString(5)).thenReturn("September2026"); + when(accounting.getObject(6)).thenReturn(5); + when(pending.executeQuery()).thenReturn(accounting); + when(increment.executeUpdate()).thenReturn(1); + when(complete.executeUpdate()).thenReturn(1); + when(advance.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, pending, + increment, complete, copy, advance); + + new SharedMysqlPurchaseJournal(fixture.table, false).copyPeriodBoundary( + "MonthTotal", "LastMonthTotal", "time-copy:MONTH:2026-09"); + + org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class); + verify(fixture.work, org.mockito.Mockito.times(7)).prepareStatement(sql.capture()); + String incrementSql = sql.getAllValues().get(3); + assertTrue(incrementSql.contains("`MonthTotal` = COALESCE(`MonthTotal`, 0) + 1")); + assertTrue(incrementSql.contains("`September2026` = COALESCE(`September2026`, 0) + 1")); + assertFalse(incrementSql.contains("LEAST")); + verify(increment).setString(1, "player"); + } + + @Test + void pendingAccountingAppliesCapsOnlyToUnreservedMonthIncrements() { + assertEquals(5, SharedMysqlPurchaseJournal.pendingAccountingMaximum(4, 4, 5)); + assertEquals(null, SharedMysqlPurchaseJournal.pendingAccountingMaximum(4, 4 | 1024, 5)); + assertEquals(null, SharedMysqlPurchaseJournal.pendingAccountingMaximum(2, 2, 5)); + } + @Test void periodIncrementLocksTheSameBoundaryRowBeforeUpdatingTheUser() throws Exception { Fixture fixture = fixture(); From a0f3800ef70e526fc9d7401102750a7de26a87c5 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:05:04 -0600 Subject: [PATCH 52/61] Roll back failed vote snapshot saves --- .../votingplugin/data/ServerData.java | 56 +++++++++++++++---- .../ServerDataTimeChangeRecoveryTest.java | 43 ++++++++++++++ 2 files changed, 88 insertions(+), 11 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index 2c100f9c4e..d6f82c0ecf 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -150,16 +150,22 @@ public void addTimeVoted(int num, VoteTimeQueue vote) { /** Replaces the durable timed-vote snapshot with one ordered in-memory queue. */ public synchronized void replaceTimedVoteCache(List votes) { - getData().set("TimedVoteCache", null); - int index = 0; - for (VoteTimeQueue vote : votes) { - String path = "TimedVoteCache." + index++; - getData().set(path + ".Name", vote.getName()); - getData().set(path + ".Service", vote.getService()); - getData().set(path + ".Time", vote.getTime()); - getData().set(path + ".VoteId", vote.getVoteId() == null ? null : vote.getVoteId().toString()); + Map previous = snapshotSection("TimedVoteCache"); + try { + getData().set("TimedVoteCache", null); + int index = 0; + for (VoteTimeQueue vote : votes) { + String path = "TimedVoteCache." + index++; + getData().set(path + ".Name", vote.getName()); + getData().set(path + ".Service", vote.getService()); + getData().set(path + ".Time", vote.getTime()); + getData().set(path + ".VoteId", vote.getVoteId() == null ? null : vote.getVoteId().toString()); + } + saveData(); + } catch (RuntimeException failure) { + restoreSection("TimedVoteCache", previous); + throw failure; } - saveData(); } /** Retains an ambiguously processed timed vote without admitting it to replay. */ @@ -175,8 +181,36 @@ public synchronized void quarantineTimedVote(VoteTimeQueue vote) { /** Persists that a delivery crossed into effects which cannot safely be replayed. */ public synchronized void markVoteReplayUnsafe(UUID voteId) { - getData().set(VOTE_REPLAY_UNSAFE + "." + voteId, true); - saveData(); + String path = VOTE_REPLAY_UNSAFE + "." + voteId; + boolean existed = getData().contains(path); + Object previous = getData().get(path); + try { + getData().set(path, true); + saveData(); + } catch (RuntimeException failure) { + getData().set(path, existed ? previous : null); + ConfigurationSection remaining = getData().getConfigurationSection(VOTE_REPLAY_UNSAFE); + if (remaining != null && remaining.getKeys(false).isEmpty()) getData().set(VOTE_REPLAY_UNSAFE, null); + throw failure; + } + } + + private Map snapshotSection(String path) { + ConfigurationSection section = getData().getConfigurationSection(path); + if (section == null) return null; + Map snapshot = new HashMap<>(); + for (Map.Entry entry : section.getValues(true).entrySet()) { + if (!(entry.getValue() instanceof ConfigurationSection)) snapshot.put(entry.getKey(), entry.getValue()); + } + return snapshot; + } + + private void restoreSection(String path, Map snapshot) { + getData().set(path, null); + if (snapshot == null) return; + for (Map.Entry entry : snapshot.entrySet()) { + getData().set(path + "." + entry.getKey(), entry.getValue()); + } } public synchronized boolean isVoteReplayUnsafe(UUID voteId) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java index d79895adb9..a4e2215102 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java @@ -114,6 +114,49 @@ void acknowledgedVoteRemovesOnlyItsReplayFence() { verify(coreData, org.mockito.Mockito.times(3)).saveData(); } + @Test + void failedReplayFenceSaveRestoresThePreviousYamlState() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); + YamlConfiguration yaml = new YamlConfiguration(); + when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile()); + when(plugin.getServerDataFile()).thenReturn(coreData); + when(coreData.getData()).thenReturn(yaml); + ServerData data = new ServerData(plugin); + UUID existing = UUID.randomUUID(); + UUID failed = UUID.randomUUID(); + data.markVoteReplayUnsafe(existing); + doThrow(new IllegalStateException("disk unavailable")).when(coreData).saveData(); + + assertThrows(IllegalStateException.class, () -> data.markVoteReplayUnsafe(failed)); + + assertTrue(data.isVoteReplayUnsafe(existing)); + assertFalse(data.isVoteReplayUnsafe(failed)); + } + + @Test + void failedTimedVoteSnapshotSaveRestoresThePreviousYamlState() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); + YamlConfiguration yaml = new YamlConfiguration(); + when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile()); + when(plugin.getServerDataFile()).thenReturn(coreData); + when(coreData.getData()).thenReturn(yaml); + ServerData data = new ServerData(plugin); + VoteTimeQueue existing = new VoteTimeQueue(UUID.randomUUID(), "Alex", "old.example", 123L); + VoteTimeQueue failed = new VoteTimeQueue(UUID.randomUUID(), "Steve", "new.example", 124L); + data.replaceTimedVoteCache(List.of(existing)); + doThrow(new IllegalStateException("disk unavailable")).when(coreData).saveData(); + + assertThrows(IllegalStateException.class, () -> data.replaceTimedVoteCache(List.of(failed))); + + assertEquals("Alex", yaml.getString("VotingPlugin.TimedVoteCache.0.Name")); + assertEquals("old.example", yaml.getString("VotingPlugin.TimedVoteCache.0.Service")); + assertEquals(existing.getVoteId().toString(), + yaml.getString("VotingPlugin.TimedVoteCache.0.VoteId")); + assertFalse(yaml.contains("VotingPlugin.TimedVoteCache.1")); + } + @Test void ambiguousTimedVoteIsStoredOutsideTheAutomaticReplayQueue() { VotingPluginMain plugin = mock(VotingPluginMain.class); From 8763bf2d563bd1fcc715dd049042bce49bc2e6fe Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:14:23 -0600 Subject: [PATCH 53/61] Preserve in-flight timed votes on shutdown --- .../timequeue/TimeQueueHandler.java | 47 +++++++++++++++---- .../TimeQueueHandlerRejectionTest.java | 40 ++++++++++++++++ 2 files changed, 78 insertions(+), 9 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java index 335c17f6bb..5857601d95 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java @@ -35,6 +35,8 @@ public class TimeQueueHandler implements Listener { private final Deque timeChangeQueue = new ConcurrentLinkedDeque<>(); private final Deque completedDeliveries = new ArrayDeque<>(); private final Object queuePersistenceLock = new Object(); + /** Guarded by {@link #queuePersistenceLock}. */ + private VoteTimeQueue inFlightVote; private VotingPluginMain plugin; private final AtomicBoolean retryPending = new AtomicBoolean(); @@ -78,7 +80,7 @@ public boolean addVoteDurably(UUID voteId, String voteUsername, String voteSiteN synchronized (queuePersistenceLock) { timeChangeQueue.add(vote); try { - plugin.getServerData().replaceTimedVoteCache(new ArrayList<>(timeChangeQueue)); + plugin.getServerData().replaceTimedVoteCache(pendingVoteSnapshot()); return true; } catch (RuntimeException persistenceFailure) { timeChangeQueue.remove(vote); @@ -187,9 +189,12 @@ private boolean persistQueueSnapshot() { private boolean persistQueueSnapshot(UUID completedVoteId) { List durableCompletions; synchronized (queuePersistenceLock) { - if (completedVoteId != null) completedDeliveries.addLast(completedVoteId); + if (completedVoteId != null) { + inFlightVote = null; + completedDeliveries.addLast(completedVoteId); + } try { - plugin.getServerData().replaceTimedVoteCache(new ArrayList<>(timeChangeQueue)); + plugin.getServerData().replaceTimedVoteCache(pendingVoteSnapshot()); durableCompletions = new ArrayList<>(completedDeliveries); completedDeliveries.clear(); } catch (RuntimeException persistenceFailure) { @@ -212,8 +217,14 @@ private void ensureTransitionActive(TimeChangeTransition transition) { * Processes all votes in the queue. */ public void processQueue() { - while (getTimeChangeQueue().size() > 0) { - VoteTimeQueue vote = getTimeChangeQueue().remove(); + while (true) { + VoteTimeQueue vote; + synchronized (queuePersistenceLock) { + if (inFlightVote != null) return; + vote = timeChangeQueue.pollFirst(); + if (vote == null) return; + inFlightVote = vote; + } PlayerVoteEvent voteEvent = new PlayerVoteEvent( plugin.getVoteSiteManager().getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, vote.getService()), true), vote.getName(), vote.getService(), true); @@ -226,7 +237,10 @@ public void processQueue() { try { plugin.getServerData().quarantineTimedVote(vote); } catch (RuntimeException persistenceFailure) { - timeChangeQueue.addFirst(vote); + synchronized (queuePersistenceLock) { + timeChangeQueue.addFirst(vote); + if (inFlightVote == vote) inFlightVote = null; + } plugin.getLogger().severe("Unable to quarantine ambiguous timed vote " + voteEvent.getVoteId() + "; retained it in the durable retry queue"); plugin.debug(persistenceFailure); @@ -235,13 +249,19 @@ public void processQueue() { } plugin.getLogger().severe("Timed vote " + voteEvent.getVoteId() + " reached an ambiguous post-effect failure and was retained for manual review"); + synchronized (queuePersistenceLock) { + if (inFlightVote == vote) inFlightVote = null; + } if (!persistQueueSnapshot()) { scheduleRetry(true); return; } continue; } - timeChangeQueue.addFirst(vote); + synchronized (queuePersistenceLock) { + timeChangeQueue.addFirst(vote); + if (inFlightVote == vote) inFlightVote = null; + } scheduleRetry(!persistQueueSnapshot()); return; } @@ -262,10 +282,19 @@ public void processQueue() { */ public void save() { synchronized (queuePersistenceLock) { - if (!timeChangeQueue.isEmpty()) { - plugin.getServerData().replaceTimedVoteCache(new ArrayList<>(timeChangeQueue)); + List pending = pendingVoteSnapshot(); + if (!pending.isEmpty()) { + plugin.getServerData().replaceTimedVoteCache(pending); } timeChangeQueue.clear(); } } + + /** Must be called while holding {@link #queuePersistenceLock}. */ + private List pendingVoteSnapshot() { + List pending = new ArrayList<>(timeChangeQueue.size() + (inFlightVote == null ? 0 : 1)); + if (inFlightVote != null) pending.add(inFlightVote); + pending.addAll(timeChangeQueue); + return pending; + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java index 2bd5663236..a0ba10466f 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; @@ -19,9 +20,12 @@ import java.util.List; import java.util.Set; import java.util.UUID; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Logger; import org.bukkit.configuration.ConfigurationSection; @@ -198,6 +202,42 @@ void postAdmissionProcessingFailureRetainsTheSameQueuedVote() { verify(serverData).replaceTimedVoteCache(List.of(vote)); } + @Test + void shutdownSnapshotIncludesTheVoteCurrentlyBeingProcessed() throws Exception { + when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of()); + TimeQueueHandler handler = new TimeQueueHandler(plugin); + VoteTimeQueue first = new VoteTimeQueue(UUID.randomUUID(), "Alex", "first.example", 123L); + VoteTimeQueue second = new VoteTimeQueue(UUID.randomUUID(), "Steve", "second.example", 124L); + handler.getTimeChangeQueue().add(first); + handler.getTimeChangeQueue().add(second); + CountDownLatch processing = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + org.bukkit.plugin.PluginManager pluginManager = plugin.getServer().getPluginManager(); + doAnswer(invocation -> { + processing.countDown(); + release.await(); + throw new CancellationException("shutdown"); + }).when(pluginManager).callEvent(any(PlayerVoteEvent.class)); + AtomicReference failure = new AtomicReference<>(); + Thread worker = new Thread(() -> { + try { + handler.processQueue(); + } catch (Throwable thrown) { + failure.set(thrown); + } + }); + worker.start(); + assertTrue(processing.await(1, TimeUnit.SECONDS)); + + handler.save(); + + verify(serverData).replaceTimedVoteCache(List.of(first, second)); + release.countDown(); + worker.join(1000L); + assertFalse(worker.isAlive()); + assertTrue(failure.get() instanceof CancellationException); + } + @Test void ambiguousPostEffectFailureIsQuarantinedInsteadOfReplayed() { when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of()); From 998c9beb4f1c32b217e89e9dc7612789fef50872 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:42:55 -0600 Subject: [PATCH 54/61] Preserve async vote retry ownership --- .../votingplugin/events/PlayerVoteEvent.java | 27 ++++++++++ .../listeners/PlayerVoteListener.java | 12 ++++- .../votingplugin/listeners/VotiferEvent.java | 11 ++++ .../timequeue/TimeQueueHandler.java | 20 +++++++- .../PlayerVoteListenerAdmissionTest.java | 51 ++++++++++++++++++- .../TimeQueueHandlerRejectionTest.java | 23 +++++++++ 6 files changed, 141 insertions(+), 3 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java index 8adaf68d8d..c7f8e0b645 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java @@ -1,6 +1,8 @@ package com.bencodez.votingplugin.events; import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.event.Event; import org.bukkit.event.HandlerList; @@ -106,6 +108,31 @@ public static HandlerList getHandlerList() { @Setter private boolean deferredDeliveryCompletion; + private final CompletableFuture processingCompletion = new CompletableFuture<>(); + + @Getter + private volatile boolean processingPending; + + /** + * Completion signal for producers that fire this event from the platform thread. + * Processing is moved to the vote executor in that case, so callers must not + * interpret a synchronous return from Bukkit event dispatch as vote completion. + */ + public CompletionStage getProcessingCompletion() { + return processingCompletion.minimalCompletionStage(); + } + + /** Marks asynchronous vote processing as admitted but not yet complete. */ + public void markProcessingPending() { + processingPending = true; + } + + /** Publishes the final processing flags to an asynchronous event producer. */ + public void completeProcessing() { + processingPending = false; + processingCompletion.complete(this); + } + public boolean isProcessingIncomplete() { return accountingAdmissionFailed || processingFailed; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java index d2fcb26895..4ca2568ed2 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java @@ -37,9 +37,17 @@ public PlayerVoteListener(VotingPluginMain plugin) { @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onplayerVote(PlayerVoteEvent event) { if (!VoteTaskAdmission.isVoteTask() && Bukkit.isPrimaryThread()) { + if (event.isDeferredDeliveryCompletion()) { + failAdmission(event, new SharedVoteAdmissionException( + "Durable vote producer must retry processing from the vote executor")); + event.completeProcessing(); + return; + } PlatformVoteState platformState = PlatformVoteState.capture(plugin, event.getPlayer()); + event.markProcessingPending(); if (!VoteTaskAdmission.trySubmit(plugin.getVoteTimer(), () -> processVote(event, platformState))) { failAdmission(event, new SharedVoteAdmissionException("Vote executor rejected accounting admission")); + event.completeProcessing(); } return; } @@ -51,11 +59,13 @@ private void processVote(PlayerVoteEvent event, PlatformVoteState platformState) SharedVoteProcessor.process(new BukkitOperations(plugin, event, platformState)); } catch (SharedVoteAdmissionException admissionFailure) { failAdmission(event, admissionFailure); - } catch (RuntimeException processingFailure) { + } catch (RuntimeException processingFailure) { event.setProcessingFailed(true); plugin.getLogger().severe("Vote processing did not complete for " + event.getPlayer() + '/' + event.getServiceSite() + "; a durable producer may retry it"); plugin.debug(processingFailure); + } finally { + event.completeProcessing(); } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java index 8aa6abd472..ead143f95c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java @@ -2,6 +2,7 @@ import java.util.UUID; +import org.bukkit.Bukkit; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; @@ -42,6 +43,13 @@ public void processVote(String voteSite, String voteUsername) { } public void processVote(String voteSite, String voteUsername, UUID voteId) { + if (!VoteTaskAdmission.isVoteTask() && Bukkit.getServer() != null && Bukkit.isPrimaryThread()) { + if (!VoteTaskAdmission.trySubmit(plugin.getVoteTimer(), + () -> processVote(voteSite, voteUsername, voteId))) { + retainForAccountingRetry(voteSite, voteUsername, voteId); + } + return; + } VoteOutcome outcome = processVoteAttempt(voteSite, voteUsername, voteId, false); if (outcome == VoteOutcome.RETRY) { retainForAccountingRetry(voteSite, voteUsername, voteId); @@ -64,6 +72,9 @@ public VoteOutcome processQueuedVoteOutcome(String voteSite, String voteUsername private VoteOutcome processVoteAttempt(String voteSite, String voteUsername, UUID voteId, boolean deferredDeliveryCompletion) { + if (!VoteTaskAdmission.isVoteTask() && Bukkit.getServer() != null && Bukkit.isPrimaryThread()) { + return VoteOutcome.RETRY; + } try { plugin.getServerData().addServiceSite(voteSite); if (plugin.getBungeeSettings().isUseBungeecoord() && !plugin.getBungeeSettings().isVotifierBypass() diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java index 5857601d95..e5ad2a9da8 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java @@ -203,7 +203,25 @@ private boolean persistQueueSnapshot(UUID completedVoteId) { return false; } } - for (UUID voteId : durableCompletions) VoteShopPurchaseService.completeVoteDelivery(plugin, voteId); + List failedRetirements = new ArrayList<>(); + for (UUID voteId : durableCompletions) { + try { + VoteShopPurchaseService.completeVoteDelivery(plugin, voteId); + } catch (RuntimeException persistenceFailure) { + failedRetirements.add(voteId); + plugin.getLogger().severe("Unable to retire completed timed vote " + voteId + + "; its durable completion will be retried"); + plugin.debug(persistenceFailure); + } + } + if (!failedRetirements.isEmpty()) { + synchronized (queuePersistenceLock) { + for (int index = failedRetirements.size() - 1; index >= 0; index--) { + completedDeliveries.addFirst(failedRetirements.get(index)); + } + } + scheduleRetry(true); + } return true; } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerAdmissionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerAdmissionTest.java index 181d11fb69..4294adec89 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerAdmissionTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerAdmissionTest.java @@ -21,6 +21,7 @@ import com.bencodez.simpleapi.scheduler.BukkitScheduler; import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.core.vote.SharedVoteAdmissionException; import com.bencodez.votingplugin.core.vote.SharedVoteProcessor; import com.bencodez.votingplugin.events.PlayerVoteEvent; import com.bencodez.votingplugin.user.UserManager; @@ -40,7 +41,34 @@ void primaryThreadEventDefersAccountingToTheVoteExecutor() { } verify(voteExecutor).submit(any(Runnable.class)); - assertFalse(event.isAccountingAdmissionFailed()); + assertTrue(event.isProcessingPending()); + assertFalse(event.isProcessingIncomplete()); + assertFalse(event.getProcessingCompletion().toCompletableFuture().isDone()); + } + + @Test + void asynchronousFailureCompletesTheProducerSignal() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + ScheduledExecutorService voteExecutor = mock(ScheduledExecutorService.class); + PlayerVoteEvent event = new PlayerVoteEvent(null, "player", "site", false); + when(plugin.getVoteTimer()).thenReturn(voteExecutor); + when(plugin.getLogger()).thenReturn(Logger.getLogger("PlayerVoteListenerAdmissionTest")); + org.mockito.ArgumentCaptor submitted = org.mockito.ArgumentCaptor.forClass(Runnable.class); + + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class); + MockedStatic processor = org.mockito.Mockito.mockStatic(SharedVoteProcessor.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + processor.when(() -> SharedVoteProcessor.process(any())) + .thenThrow(new SharedVoteAdmissionException("storage unavailable")); + new PlayerVoteListener(plugin).onplayerVote(event); + verify(voteExecutor).submit(submitted.capture()); + + submitted.getValue().run(); + } + + assertTrue(event.isAccountingAdmissionFailed()); + assertFalse(event.isProcessingPending()); + assertTrue(event.getProcessingCompletion().toCompletableFuture().isDone()); } @Test @@ -58,6 +86,27 @@ void rejectedDeferralSurfacesAccountingFailure() { } assertTrue(event.isAccountingAdmissionFailed()); + assertTrue(event.getProcessingCompletion().toCompletableFuture().isDone()); + } + + @Test + void primaryThreadDurableProducerIsRejectedForOwnedRetry() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + ScheduledExecutorService voteExecutor = mock(ScheduledExecutorService.class); + PlayerVoteEvent event = new PlayerVoteEvent(null, "player", "site", false); + event.setDeferredDeliveryCompletion(true); + when(plugin.getVoteTimer()).thenReturn(voteExecutor); + when(plugin.getLogger()).thenReturn(Logger.getLogger("PlayerVoteListenerAdmissionTest")); + + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + new PlayerVoteListener(plugin).onplayerVote(event); + } + + assertTrue(event.isAccountingAdmissionFailed()); + assertFalse(event.isProcessingPending()); + assertTrue(event.getProcessingCompletion().toCompletableFuture().isDone()); + verify(voteExecutor, never()).submit(any(Runnable.class)); } @Test diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java index a0ba10466f..1856182b00 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java @@ -339,6 +339,29 @@ void processingRetryWaitsForFailedSnapshotPersistence() { org.mockito.ArgumentMatchers.eq(0L), org.mockito.ArgumentMatchers.eq(TimeUnit.SECONDS)); } + @Test + void retirementFailureDoesNotStopLaterTimedVotes() { + when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of()); + TimeQueueHandler handler = new TimeQueueHandler(plugin); + clearInvocations(plugin.getBukkitScheduler()); + VoteTimeQueue first = new VoteTimeQueue(UUID.randomUUID(), "Alex", "first.example", 123L); + VoteTimeQueue second = new VoteTimeQueue(UUID.randomUUID(), "Steve", "second.example", 124L); + handler.getTimeChangeQueue().add(first); + handler.getTimeChangeQueue().add(second); + doThrow(new IllegalStateException("disk unavailable")) + .doNothing() + .when(serverData).clearVotePartyAccounting(first.getVoteId()); + + handler.processQueue(); + + assertTrue(handler.getTimeChangeQueue().isEmpty()); + verify(plugin.getServer().getPluginManager(), org.mockito.Mockito.times(2)) + .callEvent(any(PlayerVoteEvent.class)); + verify(serverData).clearVotePartyAccounting(second.getVoteId()); + verify(plugin.getBukkitScheduler()).runTaskLaterAsynchronously( + org.mockito.ArgumentMatchers.eq(plugin), any(Runnable.class), anyLong()); + } + @Test void disabledPluginDoesNotRegisterABukkitRetry() { when(plugin.isEnabled()).thenReturn(false); From 87bc5432f64081437805dbbf6fa8a2e109ddb757 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:54:24 -0600 Subject: [PATCH 55/61] Preserve vote capture and receipt retirement --- .../votingplugin/commands/CommandLoader.java | 75 ++----- .../votingplugin/commands/gui/AdminGUI.java | 24 +-- .../gui/admin/AdminVoteVotePlayer.java | 26 +-- .../listeners/VotifierVoteOverflowQueue.java | 183 ++++++++++++------ .../gui/admin/AdminVoteVotePlayerTest.java | 38 ++++ .../VotifierVoteOverflowQueueTest.java | 65 +++++++ 6 files changed, 264 insertions(+), 147 deletions(-) create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayerTest.java 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 2e95a22a51..4334e7ef7c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java @@ -92,7 +92,6 @@ 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; @@ -130,15 +129,16 @@ void runForVotingUser(VotingPluginUser user, Runnable task) { BukkitCompletionScheduler.run(plugin, user.getPlayer(), task); } - private boolean callVoteAndReport(CommandSender sender, PlayerVoteEvent event) { + private void callVoteAndReport(CommandSender sender, PlayerVoteEvent event, Runnable success) { plugin.getServer().getPluginManager().callEvent(event); - if (event.isProcessingIncomplete()) { - runForCommandSender(sender, - () -> sender.sendMessage(MessageAPI.colorize( - "&cVote could not be processed because shared storage is unavailable."))); - return false; - } - return true; + event.getProcessingCompletion().whenComplete((completed, failure) -> runForCommandSender(sender, () -> { + if (failure != null || completed.isProcessingIncomplete()) { + sender.sendMessage(MessageAPI.colorize( + "&cVote could not be processed because shared storage is unavailable.")); + } else if (success != null) { + success.run(); + } + })); } private void submitVoteAndReport(CommandSender sender, PlayerVoteEvent event) { @@ -146,12 +146,7 @@ private void submitVoteAndReport(CommandSender sender, PlayerVoteEvent event) { } private void submitVoteAndReport(CommandSender sender, PlayerVoteEvent event, Runnable success) { - if (!VoteTaskAdmission.trySubmit(plugin.getVoteTimer(), () -> { - if (callVoteAndReport(sender, event) && success != null) runForCommandSender(sender, success); - })) { - sender.sendMessage(MessageAPI.colorize( - "&cCould not trigger the vote because vote processing is busy; please try again later.")); - } + callVoteAndReport(sender, event, success); } void runBulkStorageMutation(CommandSender sender, Runnable mutation, Runnable success) { @@ -1405,31 +1400,15 @@ public void execute(CommandSender sender, String[] args) { @Override public void execute(CommandSender sender, String[] args) { sendMessage(sender, "&cTriggering vote for all voting sites..."); - int rejected = 0; for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { - if (!VoteTaskAdmission.trySubmit(plugin.getVoteTimer(), new Runnable() { - - @Override - public void run() { - PlayerVoteEvent voteEvent = new PlayerVoteEvent(site, args[1], site.getServiceSite(), - false); - if (voteEvent.getVoteSite() != null) { - if (!voteEvent.getVoteSite().isVaidServiceSite() - && !plugin.getConfigFile().isDisableNoServiceSiteMessage()) { - sendMessage(sender, - "&cPossible issue with service site, has the server gotten the vote from " - + voteEvent.getServiceSite() + "?"); - } - } - callVoteAndReport(sender, voteEvent); - } - })) { - rejected++; + PlayerVoteEvent voteEvent = new PlayerVoteEvent(site, args[1], site.getServiceSite(), false); + if (!voteEvent.getVoteSite().isVaidServiceSite() + && !plugin.getConfigFile().isDisableNoServiceSiteMessage()) { + sendMessage(sender, + "&cPossible issue with service site, has the server gotten the vote from " + + voteEvent.getServiceSite() + "?"); } - } - if (rejected > 0) { - sendMessage(sender, "&cCould not trigger " + rejected - + " vote(s) because vote processing is busy; please try again later."); + submitVoteAndReport(sender, voteEvent); } if (plugin.isYmlError()) { @@ -1456,15 +1435,7 @@ public void execute(CommandSender sender, String[] args) { sendMessage(sender, "&cTriggering vote..."); } - if (!VoteTaskAdmission.trySubmit(plugin.getVoteTimer(), new Runnable() { - - @Override - public void run() { - callVoteAndReport(sender, voteEvent); - } - })) { - sendMessage(sender, "&cCould not trigger the vote because vote processing is busy; please try again later."); - } + submitVoteAndReport(sender, voteEvent); if (plugin.isYmlError()) { sendMessage(sender, "&3Detected yml error, please check server log for details"); @@ -1494,15 +1465,7 @@ public void execute(CommandSender sender, String[] args) { + voteEvent.getServiceSite() + "?"); } } - if (!VoteTaskAdmission.trySubmit(plugin.getVoteTimer(), new Runnable() { - - @Override - public void run() { - callVoteAndReport(sender, voteEvent); - } - })) { - sendMessage(sender, "&cCould not trigger the vote because vote processing is busy; please try again later."); - } + submitVoteAndReport(sender, voteEvent); if (plugin.isYmlError()) { sendMessage(sender, "&3Detected yml error, please check server log for details"); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/AdminGUI.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/AdminGUI.java index 28a0aa27ae..b611661330 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/AdminGUI.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/AdminGUI.java @@ -33,7 +33,6 @@ import com.bencodez.votingplugin.commands.gui.admin.milestones.AdminVoteVoteMilestones; import com.bencodez.votingplugin.commands.gui.admin.voteshop.AdminVoteVoteShop; import com.bencodez.votingplugin.events.PlayerVoteEvent; -import com.bencodez.votingplugin.util.VoteTaskAdmission; import com.bencodez.votingplugin.votesites.VoteSite; /** @@ -325,21 +324,14 @@ public void onInput(Player player, String value) { if (ob != null) { VoteSite site = (VoteSite) ob; PlayerVoteEvent voteEvent = new PlayerVoteEvent(site, value, site.getServiceSite(), false); - if (!VoteTaskAdmission.trySubmit(plugin.getVoteTimer(), new Runnable() { - - @Override - public void run() { - plugin.getServer().getPluginManager().callEvent(voteEvent); - if (voteEvent.isProcessingIncomplete()) { - plugin.getBukkitScheduler().runTask(plugin, - () -> player.sendMessage("Vote could not be processed because shared storage is unavailable."), - player); - } - } - })) { - player.sendMessage( - "Vote could not be triggered because vote processing is busy; please try again later."); - } + plugin.getServer().getPluginManager().callEvent(voteEvent); + voteEvent.getProcessingCompletion().whenComplete((completed, failure) -> { + if (failure == null && !completed.isProcessingIncomplete()) return; + plugin.getBukkitScheduler().runTask(plugin, + () -> player.sendMessage( + "Vote could not be processed because shared storage is unavailable."), + player); + }); } } }); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayer.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayer.java index e156a2454a..ac7fc45d11 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayer.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayer.java @@ -12,7 +12,6 @@ import com.bencodez.advancedcore.api.inventory.editgui.EditGUI; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.events.PlayerVoteEvent; -import com.bencodez.votingplugin.util.VoteTaskAdmission; import com.bencodez.votingplugin.votesites.VoteSite; /** @@ -70,18 +69,7 @@ public void onClick(ClickEvent clickEvent) { + voteEvent.getServiceSite() + "?"); } } - if (!VoteTaskAdmission.trySubmit(plugin.getVoteTimer(), () -> { - plugin.getServer().getPluginManager().callEvent(voteEvent); - if (voteEvent.isProcessingIncomplete()) { - plugin.getBukkitScheduler().runTask(plugin, - () -> sendMessage(clickEvent.getPlayer(), - "&cVote could not be processed because shared storage is unavailable."), - clickEvent.getPlayer()); - } - })) { - sendMessage(clickEvent.getPlayer(), - "&cVote could not be triggered because vote processing is busy; please try again later."); - } + dispatchVote(clickEvent.getPlayer(), voteEvent); if (plugin.isYmlError()) { sendMessage(clickEvent.getPlayer(), @@ -93,6 +81,18 @@ public void onClick(ClickEvent clickEvent) { inv.openInventory(player); } + + void dispatchVote(Player player, PlayerVoteEvent voteEvent) { + // Dispatch from the player-owned thread so PlayerVoteListener can capture + // Bukkit state before it hands storage and accounting to the vote worker. + plugin.getServer().getPluginManager().callEvent(voteEvent); + voteEvent.getProcessingCompletion().whenComplete((completed, failure) -> { + if (failure == null && !completed.isProcessingIncomplete()) return; + plugin.getBukkitScheduler().runTask(plugin, + () -> sendMessage("&cVote could not be processed because shared storage is unavailable."), + player); + }); + } @Override public void onDialog(Player player) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java index 500a66b543..02fd5b564b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java @@ -54,6 +54,7 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable { private final ArrayDeque retiredVotes = new ArrayDeque<>(); private boolean drainScheduled; private boolean persistenceScheduled; + private boolean retirementRetryScheduled; private boolean persistenceDirty; private boolean started; private boolean closed; @@ -96,6 +97,7 @@ public void start() { synchronized (lock) { if (closed || started) return; started = true; + scheduleRetirementRetryLocked(0L); scheduleDrainLocked(); } } @@ -115,7 +117,7 @@ public boolean enqueue(String username, String serviceSite) { public boolean enqueue(String username, String serviceSite, UUID voteId) { if (username == null || serviceSite == null || voteId == null) return false; synchronized (lock) { - if (closed || entries.size() >= MAX_ENTRIES) return false; + if (closed || ownedEntriesLocked() >= MAX_ENTRIES) return false; entries.addLast(new PendingVote(username, serviceSite, System.currentTimeMillis(), voteId)); stateVersion++; requestPersistenceLocked(); @@ -144,12 +146,12 @@ public boolean quarantine(String username, String serviceSite, UUID voteId) { private boolean admitDurably(PendingVote pending) { synchronized (lock) { - if (closed || entries.size() >= MAX_ENTRIES) return false; + if (closed || ownedEntriesLocked() >= MAX_ENTRIES) return false; entries.addLast(pending); stateVersion++; } - List snapshot; + QueueSnapshot snapshot; long snapshotVersion; try { synchronized (persistenceWriteLock) { @@ -157,7 +159,7 @@ private boolean admitDurably(PendingVote pending) { // snapshot cannot overtake this confirmed quarantine write. synchronized (lock) { if (closed) return false; - snapshot = new ArrayList<>(entries); + snapshot = snapshotLocked(); snapshotVersion = stateVersion; } writeSnapshotLocked(snapshot); @@ -172,16 +174,12 @@ private boolean admitDurably(PendingVote pending) { return false; } - List completedRetirements = new ArrayList<>(); synchronized (lock) { durableVersion = Math.max(durableVersion, snapshotVersion); - while (!retiredVotes.isEmpty() && retiredVotes.peekFirst().version <= durableVersion) { - completedRetirements.add(retiredVotes.removeFirst().voteId); - } + scheduleRetirementRetryLocked(0L); if (stateVersion != snapshotVersion) requestPersistenceLocked(); scheduleDrainLocked(); } - for (UUID completedVoteId : completedRetirements) completeDelivery(completedVoteId); return true; } @@ -192,10 +190,14 @@ private boolean admitDurably(PendingVote pending) { */ public int size() { synchronized (lock) { - return entries.size(); + return ownedEntriesLocked(); } } + private int ownedEntriesLocked() { + return entries.size() + retiredVotes.size(); + } + private void scheduleDrain() { synchronized (lock) { scheduleDrainLocked(); @@ -302,9 +304,47 @@ private void requestPersistenceLocked() { } } + private void scheduleRetirementRetryLocked(long delayMillis) { + if (closed || !started || retiredVotes.isEmpty() || retirementRetryScheduled) return; + boolean durableRetirement = retiredVotes.stream().anyMatch(retired -> retired.version <= durableVersion); + if (!durableRetirement) return; + retirementRetryScheduled = true; + try { + worker.schedule(this::retryRetirements, delayMillis, TimeUnit.MILLISECONDS); + } catch (RejectedExecutionException ignored) { + retirementRetryScheduled = false; + } + } + + private void retryRetirements() { + List candidates = new ArrayList<>(); + synchronized (lock) { + retirementRetryScheduled = false; + if (closed) return; + for (RetiredVote retired : retiredVotes) { + if (retired.version <= durableVersion) candidates.add(retired); + } + } + List completed = new ArrayList<>(); + for (RetiredVote retired : candidates) { + if (completeDelivery(retired.voteId)) completed.add(retired); + } + synchronized (lock) { + if (closed) return; + if (!completed.isEmpty()) { + retiredVotes.removeAll(completed); + stateVersion++; + requestPersistenceLocked(); + } + if (completed.size() != candidates.size()) { + scheduleRetirementRetryLocked(RETRY_DELAY_MILLIS); + } + } + } + private void persistLoop() { while (true) { - List snapshot; + QueueSnapshot snapshot; long snapshotVersion; synchronized (lock) { if (!persistenceDirty) { @@ -312,7 +352,7 @@ private void persistLoop() { return; } persistenceDirty = false; - snapshot = new ArrayList<>(entries); + snapshot = snapshotLocked(); snapshotVersion = stateVersion; } try { @@ -329,23 +369,17 @@ private void persistLoop() { } return; } - List completedRetirements = new ArrayList<>(); synchronized (lock) { // closeBlocking owns the final snapshot and marker retirement once // closed; writeSnapshot deliberately becomes a no-op in that race. if (closed) return; durableVersion = Math.max(durableVersion, snapshotVersion); - while (!retiredVotes.isEmpty() && retiredVotes.peekFirst().version <= durableVersion) { - completedRetirements.add(retiredVotes.removeFirst().voteId); - } + scheduleRetirementRetryLocked(0L); if (!persistenceDirty) { persistenceScheduled = false; scheduleDrainLocked(); } } - for (UUID voteId : completedRetirements) { - completeDelivery(voteId); - } synchronized (lock) { if (!persistenceDirty) return; } @@ -358,39 +392,59 @@ private void load() { YamlConfiguration yaml = new YamlConfiguration(); yaml.loadFromString(readQueueFile()); Object raw = yaml.get("Votes"); - if (!(raw instanceof List values)) return; boolean skipped = false; - for (Object value : values) { - if (entries.size() >= MAX_ENTRIES) { - skipped = true; - break; - } - if (!(value instanceof Map map)) { - skipped = true; - continue; - } - Object username = map.get("Username"); - Object serviceSite = map.get("ServiceSite"); - Object time = map.get("Time"); - Object rawVoteId = map.get("VoteId"); - if (!(username instanceof String name) || !(serviceSite instanceof String site) - || !(time instanceof Number timestamp) - || !MinecraftUsernameValidator.isValid(name, plugin.getOptions().getBedrockPlayerPrefix()) - || !ServiceSiteValidator.isValid(site) || timestamp.longValue() <= 0) { - skipped = true; - continue; + if (raw instanceof List values) { + for (Object value : values) { + if (ownedEntriesLocked() >= MAX_ENTRIES) { + skipped = true; + break; + } + if (!(value instanceof Map map)) { + skipped = true; + continue; + } + Object username = map.get("Username"); + Object serviceSite = map.get("ServiceSite"); + Object time = map.get("Time"); + Object rawVoteId = map.get("VoteId"); + if (!(username instanceof String name) || !(serviceSite instanceof String site) + || !(time instanceof Number timestamp) + || !MinecraftUsernameValidator.isValid(name, plugin.getOptions().getBedrockPlayerPrefix()) + || !ServiceSiteValidator.isValid(site) || timestamp.longValue() <= 0) { + skipped = true; + continue; + } + UUID voteId; + try { + voteId = rawVoteId instanceof String id ? UUID.fromString(id) : UUID.randomUUID(); + if (!(rawVoteId instanceof String)) skipped = true; + } catch (IllegalArgumentException invalidVoteId) { + skipped = true; + continue; + } + PendingVote pending = new PendingVote(name, site, timestamp.longValue(), voteId); + pending.quarantined = Boolean.TRUE.equals(map.get("Quarantined")); + entries.addLast(pending); } - UUID voteId; - try { - voteId = rawVoteId instanceof String id ? UUID.fromString(id) : UUID.randomUUID(); - if (!(rawVoteId instanceof String)) skipped = true; - } catch (IllegalArgumentException invalidVoteId) { - skipped = true; - continue; + } else if (raw != null) { + skipped = true; + } + Object rawRetirements = yaml.get("Retirements"); + if (rawRetirements instanceof List values) { + for (Object value : values) { + if (ownedEntriesLocked() >= MAX_ENTRIES) { + skipped = true; + break; + } + try { + if (!(value instanceof String id)) throw new IllegalArgumentException("non-string retirement"); + retiredVotes.addLast(new RetiredVote(UUID.fromString(id), 0L)); + } catch (IllegalArgumentException invalidRetirement) { + skipped = true; + } } - PendingVote pending = new PendingVote(name, site, timestamp.longValue(), voteId); - pending.quarantined = Boolean.TRUE.equals(map.get("Quarantined")); - entries.addLast(pending); + } else if (rawRetirements != null) { + skipped = true; } if (skipped) { synchronized (lock) { @@ -415,7 +469,13 @@ private String readQueueFile() throws IOException { } } - private void writeSnapshot(List snapshot, long snapshotVersion) throws IOException { + private QueueSnapshot snapshotLocked() { + List retirements = new ArrayList<>(); + for (RetiredVote retired : retiredVotes) retirements.add(retired.voteId); + return new QueueSnapshot(new ArrayList<>(entries), retirements); + } + + private void writeSnapshot(QueueSnapshot snapshot, long snapshotVersion) throws IOException { synchronized (persistenceWriteLock) { if (closed) return; // A confirmed synchronous quarantine snapshot may overtake a worker @@ -427,10 +487,10 @@ private void writeSnapshot(List snapshot, long snapshotVersion) thr } } - private void writeSnapshotLocked(List snapshot) throws IOException { + private void writeSnapshotLocked(QueueSnapshot snapshot) throws IOException { YamlConfiguration yaml = new YamlConfiguration(); List> values = new ArrayList<>(); - for (PendingVote pending : snapshot) { + for (PendingVote pending : snapshot.votes) { Map value = new LinkedHashMap<>(); value.put("Username", pending.username); value.put("ServiceSite", pending.serviceSite); @@ -440,6 +500,7 @@ private void writeSnapshotLocked(List snapshot) throws IOException values.add(value); } yaml.set("Votes", values); + yaml.set("Retirements", snapshot.retirements.stream().map(UUID::toString).toList()); byte[] bytes = yaml.saveToString().getBytes(StandardCharsets.UTF_8); if (bytes.length > MAX_FILE_BYTES) throw new IOException("queue snapshot exceeds size limit"); Path parent = file.toAbsolutePath().normalize().getParent(); @@ -462,13 +523,13 @@ private void writeSnapshotLocked(List snapshot) throws IOException @Override public void close() { - List snapshot; + QueueSnapshot snapshot; synchronized (lock) { if (closed) return; closed = true; // At-least-once delivery: retain any unacknowledged callback. A callback // interrupted during shutdown must be replayed rather than silently lost. - snapshot = new ArrayList<>(entries); + snapshot = snapshotLocked(); persistenceScheduled = false; } worker.shutdownNow(); @@ -481,26 +542,22 @@ public void close() { synchronized (persistenceWriteLock) { writeSnapshotLocked(snapshot); } - List completedRetirements = new ArrayList<>(); - synchronized (lock) { - while (!retiredVotes.isEmpty()) completedRetirements.add(retiredVotes.removeFirst().voteId); - } - for (UUID voteId : completedRetirements) { - completeDelivery(voteId); - } + for (UUID voteId : snapshot.retirements) completeDelivery(voteId); } catch (IOException failure) { plugin.getLogger().warning("Unable to persist queued Votifier votes during shutdown: " + failure.getClass().getSimpleName()); } } - private void completeDelivery(UUID voteId) { + private boolean completeDelivery(UUID voteId) { try { VoteShopPurchaseService.completeVoteDelivery(plugin, voteId); + return true; } catch (RuntimeException failure) { plugin.getLogger().warning("Unable to retire acknowledged Votifier vote replay fence: " + failure.getClass().getSimpleName()); plugin.debug(failure); + return false; } } @@ -522,6 +579,8 @@ private PendingVote(String username, String serviceSite, long time, UUID voteId) private record RetiredVote(UUID voteId, long version) { } + private record QueueSnapshot(List votes, List retirements) { } + @FunctionalInterface public interface VoteProcessor { VoteOutcome accept(String serviceSite, String username, UUID voteId); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayerTest.java new file mode 100644 index 0000000000..7f4f57301a --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayerTest.java @@ -0,0 +1,38 @@ +package com.bencodez.votingplugin.commands.gui.admin; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +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.concurrent.ScheduledExecutorService; + +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.Test; + +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.events.PlayerVoteEvent; + +class AdminVoteVotePlayerTest { + @Test + void dispatchesFromPlayerThreadBeforeVoteWorkerAdmission() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + Server server = mock(Server.class); + PluginManager pluginManager = mock(PluginManager.class); + ScheduledExecutorService voteTimer = mock(ScheduledExecutorService.class); + Player player = mock(Player.class); + PlayerVoteEvent event = new PlayerVoteEvent(null, "Steve", "example.org", false); + when(plugin.getServer()).thenReturn(server); + when(server.getPluginManager()).thenReturn(pluginManager); + when(plugin.getVoteTimer()).thenReturn(voteTimer); + + new AdminVoteVotePlayer(plugin, player, "Steve").dispatchVote(player, event); + + verify(pluginManager).callEvent(event); + verify(voteTimer, never()).submit(any(Runnable.class)); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java index 410270d467..37a49294f6 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java @@ -20,6 +20,8 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.logging.Logger; import org.junit.jupiter.api.Test; @@ -85,6 +87,69 @@ void successfulVoteRetiresReplayFenceAfterQueueSnapshot(@TempDir Path dataFolder } } + @Test + void failedReceiptRetirementSurvivesRestartWithoutReplayingVote(@TempDir Path dataFolder) throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + ServerData serverData = mock(ServerData.class); + ScheduledExecutorService voteTimer = Executors.newSingleThreadScheduledExecutor(); + AtomicBoolean failRetirement = new AtomicBoolean(true); + AtomicInteger retirementAttempts = new AtomicInteger(); + AtomicInteger processed = new AtomicInteger(); + java.util.UUID voteId = java.util.UUID.randomUUID(); + when(plugin.getDataFolder()).thenReturn(dataFolder.toFile()); + when(plugin.getVoteTimer()).thenReturn(voteTimer); + when(plugin.getServerData()).thenReturn(serverData); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getLogger()).thenReturn(Logger.getLogger("VotifierVoteOverflowQueueTest")); + doAnswer(invocation -> { + retirementAttempts.incrementAndGet(); + if (failRetirement.get()) throw new IllegalStateException("storage unavailable"); + return null; + }).when(serverData).clearVotePartyAccounting(voteId); + + VotifierVoteOverflowQueue queue = new VotifierVoteOverflowQueue(plugin, (site, user, id) -> { + processed.incrementAndGet(); + return VotifierVoteOverflowQueue.VoteOutcome.COMPLETE; + }); + try { + assertTrue(queue.enqueue("Steve", "example.org", voteId)); + queue.start(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); + Path file = dataFolder.resolve("VotifierVoteQueue.yml"); + while ((retirementAttempts.get() == 0 || !Files.exists(file) + || !Files.readString(file).contains(voteId.toString())) && System.nanoTime() < deadline) { + Thread.sleep(10L); + } + assertTrue(Files.readString(file).contains(voteId.toString())); + assertEquals(1, processed.get()); + } finally { + queue.close(); + voteTimer.shutdownNow(); + } + + failRetirement.set(false); + ScheduledExecutorService restartedTimer = Executors.newSingleThreadScheduledExecutor(); + when(plugin.getVoteTimer()).thenReturn(restartedTimer); + VotifierVoteOverflowQueue restarted = new VotifierVoteOverflowQueue(plugin, (site, user, id) -> { + processed.incrementAndGet(); + return VotifierVoteOverflowQueue.VoteOutcome.COMPLETE; + }); + try { + restarted.start(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); + Path file = dataFolder.resolve("VotifierVoteQueue.yml"); + while ((restarted.size() != 0 || Files.readString(file).contains(voteId.toString())) + && System.nanoTime() < deadline) Thread.sleep(10L); + assertEquals(0, restarted.size()); + assertTrue(!Files.readString(file).contains(voteId.toString())); + assertEquals(1, processed.get()); + assertTrue(retirementAttempts.get() >= 2); + } finally { + restarted.close(); + restartedTimer.shutdownNow(); + } + } + @Test void failedQueuedAttemptKeepsTheExistingEntry(@TempDir Path dataFolder) throws Exception { VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); From fd8173c44c07469b4feb46e676feffe1c61096e3 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 26 Sep 2026 18:35:30 -0600 Subject: [PATCH 56/61] Complete cancelled vote processing signals --- .../listeners/PlayerVoteListener.java | 6 ++++- .../PlayerVoteListenerAdmissionTest.java | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java index 16b2900887..4d6f9f4b00 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java @@ -34,8 +34,12 @@ public PlayerVoteListener(VotingPluginMain plugin) { this.plugin = plugin; } - @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false) public void onplayerVote(PlayerVoteEvent event) { + if (event.isCancelled()) { + event.completeProcessing(); + return; + } if (!VoteTaskAdmission.isVoteTask() && Bukkit.isPrimaryThread()) { if (event.isDeferredDeliveryCompletion()) { failAdmission(event, new SharedVoteAdmissionException( diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerAdmissionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerAdmissionTest.java index c35fbbcfb3..b206a71148 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerAdmissionTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerAdmissionTest.java @@ -28,6 +28,28 @@ import com.bencodez.votingplugin.user.VotingPluginUser; class PlayerVoteListenerAdmissionTest { + @Test + void cancelledEventCompletesProducerSignalWithoutProcessing() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + ScheduledExecutorService voteExecutor = mock(ScheduledExecutorService.class); + PlayerVoteEvent event = new PlayerVoteEvent(null, "player", "site", false); + event.setCancelled(true); + when(plugin.getVoteTimer()).thenReturn(voteExecutor); + + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class); + MockedStatic processor = org.mockito.Mockito.mockStatic(SharedVoteProcessor.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + new PlayerVoteListener(plugin).onplayerVote(event); + + processor.verifyNoInteractions(); + } + + assertTrue(event.isCancelled()); + assertFalse(event.isProcessingPending()); + assertTrue(event.getProcessingCompletion().toCompletableFuture().isDone()); + verify(voteExecutor, never()).submit(any(Runnable.class)); + } + @Test void primaryThreadEventDefersAccountingToTheVoteExecutor() { VotingPluginMain plugin = mock(VotingPluginMain.class); From 672b456acb660003af89a16f02ec2e5408beccbd Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 26 Sep 2026 18:54:53 -0600 Subject: [PATCH 57/61] Close vote recovery durability gaps --- .../votingplugin/data/ServerData.java | 28 +++++--- .../data/TimeChangeUserCheckpointStore.java | 1 + .../service/SharedMysqlPurchaseJournal.java | 53 +++++++++++++++ .../service/VoteShopPurchaseService.java | 9 ++- .../ServerDataTimeChangeRecoveryTest.java | 68 +++++++++++++++++++ .../SharedMysqlPurchaseJournalTest.java | 21 ++++++ 6 files changed, 171 insertions(+), 9 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index 59c8ef3681..0f9b6ef2af 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -229,10 +229,16 @@ public synchronized boolean isVoteReplayUnsafe(UUID voteId) { public synchronized void clearVoteReplayUnsafe(UUID voteId) { if (voteId == null) return; if (!getData().contains(VOTE_REPLAY_UNSAFE + "." + voteId)) return; - getData().set(VOTE_REPLAY_UNSAFE + "." + voteId, null); - ConfigurationSection remaining = getData().getConfigurationSection(VOTE_REPLAY_UNSAFE); - if (remaining != null && remaining.getKeys(false).isEmpty()) getData().set(VOTE_REPLAY_UNSAFE, null); - saveData(); + Map previous = snapshotSection(VOTE_REPLAY_UNSAFE); + try { + getData().set(VOTE_REPLAY_UNSAFE + "." + voteId, null); + ConfigurationSection remaining = getData().getConfigurationSection(VOTE_REPLAY_UNSAFE); + if (remaining != null && remaining.getKeys(false).isEmpty()) getData().set(VOTE_REPLAY_UNSAFE, null); + saveData(); + } catch (RuntimeException | Error failure) { + restoreSection(VOTE_REPLAY_UNSAFE, previous); + throw failure; + } } /** @@ -574,10 +580,16 @@ public synchronized void clearVotePartyAccounting(UUID voteId) { if (voteId == null) return; String receiptPath = VOTE_PARTY_ACCOUNTING + "." + voteId; if (!getData().contains(receiptPath)) return; - getData().set(receiptPath, null); - ConfigurationSection remaining = getData().getConfigurationSection(VOTE_PARTY_ACCOUNTING); - if (remaining != null && remaining.getKeys(false).isEmpty()) getData().set(VOTE_PARTY_ACCOUNTING, null); - saveData(); + Map previous = snapshotSection(VOTE_PARTY_ACCOUNTING); + try { + getData().set(receiptPath, null); + ConfigurationSection remaining = getData().getConfigurationSection(VOTE_PARTY_ACCOUNTING); + if (remaining != null && remaining.getKeys(false).isEmpty()) getData().set(VOTE_PARTY_ACCOUNTING, null); + saveData(); + } catch (RuntimeException | Error failure) { + restoreSection(VOTE_PARTY_ACCOUNTING, previous); + throw failure; + } } /** diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/TimeChangeUserCheckpointStore.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/TimeChangeUserCheckpointStore.java index 8b4e85faee..103a4188ca 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/TimeChangeUserCheckpointStore.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/TimeChangeUserCheckpointStore.java @@ -123,6 +123,7 @@ private void persist(TimeType type, State replacement) { StandardOpenOption.WRITE); DurableFiles.publishStagedFile(temporary, file); } catch (DurableFiles.PublishedException published) { + if (previous == null) states.remove(type); else states.put(type, previous); throw new IllegalStateException("Checkpoint was published but directory durability failed", published); } catch (IOException failure) { if (previous == null) states.remove(type); else states.put(type, previous); 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 index 39558bf962..1388eb19a1 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -512,6 +512,48 @@ void markVoteReplayUnsafe(UUID voteId) throws SQLException { } } + /** Retains a bounded replay tombstone after the durable delivery owner completes. */ + void retireVoteAccounting(UUID voteId, long retiredAt) throws SQLException { + if (voteId == null) return; + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try { + String retire = "UPDATE " + qiAccounting() + " SET " + qi("retired_at") + " = COALESCE(" + + qi("retired_at") + ", ?) WHERE " + qi("vote_id") + " = ?"; + try (PreparedStatement statement = connection.prepareStatement(retire)) { + statement.setLong(1, retiredAt); + statement.setString(2, voteId.toString()); + statement.executeUpdate(); + } + long cutoff = retiredAt - TERMINAL_RETENTION_MILLIS; + String select = "SELECT " + qi("vote_id") + " FROM " + qiAccounting() + " WHERE " + + qi("retired_at") + " IS NOT NULL AND " + qi("retired_at") + + " <= ? ORDER BY " + qi("retired_at") + " ASC LIMIT ?"; + List expired = new ArrayList<>(); + try (PreparedStatement statement = connection.prepareStatement(select)) { + statement.setLong(1, cutoff); + statement.setInt(2, CLEANUP_BATCH_SIZE); + try (ResultSet result = statement.executeQuery()) { + while (result.next()) expired.add(result.getString(1)); + } + } + String delete = "DELETE FROM " + qiAccounting() + " WHERE " + qi("vote_id") + " = ? AND " + + qi("retired_at") + " IS NOT NULL AND " + qi("retired_at") + " <= ?"; + try (PreparedStatement statement = connection.prepareStatement(delete)) { + for (String expiredId : expired) { + statement.setString(1, expiredId); + statement.setLong(2, cutoff); + statement.executeUpdate(); + } + } + connection.commit(); + } catch (SQLException failure) { + rollback(connection); + throw failure; + } + } + } + private int countPendingPeriodTotals(Connection connection, UUID voteId, String uuid, int operation) throws SQLException { String select = "SELECT COUNT(*) FROM " + qiAccounting() + " WHERE " + qi("player_uuid") @@ -1567,6 +1609,7 @@ private void ensureAccountingSchema(Connection connection) throws SQLException { + qi("point_amount") + " INT NULL, " + qi("point_cap") + " INT NULL, " + qi("point_column") + " VARCHAR(128) NULL, " + qi("replay_unsafe") + " INT NOT NULL DEFAULT 0, " + + qi("retired_at") + " BIGINT NULL, " + qi("created_at") + " BIGINT NOT NULL, PRIMARY KEY (" + qi("vote_id") + "));"; try (PreparedStatement statement = connection.prepareStatement(create)) { @@ -1584,6 +1627,7 @@ private void ensureAccountingSchema(Connection connection) throws SQLException { ensureAccountingColumn(connection, "point_cap", "INT NULL"); ensureAccountingColumn(connection, "point_column", "VARCHAR(128) NULL"); ensureAccountingColumn(connection, "replay_unsafe", "INT NOT NULL DEFAULT 0"); + ensureAccountingColumn(connection, "retired_at", "BIGINT NULL"); try (PreparedStatement migrate = connection.prepareStatement("UPDATE " + qiAccounting() + " SET " + qi("requested") + " = " + qi("completed") + " WHERE " + qi("requested") + " = 0 AND " + qi("completed") + " <> 0")) { @@ -1598,6 +1642,15 @@ private void ensureAccountingSchema(Connection connection) throws SQLException { } catch (SQLException failure) { if (failure.getErrorCode() != 1061 && !"42P07".equals(failure.getSQLState())) throw failure; } + String retiredIndex = "vp_vsa_retired_" + hash(accountingTable).substring(0, 16); + String createRetiredIndex = "CREATE INDEX " + + (table.getDbType() == DbType.POSTGRESQL ? "IF NOT EXISTS " : "") + qi(retiredIndex) + " ON " + + qiAccounting() + " (" + qi("retired_at") + ");"; + try (PreparedStatement statement = connection.prepareStatement(createRetiredIndex)) { + statement.executeUpdate(); + } catch (SQLException failure) { + if (failure.getErrorCode() != 1061 && !"42P07".equals(failure.getSQLState())) throw failure; + } } private void ensureAccountingColumn(Connection connection, String column, String definition) throws SQLException { 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 49652abc72..8f2894dbee 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 @@ -799,7 +799,14 @@ public static boolean markVoteReplayUnsafe(VotingPluginMain plugin, UUID voteId) public static void completeVoteDelivery(VotingPluginMain plugin, UUID voteId) { if (voteId == null) return; plugin.getServerData().clearVotePartyAccounting(voteId); - if (!canRecoverSharedMysqlPurchases(plugin)) plugin.getServerData().clearVoteReplayUnsafe(voteId); + if (canRecoverSharedMysqlPurchases(plugin)) { + try { + SharedMysqlPurchaseJournal.forTable(plugin.getMysql()).retireVoteAccounting(voteId, + System.currentTimeMillis()); + } catch (SQLException failure) { + throw new IllegalStateException("Unable to retire shared MySQL vote accounting", failure); + } + } else plugin.getServerData().clearVoteReplayUnsafe(voteId); } public static void finishMysqlVoteAccounting(UUID voteId) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java index b620252c99..51f175f999 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java @@ -12,10 +12,13 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.mockStatic; import java.util.List; import java.util.UUID; import java.nio.file.Path; +import java.io.IOException; +import java.util.concurrent.atomic.AtomicInteger; import org.bukkit.configuration.file.YamlConfiguration; import org.junit.jupiter.api.Test; @@ -33,6 +36,7 @@ import com.bencodez.votingplugin.data.ServerData.TimeChangeUserProgress; import com.bencodez.votingplugin.data.ServerData.TimeChangeUserPolicy; import com.bencodez.votingplugin.timequeue.VoteTimeQueue; +import com.bencodez.votingplugin.util.DurableFiles; class ServerDataTimeChangeRecoveryTest { @TempDir @@ -134,6 +138,70 @@ void failedReplayFenceSaveRestoresThePreviousYamlState() { assertFalse(data.isVoteReplayUnsafe(failed)); } + @Test + void failedReplayFenceRetirementRestoresThePreviousYamlState() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); + YamlConfiguration yaml = new YamlConfiguration(); + when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile()); + when(plugin.getServerDataFile()).thenReturn(coreData); + when(coreData.getData()).thenReturn(yaml); + ServerData data = new ServerData(plugin); + UUID voteId = UUID.randomUUID(); + data.markVoteReplayUnsafe(voteId); + doThrow(new IllegalStateException("disk unavailable")).when(coreData).saveData(); + + assertThrows(IllegalStateException.class, () -> data.clearVoteReplayUnsafe(voteId)); + + assertTrue(data.isVoteReplayUnsafe(voteId)); + } + + @Test + void failedVotePartyRetirementRestoresThePreviousYamlState() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); + YamlConfiguration yaml = new YamlConfiguration(); + when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile()); + when(plugin.getServerDataFile()).thenReturn(coreData); + when(coreData.getData()).thenReturn(yaml); + ServerData data = new ServerData(plugin); + UUID voteId = UUID.randomUUID(); + assertTrue(data.incrementVotePartyTotal(voteId)); + doThrow(new IllegalStateException("disk unavailable")).when(coreData).saveData(); + + assertThrows(IllegalStateException.class, () -> data.clearVotePartyAccounting(voteId)); + + assertTrue(yaml.contains("VotingPlugin.VoteParty.Accounting." + voteId)); + } + + @Test + void publishedCheckpointDurabilityFailureRequiresAnotherPublicationAttempt() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class); + YamlConfiguration yaml = new YamlConfiguration(); + when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile()); + when(plugin.getServerDataFile()).thenReturn(coreData); + when(coreData.getData()).thenReturn(yaml); + ServerData data = new ServerData(plugin); + TimeChangeTransition transition = transition("WEEK:2026-W38", "2026-W38", TimeType.WEEK); + AtomicInteger attempts = new AtomicInteger(); + + try (org.mockito.MockedStatic files = mockStatic(DurableFiles.class)) { + files.when(() -> DurableFiles.publishStagedFile( + org.mockito.ArgumentMatchers.any(Path.class), org.mockito.ArgumentMatchers.any(Path.class))) + .thenAnswer(ignored -> { + if (attempts.getAndIncrement() == 0) + throw new DurableFiles.PublishedException(new IOException("directory force failed")); + return null; + }); + + assertThrows(IllegalStateException.class, () -> data.beginTimeChangeRecovery(transition)); + data.beginTimeChangeRecovery(transition); + } + + assertEquals(2, attempts.get()); + } + @Test void failedTimedVoteSnapshotSaveRestoresThePreviousYamlState() { VotingPluginMain plugin = mock(VotingPluginMain.class); 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 index 2d1b61a1b4..2b51e0aa59 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -24,6 +24,27 @@ import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; class SharedMysqlPurchaseJournalTest { + @Test + void retiredVoteAccountingKeepsABoundedTombstoneAndCleansExpiredRows() throws Exception { + Fixture fixture = fixture(); + PreparedStatement retire = mock(PreparedStatement.class); + PreparedStatement select = mock(PreparedStatement.class); + PreparedStatement delete = mock(PreparedStatement.class); + ResultSet expired = ids("expired-vote-id"); + when(select.executeQuery()).thenReturn(expired); + when(fixture.work.prepareStatement(anyString())).thenReturn(retire, select, delete); + UUID voteId = UUID.randomUUID(); + long retiredAt = SharedMysqlPurchaseJournal.TERMINAL_RETENTION_MILLIS + 5000L; + + new SharedMysqlPurchaseJournal(fixture.table, false).retireVoteAccounting(voteId, retiredAt); + + verify(retire).setLong(1, retiredAt); + verify(retire).setString(2, voteId.toString()); + verify(select).setLong(1, 5000L); + verify(delete).setString(1, "expired-vote-id"); + verify(delete).setLong(2, 5000L); + verify(fixture.work).commit(); + } @Test void dailyStreakResetCompletionFollowsThePersistedCopyMarker() throws Exception { Fixture fixture = fixture(); From 9c086cc7be8b7787f91b3ede1f6c7c98afab7e35 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:11:50 -0600 Subject: [PATCH 58/61] Fix vote event dispatch and accounting cleanup --- .../votingplugin/commands/CommandLoader.java | 20 +++++++++---------- .../votingplugin/commands/gui/AdminGUI.java | 2 +- .../gui/admin/AdminVoteVotePlayer.java | 2 +- .../votingplugin/events/PlayerVoteEvent.java | 20 ++++++++++++++++--- .../service/SharedMysqlPurchaseJournal.java | 6 ++++-- .../gui/admin/AdminVoteVotePlayerTest.java | 10 +++++++++- .../SharedMysqlPurchaseJournalTest.java | 4 ++++ 7 files changed, 46 insertions(+), 18 deletions(-) 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 4334e7ef7c..f0c7144cd5 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java @@ -1401,7 +1401,7 @@ public void execute(CommandSender sender, String[] args) { public void execute(CommandSender sender, String[] args) { sendMessage(sender, "&cTriggering vote for all voting sites..."); for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { - PlayerVoteEvent voteEvent = new PlayerVoteEvent(site, args[1], site.getServiceSite(), false); + PlayerVoteEvent voteEvent = new PlayerVoteEvent(site, args[1], site.getServiceSite(), false, false); if (!voteEvent.getVoteSite().isVaidServiceSite() && !plugin.getConfigFile().isDisableNoServiceSiteMessage()) { sendMessage(sender, @@ -1424,7 +1424,7 @@ public void execute(CommandSender sender, String[] args) { @Override public void execute(CommandSender sender, String[] args) { PlayerVoteEvent voteEvent = new PlayerVoteEvent(plugin.getVoteSiteManager().getVoteSite(args[2], true), - args[1], args[2], false); + args[1], args[2], false, false); if (voteEvent.getVoteSite() != null) { if (!voteEvent.getVoteSite().isVaidServiceSite() && !plugin.getConfigFile().isDisableNoServiceSiteMessage()) { @@ -1453,7 +1453,7 @@ public void execute(CommandSender sender, String[] args) { @Override public void execute(CommandSender sender, String[] args) { PlayerVoteEvent voteEvent = new PlayerVoteEvent( - plugin.getVoteSiteManager().getVoteSite(args[2], true), args[1], args[2], false); + plugin.getVoteSiteManager().getVoteSite(args[2], true), args[1], args[2], false, false); if (!plugin.getConfigFile().isDisableNoServiceSiteMessage() && !isPlayer(sender)) { sendMessage(sender, "&cTriggering vote..."); } @@ -1501,7 +1501,7 @@ public void execute(CommandSender sender, String[] args) { sendMessage(sender, "&cTriggering vote for all voting sites..."); for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { PlayerVoteEvent voteEvent = new PlayerVoteEvent(site, args[1], site.getServiceSite(), - false); + false, false); if (voteEvent.getVoteSite() != null) { if (!voteEvent.getVoteSite().isVaidServiceSite() && !plugin.getConfigFile().isDisableNoServiceSiteMessage()) { @@ -1527,7 +1527,7 @@ public void execute(CommandSender sender, String[] args) { @Override public void execute(CommandSender sender, String[] args) { PlayerVoteEvent voteEvent = new PlayerVoteEvent( - plugin.getVoteSiteManager().getVoteSite(args[3], true), args[1], args[3], false); + plugin.getVoteSiteManager().getVoteSite(args[3], true), args[1], args[3], false, false); if (!plugin.getConfigFile().isDisableNoServiceSiteMessage() && !isPlayer(sender)) { sendMessage(sender, "&cTriggering vote..."); } @@ -3195,11 +3195,11 @@ public void onClick(ClickEvent clickEvent) { @Override public void onInput(Player player, String value) { PlayerVoteEvent voteEvent = new PlayerVoteEvent( - plugin.getVoteSiteManager().getVoteSite(value, true), - UserGUI.getInstance().getCurrentPlayer(player), - plugin.getVoteSiteManager() - .getVoteSiteServiceSite(value), - false); + plugin.getVoteSiteManager().getVoteSite(value, true), + UserGUI.getInstance().getCurrentPlayer(player), + plugin.getVoteSiteManager() + .getVoteSiteServiceSite(value), + false, false); String target = UserGUI.getInstance().getCurrentPlayer(player); submitVoteAndReport(player, voteEvent, () -> player .sendMessage("Forced vote for " + target + " on " + value)); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/AdminGUI.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/AdminGUI.java index b611661330..ab8c8c2eb2 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/AdminGUI.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/AdminGUI.java @@ -323,7 +323,7 @@ public void onInput(Player player, String value) { Object ob = PlayerUtils.getPlayerMeta(plugin, player, "VoteSite"); if (ob != null) { VoteSite site = (VoteSite) ob; - PlayerVoteEvent voteEvent = new PlayerVoteEvent(site, value, site.getServiceSite(), false); + PlayerVoteEvent voteEvent = new PlayerVoteEvent(site, value, site.getServiceSite(), false, false); plugin.getServer().getPluginManager().callEvent(voteEvent); voteEvent.getProcessingCompletion().whenComplete((completed, failure) -> { if (failure == null && !completed.isProcessingIncomplete()) return; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayer.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayer.java index ac7fc45d11..004606ae7b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayer.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayer.java @@ -60,7 +60,7 @@ public void onChest(Player player) { @Override public void onClick(ClickEvent clickEvent) { VoteSite site = (VoteSite) getData("site"); - PlayerVoteEvent voteEvent = new PlayerVoteEvent(site, playerName, site.getServiceSite(), false); + PlayerVoteEvent voteEvent = new PlayerVoteEvent(site, playerName, site.getServiceSite(), false, false); sendMessage(clickEvent.getPlayer(), "&cTriggering vote..."); if (voteEvent.getVoteSite() != null) { if (!voteEvent.getVoteSite().isVaidServiceSite()) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java index c7f8e0b645..95927aa0c8 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java @@ -145,9 +145,23 @@ public boolean isProcessingIncomplete() { * @param serviceSite the service site name * @param realVote whether this is a real vote */ - public PlayerVoteEvent(VoteSite voteSite, String voteUsername, String serviceSite, boolean realVote) { - super(true); - this.player = voteUsername; + public PlayerVoteEvent(VoteSite voteSite, String voteUsername, String serviceSite, boolean realVote) { + this(voteSite, voteUsername, serviceSite, realVote, true); + } + + /** + * Constructs a vote event with an explicit Bukkit dispatch contract. + * + * @param voteSite the vote site + * @param voteUsername the username of the voter + * @param serviceSite the service site name + * @param realVote whether this is a real vote + * @param asynchronous whether Bukkit will dispatch the event off its primary thread + */ + public PlayerVoteEvent(VoteSite voteSite, String voteUsername, String serviceSite, boolean realVote, + boolean asynchronous) { + super(asynchronous); + this.player = voteUsername; this.voteSite = voteSite; this.realVote = realVote; this.serviceSite = serviceSite; 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 index 1388eb19a1..cdfd760184 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -528,7 +528,8 @@ void retireVoteAccounting(UUID voteId, long retiredAt) throws SQLException { long cutoff = retiredAt - TERMINAL_RETENTION_MILLIS; String select = "SELECT " + qi("vote_id") + " FROM " + qiAccounting() + " WHERE " + qi("retired_at") + " IS NOT NULL AND " + qi("retired_at") - + " <= ? ORDER BY " + qi("retired_at") + " ASC LIMIT ?"; + + " <= ? AND " + qi("requested") + " = " + qi("completed") + " ORDER BY " + + qi("retired_at") + " ASC LIMIT ?"; List expired = new ArrayList<>(); try (PreparedStatement statement = connection.prepareStatement(select)) { statement.setLong(1, cutoff); @@ -538,7 +539,8 @@ void retireVoteAccounting(UUID voteId, long retiredAt) throws SQLException { } } String delete = "DELETE FROM " + qiAccounting() + " WHERE " + qi("vote_id") + " = ? AND " - + qi("retired_at") + " IS NOT NULL AND " + qi("retired_at") + " <= ?"; + + qi("retired_at") + " IS NOT NULL AND " + qi("retired_at") + " <= ? AND " + + qi("requested") + " = " + qi("completed"); try (PreparedStatement statement = connection.prepareStatement(delete)) { for (String expiredId : expired) { statement.setString(1, expiredId); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayerTest.java index 7f4f57301a..55f7a799a2 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayerTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayerTest.java @@ -1,5 +1,7 @@ package com.bencodez.votingplugin.commands.gui.admin; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.RETURNS_DEEP_STUBS; import static org.mockito.Mockito.mock; @@ -25,14 +27,20 @@ void dispatchesFromPlayerThreadBeforeVoteWorkerAdmission() { PluginManager pluginManager = mock(PluginManager.class); ScheduledExecutorService voteTimer = mock(ScheduledExecutorService.class); Player player = mock(Player.class); - PlayerVoteEvent event = new PlayerVoteEvent(null, "Steve", "example.org", false); + PlayerVoteEvent event = new PlayerVoteEvent(null, "Steve", "example.org", false, false); when(plugin.getServer()).thenReturn(server); when(server.getPluginManager()).thenReturn(pluginManager); when(plugin.getVoteTimer()).thenReturn(voteTimer); new AdminVoteVotePlayer(plugin, player, "Steve").dispatchVote(player, event); + assertFalse(event.isAsynchronous()); verify(pluginManager).callEvent(event); verify(voteTimer, never()).submit(any(Runnable.class)); } + + @Test + void existingVoteEventConstructorRetainsItsAsynchronousContract() { + assertTrue(new PlayerVoteEvent(null, "Steve", "example.org", false).isAsynchronous()); + } } 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 index 2b51e0aa59..affa541d0b 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -43,6 +43,10 @@ void retiredVoteAccountingKeepsABoundedTombstoneAndCleansExpiredRows() throws Ex verify(select).setLong(1, 5000L); verify(delete).setString(1, "expired-vote-id"); verify(delete).setLong(2, 5000L); + 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("`requested` = `completed`")); + assertTrue(sql.getAllValues().get(2).contains("`requested` = `completed`")); verify(fixture.work).commit(); } @Test From d42eee260e782b64823da82f274064f8837c86fc Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:26:26 -0600 Subject: [PATCH 59/61] Dispatch admin votes from the target owner --- .../votingplugin/commands/CommandLoader.java | 3 ++- .../votingplugin/commands/gui/AdminGUI.java | 3 ++- .../gui/admin/AdminVoteVotePlayer.java | 5 ++-- .../listeners/PlayerVoteListener.java | 2 +- .../util/BukkitVoteEventDispatcher.java | 19 ++++++++++++++ .../gui/admin/AdminVoteVotePlayerTest.java | 25 +++++++++++++------ .../PlayerVoteListenerAdmissionTest.java | 18 +++++++++++++ 7 files changed, 61 insertions(+), 14 deletions(-) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitVoteEventDispatcher.java 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 f0c7144cd5..544d28fcaa 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java @@ -93,6 +93,7 @@ import com.bencodez.votingplugin.user.VotingPluginUser; import com.bencodez.votingplugin.user.PointTransferResult; import com.bencodez.votingplugin.util.BukkitCompletionScheduler; +import com.bencodez.votingplugin.util.BukkitVoteEventDispatcher; import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseResult; import com.bencodez.votingplugin.voteshop.shop.VoteShopEntry; import com.bencodez.votingplugin.voteshop.shop.VoteShopItem; @@ -130,7 +131,7 @@ void runForVotingUser(VotingPluginUser user, Runnable task) { } private void callVoteAndReport(CommandSender sender, PlayerVoteEvent event, Runnable success) { - plugin.getServer().getPluginManager().callEvent(event); + BukkitVoteEventDispatcher.dispatch(plugin, event); event.getProcessingCompletion().whenComplete((completed, failure) -> runForCommandSender(sender, () -> { if (failure != null || completed.isProcessingIncomplete()) { sender.sendMessage(MessageAPI.colorize( diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/AdminGUI.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/AdminGUI.java index ab8c8c2eb2..983f25658c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/AdminGUI.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/AdminGUI.java @@ -33,6 +33,7 @@ import com.bencodez.votingplugin.commands.gui.admin.milestones.AdminVoteVoteMilestones; import com.bencodez.votingplugin.commands.gui.admin.voteshop.AdminVoteVoteShop; import com.bencodez.votingplugin.events.PlayerVoteEvent; +import com.bencodez.votingplugin.util.BukkitVoteEventDispatcher; import com.bencodez.votingplugin.votesites.VoteSite; /** @@ -324,7 +325,7 @@ public void onInput(Player player, String value) { if (ob != null) { VoteSite site = (VoteSite) ob; PlayerVoteEvent voteEvent = new PlayerVoteEvent(site, value, site.getServiceSite(), false, false); - plugin.getServer().getPluginManager().callEvent(voteEvent); + BukkitVoteEventDispatcher.dispatch(plugin, voteEvent); voteEvent.getProcessingCompletion().whenComplete((completed, failure) -> { if (failure == null && !completed.isProcessingIncomplete()) return; plugin.getBukkitScheduler().runTask(plugin, diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayer.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayer.java index 004606ae7b..271121e0b5 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayer.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayer.java @@ -12,6 +12,7 @@ import com.bencodez.advancedcore.api.inventory.editgui.EditGUI; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.events.PlayerVoteEvent; +import com.bencodez.votingplugin.util.BukkitVoteEventDispatcher; import com.bencodez.votingplugin.votesites.VoteSite; /** @@ -83,9 +84,7 @@ public void onClick(ClickEvent clickEvent) { } void dispatchVote(Player player, PlayerVoteEvent voteEvent) { - // Dispatch from the player-owned thread so PlayerVoteListener can capture - // Bukkit state before it hands storage and accounting to the vote worker. - plugin.getServer().getPluginManager().callEvent(voteEvent); + BukkitVoteEventDispatcher.dispatch(plugin, voteEvent); voteEvent.getProcessingCompletion().whenComplete((completed, failure) -> { if (failure == null && !completed.isProcessingIncomplete()) return; plugin.getBukkitScheduler().runTask(plugin, diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java index 4d6f9f4b00..c8f0dad961 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java @@ -40,7 +40,7 @@ public void onplayerVote(PlayerVoteEvent event) { event.completeProcessing(); return; } - if (!VoteTaskAdmission.isVoteTask() && Bukkit.isPrimaryThread()) { + if (!VoteTaskAdmission.isVoteTask() && (Bukkit.isPrimaryThread() || !event.isAsynchronous())) { if (event.isDeferredDeliveryCompletion()) { failAdmission(event, new SharedVoteAdmissionException( "Durable vote producer must retry processing from the vote executor")); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitVoteEventDispatcher.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitVoteEventDispatcher.java new file mode 100644 index 0000000000..68165d2a6b --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitVoteEventDispatcher.java @@ -0,0 +1,19 @@ +package com.bencodez.votingplugin.util; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.events.PlayerVoteEvent; + +/** Dispatches synchronous vote events from the voted player's Bukkit/Folia owner. */ +public final class BukkitVoteEventDispatcher { + private BukkitVoteEventDispatcher() { + } + + public static void dispatch(VotingPluginMain plugin, PlayerVoteEvent event) { + Player target = Bukkit.getPlayerExact(event.getPlayer()); + BukkitCompletionScheduler.run(plugin, target, + () -> plugin.getServer().getPluginManager().callEvent(event)); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayerTest.java index 55f7a799a2..92aa2a5ef9 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayerTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayerTest.java @@ -2,20 +2,22 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.RETURNS_DEEP_STUBS; 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.concurrent.ScheduledExecutorService; - +import org.bukkit.Bukkit; import org.bukkit.Server; import org.bukkit.entity.Player; import org.bukkit.plugin.PluginManager; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.events.PlayerVoteEvent; @@ -25,18 +27,25 @@ void dispatchesFromPlayerThreadBeforeVoteWorkerAdmission() { VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); Server server = mock(Server.class); PluginManager pluginManager = mock(PluginManager.class); - ScheduledExecutorService voteTimer = mock(ScheduledExecutorService.class); - Player player = mock(Player.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + Player sender = mock(Player.class); + Player target = mock(Player.class); PlayerVoteEvent event = new PlayerVoteEvent(null, "Steve", "example.org", false, false); when(plugin.getServer()).thenReturn(server); when(server.getPluginManager()).thenReturn(pluginManager); - when(plugin.getVoteTimer()).thenReturn(voteTimer); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + ArgumentCaptor dispatched = ArgumentCaptor.forClass(Runnable.class); - new AdminVoteVotePlayer(plugin, player, "Steve").dispatchVote(player, event); + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayerExact("Steve")).thenReturn(target); + new AdminVoteVotePlayer(plugin, sender, "Steve").dispatchVote(sender, event); + } assertFalse(event.isAsynchronous()); + verify(scheduler).runTask(same(plugin), dispatched.capture(), same(target)); + verify(pluginManager, never()).callEvent(event); + dispatched.getValue().run(); verify(pluginManager).callEvent(event); - verify(voteTimer, never()).submit(any(Runnable.class)); } @Test diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerAdmissionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerAdmissionTest.java index b206a71148..9f700c07b6 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerAdmissionTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerAdmissionTest.java @@ -68,6 +68,24 @@ void primaryThreadEventDefersAccountingToTheVoteExecutor() { assertFalse(event.getProcessingCompletion().toCompletableFuture().isDone()); } + @Test + void synchronousFoliaOwnedEventDefersAccountingWhenNotOnPrimaryThread() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + ScheduledExecutorService voteExecutor = mock(ScheduledExecutorService.class); + PlayerVoteEvent event = new PlayerVoteEvent(null, "player", "site", false, false); + when(plugin.getVoteTimer()).thenReturn(voteExecutor); + + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); + bukkit.when(() -> Bukkit.getPlayerExact("player")).thenReturn(null); + new PlayerVoteListener(plugin).onplayerVote(event); + } + + verify(voteExecutor).submit(any(Runnable.class)); + assertTrue(event.isProcessingPending()); + assertFalse(event.getProcessingCompletion().toCompletableFuture().isDone()); + } + @Test void asynchronousFailureCompletesTheProducerSignal() { VotingPluginMain plugin = mock(VotingPluginMain.class); From b2631cae06598c5b7d8d31dfcba4d202d1e186e9 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:53:05 -0600 Subject: [PATCH 60/61] Preserve failed vote completion ownership --- .../BackendOrderedVoteOverflowQueue.java | 37 +++++++++-- .../backendproxy/BackendProxyHandler.java | 63 +++++++++++++++++++ .../util/BukkitVoteEventDispatcher.java | 17 ++++- .../BackendProxyHandlerLifecycleTest.java | 63 ++++++++++++++++++- .../gui/admin/AdminVoteVotePlayerTest.java | 49 +++++++++++++++ 5 files changed, 222 insertions(+), 7 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendOrderedVoteOverflowQueue.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendOrderedVoteOverflowQueue.java index 1f40192cf3..21bf3a55c5 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendOrderedVoteOverflowQueue.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendOrderedVoteOverflowQueue.java @@ -341,11 +341,26 @@ PendingEnvelope peekDurable() { /** Persists completion off the platform thread before removing the durable head. */ void acknowledgeAsync(PendingEnvelope expected, Consumer completion) { + completeHeadAsync(expected, null, completion); + } + + /** Atomically replaces the durable head with follow-up work. */ + void replaceAsync(PendingEnvelope expected, JsonEnvelope replacement, Consumer completion) { + PendingEnvelope pendingReplacement = pending(replacement); + if (pendingReplacement == null) { + completion.accept(false); + return; + } + completeHeadAsync(expected, pendingReplacement, completion); + } + + private void completeHeadAsync(PendingEnvelope expected, PendingEnvelope replacement, + Consumer completion) { PendingAcknowledgement request = null; synchronized (lock) { if (!closeRequested.get() && !closed && !loadFailed && expected != null && entries.peekFirst() == expected && pendingAcknowledgement == null && pendingFailure == null) { - request = new PendingAcknowledgement(expected, completion); + request = new PendingAcknowledgement(expected, replacement, completion); pendingAcknowledgement = request; } } @@ -382,6 +397,7 @@ private Boolean acknowledge(PendingAcknowledgement request) { } List active = payloadSnapshotLocked(); active.remove(0); + if (request.replacement != null) active.add(0, request.replacement.payload); try { writeSnapshotLocked(active, failedSnapshotLocked()); } catch (IOException failure) { @@ -389,6 +405,7 @@ private Boolean acknowledge(PendingAcknowledgement request) { return false; } entries.removeFirst(); + if (request.replacement != null) entries.addFirst(request.replacement); durableVersion = ++stateVersion; request.stored = true; pendingAcknowledgement = null; @@ -464,7 +481,8 @@ private static boolean isOrderedVoteMessage(JsonEnvelope envelope) { return VotingPluginWire.SUB_VOTE.equals(subChannel) || VotingPluginWire.SUB_VOTE_ONLINE.equals(subChannel) || VotingPluginWire.SUB_VOTE_UPDATE.equals(subChannel) - || VotingPluginWire.SUB_VOTE_DELIVERY_RECEIPT_RELEASE.equals(subChannel); + || VotingPluginWire.SUB_VOTE_DELIVERY_RECEIPT_RELEASE.equals(subChannel) + || BackendProxyHandler.isLegacyDeliveryRetirement(envelope); } private void requestPersistenceLocked() { @@ -689,7 +707,12 @@ private void closeBlocking() { && (closingFailure.expected == null || entries.peekFirst() == closingFailure.expected); canStoreAcknowledgement = closingAcknowledgement != null && !closingAcknowledgement.stored && entries.peekFirst() == closingAcknowledgement.expected; - if (canStoreAcknowledgement) snapshot.remove(0); + if (canStoreAcknowledgement) { + snapshot.remove(0); + if (closingAcknowledgement.replacement != null) { + snapshot.add(0, closingAcknowledgement.replacement.payload); + } + } if (canStoreFailure) { if (closingFailure.expected != null) snapshot.remove(0); if (!closingFailureAlreadyStored) failures.add(closingFailure.failed.payload); @@ -733,6 +756,9 @@ private void closeBlocking() { if (saved && canStoreAcknowledgement) { synchronized (lock) { entries.removeFirst(); + if (closingAcknowledgement.replacement != null) { + entries.addFirst(closingAcknowledgement.replacement); + } closingAcknowledgement.stored = true; } } @@ -756,12 +782,15 @@ enum CloseState { OPEN, CLOSING, WAITING_TO_PERSIST, SNAPSHOT_REPLACED, COMPLETE private static final class PendingAcknowledgement { private final PendingEnvelope expected; + private final PendingEnvelope replacement; private final Consumer completion; private final AtomicBoolean completed = new AtomicBoolean(); private boolean stored; - private PendingAcknowledgement(PendingEnvelope expected, Consumer completion) { + private PendingAcknowledgement(PendingEnvelope expected, PendingEnvelope replacement, + Consumer completion) { this.expected = expected; + this.replacement = replacement; this.completion = completion; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java index d9f490702b..6006ad5277 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java @@ -40,6 +40,7 @@ * Coordinates backend/proxy communication components. */ public class BackendProxyHandler implements Listener { + private static final String SUB_LEGACY_DELIVERY_RETIREMENT = "LegacyVoteDeliveryRetirement"; private final VotingPluginMain plugin; static final int MAX_ORDERED_VOTE_QUEUE = 256; @@ -436,6 +437,16 @@ private void runNextOrderedVoteDispatch() { } }; try { + if (isLegacyDeliveryRetirement(next)) { + try { + completeLegacyDeliveryRetirement(next); + complete.accept(OrderedVoteOutcome.COMPLETE_WITHOUT_RETIREMENT); + } catch (RuntimeException retirementFailure) { + plugin.debug(retirementFailure); + complete.accept(OrderedVoteOutcome.RETRY); + } + return; + } if (dispatchDurableReceiptRelease(messageRouter, next)) { complete.accept(OrderedVoteOutcome.COMPLETE); return; @@ -470,6 +481,16 @@ private void finishOrderedVoteDispatch(BackendOrderedVoteOverflowQueue.PendingEn boolean successful = outcome == OrderedVoteOutcome.COMPLETE || outcome == OrderedVoteOutcome.COMPLETE_WITHOUT_RETIREMENT; boolean retireDelivery = outcome == OrderedVoteOutcome.COMPLETE; + if (successful && retireDelivery && requiresLegacyDeliveryRetirement(envelope)) { + JsonEnvelope retirement = legacyDeliveryRetirement(envelope); + if (overflowEntry != null && orderedVoteOverflow != null) { + orderedVoteOverflow.replaceAsync(overflowEntry, retirement, + stored -> completeOrderedVoteAcknowledgement(stored, retirement, false)); + } else { + replaceInMemoryVoteWithRetirement(envelope, retirement); + } + return; + } if (successful && overflowEntry != null && orderedVoteOverflow != null) { orderedVoteOverflow.acknowledgeAsync(overflowEntry, stored -> completeOrderedVoteAcknowledgement(stored, envelope, retireDelivery)); @@ -492,6 +513,48 @@ private void finishOrderedVoteDispatch(BackendOrderedVoteOverflowQueue.PendingEn if (completedInMemory && retireDelivery) completeVoteDelivery(envelope); } + private boolean requiresLegacyDeliveryRetirement(JsonEnvelope envelope) { + if (envelope == null || VotingPluginWire.requestsVoteDeliveryAcknowledgement(envelope)) return false; + if (!VotingPluginWire.SUB_VOTE.equals(envelope.getSubChannel()) + && !VotingPluginWire.SUB_VOTE_ONLINE.equals(envelope.getSubChannel())) return false; + return VotingPluginWire.resolveVoteId(VotingPluginWire.readVote(envelope)) != null; + } + + static boolean isLegacyDeliveryRetirement(JsonEnvelope envelope) { + return envelope != null && SUB_LEGACY_DELIVERY_RETIREMENT.equals(envelope.getSubChannel()); + } + + private JsonEnvelope legacyDeliveryRetirement(JsonEnvelope envelope) { + UUID voteId = VotingPluginWire.resolveVoteId(VotingPluginWire.readVote(envelope)); + return JsonEnvelope.builder(SUB_LEGACY_DELIVERY_RETIREMENT) + .put(VotingPluginWire.K_VOTE_ID, voteId.toString()).build(); + } + + private void completeLegacyDeliveryRetirement(JsonEnvelope envelope) { + VoteShopPurchaseService.completeVoteDelivery(plugin, + UUID.fromString(envelope.getFields().get(VotingPluginWire.K_VOTE_ID))); + } + + private void replaceInMemoryVoteWithRetirement(JsonEnvelope envelope, JsonEnvelope retirement) { + synchronized (orderedVoteDispatch) { + if (orderedVoteDispatchInFlight != envelope || orderedVoteDispatchQueue.peekFirst() != envelope) { + orderedVoteQuarantineFailed = true; + orderedVoteDispatchPaused = true; + if (plugin != null && plugin.getLogger() != null) { + plugin.getLogger().severe("Unable to retain legacy proxy vote retirement; processing has stopped"); + } + } else { + orderedVoteDispatchQueue.removeFirst(); + orderedVoteDispatchQueue.addFirst(retirement); + } + orderedVoteDispatchInFlight = null; + orderedVoteOverflowInFlight = null; + orderedVoteDispatchActive = false; + orderedVoteDispatch.notifyAll(); + scheduleOrderedVoteDispatchLocked(); + } + } + private void completeOrderedVoteAcknowledgement(boolean stored, JsonEnvelope envelope, boolean retireDelivery) { synchronized (orderedVoteDispatch) { if (stored) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitVoteEventDispatcher.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitVoteEventDispatcher.java index 68165d2a6b..e55d5fff03 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitVoteEventDispatcher.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitVoteEventDispatcher.java @@ -13,7 +13,20 @@ private BukkitVoteEventDispatcher() { public static void dispatch(VotingPluginMain plugin, PlayerVoteEvent event) { Player target = Bukkit.getPlayerExact(event.getPlayer()); - BukkitCompletionScheduler.run(plugin, target, - () -> plugin.getServer().getPluginManager().callEvent(event)); + Runnable rejected = () -> fail(event); + BukkitCompletionScheduler.run(plugin, target, () -> { + try { + plugin.getServer().getPluginManager().callEvent(event); + } catch (RuntimeException | Error dispatchFailure) { + plugin.debug(dispatchFailure); + fail(event); + throw dispatchFailure; + } + }, rejected); + } + + private static void fail(PlayerVoteEvent event) { + event.setProcessingFailed(true); + event.completeProcessing(); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandlerLifecycleTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandlerLifecycleTest.java index 4fea23db38..425df90670 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandlerLifecycleTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandlerLifecycleTest.java @@ -104,8 +104,69 @@ void completedLegacyVoteRetiresTheTotalsReplayFence() throws Exception { handler.dispatchIncomingAfterPublication(VotingPluginWire.vote("Player", UUID.randomUUID().toString(), "Service", 100L, true, true, legacyTotals, null, true, false, 1, 1), mock(Runnable.class)); asyncTasks.removeFirst().run(); + asyncTasks.removeFirst().run(); verify(serverData).clearVoteReplayUnsafe(voteId); + verify(router).handleOrderedVote(any(JsonEnvelope.class), any()); + } + + @Test + void failedLegacyVoteRetirementKeepsDurableQueueOwnershipAndRetries(@TempDir Path tempDir) throws Exception { + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + ServerData serverData = mock(ServerData.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.getServerData()).thenReturn(serverData); + when(plugin.getDataFolder()).thenReturn(tempDir.toFile()); + when(plugin.getLogger()).thenReturn(Logger.getLogger("legacy-retirement-retry-test")); + LinkedBlockingQueue asyncTasks = new LinkedBlockingQueue<>(); + doAnswer(invocation -> { + asyncTasks.add(invocation.getArgument(1)); + return null; + }).when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin); + BackendProxyHandler handler = new BackendProxyHandler(plugin, new ProcessedVoteCache(), overflow); + BackendProxyMessageRouter router = mock(BackendProxyMessageRouter.class); + setField(handler, "messageRouter", router); + doAnswer(invocation -> { + invocation.>getArgument(1) + .accept(OrderedVoteOutcome.COMPLETE); + return null; + }).when(router).handleOrderedVote(any(JsonEnvelope.class), any()); + handler.activateInboundMessages(); + UUID voteId = UUID.randomUUID(); + doThrow(new IllegalStateException("storage unavailable")).doNothing() + .when(serverData).clearVoteReplayUnsafe(voteId); + JsonEnvelope vote = VotingPluginWire.vote("Player", UUID.randomUUID().toString(), + "Service", 100L, true, true, "1//2//3//4//5//0//6//7//8//" + voteId, + null, true, false, 1, 1); + + try { + assertTrue(overflow.enqueue(vote)); + asyncTasks.poll(3, TimeUnit.SECONDS).run(); + Runnable retirement = asyncTasks.poll(3, TimeUnit.SECONDS); + assertNotNull(retirement, "durable vote must be replaced by retirement work"); + + BackendOrderedVoteOverflowQueue recovered = new BackendOrderedVoteOverflowQueue(plugin); + try { + assertEquals(1, recovered.size(), "retirement ownership must survive restart"); + assertTrue(BackendProxyHandler.isLegacyDeliveryRetirement(recovered.peekDurable().envelope())); + } finally { + recovered.close(); + } + + retirement.run(); + Runnable retry = asyncTasks.poll(3, TimeUnit.SECONDS); + assertNotNull(retry, "failed retirement must retry from the durable queue"); + retry.run(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); + while (overflow.size() != 0 && System.nanoTime() < deadline) Thread.sleep(10L); + assertEquals(0, overflow.size()); + verify(serverData, times(2)).clearVoteReplayUnsafe(voteId); + verify(router).handleOrderedVote(any(JsonEnvelope.class), any()); + } finally { + overflow.close(); + } } @Test @@ -853,7 +914,7 @@ void durableCompletionIsPersistedBeforeNextVoteRuns(@TempDir Path tempDir) throw setField(handler, "messageRouter", router); doAnswer(invocation -> { invocation.>getArgument(1) - .accept(OrderedVoteOutcome.COMPLETE); + .accept(OrderedVoteOutcome.COMPLETE_WITHOUT_RETIREMENT); return null; }).when(router).handleOrderedVote(any(JsonEnvelope.class), any()); handler.activateInboundMessages(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayerTest.java index 92aa2a5ef9..41bf8361a5 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayerTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayerTest.java @@ -1,14 +1,18 @@ package com.bencodez.votingplugin.commands.gui.admin; 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.same; import static org.mockito.Mockito.RETURNS_DEEP_STUBS; 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.RejectedExecutionException; + import org.bukkit.Bukkit; import org.bukkit.Server; import org.bukkit.entity.Player; @@ -52,4 +56,49 @@ void dispatchesFromPlayerThreadBeforeVoteWorkerAdmission() { void existingVoteEventConstructorRetainsItsAsynchronousContract() { assertTrue(new PlayerVoteEvent(null, "Steve", "example.org", false).isAsynchronous()); } + + @Test + void rejectedOwnerDispatchCompletesVoteAsFailed() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + Player target = mock(Player.class); + PlayerVoteEvent event = new PlayerVoteEvent(null, "Steve", "example.org", false, false); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doThrow(new RejectedExecutionException("disabled")) + .when(scheduler).runTask(same(plugin), org.mockito.ArgumentMatchers.any(Runnable.class), same(target)); + doThrow(new RejectedExecutionException("disabled")) + .when(scheduler).runTask(same(plugin), org.mockito.ArgumentMatchers.any(Runnable.class)); + + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayerExact("Steve")).thenReturn(target); + com.bencodez.votingplugin.util.BukkitVoteEventDispatcher.dispatch(plugin, event); + } + + assertTrue(event.isProcessingFailed()); + assertTrue(event.getProcessingCompletion().toCompletableFuture().isDone()); + } + + @Test + void eventDispatchFailureCompletesVoteAsFailed() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + Server server = mock(Server.class); + PluginManager pluginManager = mock(PluginManager.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + PlayerVoteEvent event = new PlayerVoteEvent(null, "Steve", "example.org", false, false); + when(plugin.getServer()).thenReturn(server); + when(server.getPluginManager()).thenReturn(pluginManager); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doThrow(new IllegalStateException("dispatch failed")).when(pluginManager).callEvent(event); + ArgumentCaptor dispatched = ArgumentCaptor.forClass(Runnable.class); + + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayerExact("Steve")).thenReturn(null); + com.bencodez.votingplugin.util.BukkitVoteEventDispatcher.dispatch(plugin, event); + } + verify(scheduler).runTask(same(plugin), dispatched.capture()); + assertThrows(IllegalStateException.class, () -> dispatched.getValue().run()); + + assertTrue(event.isProcessingFailed()); + assertTrue(event.getProcessingCompletion().toCompletableFuture().isDone()); + } } From d0b7e9916a593e7a21b5e8bbf175ac0af86cf7e5 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:05:02 -0600 Subject: [PATCH 61/61] Retire completed vote point receipts --- .../user/SharedMysqlPointMutator.java | 5 +++++ .../votingplugin/user/VotingPluginUser.java | 10 ++++++++++ .../service/VoteShopPurchaseService.java | 1 + .../user/VotingPluginUserVotePointsTest.java | 17 +++++++++++++++++ .../service/VoteShopPurchaseServiceTest.java | 17 +++++++++++++++++ 5 files changed, 50 insertions(+) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index b33ec3f82b..3bf1a60b27 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -328,6 +328,11 @@ void acknowledgePointAdditionNow(String operationId) { } } + void acknowledgePointAdditionOrThrow(String operationId) throws SQLException { + if (!canRecoverSharedMysqlPointJournals(plugin) || operationId == null || operationId.isEmpty()) return; + SharedPointAdditionJournal.forTable(plugin.getMysql()).acknowledge(operationId, System.currentTimeMillis()); + } + void set(VotingPluginUser user, int value, boolean async) { String pointsColumn = user.getPointsPath(); run(() -> setAbsolute(user, value, pointsColumn), async); 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 425e47648a..af33eed5ad 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -688,6 +688,16 @@ public CompletionStage acknowledgeStorageAwarePointOperation(String operat return new SharedMysqlPointMutator(plugin).acknowledgePointAddition(operationId); } + /** Makes a completed vote-point receipt eligible for bounded retirement. */ + public static void acknowledgeVotePointDelivery(VotingPluginMain plugin, UUID voteId) { + if (voteId == null) return; + try { + new SharedMysqlPointMutator(plugin).acknowledgePointAdditionOrThrow("vote-points:" + voteId); + } catch (java.sql.SQLException failure) { + throw new IllegalStateException("Unable to acknowledge shared MySQL vote points", failure); + } + } + /** * Adds points and reports the committed total after shared-MySQL persistence * completes. The callback runs on the user's Bukkit/entity lane. 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 8f2894dbee..7ce878e767 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 @@ -807,6 +807,7 @@ public static void completeVoteDelivery(VotingPluginMain plugin, UUID voteId) { throw new IllegalStateException("Unable to retire shared MySQL vote accounting", failure); } } else plugin.getServerData().clearVoteReplayUnsafe(voteId); + VotingPluginUser.acknowledgeVotePointDelivery(plugin, voteId); } public static void finishMysqlVoteAccounting(UUID voteId) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVotePointsTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVotePointsTest.java index e8ddc2f49a..2ca6e38fbc 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVotePointsTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVotePointsTest.java @@ -66,4 +66,21 @@ void repeatedVoteIdentityCommitsPointsAndDispatchesTheReceiveHookOnlyOnce() thro org.mockito.ArgumentMatchers.eq(operationId), anyLong()); verify(pluginManager).callEvent(isA(PlayerReceivePointsEvent.class)); } + + @Test + void deliveryCompletionAcknowledgesVotePointReceipt() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + MySQL table = mock(MySQL.class); + when(plugin.getMysql()).thenReturn(table); + SharedPointAdditionJournal journal = mock(SharedPointAdditionJournal.class); + UUID voteId = UUID.randomUUID(); + + try (MockedStatic journals = mockStatic(SharedPointAdditionJournal.class)) { + journals.when(() -> SharedPointAdditionJournal.forTable(table)).thenReturn(journal); + VotingPluginUser.acknowledgeVotePointDelivery(plugin, voteId); + } + + verify(journal).acknowledge(org.mockito.ArgumentMatchers.eq("vote-points:" + voteId), anyLong()); + } } 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 index 0abc164bfe..ba359cc434 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -64,6 +64,23 @@ import com.bencodez.votingplugin.voteshop.shop.VoteShopItem; class VoteShopPurchaseServiceTest { + @Test + void deliveryCompletionAcknowledgesVotePointReceipt() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + com.bencodez.votingplugin.data.ServerData serverData = + mock(com.bencodez.votingplugin.data.ServerData.class); + when(plugin.getServerData()).thenReturn(serverData); + UUID voteId = UUID.randomUUID(); + + try (org.mockito.MockedStatic users = org.mockito.Mockito.mockStatic(VotingPluginUser.class)) { + VoteShopPurchaseService.completeVoteDelivery(plugin, voteId); + users.verify(() -> VotingPluginUser.acknowledgeVotePointDelivery(plugin, voteId)); + } + + verify(serverData).clearVoteReplayUnsafe(voteId); + } + @Test void purchaseRecoveryEligibilityIgnoresCurrentPerServerPointsSetting() { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);