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