Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
6b69462
Make vote shop debit atomic
BenCodez Sep 7, 2026
4b12ebf
Test concurrent vote shop debits
BenCodez Sep 7, 2026
1d44c4b
Use stable purchase locks and async persistence
BenCodez Sep 7, 2026
70fe42f
Use stable purchase locks and async persistence
BenCodez Sep 7, 2026
a199326
Keep purchase locks stable across reloads
BenCodez Sep 7, 2026
e335aec
Keep purchase locks stable across reloads
BenCodez Sep 7, 2026
9c176fd
Address review feedback with regression coverage
BenCodez Sep 7, 2026
dd3a770
Address review feedback with regression coverage
BenCodez Sep 7, 2026
6a5cd23
Address latest review feedback
BenCodez Sep 7, 2026
ebfbc55
Make concurrent debit regression deterministic
BenCodez Sep 8, 2026
b8b695e
Merge remote-tracking branch 'origin/master' into security/pr-1600-local
BenCodez Sep 8, 2026
ca4471d
fix: make shared vote shop purchases atomic
BenCodez Sep 8, 2026
38ae0d6
Preserve synchronous shared point updates
BenCodez Sep 8, 2026
e07be21
Preserve purchase and transfer semantics
BenCodez Sep 8, 2026
c5ddb3c
Reserve shared transfer points before hooks
BenCodez Sep 8, 2026
ca387d7
Release shared transfer hooks before callbacks
BenCodez Sep 8, 2026
dc25f95
Close shared point mutation consistency gaps
BenCodez Sep 8, 2026
520aa14
Journal shared vote shop purchases
BenCodez Sep 8, 2026
4d3f404
Recover shared transfers and bound journal names
BenCodez Sep 8, 2026
8d07e5a
Make shared MySQL purchases failure-safe
BenCodez Sep 8, 2026
0a4913e
Harden shared MySQL point completion and recovery
BenCodez Sep 8, 2026
58c5d0d
Close shared point settlement edge cases
BenCodez Sep 8, 2026
4928b10
Close shared point settlement edge cases
BenCodez Sep 8, 2026
bdd471b
Preserve shared point mutation state
BenCodez Sep 8, 2026
e1aa435
Merge remote-tracking branch 'origin/master' into security/pr-1600-local
BenCodez Sep 8, 2026
a52a1ec
Keep rejected transfer recovery off Bukkit
BenCodez Sep 8, 2026
6fe7db7
Reconcile caches after recovery refunds
BenCodez Sep 9, 2026
a2e0112
Avoid stale shared purchase cache writes
BenCodez Sep 9, 2026
1bfde24
Fix shared vote shop recovery races
BenCodez Sep 9, 2026
a58168c
Close shared vote shop recovery races
BenCodez Sep 9, 2026
cb73334
Bound shared point and purchase recovery work
BenCodez Sep 9, 2026
f3141f2
Wait for shared point reward persistence
BenCodez Sep 9, 2026
35f3ac2
Handle rejected shared point submissions
BenCodez Sep 9, 2026
e7fb241
Keep shared shop validation off GUI lanes
BenCodez Sep 9, 2026
b35abf4
Recover rejected shared point operations
BenCodez Sep 9, 2026
6b87b9f
Avoid blocking vote shop placeholder reads
BenCodez Sep 9, 2026
87de933
Fix shared vote shop recovery and cache refresh
BenCodez Sep 9, 2026
c687cbf
Handle rejected transfer settlement and cache races
BenCodez Sep 9, 2026
21ac3f9
Harden shared point settlement and callback recovery
BenCodez Sep 10, 2026
c94842e
Harden vote shop recovery paths
BenCodez Sep 10, 2026
e8e9316
Exclude optimistic points from cache dumps
BenCodez Sep 10, 2026
3c049f4
Align weekly reset generation with configured period
BenCodez Sep 10, 2026
367f941
Complete atomic point transfer safeguards
BenCodez Sep 10, 2026
96c8504
Harden shared MySQL purchase completion
BenCodez Sep 10, 2026
7ed6948
Await durable shared point rewards
BenCodez Sep 10, 2026
adcfa27
Make shared point rewards retry safe
BenCodez Sep 11, 2026
68976d5
Harden indeterminate shared point operations
BenCodez Sep 11, 2026
0647d07
Fence shared vote shop resets from cache dumps
BenCodez Sep 13, 2026
8bc2e1c
Fence all shared MySQL cache dumps
BenCodez Sep 13, 2026
36a5979
Document shared limit cache contract
BenCodez Sep 13, 2026
64a983b
Track epochs for every limited purchase
BenCodez Sep 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions VotingPlugin/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,12 @@
<version>3.8.2-SNAPSHOT</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.14.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,7 @@ public void onPostLoad() {
voteTester = new VoteTester(plugin);

loadVoteTimer();
getVotingPluginUserManager().startSharedPointTransferRecovery();

if (bungeeSettings.isUseBungeecoord()) {
loadBungeeHandler();
Expand Down Expand Up @@ -1539,6 +1540,9 @@ private void reloadPlugin(boolean userStorage, boolean reconcileHostedControl) {
updateAdvancedCoreHook();

reloadAdvancedCore(userStorage);
// PerServerPoints can switch shared point storage on during a live reload.
// Re-evaluate after storage has reloaded; UserManager keeps this lifecycle task unique.
getVotingPluginUserManager().startSharedPointTransferRecovery();

if (bungeeSettings.isUseBungeecoord()) {
BackendProxyHandler handler = getBackendProxyHandler();
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,8 @@ protected void addItemButton(BInventory inv, final Player player, final VotingPl
@Override
public void onClick(ClickEvent event) {
VotingPluginUser clickedUser = getUser(event.getPlayer());
if (plugin.getConfigFile().isExtraVoteShopCheck()) {
clickedUser.cache();
}
plugin.getVoteShopManager().getPurchaseService().refreshUserForPurchaseValidation(clickedUser,
plugin.getConfigFile().isExtraVoteShopCheck());

if (item.isNotBuyable()) {
clickedUser.sendMessage(plugin.getConfigFile().getFormatShopNotPurchasable());
Expand Down Expand Up @@ -224,20 +223,21 @@ public void onClick(ClickEvent clickEvent) {
*/
protected void handlePurchase(Player player, VotingPluginUser currentUser, VoteShopItem item,
VoteShopCategory category) {
VoteShopPurchaseResult result = plugin.getVoteShopManager().purchase(player, currentUser, item);
if (result != VoteShopPurchaseResult.SUCCESS) {
plugin.getVoteShopManager().getPurchaseService().sendFailureMessage(player, currentUser, item, result);
return;
}
plugin.getVoteShopManager().purchase(player, currentUser, item, result -> {
if (result != VoteShopPurchaseResult.SUCCESS) {
plugin.getVoteShopManager().getPurchaseService().sendFailureMessage(player, currentUser, item, result);
return;
}

plugin.getCommandLoader().processSlotClick(player, currentUser, item.getIdentifier());
if (plugin.getVoteShopManager().getDefinition().isReopenGuiOnPurchase()) {
if (category != null) {
new VoteShopCategoryMenu(plugin, player, currentUser, category).open(GUIMethod.CHEST);
} else {
plugin.getCommandLoader().processSlotClick(player, currentUser, "shop");
plugin.getCommandLoader().processSlotClick(player, currentUser, item.getIdentifier());
if (plugin.getVoteShopManager().getDefinition().isReopenGuiOnPurchase()) {
if (category != null) {
new VoteShopCategoryMenu(plugin, player, currentUser, category).open(GUIMethod.CHEST);
} else {
plugin.getCommandLoader().processSlotClick(player, currentUser, "shop");
}
}
}
});
}

/**
Expand All @@ -257,4 +257,4 @@ protected VotingPluginUser getUser(Player player) {
public void open() {
open(GUIMethod.CHEST);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.bencodez.votingplugin.commands.gui.player;

import java.util.ArrayList;
import java.util.concurrent.atomic.AtomicBoolean;

import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
Expand Down Expand Up @@ -31,6 +32,8 @@ public class VoteShopConfirm extends GUIHandler {

private VotingPluginUser user;

private final AtomicBoolean purchaseSubmitted = new AtomicBoolean();

/**
* Creates the GUI.
*
Expand Down Expand Up @@ -71,20 +74,23 @@ public void onChest(final Player player) {

@Override
public void onClick(ClickEvent event) {
user.cache();
VoteShopPurchaseResult result = plugin.getVoteShopManager().purchase(player, user, item);
if (result != VoteShopPurchaseResult.SUCCESS) {
plugin.getVoteShopManager().getPurchaseService().sendFailureMessage(player, user, item, result);
returnToPrevious(event.getPlayer());
return;
}
if (!beginPurchase()) return;
event.closeInventory();
plugin.getVoteShopManager().getPurchaseService().refreshUserForPurchaseValidation(user, true);
plugin.getVoteShopManager().purchase(player, user, item, result -> {
Comment thread
BenCodez marked this conversation as resolved.
if (result != VoteShopPurchaseResult.SUCCESS) {
plugin.getVoteShopManager().getPurchaseService().sendFailureMessage(player, user, item, result);
returnToPrevious(event.getPlayer());
return;
}

plugin.getCommandLoader().processSlotClick(player, user, item.getIdentifier());
if (item.isCloseGUI()) {
event.closeInventory();
} else {
returnToPrevious(event.getPlayer());
}
plugin.getCommandLoader().processSlotClick(player, user, item.getIdentifier());
if (item.isCloseGUI()) {
event.closeInventory();
} else {
returnToPrevious(event.getPlayer());
}
});
}
});
inv.addButton(new BInventoryButton(new ItemBuilder(plugin.getShopFile().getShopConfirmPurchaseNoItem())) {
Expand All @@ -101,6 +107,10 @@ public void onClick(ClickEvent event) {
inv.openInventory(player);
}

boolean beginPurchase() {
return purchaseSubmitted.compareAndSet(false, true);
}

@Override
public void onDialog(Player player) {
PlayerUtils.setPlayerMeta(plugin, player, "ident", item.getIdentifier());
Expand All @@ -113,23 +123,24 @@ public void onDialog(Player player) {
.noText(new ItemBuilder(plugin.getShopFile().getShopConfirmPurchaseNoItem()).getName())
.onYes(payload -> {
Player clicked = player.getServer().getPlayer(payload.owner());
if (clicked == null) {
return;
}

user.cache();
VoteShopPurchaseResult result = plugin.getVoteShopManager().purchase(clicked, user, item);
if (result != VoteShopPurchaseResult.SUCCESS) {
plugin.getVoteShopManager().getPurchaseService().sendFailureMessage(clicked, user, item,
result);
returnToPrevious(clicked);
if (clicked == null || !beginPurchase()) {
return;
}

plugin.getCommandLoader().processSlotClick(clicked, user, item.getIdentifier());
if (!item.isCloseGUI()) {
returnToPrevious(clicked);
}
plugin.getVoteShopManager().getPurchaseService().refreshUserForPurchaseValidation(user, true);
plugin.getVoteShopManager().purchase(clicked, user, item, result -> {
if (result != VoteShopPurchaseResult.SUCCESS) {
plugin.getVoteShopManager().getPurchaseService().sendFailureMessage(clicked, user, item,
result);
returnToPrevious(clicked);
return;
}

plugin.getCommandLoader().processSlotClick(clicked, user, item.getIdentifier());
if (!item.isCloseGUI()) {
returnToPrevious(clicked);
}
});
}).onNo(payload -> {
Player clicked = player.getServer().getPlayer(payload.owner());
if (clicked != null) {
Expand All @@ -155,4 +166,4 @@ protected void returnToPrevious(Player player) {
public void open() {
open(GUIMethod.CHEST);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,16 @@ public String getDiscordSRVTopVoterRankDisplay(TopVoter topVoter) {

@ConfigDataString(path = "Format.Commands.Vote.GivePoints.NotEnoughPoints")
@Getter
private String formatCommandsVoteGivePointsNotEnoughPoints = "&cNot enough points";
private String formatCommandsVoteGivePointsNotEnoughPoints = "&cNot enough points";

@ConfigDataString(path = "Format.Commands.Vote.GivePoints.Unavailable")
@Getter
private String formatCommandsVoteGivePointsUnavailable = "&cUnable to transfer points right now, please try again";

@ConfigDataString(path = "Format.Commands.Vote.GivePoints.PendingConfirmation")
@Getter
private String formatCommandsVoteGivePointsPendingConfirmation =
"&eTransfer pending confirmation; do not retry it";

@ConfigDataString(path = "Format.Commands.Vote.GivePoints.NotJoinedServer")
@Getter
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package com.bencodez.votingplugin.rewards.builtin;

import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HashMap;
import java.util.concurrent.CompletionStage;

import org.bukkit.Material;
import org.bukkit.configuration.ConfigurationSection;
Expand Down Expand Up @@ -46,8 +51,79 @@ public void onValidate(Reward reward, RewardInject inject, ConfigurationSection
public String onRewardRequest(Reward reward, com.bencodez.advancedcore.api.user.AdvancedCoreUser user, int num,
HashMap<String, String> placeholders) {
VotingPluginUser vpUser = plugin.getVotingPluginUserManager().getVotingPluginUser(user);
String result = "" + vpUser.addPoints(num);
// Reward injection is a synchronous chain, so publish the storage-aware
// predicted total immediately while shared-MySQL persistence stays off the
// Bukkit/Folia entity lane. Ordinary storage retains its synchronous path.
String result = "" + vpUser.addPointsStorageAware(num);
Comment thread
BenCodez marked this conversation as resolved.
plugin.debug("Setting points to " + result);
return result;
}

@Override
public boolean supportsAsyncRequest() {
return true;
}

@Override
public CompletionStage<String> onRewardRequestAsync(Reward reward,
com.bencodez.advancedcore.api.user.AdvancedCoreUser user, int num,
HashMap<String, String> placeholders) {
VotingPluginUser vpUser = plugin.getVotingPluginUserManager().getVotingPluginUser(user);
String operationId = replayOperationId(vpUser);
CompletionStage<Integer> addition = operationId == null ? vpUser.addPointsStorageAwareAsync(num)
: vpUser.addPointsStorageAwareAsync(num, operationId);
return addition.thenApply(total -> {
String result = String.valueOf(total);
plugin.debug("Setting points to " + result);
return result;
});
}

@Override
public CompletionStage<Void> onReplayCheckpointPersisted(Reward reward,
com.bencodez.advancedcore.api.user.AdvancedCoreUser user, String occurrenceId, String injectionKey) {
VotingPluginUser vpUser = plugin.getVotingPluginUserManager().getVotingPluginUser(user);
String operationId = replayOperationId(vpUser, occurrenceId, injectionKey);
return operationId == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: vpUser.acknowledgeStorageAwarePointOperation(operationId);
}

/**
* AdvancedCore #317 exposes a durable occurrence identity for a queued replay
* plus its active stage path. Use both when present, but retain compatibility
* with releases that cannot distinguish a retry from a new reward occurrence.
*/
private static String replayOperationId(VotingPluginUser user) {
try {
Method currentReplayOccurrenceId = Reward.class.getMethod("currentReplayOccurrenceId");
Method currentReplayKey = Reward.class.getMethod("currentReplayKey");
Object occurrence = currentReplayOccurrenceId.invoke(null);
Object value = currentReplayKey.invoke(null);
if (!(occurrence instanceof String) || ((String) occurrence).isEmpty()
|| !(value instanceof String) || ((String) value).isEmpty()) return null;
return replayOperationId(user, (String) occurrence, (String) value);
} catch (ReflectiveOperationException | SecurityException ignored) {
return null;
}
}

private static String replayOperationId(VotingPluginUser user, String occurrenceId, String injectionKey) {
if (occurrenceId == null || occurrenceId.isEmpty() || injectionKey == null || injectionKey.isEmpty()) return null;
return sha256("VotingPlugin:shared-points-reward:v1\0" + user.getUUID() + '\0' + occurrenceId + '\0'
+ injectionKey);
}

private static String sha256(String value) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
StringBuilder result = new StringBuilder(digest.length * 2);
for (byte element : digest) {
result.append(Character.forDigit((element >>> 4) & 0xf, 16));
result.append(Character.forDigit(element & 0xf, 16));
}
return result.toString();
} catch (NoSuchAlgorithmException failure) {
throw new IllegalStateException("SHA-256 is unavailable", failure);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import com.bencodez.simpleapi.sql.DataType;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.user.VotingPluginUser;
import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService;

/**
* Handles top voter rankings and statistics.
Expand Down Expand Up @@ -235,7 +236,8 @@ public void onDayChange(DayChangeEvent event) {

for (String shopIdent : plugin.getShopFile().getShopIdentifiers()) {
if (plugin.getShopFile().getVoteShopResetDaily(shopIdent)) {
resetVoteShopLimit(shopIdent);
resetVoteShopLimit(shopIdent,
VoteShopPurchaseService.currentLimitGenerationId(plugin, shopIdent));
}
}

Expand Down Expand Up @@ -357,7 +359,8 @@ public void onMonthChange(MonthChangeEvent event) {

for (String shopIdent : plugin.getShopFile().getShopIdentifiers()) {
if (plugin.getShopFile().getVoteShopResetMonthly(shopIdent)) {
resetVoteShopLimit(shopIdent);
resetVoteShopLimit(shopIdent,
VoteShopPurchaseService.currentLimitGenerationId(plugin, shopIdent));
}
}

Expand Down Expand Up @@ -480,7 +483,8 @@ public void onWeekChange(WeekChangeEvent event) {

for (String shopIdent : plugin.getShopFile().getShopIdentifiers()) {
if (plugin.getShopFile().getVoteShopResetWeekly(shopIdent)) {
resetVoteShopLimit(shopIdent);
resetVoteShopLimit(shopIdent,
VoteShopPurchaseService.currentLimitGenerationId(plugin, shopIdent));
}
}

Expand Down Expand Up @@ -526,7 +530,17 @@ public void resetTotals(TopVoter topVoter) {
* @param shopIdent the shop identifier
*/
public void resetVoteShopLimit(String shopIdent) {
plugin.getUserManager().removeAllKeyValues("VoteShopLimit" + shopIdent, DataType.INTEGER);
resetVoteShopLimit(shopIdent, null);
}

private void resetVoteShopLimit(String shopIdent, String resetGeneration) {
String limitColumn = "VoteShopLimit" + shopIdent;
if (UserStorage.MYSQL.equals(plugin.getStorageType()) && !plugin.getBungeeSettings().isPerServerPoints()) {
if (resetGeneration == null) VoteShopPurchaseService.resetSharedMysqlLimit(plugin, limitColumn);
else VoteShopPurchaseService.resetSharedMysqlLimit(plugin, limitColumn, resetGeneration);
return;
}
plugin.getUserManager().removeAllKeyValues(limitColumn, DataType.INTEGER);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.bencodez.votingplugin.user;

/** The externally observable outcome of a point transfer. */
public enum PointTransferResult {
SUCCESS,
INSUFFICIENT_POINTS,
CANCELLED,
PENDING_CONFIRMATION,
UNAVAILABLE
}
Loading
Loading