mirror of
https://github.com/BenCodez/VotingPlugin.git
synced 2026-11-04 09:02:00 -05:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8fa948ecd | ||
|
|
4b83cc08bd | ||
|
|
b212f5cabb | ||
|
|
e09fa0f039 | ||
|
|
79bbdb1395 | ||
|
|
51f0b5e4a9 | ||
|
|
eb9fc8618b | ||
|
|
86735060bc | ||
|
|
f2eb2152fc | ||
|
|
d2bc2feb8e | ||
|
|
b34229ced0 | ||
|
|
9f56166708 | ||
|
|
7746fc52dc | ||
|
|
ee33a291a6 | ||
|
|
66d1c858c7 | ||
|
|
2e8cb4bd5d |
No files matched your search
@@ -271,7 +271,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.5.4</version>
|
||||
<version>3.6.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>default-test</id>
|
||||
@@ -408,7 +408,7 @@
|
||||
<dependency>
|
||||
<groupId>org.yaml</groupId>
|
||||
<artifactId>snakeyaml</artifactId>
|
||||
<version>2.6</version>
|
||||
<version>2.7</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.spigotmc</groupId>
|
||||
@@ -431,7 +431,7 @@
|
||||
<dependency>
|
||||
<groupId>com.google.code.gson</groupId>
|
||||
<artifactId>gson</artifactId>
|
||||
<version>2.14.0</version>
|
||||
<version>2.15.0</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -482,7 +482,7 @@
|
||||
<dependency>
|
||||
<groupId>redis.clients</groupId>
|
||||
<artifactId>jedis</artifactId>
|
||||
<version>8.0.1</version>
|
||||
<version>8.0.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>me.clip</groupId>
|
||||
|
||||
@@ -865,7 +865,7 @@ public class CommandLoader {
|
||||
@Override
|
||||
public void executeSinglePlayer(CommandSender sender, String[] args) {
|
||||
VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(args[1]);
|
||||
user.cache();
|
||||
if (!UserStorage.MYSQL.equals(plugin.getStorageType())) user.cache();
|
||||
user.removePoints(Integer.parseInt(args[3]), removed -> {
|
||||
if (!removed) {
|
||||
runForCommandSender(sender, () -> sender.sendMessage(MessageAPI.colorize(
|
||||
|
||||
@@ -11,10 +11,13 @@ import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.ScheduledThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.configuration.file.FileConfiguration;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
|
||||
import com.bencodez.advancedcore.api.rewards.Reward;
|
||||
import com.bencodez.advancedcore.api.rewards.RewardOptions;
|
||||
import com.bencodez.advancedcore.api.user.AdvancedCoreUser;
|
||||
import com.bencodez.advancedcore.api.user.UserDataFetchMode;
|
||||
@@ -36,12 +39,14 @@ public class CoolDownCheck implements Listener {
|
||||
private final VotingPluginMain plugin;
|
||||
|
||||
private final Map<UUID, ScheduledFuture<?>> perSiteTasks = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, Object> perSiteGenerations = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, ScheduledFuture<?>> allSiteTasks = new ConcurrentHashMap<>();
|
||||
|
||||
@Getter
|
||||
private final ScheduledExecutorService timer;
|
||||
|
||||
private volatile boolean cooldownCheckEnabled = false;
|
||||
private volatile boolean stopped = false;
|
||||
|
||||
/**
|
||||
* Constructs a new cooldown check manager.
|
||||
@@ -74,6 +79,8 @@ public class CoolDownCheck implements Listener {
|
||||
* Shuts down the cooldown check timer.
|
||||
*/
|
||||
public void shutdown() {
|
||||
stopped = true;
|
||||
perSiteGenerations.clear();
|
||||
timer.shutdownNow();
|
||||
|
||||
for (ScheduledFuture<?> f : perSiteTasks.values()) {
|
||||
@@ -147,6 +154,7 @@ public class CoolDownCheck implements Listener {
|
||||
user.updateTempCacheWithColumns(cols);
|
||||
cols.clear(); // help GC (only useful if reused)
|
||||
}
|
||||
capturePlayerName(user);
|
||||
|
||||
checkPerSite(site, user);
|
||||
} finally {
|
||||
@@ -219,13 +227,20 @@ public class CoolDownCheck implements Listener {
|
||||
* Site-specific version used by batch checks / checkAllVoteSite.
|
||||
*/
|
||||
private void checkPerSite(VoteSite site, VotingPluginUser user) {
|
||||
checkPerSite(site, user, null);
|
||||
}
|
||||
|
||||
private HashMap<String, Boolean> checkPerSite(VoteSite site, VotingPluginUser user,
|
||||
HashMap<String, Boolean> coolDownChecks) {
|
||||
if (!plugin.getConfigFile().isPerSiteCoolDownEvents() || site == null) {
|
||||
return;
|
||||
return coolDownChecks;
|
||||
}
|
||||
|
||||
HashMap<String, Boolean> coolDownChecks = user.getCoolDownCheckSiteList();
|
||||
if (coolDownChecks == null) {
|
||||
coolDownChecks = user.getCoolDownCheckSiteList();
|
||||
}
|
||||
if (Boolean.TRUE.equals(coolDownChecks.get(site.getKey()))) {
|
||||
return;
|
||||
return coolDownChecks;
|
||||
}
|
||||
|
||||
if (user.canVoteSite(site)) {
|
||||
@@ -237,6 +252,7 @@ public class CoolDownCheck implements Listener {
|
||||
PlayerVoteSiteCoolDownEndEvent event = new PlayerVoteSiteCoolDownEndEvent(user, site);
|
||||
plugin.getServer().getPluginManager().callEvent(event);
|
||||
}
|
||||
return coolDownChecks;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,8 +283,8 @@ public class CoolDownCheck implements Listener {
|
||||
*/
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onCoolDownEnd(PlayerVoteCoolDownEndEvent event) {
|
||||
plugin.getRewardHandler().giveReward(event.getPlayer(), plugin.getSpecialRewardsConfig().getData(),
|
||||
"VoteCoolDownEndedReward", new RewardOptions());
|
||||
dispatchCooldownReward(event.getPlayer(), plugin.getSpecialRewardsConfig().getData(),
|
||||
"VoteCoolDownEndedReward", "all", new RewardOptions());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -278,11 +294,73 @@ public class CoolDownCheck implements Listener {
|
||||
*/
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onCoolDownEnd(PlayerVoteSiteCoolDownEndEvent event) {
|
||||
plugin.getRewardHandler().giveReward(event.getPlayer(), event.getSite().getSiteData(), "CoolDownEndRewards",
|
||||
new RewardOptions().addPlaceholder("sitename", event.getSite().getDisplayName())
|
||||
dispatchCooldownReward(event.getPlayer(), event.getSite().getSiteData(), "CoolDownEndRewards",
|
||||
"site:" + event.getSite().getKey(), new RewardOptions()
|
||||
.addPlaceholder("sitename", event.getSite().getDisplayName())
|
||||
.addPlaceholder("url", event.getSite().getVoteURL(false)));
|
||||
}
|
||||
|
||||
private void dispatchCooldownReward(VotingPluginUser user, ConfigurationSection data, String path,
|
||||
String site, RewardOptions options) {
|
||||
boolean configured = false;
|
||||
try {
|
||||
configured = hasConfiguredCooldownReward(data, path);
|
||||
} catch (RuntimeException ignored) {
|
||||
// Preserve legacy dispatch even when diagnostic configuration inspection fails.
|
||||
}
|
||||
if (!configured) {
|
||||
plugin.getRewardHandler().giveReward(user, data, path, options);
|
||||
return;
|
||||
}
|
||||
String occurrence = UUID.randomUUID().toString();
|
||||
String player = user.getUUID();
|
||||
String safeSite = String.valueOf(site).replaceAll("[\\r\\n\\p{Cntrl}]", "?");
|
||||
long time = System.currentTimeMillis();
|
||||
String context = " occurrence=" + occurrence + " player=" + player + " site=" + safeSite + " time=" + time;
|
||||
logCooldownDiagnostic(false, "Cooldown reward status=attempted" + context);
|
||||
options.setAsyncFailureObserver(failure -> logCooldownFailure(context, failure));
|
||||
try {
|
||||
plugin.getRewardHandler().giveReward(user, data, path, options);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
logCooldownFailure(context, failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasConfiguredCooldownReward(ConfigurationSection data, String path) {
|
||||
if (data == null) return false;
|
||||
if (data instanceof FileConfiguration) {
|
||||
return plugin.getRewardHandler().hasRewards((FileConfiguration) data, path);
|
||||
}
|
||||
if (data.isList(path)) return !data.getList(path, new ArrayList<>()).isEmpty();
|
||||
if (data.isConfigurationSection(path)) return !data.getConfigurationSection(path).getKeys(false).isEmpty();
|
||||
return data.isString(path) && !data.getString(path, "").isEmpty();
|
||||
}
|
||||
|
||||
private void logCooldownFailure(String context, Throwable failure) {
|
||||
int completed = -1;
|
||||
Throwable current = failure;
|
||||
for (int depth = 0; current != null && depth < 32; depth++, current = current.getCause()) {
|
||||
if (current instanceof Reward.RewardReplayFailure) {
|
||||
completed = ((Reward.RewardReplayFailure) current).getCompletedInjectionCount();
|
||||
break;
|
||||
}
|
||||
}
|
||||
String error = failure == null ? "unknown" : failure.getClass().getSimpleName();
|
||||
logCooldownDiagnostic(true, "Cooldown reward outcome=unknown/partial" + context + " error=" + error
|
||||
+ " completedInjections=" + (completed < 0 ? "unknown" : completed)
|
||||
+ "; inspect effects before reissuing");
|
||||
}
|
||||
|
||||
private void logCooldownDiagnostic(boolean warning, String message) {
|
||||
try {
|
||||
if (warning) plugin.getLogger().warning(message);
|
||||
else plugin.getLogger().info(message);
|
||||
} catch (RuntimeException ignored) {
|
||||
// A broken logger must not suppress or retry the reward.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a cooldown check for a user.
|
||||
*
|
||||
@@ -324,7 +402,7 @@ public class CoolDownCheck implements Listener {
|
||||
* @param force whether to force the schedule
|
||||
*/
|
||||
public void schedulePerSite(VotingPluginUser user, boolean force) {
|
||||
if (!plugin.getConfigFile().isPerSiteCoolDownEvents()) {
|
||||
if (stopped || !plugin.getConfigFile().isPerSiteCoolDownEvents()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -333,6 +411,7 @@ public class CoolDownCheck implements Listener {
|
||||
}
|
||||
|
||||
final UUID uuid = user.getJavaUUID();
|
||||
final String scheduledName = user.getPlayerName();
|
||||
|
||||
final NextSite next = user.getNextSiteAvailable();
|
||||
|
||||
@@ -340,8 +419,13 @@ public class CoolDownCheck implements Listener {
|
||||
if (existing != null) {
|
||||
existing.cancel(false);
|
||||
}
|
||||
if (stopped) {
|
||||
perSiteGenerations.remove(uuid);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (next == null || next.getSite() == null || next.getSecondsUntilAvailable() <= 0) {
|
||||
perSiteGenerations.remove(uuid);
|
||||
plugin.extraDebug(user.getUUID() + "/" + user.getPlayerName() + " not scheduling per-site cooldown check");
|
||||
return null; // remove entry
|
||||
}
|
||||
@@ -349,21 +433,60 @@ public class CoolDownCheck implements Listener {
|
||||
plugin.devDebug("PerSiteCoolDownEvent schedule: site=" + next.getSite().getKey() + " time="
|
||||
+ next.getSecondsUntilAvailable() + " seconds");
|
||||
|
||||
return timer.schedule(() -> {
|
||||
if (!plugin.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only check the next site; this will set the cooldown-end flag + fire event if ready
|
||||
checkPerSite(next.getSite(), user);
|
||||
|
||||
// Schedule the following site (if any)
|
||||
schedulePerSite(user, false);
|
||||
|
||||
}, next.getSecondsUntilAvailable() + 2, TimeUnit.SECONDS);
|
||||
Object generation = new Object();
|
||||
perSiteGenerations.put(uuid, generation);
|
||||
try {
|
||||
return timer.schedule(() -> runPerSiteTimer(uuid, scheduledName, next, generation, 0),
|
||||
next.getSecondsUntilAvailable() + 2, TimeUnit.SECONDS);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
perSiteGenerations.remove(uuid, generation);
|
||||
throw failure;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private void runPerSiteTimer(UUID uuid, String scheduledName, NextSite next, Object generation, int retries) {
|
||||
if (stopped || !plugin.isEnabled() || perSiteGenerations.get(uuid) != generation) return;
|
||||
UUID occurrence = UUID.randomUUID();
|
||||
boolean[] retried = { false };
|
||||
try {
|
||||
VotingPluginUser current;
|
||||
try {
|
||||
// The batch user's temporary row may have been cleared or replaced by a newer vote.
|
||||
current = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, false);
|
||||
if (!current.isCached()) current.userDataFetechMode(UserDataFetchMode.NO_CACHE);
|
||||
String storedName = current.getData().getString("PlayerName", current.getUserDataFetchMode());
|
||||
String name = storedName != null && !storedName.isBlank() ? storedName : scheduledName;
|
||||
if (name != null && !name.isBlank()) current.setPlayerName(name);
|
||||
} catch (RuntimeException refreshFailure) {
|
||||
if (retries < 3) {
|
||||
perSiteTasks.compute(uuid, (key, existing) -> {
|
||||
if (stopped || !plugin.isEnabled() || perSiteGenerations.get(uuid) != generation) return existing;
|
||||
ScheduledFuture<?> retry = timer.schedule(
|
||||
() -> runPerSiteTimer(uuid, scheduledName, next, generation, retries + 1),
|
||||
30, TimeUnit.SECONDS);
|
||||
retried[0] = true;
|
||||
return retry;
|
||||
});
|
||||
if (retried[0]) return;
|
||||
}
|
||||
throw refreshFailure;
|
||||
}
|
||||
if (perSiteGenerations.get(uuid) != generation) return;
|
||||
checkPerSite(next.getSite(), current);
|
||||
if (perSiteGenerations.get(uuid) == generation) schedulePerSite(current, false);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
String siteKey = String.valueOf(next.getSite().getKey()).replaceAll("[\\r\\n\\p{Cntrl}]", "?");
|
||||
logCooldownDiagnostic(true, "Cooldown timer failed: occurrence=" + occurrence + " player=" + uuid
|
||||
+ " site=" + siteKey + " time=" + System.currentTimeMillis() + " error="
|
||||
+ failure.getClass().getSimpleName() + "; reward outcome is uncertain; inspect effects before reissuing");
|
||||
throw failure;
|
||||
} finally {
|
||||
if (!retried[0]) perSiteGenerations.remove(uuid, generation);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a vote event for cooldown scheduling.
|
||||
*
|
||||
@@ -381,6 +504,14 @@ public class CoolDownCheck implements Listener {
|
||||
}
|
||||
}
|
||||
|
||||
/** Preserve the name needed by asynchronous rewards after the temporary row is cleared. */
|
||||
private static void capturePlayerName(VotingPluginUser user) {
|
||||
String name = user.getPlayerName();
|
||||
if (name != null && !name.isBlank()) {
|
||||
user.setPlayerName(name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performance-optimized: scan users ONCE, process all daily-delay sites.
|
||||
*/
|
||||
@@ -421,9 +552,11 @@ public class CoolDownCheck implements Listener {
|
||||
user.updateTempCacheWithColumns(cols);
|
||||
cols.clear();
|
||||
}
|
||||
capturePlayerName(user);
|
||||
|
||||
HashMap<String, Boolean> coolDownChecks = null;
|
||||
for (int i = 0; i < dailySites.size(); i++) {
|
||||
checkPerSite(dailySites.get(i), user);
|
||||
coolDownChecks = checkPerSite(dailySites.get(i), user, coolDownChecks);
|
||||
}
|
||||
|
||||
check(user);
|
||||
|
||||
+213
-16
@@ -67,6 +67,8 @@ public class PlaceHolders {
|
||||
private record PlatformUpdateKey(UUID uuid, PlaceHolder<VotingPluginUser> placeholder) { }
|
||||
private final ConcurrentHashMap<PlatformUpdateKey, Long> platformUpdateGenerations = new ConcurrentHashMap<>();
|
||||
private boolean userDataChangeListenerRegistered;
|
||||
private final PointPlaceholderFence pointFence = new PointPlaceholderFence();
|
||||
private final ConcurrentHashMap<UUID, Object> pointRecoveryWorkers = new ConcurrentHashMap<>();
|
||||
|
||||
@Getter
|
||||
private PlaceholderCacheLevel cacheLevel;
|
||||
@@ -185,6 +187,103 @@ public class PlaceHolders {
|
||||
* @return the placeholder value
|
||||
*/
|
||||
public String getPlaceholderValue(OfflinePlayer p, String identifier, boolean javascript, boolean forceProcess,
|
||||
boolean useCache) {
|
||||
if (p == null || !isPointIdentifier(identifier)) {
|
||||
return getPlaceholderValueUnfenced(p, identifier, javascript, forceProcess, useCache);
|
||||
}
|
||||
VotingPluginUser pointUser = resolvePlaceholderUser(p);
|
||||
UUID uuid = pointUser.getJavaUUID();
|
||||
var generation = pointFence.generation(uuid);
|
||||
try {
|
||||
if (!pointFence.readable(uuid, generation)) {
|
||||
if (pointFence.recoverable(uuid)) schedulePointRecovery(pointUser);
|
||||
return "...";
|
||||
}
|
||||
if (com.bencodez.advancedcore.api.user.UserStorage.MYSQL.equals(plugin.getStorageType())) {
|
||||
var snapshot = publishedPointSnapshot(pointUser);
|
||||
if (snapshot == null) {
|
||||
schedulePointRecovery(pointUser);
|
||||
return "...";
|
||||
}
|
||||
// A complete row with invalid data is unavailable, not a missing snapshot to reload.
|
||||
if (pointBalance(snapshot, pointUser.getPointsPath()) == null) return "...";
|
||||
}
|
||||
String value = getPlaceholderValueUnfenced(p, identifier, javascript, forceProcess, useCache);
|
||||
return pointFence.readable(uuid, generation) ? value : "...";
|
||||
} finally {
|
||||
pointFence.release(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot-only point balance for direct API users on Bukkit/Folia threads.
|
||||
* A pending/uncertain debit or incomplete publication is unavailable, not
|
||||
* a genuine zero. This shares the same mutation fence as point placeholders.
|
||||
*
|
||||
* @param user the requested user
|
||||
* @return a published point balance, or empty until it is safe to read
|
||||
*/
|
||||
public java.util.OptionalInt getPointBalanceIfReady(VotingPluginUser user) {
|
||||
if (user == null || !plugin.isEnabled() || plugin.getPlaceholders() != this) {
|
||||
return java.util.OptionalInt.empty();
|
||||
}
|
||||
UUID uuid = user.getJavaUUID();
|
||||
var generation = pointFence.generation(uuid);
|
||||
try {
|
||||
if (!pointFence.readable(uuid, generation)) {
|
||||
if (pointFence.recoverable(uuid)) schedulePointRecovery(user);
|
||||
return java.util.OptionalInt.empty();
|
||||
}
|
||||
var snapshot = publishedPointSnapshot(user);
|
||||
if (snapshot == null) {
|
||||
schedulePointRecovery(user);
|
||||
return java.util.OptionalInt.empty();
|
||||
}
|
||||
Integer amount = pointBalance(snapshot, user.getPointsPath());
|
||||
if (amount == null || !pointFence.readable(uuid, generation)) {
|
||||
return java.util.OptionalInt.empty();
|
||||
}
|
||||
return java.util.OptionalInt.of(amount);
|
||||
} finally {
|
||||
pointFence.release(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
private java.util.Map<String, com.bencodez.simpleapi.sql.data.DataValue> publishedPointSnapshot(VotingPluginUser user) {
|
||||
var cache = plugin.getUserManager().getDataManager().getUserDataCache().get(user.getJavaUUID());
|
||||
return cache == null ? null : cache.snapshotIfPublished();
|
||||
}
|
||||
|
||||
private Integer pointBalance(VotingPluginUser user) {
|
||||
if (!com.bencodez.advancedcore.api.user.UserStorage.MYSQL.equals(plugin.getStorageType())) return user.getPoints();
|
||||
return pointBalance(publishedPointSnapshot(user), user.getPointsPath());
|
||||
}
|
||||
|
||||
private Integer pointBalance(java.util.Map<String, com.bencodez.simpleapi.sql.data.DataValue> snapshot, String column) {
|
||||
if (snapshot == null) return null;
|
||||
// AC publishes a complete row; absence has the historical integer default.
|
||||
if (!snapshot.containsKey(column)) return 0;
|
||||
var value = snapshot.get(column);
|
||||
if (value == null) return null;
|
||||
try {
|
||||
if (value.isInt()) return value.getInt();
|
||||
if (value.isString()) return Integer.valueOf(value.getString());
|
||||
} catch (NumberFormatException malformed) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void schedulePointRecovery(VotingPluginUser user) {
|
||||
schedulePlaceholderCheck(user);
|
||||
}
|
||||
|
||||
private static boolean isPointIdentifier(String identifier) {
|
||||
String key = identifier.toLowerCase(Locale.ROOT);
|
||||
return key.equals("points") || key.equals("points_format") || key.startsWith("enoughpoints_");
|
||||
}
|
||||
|
||||
private String getPlaceholderValueUnfenced(OfflinePlayer p, String identifier, boolean javascript, boolean forceProcess,
|
||||
boolean useCache) {
|
||||
if (plugin.getConfigFile().isUseJavascriptPlaceholders() && javascript && p != null) {
|
||||
identifier = PlaceholderUtils.replaceJavascript(p, identifier);
|
||||
@@ -498,7 +597,8 @@ public class PlaceHolders {
|
||||
|
||||
@Override
|
||||
public String placeholderRequest(VotingPluginUser user, String identifier) {
|
||||
return Integer.toString(user.getPoints());
|
||||
Integer points = pointBalance(user);
|
||||
return points == null ? "..." : Integer.toString(points);
|
||||
}
|
||||
}.withDescription("User points").updateDataKey("Points"));
|
||||
|
||||
@@ -507,7 +607,8 @@ public class PlaceHolders {
|
||||
@Override
|
||||
public String placeholderRequest(VotingPluginUser user, String identifier) {
|
||||
NumberFormat numberFormat = NumberFormat.getNumberInstance(Locale.US);
|
||||
return numberFormat.format(user.getPoints());
|
||||
Integer points = pointBalance(user);
|
||||
return points == null ? "..." : numberFormat.format(points);
|
||||
}
|
||||
}.withDescription("User points").updateDataKey("Points"));
|
||||
|
||||
@@ -633,7 +734,9 @@ public class PlaceHolders {
|
||||
return "invalid";
|
||||
}
|
||||
int number = Integer.parseInt(identifier.split("_")[1]);
|
||||
if (user.getPoints() >= number) {
|
||||
Integer points = pointBalance(user);
|
||||
if (points == null) return "...";
|
||||
if (points >= number) {
|
||||
return "true";
|
||||
}
|
||||
return "false";
|
||||
@@ -1415,6 +1518,13 @@ public class PlaceHolders {
|
||||
@SuppressWarnings("unchecked")
|
||||
private void updateCachedPlaceholder(PlaceHolder<VotingPluginUser> placeholder, VotingPluginUser user,
|
||||
UUID uuid, String... keys) {
|
||||
var pointGeneration = pointFence.generation(uuid);
|
||||
try {
|
||||
if ("Points".equalsIgnoreCase(placeholder.getUpdateDataKey())
|
||||
&& !pointFence.readable(uuid, pointGeneration)) {
|
||||
if (pointFence.recoverable(uuid)) schedulePointRecovery(user);
|
||||
return;
|
||||
}
|
||||
for (String ident : Set.copyOf(placeholder.getCache().keySet())) {
|
||||
ConcurrentHashMap<UUID, String> values = placeholder.getCache().get(ident);
|
||||
if (values == null) continue;
|
||||
@@ -1435,7 +1545,11 @@ public class PlaceHolders {
|
||||
}
|
||||
} else {
|
||||
String value = placeholder.placeholderRequest(user, ident);
|
||||
values.put(uuid, value);
|
||||
if ("Points".equalsIgnoreCase(placeholder.getUpdateDataKey())) {
|
||||
pointFence.publish(uuid, pointGeneration, () -> {
|
||||
if (plugin.isEnabled()) values.put(uuid, value);
|
||||
});
|
||||
} else values.put(uuid, value);
|
||||
if (getCacheLevel().onlineOnly() && !plugin.getPlaceholderPlayerPresence().isOnline(uuid)) {
|
||||
values.remove(uuid);
|
||||
continue;
|
||||
@@ -1444,6 +1558,9 @@ public class PlaceHolders {
|
||||
+ matchingKey(placeholder, keys) + " with " + value);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pointFence.release(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
private void publishCachedValue(PlaceHolder<VotingPluginUser> placeholder, VotingPluginUser user, UUID uuid,
|
||||
@@ -1468,6 +1585,52 @@ public class PlaceHolders {
|
||||
return placeholder.getUpdateDataKey();
|
||||
}
|
||||
|
||||
/** Blocks stale point results before a shared debit enters its worker. */
|
||||
public void beginSharedPointDebit(UUID uuid) {
|
||||
pointFence.begin(uuid, () -> clearPointResults(uuid));
|
||||
}
|
||||
|
||||
/** Owns a queued batch without replacing its worker's mutation outcome. */
|
||||
public java.util.function.Consumer<Boolean> sharedPointAdmissionCompletion(UUID uuid) {
|
||||
beginSharedPointDebit(uuid);
|
||||
var completed = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
return settled -> {
|
||||
if (completed.compareAndSet(false, true)) pointFence.finishAdmission(uuid,
|
||||
settled && plugin.isEnabled() && plugin.getPlaceholders() == this);
|
||||
};
|
||||
}
|
||||
|
||||
private void clearPointResults(UUID uuid) {
|
||||
for (PlaceHolder<VotingPluginUser> placeholder : userDataChangeClassification.all()) {
|
||||
if ("Points".equalsIgnoreCase(placeholder.getUpdateDataKey()) && placeholder.isUsesCache()) {
|
||||
placeholder.clearCachePlayer(uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Completes the fence only after the storage worker has reconciled its snapshot. */
|
||||
public void finishSharedPointDebit(UUID uuid, boolean trustworthySnapshot) {
|
||||
finishSharedPointDebit(uuid, trustworthySnapshot, trustworthySnapshot);
|
||||
}
|
||||
|
||||
public void finishSharedPointDebit(UUID uuid, boolean knownOutcome, boolean trustworthySnapshot) {
|
||||
pointFence.finish(uuid, knownOutcome, trustworthySnapshot);
|
||||
}
|
||||
|
||||
/** Captures a worker refill generation without granting permission to retry a mutation. */
|
||||
public java.util.function.Consumer<Boolean> sharedPointRefillCompletion(UUID uuid) {
|
||||
var stamp = pointFence.beginRefill(uuid, () -> clearPointResults(uuid));
|
||||
var completed = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
return verified -> {
|
||||
if (!completed.compareAndSet(false, true)) return;
|
||||
try {
|
||||
pointFence.finishRefill(uuid, stamp, verified && plugin.isEnabled() && plugin.getPlaceholders() == this);
|
||||
} finally {
|
||||
pointFence.release(uuid);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle player logout.
|
||||
* @param user the voting plugin user
|
||||
@@ -1518,6 +1681,7 @@ public class PlaceHolders {
|
||||
public void onUpdate(VotingPluginUser user, boolean login) {
|
||||
if (user == null) return;
|
||||
UUID uuid = user.getJavaUUID();
|
||||
if (pointFence.recoverable(uuid)) schedulePointRecovery(user);
|
||||
Player owner = plugin.getPlaceholderPlayerPresence().schedulerOwner(uuid);
|
||||
PlaceholderClassification classification = userDataChangeClassification;
|
||||
List<PlaceHolder<VotingPluginUser>> platformUpdates = new ArrayList<>();
|
||||
@@ -1559,6 +1723,8 @@ public class PlaceHolders {
|
||||
* Reload placeholders.
|
||||
*/
|
||||
public void reload() {
|
||||
pointFence.invalidateLifecycle();
|
||||
pointRecoveryWorkers.clear();
|
||||
platformUpdateGenerations.clear();
|
||||
plugin.refreshPlaceholderPlayerPresence();
|
||||
cacheLevel = plugin.getConfigFile().getPlaceholderCacheLevel();
|
||||
@@ -1592,16 +1758,47 @@ public class PlaceHolders {
|
||||
* Schedule placeholder check for user.
|
||||
* @param user the voting plugin user
|
||||
*/
|
||||
public void schedulePlaceholderCheck(VotingPluginUser user) {
|
||||
plugin.getTimer().execute(new Runnable() {
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
checkNonCachedPlaceholders();
|
||||
// Admit cold offline entries only from this worker callback.
|
||||
onUpdate(user, !getCacheLevel().onlineOnly()
|
||||
|| plugin.getPlaceholderPlayerPresence().isOnline(user.getJavaUUID()));
|
||||
}
|
||||
});
|
||||
}
|
||||
public void schedulePlaceholderCheck(VotingPluginUser user) {
|
||||
if (!plugin.isEnabled() || plugin.getPlaceholders() != this) return;
|
||||
UUID uuid = user.getJavaUUID();
|
||||
Object token = new Object();
|
||||
long lifecycle = pointFence.lifecycle();
|
||||
// Common warming and direct point recovery share queued and executing admission.
|
||||
if (pointRecoveryWorkers.putIfAbsent(uuid, token) != null) return;
|
||||
try {
|
||||
plugin.getTimer().execute(() -> {
|
||||
boolean warmed = false;
|
||||
try {
|
||||
if (!plugin.isEnabled() || plugin.getPlaceholders() != this || lifecycle != pointFence.lifecycle()) return;
|
||||
var observed = pointFence.generation(uuid);
|
||||
try {
|
||||
// Invalid data in a complete published row is not a missing snapshot.
|
||||
// Pending/unknown writes cannot authorize a competing point refill.
|
||||
if (pointFence.recoverable(uuid)
|
||||
|| (pointFence.readable(uuid, observed)
|
||||
&& com.bencodez.advancedcore.api.user.UserStorage.MYSQL.equals(plugin.getStorageType())
|
||||
&& publishedPointSnapshot(user) == null)) {
|
||||
com.bencodez.votingplugin.user.SharedMysqlCacheReconciler.invalidateAndRefreshOnWorker(
|
||||
plugin, user.getUUID(), user.getPointsPath());
|
||||
}
|
||||
} finally { pointFence.release(uuid); }
|
||||
if (!plugin.isEnabled() || plugin.getPlaceholders() != this || lifecycle != pointFence.lifecycle()) return;
|
||||
checkNonCachedPlaceholders();
|
||||
// Preserve each cache level's existing offline-entry admission policy.
|
||||
onUpdate(user, !getCacheLevel().onlineOnly() || plugin.getPlaceholderPlayerPresence().isOnline(uuid));
|
||||
warmed = true;
|
||||
} finally {
|
||||
boolean retired = pointRecoveryWorkers.remove(uuid, token);
|
||||
// A new identifier may have arrived after this worker's discovery pass.
|
||||
if (retired && warmed && !placeholdersToSetCacheOn.isEmpty() && plugin.isEnabled()
|
||||
&& plugin.getPlaceholders() == this && lifecycle == pointFence.lifecycle()) {
|
||||
schedulePlaceholderCheck(user);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
pointRecoveryWorkers.remove(uuid, token);
|
||||
plugin.debug(rejected);
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package com.bencodez.votingplugin.placeholders;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Coordinates point rendering with in-flight shared-storage mutations. */
|
||||
final class PointPlaceholderFence {
|
||||
private static final class State {
|
||||
long generation;
|
||||
int pending;
|
||||
int readers;
|
||||
int refills;
|
||||
boolean uncertain;
|
||||
boolean snapshotUnavailable;
|
||||
}
|
||||
|
||||
private final Map<UUID, State> states = new HashMap<>();
|
||||
private long generation;
|
||||
private long lifecycle;
|
||||
record Stamp(long lifecycle, long generation) { }
|
||||
|
||||
synchronized Stamp generation(UUID uuid) {
|
||||
State state = states.computeIfAbsent(uuid, ignored -> {
|
||||
State created = new State();
|
||||
created.generation = ++generation;
|
||||
return created;
|
||||
});
|
||||
state.readers++;
|
||||
return new Stamp(lifecycle, state.generation);
|
||||
}
|
||||
|
||||
synchronized boolean readable(UUID uuid, Stamp observed) {
|
||||
if (lifecycle != observed.lifecycle()) return false;
|
||||
State state = states.get(uuid);
|
||||
return state != null && state.generation == observed.generation()
|
||||
&& state.pending == 0 && state.refills == 0 && !state.uncertain && !state.snapshotUnavailable;
|
||||
}
|
||||
|
||||
synchronized void release(UUID uuid) {
|
||||
State state = states.get(uuid);
|
||||
if (state == null || state.readers == 0) throw new IllegalStateException("Unbalanced point reader");
|
||||
state.readers--;
|
||||
retireIfSafe(uuid, state);
|
||||
}
|
||||
|
||||
private void retireIfSafe(UUID uuid, State state) {
|
||||
if (state.readers == 0 && state.pending == 0 && state.refills == 0 && !state.uncertain && !state.snapshotUnavailable) states.remove(uuid);
|
||||
}
|
||||
|
||||
synchronized void invalidateLifecycle() { lifecycle++; }
|
||||
synchronized long lifecycle() { return lifecycle; }
|
||||
synchronized boolean recoverable(UUID uuid) {
|
||||
State state = states.get(uuid);
|
||||
return state != null && state.snapshotUnavailable && !state.uncertain && state.pending == 0 && state.refills == 0;
|
||||
}
|
||||
|
||||
synchronized Stamp beginRefill(UUID uuid, Runnable invalidate) {
|
||||
State state = states.computeIfAbsent(uuid, ignored -> new State());
|
||||
state.generation = ++generation;
|
||||
state.snapshotUnavailable = true;
|
||||
state.refills++;
|
||||
state.readers++;
|
||||
invalidate.run();
|
||||
return new Stamp(lifecycle, state.generation);
|
||||
}
|
||||
|
||||
synchronized void begin(UUID uuid, Runnable invalidate) {
|
||||
State state = states.computeIfAbsent(uuid, ignored -> new State());
|
||||
state.generation = ++generation;
|
||||
state.pending++;
|
||||
invalidate.run();
|
||||
}
|
||||
|
||||
synchronized void finish(UUID uuid, boolean knownOutcome, boolean trustworthy) {
|
||||
State state = states.get(uuid);
|
||||
if (state == null || state.pending == 0) throw new IllegalStateException("Unbalanced point mutation");
|
||||
state.uncertain |= !knownOutcome;
|
||||
state.snapshotUnavailable = !trustworthy || state.refills != 0;
|
||||
state.pending--;
|
||||
state.generation = ++generation;
|
||||
retireIfSafe(uuid, state);
|
||||
}
|
||||
|
||||
synchronized void finishAdmission(UUID uuid, boolean workerSettled) {
|
||||
State state = states.get(uuid);
|
||||
if (state == null || state.pending == 0) throw new IllegalStateException("Unbalanced point admission");
|
||||
state.pending--;
|
||||
state.snapshotUnavailable |= !workerSettled;
|
||||
state.generation = ++generation;
|
||||
retireIfSafe(uuid, state);
|
||||
}
|
||||
|
||||
synchronized void finishRefill(UUID uuid, Stamp observed, boolean verified) {
|
||||
State state = states.get(uuid);
|
||||
if (state == null || state.refills == 0) throw new IllegalStateException("Unbalanced point refill");
|
||||
boolean current = lifecycle == observed.lifecycle() && state.generation == observed.generation();
|
||||
state.refills--;
|
||||
state.snapshotUnavailable = !(verified && current && state.pending == 0 && state.refills == 0 && !state.uncertain);
|
||||
// Even an obsolete read completion can replace storage data: retire every older render.
|
||||
state.generation = ++generation;
|
||||
}
|
||||
|
||||
synchronized void publish(UUID uuid, Stamp observed, Runnable publication) {
|
||||
if (readable(uuid, observed)) publication.run();
|
||||
}
|
||||
}
|
||||
+38
-4
@@ -93,10 +93,20 @@ public final class SharedMysqlCacheReconciler {
|
||||
public static void invalidateAndRefresh(VotingPluginMain plugin, String uuid, String... columns) {
|
||||
invalidate(plugin, uuid, columns);
|
||||
if (plugin == null || uuid == null) return;
|
||||
String pointColumn = plugin.getBungeeSettings().isPerServerPoints()
|
||||
? plugin.getBungeeSettings().getServerNameStorage() + "_Points" : "Points";
|
||||
boolean affectsPoints = columns != null && java.util.Arrays.asList(columns).contains(pointColumn);
|
||||
var placeholders = plugin.getPlaceholders();
|
||||
java.util.function.Consumer<Boolean> completion = affectsPoints && placeholders != null
|
||||
? placeholders.sharedPointRefillCompletion(UUID.fromString(uuid)) : null;
|
||||
try {
|
||||
plugin.getVotingPluginUserManager().getVotingPluginUser(UUID.fromString(uuid), false).cacheAsync();
|
||||
plugin.getBukkitScheduler().runTaskAsynchronously(plugin,
|
||||
() -> tryInvalidateAndRefreshOnWorker(plugin, uuid, columns));
|
||||
} catch (RuntimeException refreshFailure) {
|
||||
plugin.debug(refreshFailure);
|
||||
} finally {
|
||||
// This admission is not storage evidence. Only the actual worker can recover rendering.
|
||||
if (completion != null) completion.accept(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,13 +116,37 @@ public final class SharedMysqlCacheReconciler {
|
||||
* A failed refill leaves the snapshot explicitly unavailable, not false zero.
|
||||
*/
|
||||
public static void invalidateAndRefreshOnWorker(VotingPluginMain plugin, String uuid, String... columns) {
|
||||
invalidate(plugin, uuid, columns);
|
||||
if (plugin == null || uuid == null) return;
|
||||
tryInvalidateAndRefreshOnWorker(plugin, uuid, columns);
|
||||
}
|
||||
|
||||
static boolean tryInvalidateAndRefreshOnWorker(VotingPluginMain plugin, String uuid, String... columns) {
|
||||
if (plugin == null || uuid == null) return false;
|
||||
UUID playerUuid = UUID.fromString(uuid);
|
||||
String pointColumn = plugin.getBungeeSettings().isPerServerPoints()
|
||||
? plugin.getBungeeSettings().getServerNameStorage() + "_Points" : "Points";
|
||||
boolean affectsPoints = columns != null && java.util.Arrays.asList(columns).contains(pointColumn);
|
||||
var placeholders = plugin.getPlaceholders();
|
||||
java.util.function.Consumer<Boolean> completion = affectsPoints && placeholders != null
|
||||
? placeholders.sharedPointRefillCompletion(playerUuid) : null;
|
||||
boolean verified = false;
|
||||
try {
|
||||
plugin.getUserManager().getDataManager().cacheUser(UUID.fromString(uuid), null);
|
||||
invalidate(plugin, uuid, columns);
|
||||
var manager = plugin.getUserManager().getDataManager();
|
||||
manager.cacheUser(playerUuid, null);
|
||||
var cache = manager.getUserDataCache().get(playerUuid);
|
||||
if (cache != null) synchronized (cache) {
|
||||
// A complete published row may legitimately omit the selected points key.
|
||||
// Rendering separately rejects present malformed/unsupported values.
|
||||
verified = manager.getUserDataCache().get(playerUuid) == cache
|
||||
&& cache.hasPublishedStorageSnapshot();
|
||||
}
|
||||
return true;
|
||||
} catch (RuntimeException refreshFailure) {
|
||||
plugin.getLogger().warning("Shared MySQL cache refresh is unavailable after mutation; stale data was not published.");
|
||||
plugin.debug(refreshFailure);
|
||||
return false;
|
||||
} finally {
|
||||
if (completion != null) completion.accept(verified);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+179
-73
@@ -185,16 +185,29 @@ final class SharedMysqlPointMutator {
|
||||
|
||||
AddResult addCommittedToColumn(VotingPluginUser user, int amount, String operationId, String pointsColumn) {
|
||||
if (operationId == null || operationId.isEmpty()) return addCommittedToColumn(user, amount, pointsColumn);
|
||||
drainCache(user, pointsColumn);
|
||||
var placeholders = plugin.getPlaceholders();
|
||||
UUID uuid = UUID.fromString(user.getUUID());
|
||||
if (placeholders != null) placeholders.beginSharedPointDebit(uuid);
|
||||
boolean confirmed = false;
|
||||
try {
|
||||
drainCache(user, pointsColumn);
|
||||
SharedPointAdditionJournal.AdditionResult result = SharedPointAdditionJournal.forTable(plugin.getMysql())
|
||||
.add(operationId, user.getUUID(), pointsColumn, amount, System.currentTimeMillis());
|
||||
confirmed = true;
|
||||
return new AddResult(true, result.total());
|
||||
} catch (SQLException failure) {
|
||||
logFailure(failure);
|
||||
return new AddResult(MutationOutcome.INDETERMINATE, 0);
|
||||
} finally {
|
||||
discardPointsCache(user, pointsColumn);
|
||||
boolean refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, user.getUUID(), pointsColumn);
|
||||
if (placeholders != null) {
|
||||
var cache = plugin.getUserManager().getDataManager().getUserDataCache().get(uuid);
|
||||
boolean available = false;
|
||||
if (cache != null) synchronized (cache) {
|
||||
available = refreshed && confirmed && cache.hasPublishedStorageSnapshot();
|
||||
}
|
||||
placeholders.finishSharedPointDebit(uuid, confirmed, available);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,18 +222,36 @@ final class SharedMysqlPointMutator {
|
||||
|
||||
AddResult removeCommittedFromColumn(VotingPluginUser user, int amount, String operationId, String pointsColumn) {
|
||||
if (operationId == null || operationId.isEmpty()) return new AddResult(false, 0);
|
||||
drainCache(user, pointsColumn);
|
||||
var placeholders = plugin.getPlaceholders();
|
||||
UUID uuid = UUID.fromString(user.getUUID());
|
||||
if (placeholders != null) placeholders.beginSharedPointDebit(uuid);
|
||||
AddResult outcome = new AddResult(MutationOutcome.INDETERMINATE, 0);
|
||||
boolean refreshed;
|
||||
try {
|
||||
drainCache(user, pointsColumn);
|
||||
SharedPointAdditionJournal.AdditionResult result = SharedPointAdditionJournal.forTable(plugin.getMysql())
|
||||
.subtract(operationId, user.getUUID(), pointsColumn, amount, System.currentTimeMillis());
|
||||
return new AddResult(true, result.total());
|
||||
outcome = new AddResult(true, result.total());
|
||||
} catch (SQLException failure) {
|
||||
logFailure(failure);
|
||||
return new AddResult(failure instanceof SharedPointAdditionJournal.DebitRejectedException
|
||||
? MutationOutcome.REJECTED : MutationOutcome.INDETERMINATE, 0);
|
||||
if (failure instanceof SharedPointAdditionJournal.DebitRejectedException) {
|
||||
outcome = new AddResult(MutationOutcome.REJECTED, 0);
|
||||
} else {
|
||||
logFailure(failure);
|
||||
outcome = new AddResult(MutationOutcome.INDETERMINATE, 0);
|
||||
}
|
||||
} finally {
|
||||
discardPointsCache(user, pointsColumn);
|
||||
refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, user.getUUID(), pointsColumn);
|
||||
if (placeholders != null) {
|
||||
var cache = plugin.getUserManager().getDataManager().getUserDataCache().get(uuid);
|
||||
boolean available = false;
|
||||
if (cache != null) synchronized (cache) {
|
||||
available = refreshed && outcome.outcome() != MutationOutcome.INDETERMINATE
|
||||
&& cache.hasPublishedStorageSnapshot();
|
||||
}
|
||||
placeholders.finishSharedPointDebit(uuid, outcome.outcome() != MutationOutcome.INDETERMINATE, available);
|
||||
}
|
||||
}
|
||||
return new AddResult(outcome.outcome(), outcome.total(), refreshed);
|
||||
}
|
||||
|
||||
/** Looks up a completed retry before its Bukkit receive event is dispatched. */
|
||||
@@ -326,7 +357,20 @@ final class SharedMysqlPointMutator {
|
||||
|
||||
boolean set(VotingPluginUser user, int value, boolean async) {
|
||||
String pointsColumn = user.getPointsPath();
|
||||
return run(() -> setAbsolute(user, value, pointsColumn), async);
|
||||
var placeholders = plugin.getPlaceholders();
|
||||
Consumer<Boolean> admission = placeholders == null ? null
|
||||
: placeholders.sharedPointAdmissionCompletion(UUID.fromString(user.getUUID()));
|
||||
boolean accepted = run(() -> {
|
||||
boolean settled = false;
|
||||
try {
|
||||
setAbsolute(user, value, pointsColumn);
|
||||
settled = true;
|
||||
} finally {
|
||||
if (admission != null) admission.accept(settled);
|
||||
}
|
||||
}, async);
|
||||
if (!accepted && admission != null) admission.accept(false);
|
||||
return accepted;
|
||||
}
|
||||
|
||||
boolean setCommitted(VotingPluginUser user, int value) {
|
||||
@@ -498,41 +542,40 @@ final class SharedMysqlPointMutator {
|
||||
org.bukkit.entity.Player approvalPlayer = targetPlayer != null ? targetPlayer : sourcePlayer;
|
||||
String sourcePoints = source.getPointsPath();
|
||||
String targetPoints = target.getPointsPath();
|
||||
var reservationPublication = beginPointPublication(source);
|
||||
try {
|
||||
plugin.getTimer().execute(() -> {
|
||||
try {
|
||||
drainCache(source);
|
||||
drainCache(target);
|
||||
} catch (RuntimeException cacheFailure) {
|
||||
plugin.debug(cacheFailure);
|
||||
completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE);
|
||||
return;
|
||||
}
|
||||
MySQL table = plugin.getMysql();
|
||||
String transferId = UUID.randomUUID().toString();
|
||||
String owner = UUID.randomUUID().toString();
|
||||
SharedPointTransferJournal journal;
|
||||
SharedPointTransferJournal prepared = null;
|
||||
boolean known = true;
|
||||
boolean reserved = false;
|
||||
PointTransferResult failed = PointTransferResult.UNAVAILABLE;
|
||||
try {
|
||||
journal = SharedPointTransferJournal.forTable(table);
|
||||
recoverTransfers(plugin, journal);
|
||||
if (!journal.reserve(transferId, source.getUUID(), sourcePoints, debitAmount, target.getUUID(),
|
||||
debitAmount, System.currentTimeMillis())) {
|
||||
completeOnBukkit(sourcePlayer, completion, PointTransferResult.INSUFFICIENT_POINTS);
|
||||
return;
|
||||
}
|
||||
// The source cache may have been recreated while the reservation was being
|
||||
// committed. Invalidate its points after the durable debit, before any later
|
||||
// dump can restore the pre-debit balance.
|
||||
discardPointsCache(source, sourcePoints);
|
||||
drainCache(source);
|
||||
drainCache(target);
|
||||
prepared = SharedPointTransferJournal.forTable(table);
|
||||
recoverTransfers(plugin, prepared);
|
||||
known = false;
|
||||
reserved = prepared.reserve(transferId, source.getUUID(), sourcePoints, debitAmount,
|
||||
target.getUUID(), debitAmount, System.currentTimeMillis());
|
||||
known = true;
|
||||
failed = PointTransferResult.INSUFFICIENT_POINTS;
|
||||
} catch (SQLException failure) {
|
||||
// An acknowledgement/confirmation failure can follow a committed
|
||||
// reservation debit. Never allow a cache recreated during the unknown
|
||||
// outcome to flush the pre-debit source balance over it.
|
||||
discardPointsCache(source, sourcePoints);
|
||||
known |= failure instanceof SharedPointTransferJournal.PreparationFailure;
|
||||
logFailure(failure);
|
||||
completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE);
|
||||
} catch (RuntimeException cacheFailure) {
|
||||
plugin.debug(cacheFailure);
|
||||
} finally {
|
||||
boolean refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, source.getUUID(), sourcePoints);
|
||||
finishPointPublication(reservationPublication, source, sourcePoints, known, refreshed);
|
||||
}
|
||||
if (!reserved) {
|
||||
completeOnBukkit(sourcePlayer, completion, failed);
|
||||
return;
|
||||
}
|
||||
SharedPointTransferJournal journal = prepared;
|
||||
|
||||
/*
|
||||
* Do not claim the reservation until the Bukkit approval task has actually
|
||||
@@ -562,6 +605,7 @@ final class SharedMysqlPointMutator {
|
||||
// No reservation exists when the initial persistence task is rejected.
|
||||
// Still complete the command contract on the source entity lane.
|
||||
plugin.debug(schedulingFailure);
|
||||
finishPointPublication(reservationPublication, source, sourcePoints, true, false);
|
||||
completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
@@ -574,8 +618,7 @@ final class SharedMysqlPointMutator {
|
||||
System.currentTimeMillis());
|
||||
if (claim == SharedPointTransferJournal.ClaimOutcome.NOT_CLAIMED) {
|
||||
try {
|
||||
journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount);
|
||||
discardPointsCache(source, sourcePoints);
|
||||
refundReservedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
|
||||
} catch (SQLException failure) {
|
||||
logFailure(failure);
|
||||
}
|
||||
@@ -713,10 +756,7 @@ final class SharedMysqlPointMutator {
|
||||
} catch (SQLException markerFailure) {
|
||||
boolean refunded = false;
|
||||
try {
|
||||
refunded = journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount);
|
||||
if (refunded) {
|
||||
discardPointsCache(source, sourcePoints);
|
||||
}
|
||||
refunded = refundClaimedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
|
||||
} catch (SQLException refundFailure) {
|
||||
logFailure(refundFailure);
|
||||
}
|
||||
@@ -736,17 +776,24 @@ final class SharedMysqlPointMutator {
|
||||
int debitAmount,
|
||||
Consumer<PointTransferResult> completion, SharedPointTransferJournal journal, String transferId, String owner,
|
||||
String sourcePoints, String targetPoints, Integer approvedAmount) {
|
||||
var sourcePublication = beginPointPublication(source);
|
||||
var targetPublication = beginPointPublication(target);
|
||||
boolean known = false;
|
||||
PointTransferResult result;
|
||||
try {
|
||||
// The hook may have recreated either cache while it ran on Bukkit.
|
||||
drainCache(target);
|
||||
|
||||
SharedPointTransferJournal.SettlementOutcome outcome;
|
||||
try {
|
||||
drainCache(target);
|
||||
outcome = journal.settleWithConfirmation(transferId, owner, source.getUUID(), sourcePoints,
|
||||
target.getUUID(), targetPoints, debitAmount, approvedAmount);
|
||||
known = outcome != SharedPointTransferJournal.SettlementOutcome.INDETERMINATE;
|
||||
} finally {
|
||||
discardPointsCache(source, sourcePoints);
|
||||
discardPointsCache(target, targetPoints);
|
||||
boolean sourceRefreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, source.getUUID(), sourcePoints);
|
||||
boolean targetRefreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, target.getUUID(), targetPoints);
|
||||
finishPointPublication(sourcePublication, source, sourcePoints, known, sourceRefreshed);
|
||||
finishPointPublication(targetPublication, target, targetPoints, known, targetRefreshed);
|
||||
}
|
||||
result = outcome == SharedPointTransferJournal.SettlementOutcome.COMPLETED ? PointTransferResult.SUCCESS
|
||||
: outcome == SharedPointTransferJournal.SettlementOutcome.REFUNDED ? PointTransferResult.CANCELLED
|
||||
@@ -760,14 +807,29 @@ final class SharedMysqlPointMutator {
|
||||
completeOnBukkit(sourcePlayer, completion, result);
|
||||
}
|
||||
|
||||
private boolean refundReservedWithPublication(VotingPluginUser source, SharedPointTransferJournal journal,
|
||||
String transferId, String sourcePoints, int debitAmount) throws SQLException {
|
||||
var publication = beginPointPublication(source);
|
||||
boolean known = false;
|
||||
try {
|
||||
boolean refunded = journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount);
|
||||
known = true;
|
||||
return refunded;
|
||||
} catch (SQLException failure) {
|
||||
known = failure instanceof SharedPointTransferJournal.PreparationFailure;
|
||||
throw failure;
|
||||
} finally {
|
||||
boolean refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, source.getUUID(), sourcePoints);
|
||||
finishPointPublication(publication, source, sourcePoints, known, refreshed);
|
||||
}
|
||||
}
|
||||
|
||||
private void refundReservedAfterSchedulingFailure(VotingPluginUser source, org.bukkit.entity.Player sourcePlayer,
|
||||
Consumer<PointTransferResult> completion,
|
||||
SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount,
|
||||
RuntimeException failure) {
|
||||
try {
|
||||
if (journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount)) {
|
||||
discardPointsCache(source, sourcePoints);
|
||||
}
|
||||
refundReservedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
|
||||
} catch (SQLException refundFailure) {
|
||||
logFailure(refundFailure);
|
||||
}
|
||||
@@ -783,24 +845,23 @@ final class SharedMysqlPointMutator {
|
||||
// HOOK_STARTED is safe to compensate because the approval task has not
|
||||
// been submitted yet. A RESERVED row is handled by its normal refund.
|
||||
if (journal.markCompensating(transferId)) {
|
||||
refunded = journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount);
|
||||
refunded = refundClaimedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
|
||||
} else {
|
||||
refunded = journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount);
|
||||
refunded = refundReservedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
|
||||
}
|
||||
} catch (SQLException markerFailure) {
|
||||
// A lost marker acknowledgement may still have committed. Both refund
|
||||
// operations are idempotent and cover either durable pre-hook state.
|
||||
try {
|
||||
refunded = journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount);
|
||||
refunded = refundClaimedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
|
||||
if (!refunded) {
|
||||
refunded = journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount);
|
||||
refunded = refundReservedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
|
||||
}
|
||||
} catch (SQLException refundFailure) {
|
||||
logFailure(refundFailure);
|
||||
}
|
||||
logFailure(markerFailure);
|
||||
}
|
||||
if (refunded) discardPointsCache(source, sourcePoints);
|
||||
if (!refunded) {
|
||||
rememberPendingCompensationMarker(plugin, transferId);
|
||||
logIndeterminateClaim(transferId);
|
||||
@@ -815,19 +876,32 @@ final class SharedMysqlPointMutator {
|
||||
completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE);
|
||||
}
|
||||
|
||||
private boolean refundClaimedWithPublication(VotingPluginUser source, SharedPointTransferJournal journal,
|
||||
String transferId, String sourcePoints, int debitAmount) throws SQLException {
|
||||
var publication = beginPointPublication(source);
|
||||
boolean known = false;
|
||||
try {
|
||||
boolean refunded = journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount);
|
||||
known = true;
|
||||
return refunded;
|
||||
} catch (SQLException refundFailure) {
|
||||
known = refundFailure instanceof SharedPointTransferJournal.PreparationFailure;
|
||||
throw refundFailure;
|
||||
} finally {
|
||||
// A later guarded fallback or refill cannot erase an earlier ambiguous write.
|
||||
boolean refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, source.getUUID(), sourcePoints);
|
||||
finishPointPublication(publication, source, sourcePoints, known, refreshed);
|
||||
}
|
||||
}
|
||||
|
||||
void refundClaimedAfterSchedulingFailure(VotingPluginUser source, org.bukkit.entity.Player sourcePlayer,
|
||||
Consumer<PointTransferResult> completion,
|
||||
SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount,
|
||||
RuntimeException failure) {
|
||||
try {
|
||||
journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount);
|
||||
refundClaimedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
|
||||
} catch (SQLException refundFailure) {
|
||||
logFailure(refundFailure);
|
||||
} finally {
|
||||
// The refund may have committed even when its acknowledgement and
|
||||
// confirmation read both failed. Invalidate a cache recreated after the
|
||||
// original debit so it cannot later overwrite either durable outcome.
|
||||
discardPointsCache(source, sourcePoints);
|
||||
}
|
||||
plugin.debug(failure);
|
||||
completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE);
|
||||
@@ -990,9 +1064,12 @@ final class SharedMysqlPointMutator {
|
||||
|
||||
enum MutationOutcome { CONFIRMED, REJECTED, INDETERMINATE }
|
||||
|
||||
record AddResult(MutationOutcome outcome, int total) {
|
||||
record AddResult(MutationOutcome outcome, int total, boolean sourceRefreshed) {
|
||||
AddResult(MutationOutcome outcome, int total) {
|
||||
this(outcome, total, false);
|
||||
}
|
||||
AddResult(boolean success, int total) {
|
||||
this(success ? MutationOutcome.CONFIRMED : MutationOutcome.INDETERMINATE, total);
|
||||
this(success ? MutationOutcome.CONFIRMED : MutationOutcome.INDETERMINATE, total, false);
|
||||
}
|
||||
boolean success() { return outcome == MutationOutcome.CONFIRMED; }
|
||||
}
|
||||
@@ -1002,21 +1079,29 @@ final class SharedMysqlPointMutator {
|
||||
}
|
||||
|
||||
private boolean setAbsolute(VotingPluginUser user, int value, String points) {
|
||||
drainCache(user, points);
|
||||
MySQL table = plugin.getMysql();
|
||||
String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points)
|
||||
+ " = ? WHERE " + table.qi("uuid")
|
||||
+ (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?");
|
||||
try (Connection connection = requireConnection(table);
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setInt(1, value);
|
||||
statement.setString(2, user.getUUID());
|
||||
return statement.executeUpdate() == 1;
|
||||
} catch (SQLException failure) {
|
||||
logFailure(failure);
|
||||
return false;
|
||||
var publication = beginPointPublication(user);
|
||||
boolean known = true;
|
||||
try {
|
||||
drainCache(user, points);
|
||||
MySQL table = plugin.getMysql();
|
||||
String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points)
|
||||
+ " = ? WHERE " + table.qi("uuid")
|
||||
+ (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?");
|
||||
try (Connection connection = requireConnection(table);
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setInt(1, value);
|
||||
statement.setString(2, user.getUUID());
|
||||
known = false;
|
||||
int affected = statement.executeUpdate();
|
||||
known = true;
|
||||
return affected == 1;
|
||||
} catch (SQLException failure) {
|
||||
logFailure(failure);
|
||||
return false;
|
||||
}
|
||||
} finally {
|
||||
discardPointsCache(user, points);
|
||||
boolean refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, user.getUUID(), points);
|
||||
finishPointPublication(publication, user, points, known, refreshed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1101,6 +1186,27 @@ final class SharedMysqlPointMutator {
|
||||
* deliberately not discarded here because it is a distinct, later write and
|
||||
* the generic absolute-value API cannot provide cross-server atomic semantics.
|
||||
*/
|
||||
private com.bencodez.votingplugin.placeholders.PlaceHolders beginPointPublication(VotingPluginUser user) {
|
||||
var placeholders = plugin.getPlaceholders();
|
||||
if (placeholders != null) placeholders.beginSharedPointDebit(UUID.fromString(user.getUUID()));
|
||||
return placeholders;
|
||||
}
|
||||
|
||||
private void finishPointPublication(com.bencodez.votingplugin.placeholders.PlaceHolders placeholders,
|
||||
VotingPluginUser user, String column, boolean known, boolean refreshed) {
|
||||
if (placeholders == null) return;
|
||||
UUID uuid = UUID.fromString(user.getUUID());
|
||||
var caches = plugin.getUserManager().getDataManager().getUserDataCache();
|
||||
var cache = caches.get(uuid);
|
||||
boolean available = false;
|
||||
if (cache != null) synchronized (cache) {
|
||||
available = known && refreshed && caches.get(uuid) == cache && cache.hasPublishedStorageSnapshot()
|
||||
&& column.equals(user.getPointsPath())
|
||||
&& plugin.isEnabled() && plugin.getPlaceholders() == placeholders;
|
||||
}
|
||||
placeholders.finishSharedPointDebit(uuid, known, available);
|
||||
}
|
||||
|
||||
private void discardPointsCache(VotingPluginUser user) {
|
||||
discardPointsCache(user, user.getPointsPath());
|
||||
}
|
||||
|
||||
+101
-74
@@ -28,6 +28,14 @@ import com.bencodez.simpleapi.sql.mysql.DbType;
|
||||
* settlement is a second short, idempotent transaction.</p>
|
||||
*/
|
||||
final class SharedPointTransferJournal {
|
||||
/** Positive proof that this invocation did not attempt a points write. */
|
||||
static final class PreparationFailure extends SQLException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
PreparationFailure(SQLException cause) {
|
||||
super(cause.getMessage(), cause.getSQLState(), cause.getErrorCode(), cause);
|
||||
}
|
||||
}
|
||||
|
||||
private static final String RESERVED = "RESERVED";
|
||||
private static final String HOOK_STARTED = "HOOK_STARTED";
|
||||
/*
|
||||
@@ -144,51 +152,59 @@ final class SharedPointTransferJournal {
|
||||
*/
|
||||
boolean reserve(String transferId, String sourceUuid, String sourcePointsColumn, int debitPoints, String targetUuid,
|
||||
int requestedCreditPoints, long now) throws SQLException {
|
||||
TransferRow existing = find(transferId);
|
||||
if (existing != null) {
|
||||
return existing.matches(sourceUuid, targetUuid, debitPoints, requestedCreditPoints)
|
||||
&& !REFUNDED.equals(existing.state);
|
||||
}
|
||||
|
||||
String insert = "INSERT INTO " + qiJournal() + " (" + qi("transfer_id") + ", " + qi("source_uuid")
|
||||
+ ", " + qi("source_points_column") + ", " + qi("target_uuid") + ", " + qi("debit_points")
|
||||
+ ", " + qi("requested_credit_points") + ", " + qi("state") + ", " + qi("created_at")
|
||||
+ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
String points = qi(sourcePointsColumn);
|
||||
String debit = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points
|
||||
+ " - ? WHERE " + qi("uuid") + uuidCast() + " AND " + points + " >= ?";
|
||||
try (Connection connection = connection()) {
|
||||
connection.setAutoCommit(false);
|
||||
try (PreparedStatement insertStatement = connection.prepareStatement(insert);
|
||||
PreparedStatement debitStatement = connection.prepareStatement(debit)) {
|
||||
insertStatement.setString(1, transferId);
|
||||
insertStatement.setString(2, sourceUuid);
|
||||
insertStatement.setString(3, sourcePointsColumn);
|
||||
insertStatement.setString(4, targetUuid);
|
||||
insertStatement.setInt(5, debitPoints);
|
||||
insertStatement.setInt(6, requestedCreditPoints);
|
||||
insertStatement.setString(7, RESERVED);
|
||||
insertStatement.setLong(8, now);
|
||||
insertStatement.executeUpdate();
|
||||
|
||||
debitStatement.setInt(1, debitPoints);
|
||||
debitStatement.setString(2, sourceUuid);
|
||||
debitStatement.setInt(3, debitPoints);
|
||||
if (debitStatement.executeUpdate() != 1) {
|
||||
connection.rollback();
|
||||
return false;
|
||||
}
|
||||
return commitAndConfirm(connection, transferId, RESERVED);
|
||||
} catch (SQLException failure) {
|
||||
rollback(connection);
|
||||
if (isDuplicate(failure)) {
|
||||
closeQuietly(connection);
|
||||
TransferRow duplicate = find(transferId);
|
||||
return duplicate != null && duplicate.matches(sourceUuid, targetUuid, debitPoints,
|
||||
requestedCreditPoints) && !REFUNDED.equals(duplicate.state);
|
||||
}
|
||||
throw failure;
|
||||
boolean pointWriteAttempted = false;
|
||||
try {
|
||||
TransferRow existing = find(transferId);
|
||||
if (existing != null) {
|
||||
return existing.matches(sourceUuid, targetUuid, debitPoints, requestedCreditPoints)
|
||||
&& !REFUNDED.equals(existing.state);
|
||||
}
|
||||
|
||||
String insert = "INSERT INTO " + qiJournal() + " (" + qi("transfer_id") + ", " + qi("source_uuid")
|
||||
+ ", " + qi("source_points_column") + ", " + qi("target_uuid") + ", " + qi("debit_points")
|
||||
+ ", " + qi("requested_credit_points") + ", " + qi("state") + ", " + qi("created_at")
|
||||
+ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
String points = qi(sourcePointsColumn);
|
||||
String debit = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points
|
||||
+ " - ? WHERE " + qi("uuid") + uuidCast() + " AND " + points + " >= ?";
|
||||
try (Connection connection = connection()) {
|
||||
connection.setAutoCommit(false);
|
||||
try (PreparedStatement insertStatement = connection.prepareStatement(insert);
|
||||
PreparedStatement debitStatement = connection.prepareStatement(debit)) {
|
||||
insertStatement.setString(1, transferId);
|
||||
insertStatement.setString(2, sourceUuid);
|
||||
insertStatement.setString(3, sourcePointsColumn);
|
||||
insertStatement.setString(4, targetUuid);
|
||||
insertStatement.setInt(5, debitPoints);
|
||||
insertStatement.setInt(6, requestedCreditPoints);
|
||||
insertStatement.setString(7, RESERVED);
|
||||
insertStatement.setLong(8, now);
|
||||
insertStatement.executeUpdate();
|
||||
|
||||
debitStatement.setInt(1, debitPoints);
|
||||
debitStatement.setString(2, sourceUuid);
|
||||
debitStatement.setInt(3, debitPoints);
|
||||
pointWriteAttempted = true;
|
||||
if (debitStatement.executeUpdate() != 1) {
|
||||
connection.rollback();
|
||||
return false;
|
||||
}
|
||||
return commitAndConfirm(connection, transferId, RESERVED);
|
||||
} catch (SQLException failure) {
|
||||
rollback(connection);
|
||||
if (isDuplicate(failure)) {
|
||||
closeQuietly(connection);
|
||||
TransferRow duplicate = find(transferId);
|
||||
return duplicate != null && duplicate.matches(sourceUuid, targetUuid, debitPoints,
|
||||
requestedCreditPoints) && !REFUNDED.equals(duplicate.state);
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
} catch (SQLException failure) {
|
||||
if (failure instanceof PreparationFailure) throw failure;
|
||||
if (!pointWriteAttempted) throw new PreparationFailure(failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,42 +302,50 @@ final class SharedPointTransferJournal {
|
||||
*/
|
||||
boolean refundReserved(String transferId, String sourceUuid, String sourcePointsColumn, int debitPoints)
|
||||
throws SQLException {
|
||||
if (!isSafeColumn(sourcePointsColumn)) return false;
|
||||
String select = "SELECT " + qi("state") + " FROM " + qiJournal() + " WHERE " + qi("transfer_id")
|
||||
+ " = ? FOR UPDATE";
|
||||
String points = qi(sourcePointsColumn);
|
||||
String refund = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points
|
||||
+ " + ? WHERE " + qi("uuid") + uuidCast();
|
||||
String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("transfer_id")
|
||||
+ " = ?";
|
||||
try (Connection connection = connection()) {
|
||||
connection.setAutoCommit(false);
|
||||
try (PreparedStatement selectStatement = connection.prepareStatement(select)) {
|
||||
selectStatement.setString(1, transferId);
|
||||
try (ResultSet result = selectStatement.executeQuery()) {
|
||||
if (!result.next() || !RESERVED.equals(result.getString(1))) {
|
||||
boolean pointWriteAttempted = false;
|
||||
try {
|
||||
if (!isSafeColumn(sourcePointsColumn)) return false;
|
||||
String select = "SELECT " + qi("state") + " FROM " + qiJournal() + " WHERE " + qi("transfer_id")
|
||||
+ " = ? FOR UPDATE";
|
||||
String points = qi(sourcePointsColumn);
|
||||
String refund = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points
|
||||
+ " + ? WHERE " + qi("uuid") + uuidCast();
|
||||
String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("transfer_id")
|
||||
+ " = ?";
|
||||
try (Connection connection = connection()) {
|
||||
connection.setAutoCommit(false);
|
||||
try (PreparedStatement selectStatement = connection.prepareStatement(select)) {
|
||||
selectStatement.setString(1, transferId);
|
||||
try (ResultSet result = selectStatement.executeQuery()) {
|
||||
if (!result.next() || !RESERVED.equals(result.getString(1))) {
|
||||
connection.rollback();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
try (PreparedStatement refundStatement = connection.prepareStatement(refund)) {
|
||||
refundStatement.setInt(1, debitPoints);
|
||||
refundStatement.setString(2, sourceUuid);
|
||||
pointWriteAttempted = true;
|
||||
if (refundStatement.executeUpdate() != 1) {
|
||||
connection.rollback();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
try (PreparedStatement refundStatement = connection.prepareStatement(refund)) {
|
||||
refundStatement.setInt(1, debitPoints);
|
||||
refundStatement.setString(2, sourceUuid);
|
||||
if (refundStatement.executeUpdate() != 1) {
|
||||
connection.rollback();
|
||||
return false;
|
||||
try (PreparedStatement updateStatement = connection.prepareStatement(update)) {
|
||||
updateStatement.setString(1, REFUNDED);
|
||||
updateStatement.setString(2, transferId);
|
||||
if (updateStatement.executeUpdate() != 1) {
|
||||
connection.rollback();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return commitAndConfirm(connection, transferId, REFUNDED);
|
||||
}
|
||||
try (PreparedStatement updateStatement = connection.prepareStatement(update)) {
|
||||
updateStatement.setString(1, REFUNDED);
|
||||
updateStatement.setString(2, transferId);
|
||||
if (updateStatement.executeUpdate() != 1) {
|
||||
connection.rollback();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return commitAndConfirm(connection, transferId, REFUNDED);
|
||||
} catch (SQLException failure) {
|
||||
if (failure instanceof PreparationFailure) throw failure;
|
||||
if (!pointWriteAttempted) throw new PreparationFailure(failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,6 +364,7 @@ final class SharedPointTransferJournal {
|
||||
+ " + ? WHERE " + qi("uuid") + uuidCast();
|
||||
String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("transfer_id")
|
||||
+ " = ?";
|
||||
boolean pointWriteAttempted = false;
|
||||
try (Connection connection = connection()) {
|
||||
connection.setAutoCommit(false);
|
||||
try (PreparedStatement selectStatement = connection.prepareStatement(select)) {
|
||||
@@ -363,6 +388,7 @@ final class SharedPointTransferJournal {
|
||||
try (PreparedStatement refundStatement = connection.prepareStatement(refund)) {
|
||||
refundStatement.setInt(1, debitPoints);
|
||||
refundStatement.setString(2, sourceUuid);
|
||||
pointWriteAttempted = true;
|
||||
if (refundStatement.executeUpdate() != 1) {
|
||||
connection.rollback();
|
||||
return false;
|
||||
@@ -378,6 +404,7 @@ final class SharedPointTransferJournal {
|
||||
}
|
||||
return commitAndConfirm(connection, transferId, REFUNDED);
|
||||
} catch (SQLException failure) {
|
||||
if (!pointWriteAttempted) throw new PreparationFailure(failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
+2108
-1964
File diff suppressed because it is too large.
Load diff
+153
-121
@@ -29,6 +29,14 @@ import com.bencodez.simpleapi.sql.mysql.DbType;
|
||||
* arbitrary, non-idempotent side effects.</p>
|
||||
*/
|
||||
final class SharedMysqlPurchaseJournal {
|
||||
/** Positive proof that this invocation did not attempt a points write. */
|
||||
static final class PreparationFailure extends SQLException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
PreparationFailure(SQLException cause) {
|
||||
super(cause.getMessage(), cause.getSQLState(), cause.getErrorCode(), cause);
|
||||
}
|
||||
}
|
||||
|
||||
private static final String PENDING = "PENDING";
|
||||
private static final String HOOK_STARTED = "HOOK_STARTED";
|
||||
private static final String COMPENSATING = "COMPENSATING";
|
||||
@@ -119,60 +127,68 @@ final class SharedMysqlPurchaseJournal {
|
||||
/** Atomically records a pending purchase and conditionally charges it. */
|
||||
boolean reserve(String purchaseId, String uuid, String pointsColumn, String limitColumn, int cost, int limit,
|
||||
String limitGeneration, long limitGenerationExpiresAt, long now) throws SQLException {
|
||||
String insert = "INSERT INTO " + qiJournal() + " (" + qi("purchase_id") + ", " + qi("player_uuid")
|
||||
+ ", " + qi("points_column") + ", " + qi("limit_column") + ", " + qi("cost") + ", "
|
||||
+ qi("limit_value") + ", " + qi("limit_generation") + ", "
|
||||
+ qi("limit_generation_expires_at") + ", " + qi("limit_epoch") + ", " + qi("state")
|
||||
+ ", " + qi("created_at") + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
String points = qi(pointsColumn);
|
||||
StringBuilder debit = new StringBuilder("UPDATE ").append(qi(table.getTableName())).append(" SET ")
|
||||
.append(points).append(" = COALESCE(").append(points).append(", 0) - ?");
|
||||
if (limitColumn != null) {
|
||||
debit.append(", ").append(qi(limitColumn)).append(" = COALESCE(").append(qi(limitColumn))
|
||||
.append(", 0) + 1");
|
||||
}
|
||||
debit.append(" WHERE ").append(qi("uuid")).append(uuidCast()).append(" AND COALESCE(").append(points)
|
||||
.append(", 0) >= ?");
|
||||
if (limitColumn != null) {
|
||||
debit.append(" AND COALESCE(").append(qi(limitColumn)).append(", 0) < ?");
|
||||
}
|
||||
try (Connection connection = connection()) {
|
||||
connection.setAutoCommit(false);
|
||||
try {
|
||||
Long limitEpoch = limitColumn == null ? null : lockLimitEpoch(connection, limitColumn);
|
||||
try (PreparedStatement insertStatement = connection.prepareStatement(insert);
|
||||
PreparedStatement debitStatement = connection.prepareStatement(debit.toString())) {
|
||||
insertStatement.setString(1, purchaseId);
|
||||
insertStatement.setString(2, uuid);
|
||||
insertStatement.setString(3, pointsColumn);
|
||||
insertStatement.setString(4, limitColumn);
|
||||
insertStatement.setInt(5, cost);
|
||||
if (limitColumn == null) insertStatement.setNull(6, java.sql.Types.INTEGER);
|
||||
else insertStatement.setInt(6, limit);
|
||||
if (limitGeneration == null) insertStatement.setNull(7, java.sql.Types.VARCHAR);
|
||||
else insertStatement.setString(7, limitGeneration);
|
||||
if (limitGenerationExpiresAt <= 0L) insertStatement.setNull(8, java.sql.Types.BIGINT);
|
||||
else insertStatement.setLong(8, limitGenerationExpiresAt);
|
||||
if (limitEpoch == null) insertStatement.setNull(9, java.sql.Types.BIGINT);
|
||||
else insertStatement.setLong(9, limitEpoch.longValue());
|
||||
insertStatement.setString(10, PENDING);
|
||||
insertStatement.setLong(11, now);
|
||||
insertStatement.executeUpdate();
|
||||
|
||||
debitStatement.setInt(1, cost);
|
||||
debitStatement.setString(2, uuid);
|
||||
debitStatement.setInt(3, cost);
|
||||
if (limitColumn != null) debitStatement.setInt(4, limit);
|
||||
if (debitStatement.executeUpdate() != 1) {
|
||||
rollback(connection);
|
||||
return false;
|
||||
}
|
||||
return commitAndConfirm(connection, purchaseId, PENDING);
|
||||
}
|
||||
} catch (SQLException failure) {
|
||||
rollback(connection);
|
||||
throw failure;
|
||||
boolean pointWriteAttempted = false;
|
||||
try {
|
||||
String insert = "INSERT INTO " + qiJournal() + " (" + qi("purchase_id") + ", " + qi("player_uuid")
|
||||
+ ", " + qi("points_column") + ", " + qi("limit_column") + ", " + qi("cost") + ", "
|
||||
+ qi("limit_value") + ", " + qi("limit_generation") + ", "
|
||||
+ qi("limit_generation_expires_at") + ", " + qi("limit_epoch") + ", " + qi("state")
|
||||
+ ", " + qi("created_at") + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
String points = qi(pointsColumn);
|
||||
StringBuilder debit = new StringBuilder("UPDATE ").append(qi(table.getTableName())).append(" SET ")
|
||||
.append(points).append(" = COALESCE(").append(points).append(", 0) - ?");
|
||||
if (limitColumn != null) {
|
||||
debit.append(", ").append(qi(limitColumn)).append(" = COALESCE(").append(qi(limitColumn))
|
||||
.append(", 0) + 1");
|
||||
}
|
||||
debit.append(" WHERE ").append(qi("uuid")).append(uuidCast()).append(" AND COALESCE(").append(points)
|
||||
.append(", 0) >= ?");
|
||||
if (limitColumn != null) {
|
||||
debit.append(" AND COALESCE(").append(qi(limitColumn)).append(", 0) < ?");
|
||||
}
|
||||
try (Connection connection = connection()) {
|
||||
connection.setAutoCommit(false);
|
||||
try {
|
||||
Long limitEpoch = limitColumn == null ? null : lockLimitEpoch(connection, limitColumn);
|
||||
try (PreparedStatement insertStatement = connection.prepareStatement(insert);
|
||||
PreparedStatement debitStatement = connection.prepareStatement(debit.toString())) {
|
||||
insertStatement.setString(1, purchaseId);
|
||||
insertStatement.setString(2, uuid);
|
||||
insertStatement.setString(3, pointsColumn);
|
||||
insertStatement.setString(4, limitColumn);
|
||||
insertStatement.setInt(5, cost);
|
||||
if (limitColumn == null) insertStatement.setNull(6, java.sql.Types.INTEGER);
|
||||
else insertStatement.setInt(6, limit);
|
||||
if (limitGeneration == null) insertStatement.setNull(7, java.sql.Types.VARCHAR);
|
||||
else insertStatement.setString(7, limitGeneration);
|
||||
if (limitGenerationExpiresAt <= 0L) insertStatement.setNull(8, java.sql.Types.BIGINT);
|
||||
else insertStatement.setLong(8, limitGenerationExpiresAt);
|
||||
if (limitEpoch == null) insertStatement.setNull(9, java.sql.Types.BIGINT);
|
||||
else insertStatement.setLong(9, limitEpoch.longValue());
|
||||
insertStatement.setString(10, PENDING);
|
||||
insertStatement.setLong(11, now);
|
||||
insertStatement.executeUpdate();
|
||||
|
||||
debitStatement.setInt(1, cost);
|
||||
debitStatement.setString(2, uuid);
|
||||
debitStatement.setInt(3, cost);
|
||||
if (limitColumn != null) debitStatement.setInt(4, limit);
|
||||
pointWriteAttempted = true;
|
||||
if (debitStatement.executeUpdate() != 1) {
|
||||
rollback(connection);
|
||||
return false;
|
||||
}
|
||||
return commitAndConfirm(connection, purchaseId, PENDING);
|
||||
}
|
||||
} catch (SQLException failure) {
|
||||
rollback(connection);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
} catch (SQLException failure) {
|
||||
if (failure instanceof PreparationFailure) throw failure;
|
||||
if (!pointWriteAttempted) throw new PreparationFailure(failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,65 +386,73 @@ final class SharedMysqlPurchaseJournal {
|
||||
private RefundedPurchase setTerminalDetails(String purchaseId, String terminalState, long now,
|
||||
String... refundableStates)
|
||||
throws SQLException {
|
||||
boolean refund = REFUNDED.equals(terminalState);
|
||||
String refundedUuid = null;
|
||||
String refundedPointsColumn = null;
|
||||
String refundedLimitColumn = null;
|
||||
String select = "SELECT " + qi("state") + ", " + qi("player_uuid") + ", " + qi("points_column")
|
||||
+ ", " + qi("limit_column") + ", " + qi("cost") + ", " + qi("limit_generation") + ", "
|
||||
+ qi("limit_generation_expires_at") + ", " + qi("limit_epoch") + " FROM " + qiJournal() + " WHERE "
|
||||
+ qi("purchase_id") + " = ? FOR UPDATE";
|
||||
try (Connection connection = connection()) {
|
||||
connection.setAutoCommit(false);
|
||||
try (PreparedStatement selectStatement = connection.prepareStatement(select)) {
|
||||
selectStatement.setString(1, purchaseId);
|
||||
try (ResultSet result = selectStatement.executeQuery()) {
|
||||
if (!result.next()) {
|
||||
rollback(connection);
|
||||
return null;
|
||||
}
|
||||
String state = result.getString(1);
|
||||
if (COMPLETED.equals(state) || REFUNDED.equals(state)) {
|
||||
rollback(connection);
|
||||
return terminalState.equals(state) ? new RefundedPurchase(null, null, null) : null;
|
||||
}
|
||||
if (refund && !isRefundableState(state, refundableStates)) {
|
||||
rollback(connection);
|
||||
return null;
|
||||
}
|
||||
if (!refund && !HOOK_STARTED.equals(state)) {
|
||||
rollback(connection);
|
||||
return null;
|
||||
}
|
||||
String uuid = result.getString(2);
|
||||
String pointsColumn = result.getString(3);
|
||||
String limitColumn = result.getString(4);
|
||||
refundedUuid = uuid;
|
||||
refundedPointsColumn = pointsColumn;
|
||||
refundedLimitColumn = limitColumn;
|
||||
int cost = result.getInt(5);
|
||||
String limitGeneration = result.getString(6);
|
||||
Long limitEpoch = nullableLong(result, 8);
|
||||
if (refund) {
|
||||
refund(connection, uuid, pointsColumn, limitColumn, cost,
|
||||
shouldRefundLimit(connection, limitColumn, limitGeneration, limitEpoch));
|
||||
boolean pointWriteAttempted = false;
|
||||
try {
|
||||
boolean refund = REFUNDED.equals(terminalState);
|
||||
String refundedUuid = null;
|
||||
String refundedPointsColumn = null;
|
||||
String refundedLimitColumn = null;
|
||||
String select = "SELECT " + qi("state") + ", " + qi("player_uuid") + ", " + qi("points_column")
|
||||
+ ", " + qi("limit_column") + ", " + qi("cost") + ", " + qi("limit_generation") + ", "
|
||||
+ qi("limit_generation_expires_at") + ", " + qi("limit_epoch") + " FROM " + qiJournal() + " WHERE "
|
||||
+ qi("purchase_id") + " = ? FOR UPDATE";
|
||||
try (Connection connection = connection()) {
|
||||
connection.setAutoCommit(false);
|
||||
try (PreparedStatement selectStatement = connection.prepareStatement(select)) {
|
||||
selectStatement.setString(1, purchaseId);
|
||||
try (ResultSet result = selectStatement.executeQuery()) {
|
||||
if (!result.next()) {
|
||||
rollback(connection);
|
||||
return null;
|
||||
}
|
||||
String state = result.getString(1);
|
||||
if (COMPLETED.equals(state) || REFUNDED.equals(state)) {
|
||||
rollback(connection);
|
||||
return terminalState.equals(state) ? new RefundedPurchase(null, null, null) : null;
|
||||
}
|
||||
if (refund && !isRefundableState(state, refundableStates)) {
|
||||
rollback(connection);
|
||||
return null;
|
||||
}
|
||||
if (!refund && !HOOK_STARTED.equals(state)) {
|
||||
rollback(connection);
|
||||
return null;
|
||||
}
|
||||
String uuid = result.getString(2);
|
||||
String pointsColumn = result.getString(3);
|
||||
String limitColumn = result.getString(4);
|
||||
refundedUuid = uuid;
|
||||
refundedPointsColumn = pointsColumn;
|
||||
refundedLimitColumn = limitColumn;
|
||||
int cost = result.getInt(5);
|
||||
String limitGeneration = result.getString(6);
|
||||
Long limitEpoch = nullableLong(result, 8);
|
||||
if (refund) {
|
||||
boolean refundLimit = shouldRefundLimit(connection, limitColumn, limitGeneration, limitEpoch);
|
||||
pointWriteAttempted = true;
|
||||
refund(connection, uuid, pointsColumn, limitColumn, cost, refundLimit);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("purchase_id")
|
||||
+ " = ?";
|
||||
try (PreparedStatement updateStatement = connection.prepareStatement(update)) {
|
||||
updateStatement.setString(1, terminalState);
|
||||
updateStatement.setString(2, purchaseId);
|
||||
if (updateStatement.executeUpdate() != 1) {
|
||||
rollback(connection);
|
||||
return null;
|
||||
String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("purchase_id")
|
||||
+ " = ?";
|
||||
try (PreparedStatement updateStatement = connection.prepareStatement(update)) {
|
||||
updateStatement.setString(1, terminalState);
|
||||
updateStatement.setString(2, purchaseId);
|
||||
if (updateStatement.executeUpdate() != 1) {
|
||||
rollback(connection);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!commitAndConfirm(connection, purchaseId, terminalState)) return null;
|
||||
return refund ? new RefundedPurchase(refundedUuid, refundedPointsColumn, refundedLimitColumn)
|
||||
: new RefundedPurchase(null, null, null);
|
||||
} catch (SQLException failure) {
|
||||
throw failure;
|
||||
}
|
||||
if (!commitAndConfirm(connection, purchaseId, terminalState)) return null;
|
||||
return refund ? new RefundedPurchase(refundedUuid, refundedPointsColumn, refundedLimitColumn)
|
||||
: new RefundedPurchase(null, null, null);
|
||||
} catch (SQLException failure) {
|
||||
if (failure instanceof PreparationFailure) throw failure;
|
||||
if (!pointWriteAttempted) throw new PreparationFailure(failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
@@ -442,20 +466,28 @@ final class SharedMysqlPurchaseJournal {
|
||||
|
||||
private void refund(Connection connection, String uuid, String pointsColumn, String limitColumn, int cost,
|
||||
boolean refundLimit) throws SQLException {
|
||||
if (!isSafeColumn(pointsColumn) || (limitColumn != null && !isSafeColumn(limitColumn))) {
|
||||
throw new SQLException("Unsafe durable purchase column");
|
||||
}
|
||||
StringBuilder refund = new StringBuilder("UPDATE ").append(qi(table.getTableName())).append(" SET ")
|
||||
.append(qi(pointsColumn)).append(" = ").append(qi(pointsColumn)).append(" + ?");
|
||||
if (refundLimit) {
|
||||
refund.append(", ").append(qi(limitColumn)).append(" = GREATEST(COALESCE(").append(qi(limitColumn))
|
||||
.append(", 0) - 1, 0)");
|
||||
}
|
||||
refund.append(" WHERE ").append(qi("uuid")).append(uuidCast());
|
||||
try (PreparedStatement statement = connection.prepareStatement(refund.toString())) {
|
||||
statement.setInt(1, cost);
|
||||
statement.setString(2, uuid);
|
||||
if (statement.executeUpdate() != 1) throw new SQLException("Purchase refund player missing");
|
||||
boolean pointWriteAttempted = false;
|
||||
try {
|
||||
if (!isSafeColumn(pointsColumn) || (limitColumn != null && !isSafeColumn(limitColumn))) {
|
||||
throw new SQLException("Unsafe durable purchase column");
|
||||
}
|
||||
StringBuilder refund = new StringBuilder("UPDATE ").append(qi(table.getTableName())).append(" SET ")
|
||||
.append(qi(pointsColumn)).append(" = ").append(qi(pointsColumn)).append(" + ?");
|
||||
if (refundLimit) {
|
||||
refund.append(", ").append(qi(limitColumn)).append(" = GREATEST(COALESCE(").append(qi(limitColumn))
|
||||
.append(", 0) - 1, 0)");
|
||||
}
|
||||
refund.append(" WHERE ").append(qi("uuid")).append(uuidCast());
|
||||
try (PreparedStatement statement = connection.prepareStatement(refund.toString())) {
|
||||
statement.setInt(1, cost);
|
||||
statement.setString(2, uuid);
|
||||
pointWriteAttempted = true;
|
||||
if (statement.executeUpdate() != 1) throw new SQLException("Purchase refund player missing");
|
||||
}
|
||||
} catch (SQLException failure) {
|
||||
if (failure instanceof PreparationFailure) throw failure;
|
||||
if (!pointWriteAttempted) throw new PreparationFailure(failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+68
-36
@@ -186,15 +186,24 @@ public class VoteShopPurchaseService {
|
||||
// or entity task runs could pair an old debit with a newly loaded reward.
|
||||
FileConfiguration shopData = plugin.getShopFile().getData();
|
||||
HashMap<String, String> placeholders = purchasePlaceholders(item);
|
||||
var pointPlaceholders = plugin.getPlaceholders();
|
||||
Consumer<Boolean> admission = pointPlaceholders == null ? null
|
||||
: pointPlaceholders.sharedPointAdmissionCompletion(UUID.fromString(user.getUUID()));
|
||||
try {
|
||||
plugin.getTimer().execute(() -> {
|
||||
try {
|
||||
SharedPurchaseDebit debit;
|
||||
synchronized (purchaseLock(user.getUUID())) {
|
||||
// Sample the reset window beside the conditional debit. A queued
|
||||
// persistence task may otherwise cross into a new limit period.
|
||||
debit = reserveSharedMysqlPurchase(user, item,
|
||||
limitGeneration(item, System.currentTimeMillis()));
|
||||
boolean settled = false;
|
||||
try {
|
||||
synchronized (purchaseLock(user.getUUID())) {
|
||||
// Sample the reset window beside the conditional debit. A queued
|
||||
// persistence task may otherwise cross into a new limit period.
|
||||
debit = reserveSharedMysqlPurchase(user, item,
|
||||
limitGeneration(item, System.currentTimeMillis()));
|
||||
}
|
||||
settled = true;
|
||||
} finally {
|
||||
if (admission != null) admission.accept(settled);
|
||||
}
|
||||
if (debit.result() != VoteShopPurchaseResult.SUCCESS) {
|
||||
completePurchaseResult(player, completion, debit.result());
|
||||
@@ -207,6 +216,7 @@ public class VoteShopPurchaseService {
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException persistenceRejected) {
|
||||
if (admission != null) admission.accept(false);
|
||||
plugin.debug(persistenceRejected);
|
||||
completeFailedPurchase(player, completion);
|
||||
}
|
||||
@@ -479,20 +489,29 @@ public class VoteShopPurchaseService {
|
||||
}
|
||||
|
||||
private void refundCompensatingMysqlDebit(VotingPluginUser user, SharedPurchaseDebit debit) {
|
||||
var publication = plugin.getPlaceholders();
|
||||
UUID uuid = publication == null ? null : UUID.fromString(user.getUUID());
|
||||
if (publication != null) publication.beginSharedPointDebit(uuid);
|
||||
boolean known = false;
|
||||
try {
|
||||
if (debit.journal().refundCompensatingReward(debit.purchaseId())) {
|
||||
refreshPurchaseCache(user, debit.pointsColumn(), debit.limitColumn());
|
||||
}
|
||||
debit.journal().refundCompensatingReward(debit.purchaseId());
|
||||
known = true;
|
||||
} catch (SQLException failure) {
|
||||
known = failure instanceof SharedMysqlPurchaseJournal.PreparationFailure;
|
||||
// A commit/confirmation failure is indeterminate: the refund transaction
|
||||
// may have committed even though this worker could not observe its terminal
|
||||
// journal state. Drop the affected snapshots before any later cache dump so
|
||||
// a stale debit cannot overwrite a durable refund. Recovery will reconcile
|
||||
// the journal state if the transaction did not commit.
|
||||
refreshPurchaseCache(user, debit.pointsColumn(), debit.limitColumn());
|
||||
plugin.getLogger().severe("Unable to refund an incomplete vote shop purchase: "
|
||||
+ failure.getClass().getSimpleName());
|
||||
plugin.debug(failure);
|
||||
} finally {
|
||||
try {
|
||||
refreshPurchaseCache(user, debit.pointsColumn(), debit.limitColumn());
|
||||
} finally {
|
||||
if (publication != null) publication.finishSharedPointDebit(uuid, known, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -738,37 +757,50 @@ public class VoteShopPurchaseService {
|
||||
|
||||
private SharedPurchaseDebit reserveSharedMysqlPurchase(VotingPluginUser user, VoteShopItem item,
|
||||
LimitGeneration limitGeneration) {
|
||||
MySQL table = plugin.getMysql();
|
||||
String pointsColumn = user.getPointsPath();
|
||||
String limitColumn = item.getLimit() > 0 ? "VoteShopLimit" + item.getIdentifier() : null;
|
||||
ensurePerServerPointsColumn(table, pointsColumn);
|
||||
drainPurchaseCache(user, pointsColumn);
|
||||
if (limitColumn != null) {
|
||||
table.checkColumn(limitColumn, DataType.INTEGER);
|
||||
}
|
||||
var publication = plugin.getPlaceholders();
|
||||
UUID pointUuid = UUID.fromString(user.getUUID());
|
||||
if (publication != null) publication.beginSharedPointDebit(pointUuid);
|
||||
boolean known = true;
|
||||
try {
|
||||
SharedMysqlPurchaseJournal journal = SharedMysqlPurchaseJournal.forTable(table);
|
||||
String purchaseId = UUID.randomUUID().toString();
|
||||
if (journal.reserve(purchaseId, user.getUUID(), pointsColumn, limitColumn, item.getCost(), item.getLimit(),
|
||||
limitGeneration.value(), limitGeneration.expiresAt(), System.currentTimeMillis())) {
|
||||
// reserve() returns only after its transaction and connection are closed;
|
||||
// NO_CACHE reads must not contend with its one-connection pool handle.
|
||||
refreshPurchaseCache(user, pointsColumn, limitColumn);
|
||||
return new SharedPurchaseDebit(VoteShopPurchaseResult.SUCCESS, journal, purchaseId, pointsColumn,
|
||||
limitColumn);
|
||||
MySQL table = plugin.getMysql();
|
||||
String pointsColumn = user.getPointsPath();
|
||||
String limitColumn = item.getLimit() > 0 ? "VoteShopLimit" + item.getIdentifier() : null;
|
||||
ensurePerServerPointsColumn(table, pointsColumn);
|
||||
drainPurchaseCache(user, pointsColumn);
|
||||
if (limitColumn != null) {
|
||||
table.checkColumn(limitColumn, DataType.INTEGER);
|
||||
}
|
||||
try {
|
||||
SharedMysqlPurchaseJournal journal = SharedMysqlPurchaseJournal.forTable(table);
|
||||
String purchaseId = UUID.randomUUID().toString();
|
||||
known = false;
|
||||
boolean reserved = journal.reserve(purchaseId, user.getUUID(), pointsColumn, limitColumn, item.getCost(), item.getLimit(),
|
||||
limitGeneration.value(), limitGeneration.expiresAt(), System.currentTimeMillis());
|
||||
known = true;
|
||||
if (reserved) {
|
||||
// reserve() returns only after its transaction and connection are closed;
|
||||
// NO_CACHE reads must not contend with its one-connection pool handle.
|
||||
refreshPurchaseCache(user, pointsColumn, limitColumn);
|
||||
return new SharedPurchaseDebit(VoteShopPurchaseResult.SUCCESS, journal, purchaseId, pointsColumn,
|
||||
limitColumn);
|
||||
}
|
||||
} catch (SQLException failure) {
|
||||
known |= failure instanceof SharedMysqlPurchaseJournal.PreparationFailure;
|
||||
// reserve() can throw after its commit acknowledgement and confirmation
|
||||
// both fail. The debit may therefore be durable even though this caller
|
||||
// reports FAILED; drop snapshots recreated during that transaction so a
|
||||
// later cache dump cannot restore the pre-reservation values.
|
||||
refreshPurchaseCache(user, pointsColumn, limitColumn);
|
||||
plugin.getLogger().severe("Unable to atomically debit vote shop points: "
|
||||
+ failure.getClass().getSimpleName());
|
||||
plugin.debug(failure);
|
||||
return new SharedPurchaseDebit(VoteShopPurchaseResult.FAILED, null, null, null, null);
|
||||
}
|
||||
} catch (SQLException failure) {
|
||||
// reserve() can throw after its commit acknowledgement and confirmation
|
||||
// both fail. The debit may therefore be durable even though this caller
|
||||
// reports FAILED; drop snapshots recreated during that transaction so a
|
||||
// later cache dump cannot restore the pre-reservation values.
|
||||
refreshPurchaseCache(user, pointsColumn, limitColumn);
|
||||
plugin.getLogger().severe("Unable to atomically debit vote shop points: "
|
||||
+ failure.getClass().getSimpleName());
|
||||
plugin.debug(failure);
|
||||
return new SharedPurchaseDebit(VoteShopPurchaseResult.FAILED, null, null, null, null);
|
||||
return new SharedPurchaseDebit(sharedMysqlFailure(user, item, limitColumn), null, null, null, null);
|
||||
} finally {
|
||||
if (publication != null) publication.finishSharedPointDebit(pointUuid, known, false);
|
||||
}
|
||||
return new SharedPurchaseDebit(sharedMysqlFailure(user, item, limitColumn), null, null, null, null);
|
||||
}
|
||||
|
||||
/** The legacy cache path created server-suffixed points columns before direct SQL used them. */
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
package com.bencodez.votingplugin.tests.commands;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.OfflinePlayer;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import com.bencodez.advancedcore.AdvancedCorePlugin;
|
||||
import com.bencodez.advancedcore.api.command.CommandHandler;
|
||||
import com.bencodez.advancedcore.api.command.PlayerCommandHandler;
|
||||
import com.bencodez.advancedcore.api.user.UserStorage;
|
||||
import com.bencodez.advancedcore.api.user.usercache.UserDataCache;
|
||||
import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL;
|
||||
import com.bencodez.simpleapi.sql.data.DataValueInt;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.commands.CommandLoader;
|
||||
import com.bencodez.votingplugin.placeholders.PlaceHolders;
|
||||
import com.bencodez.votingplugin.placeholders.PlaceholderCacheLevel;
|
||||
import com.bencodez.votingplugin.placeholders.PlaceholderPlayerPresence;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
|
||||
class RejectedPointDebitPlaceholderTest {
|
||||
@Test void rejectedDebitRetiresStalePointPlaceholderBeforeCallback() throws Exception { debitOutcomeAndRefillRecovery(false, false); }
|
||||
@Test void confirmedDebitRecoversAfterRefillFailure() throws Exception { debitOutcomeAndRefillRecovery(true, true); }
|
||||
@Test void rejectedDebitRecoversAfterRefillFailure() throws Exception { debitOutcomeAndRefillRecovery(false, true); }
|
||||
|
||||
private void debitOutcomeAndRefillRecovery(boolean confirmed, boolean failInitialRefill) throws Exception {
|
||||
var uuid = UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||
var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
var timer = mock(ScheduledExecutorService.class);
|
||||
var pending = new java.util.ArrayDeque<Runnable>();
|
||||
doAnswer(call -> { pending.addLast(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class));
|
||||
var user = mock(VotingPluginUser.class, CALLS_REAL_METHODS);
|
||||
var field = VotingPluginUser.class.getDeclaredField("plugin");
|
||||
field.setAccessible(true);
|
||||
field.set(user, plugin);
|
||||
doReturn(uuid.toString()).when(user).getUUID();
|
||||
doReturn(uuid).when(user).getJavaUUID();
|
||||
doReturn("Points").when(user).getPointsPath();
|
||||
doReturn(false).when(user).isCached();
|
||||
doNothing().when(user).cache();
|
||||
doReturn(mock(Player.class)).when(user).getPlayer();
|
||||
doReturn(4).when(user).getPoints();
|
||||
when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL);
|
||||
when(plugin.getTimer()).thenReturn(timer);
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getConfigFile().getPlaceholderCacheLevel()).thenReturn(PlaceholderCacheLevel.AUTOALL);
|
||||
when(plugin.getConfigFile().getCachedPlaceholders()).thenReturn(new ArrayList<>(List.of("points")));
|
||||
when(plugin.getVoteSiteManager().getVoteSitesEnabled()).thenReturn(new ArrayList<>());
|
||||
when(plugin.getPlaceholderPlayerPresence()).thenReturn(new PlaceholderPlayerPresence());
|
||||
var offline = mock(OfflinePlayer.class);
|
||||
when(offline.getUniqueId()).thenReturn(uuid);
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(offline)).thenReturn(user);
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser("TestUser")).thenReturn(user);
|
||||
var cache = new UserDataCache(null, uuid);
|
||||
cache.updateCache(new HashMap<>(Map.of("Points", new DataValueInt(20))));
|
||||
var manager = plugin.getUserManager().getDataManager();
|
||||
when(manager.getUserDataCache()).thenReturn(new ConcurrentHashMap<>(Map.of(uuid, cache)));
|
||||
var refillFails = new java.util.concurrent.atomic.AtomicBoolean(failInitialRefill);
|
||||
doAnswer(call -> {
|
||||
if (refillFails.get()) throw new IllegalStateException("synthetic refill unavailable");
|
||||
cache.updateSharedSnapshot(new HashMap<>(Map.of("Points", new DataValueInt(4))), cache.getSharedSnapshotVersion());
|
||||
return null;
|
||||
}).when(manager).cacheUser(uuid, null);
|
||||
var table = mock(MySQL.class);
|
||||
var sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, RETURNS_DEEP_STUBS);
|
||||
var connection = mock(Connection.class);
|
||||
var statement = mock(PreparedStatement.class);
|
||||
when(statement.executeUpdate()).thenReturn(confirmed ? 1 : 0);
|
||||
var read = mock(PreparedStatement.class);
|
||||
var total = mock(ResultSet.class);
|
||||
when(total.next()).thenReturn(true);
|
||||
when(total.getInt(1)).thenReturn(4);
|
||||
when(read.executeQuery()).thenReturn(total);
|
||||
var lookup = mock(PreparedStatement.class);
|
||||
when(lookup.executeQuery()).thenReturn(mock(ResultSet.class));
|
||||
when(plugin.getMysql()).thenReturn(table);
|
||||
when(table.getTableName()).thenReturn("VotingPlugin_Users");
|
||||
when(table.qi(anyString())).thenAnswer(call -> "`" + call.getArgument(0) + "`");
|
||||
when(table.getMysql()).thenReturn(sql);
|
||||
when(sql.getConnectionManager().getConnection()).thenReturn(connection);
|
||||
when(connection.prepareStatement(anyString())).thenAnswer(call ->
|
||||
call.<String>getArgument(0).startsWith("SELECT `player_uuid`") ? lookup
|
||||
: call.<String>getArgument(0).startsWith("SELECT `Points` FROM") ? read : statement);
|
||||
var commands = new ArrayList<CommandHandler>();
|
||||
when(plugin.getAdminVoteCommand()).thenReturn(commands);
|
||||
var oldInstance = AdvancedCorePlugin.getInstance();
|
||||
var oldPlugin = VotingPluginMain.plugin;
|
||||
AdvancedCorePlugin.setInstance(plugin);
|
||||
VotingPluginMain.plugin = plugin;
|
||||
try (var coreCommands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class)) {
|
||||
var helper = mock(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
coreCommands.when(com.bencodez.advancedcore.command.CommandLoader::getInstance).thenReturn(helper);
|
||||
when(helper.getBasicAdminCommands("VotingPlugin")).thenReturn(new ArrayList<>());
|
||||
var placeholders = new PlaceHolders(plugin);
|
||||
when(plugin.getPlaceholders()).thenReturn(placeholders);
|
||||
placeholders.load();
|
||||
var points = placeholders.getPlaceholders().stream().filter(p -> p.matches("points")).findFirst().orElseThrow();
|
||||
points.getCache().get("points").put(uuid, "20");
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
bukkit.when(Bukkit::isPrimaryThread).thenReturn(true);
|
||||
assertEquals("20", placeholders.getPlaceHolder(offline, "points", false));
|
||||
var register = CommandLoader.class.getDeclaredMethod("loadAdminVoteCommand");
|
||||
register.setAccessible(true);
|
||||
register.invoke(new CommandLoader(plugin));
|
||||
var command = (PlayerCommandHandler) commands.stream()
|
||||
.filter(c -> Arrays.equals(c.getArgs(), new String[] {"User", "(player)", "RemovePoints", "(number)"}))
|
||||
.findFirst().orElseThrow();
|
||||
command.executeSinglePlayer(mock(CommandSender.class), new String[] {"User", "TestUser", "RemovePoints", "16"});
|
||||
assertEquals("...", placeholders.getPlaceHolder(offline, "points", false));
|
||||
verify(user, never()).cache();
|
||||
verify(sql.getConnectionManager(), never()).getConnection();
|
||||
assertEquals(1, pending.size());
|
||||
pending.removeFirst().run();
|
||||
if (failInitialRefill) {
|
||||
assertNull(cache.snapshotIfPublished());
|
||||
assertEquals("...", placeholders.getPlaceHolder(offline, "points_nocache", false));
|
||||
assertEquals(1, pending.size(), "normal request must admit recovery once");
|
||||
refillFails.set(false);
|
||||
pending.removeFirst().run();
|
||||
} else {
|
||||
assertEquals("...", placeholders.getPlaceHolder(offline, "points", false));
|
||||
assertEquals(1, pending.size());
|
||||
pending.removeFirst().run();
|
||||
}
|
||||
assertEquals(4, cache.snapshotIfPublished().get("Points").getInt());
|
||||
assertEquals("4", placeholders.getPlaceHolder(offline, "points", false));
|
||||
assertTrue(pending.isEmpty());
|
||||
}
|
||||
} finally {
|
||||
AdvancedCorePlugin.setInstance(oldInstance);
|
||||
VotingPluginMain.plugin = oldPlugin;
|
||||
}
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package com.bencodez.votingplugin.tests.cooldown;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.bencodez.advancedcore.api.rewards.RewardHandler;
|
||||
import com.bencodez.advancedcore.api.rewards.RewardOptions;
|
||||
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.simpleapi.sql.Column;
|
||||
import com.bencodez.simpleapi.sql.data.DataValueString;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.config.Config;
|
||||
import com.bencodez.votingplugin.config.SpecialRewardsConfig;
|
||||
import com.bencodez.votingplugin.cooldown.CoolDownCheck;
|
||||
import com.bencodez.votingplugin.events.PlayerVoteCoolDownEndEvent;
|
||||
import com.bencodez.votingplugin.events.PlayerVoteSiteCoolDownEndEvent;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
import com.bencodez.votingplugin.votesites.VoteSite;
|
||||
import com.bencodez.votingplugin.votesites.VoteSiteManager;
|
||||
|
||||
class CoolDownBulkDispatchTest {
|
||||
private static final UUID UUID_VALUE = UUID.fromString("00000000-0000-0000-0000-000000000123");
|
||||
|
||||
@Test void singleSiteScanKeepsIdentityUntilDeferredRewardRuns() throws Exception { exercise(false, "OfflinePlayer", null, "OfflinePlayer"); }
|
||||
@Test void allSitesScanKeepsIdentityUntilDeferredRewardRuns() throws Exception { exercise(true, "OfflinePlayer", null, "OfflinePlayer"); }
|
||||
@Test void absentNameStillDispatchesTheEvent() throws Exception { exercise(false, null, null, ""); }
|
||||
@Test void explicitNameSurvivesCachedNameAndClear() throws Exception { exercise(true, "OldName", "OnlineName", "OnlineName"); }
|
||||
|
||||
@Test void uuidEntryPointsUseExistingUserAndOfflineFetchMode() {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
VotingPluginUser user = mock(VotingPluginUser.class);
|
||||
VoteSite site = mock(VoteSite.class);
|
||||
Config config = mock(Config.class);
|
||||
VoteSiteManager sites = mock(VoteSiteManager.class);
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(UUID_VALUE)).thenReturn(user);
|
||||
when(user.isOnline()).thenReturn(false);
|
||||
when(user.getCoolDownCheck()).thenReturn(true);
|
||||
when(user.canVoteAll()).thenReturn(true);
|
||||
when(user.getJavaUUID()).thenReturn(UUID_VALUE);
|
||||
when(user.getCoolDownCheckSiteList()).thenReturn(new HashMap<>());
|
||||
when(user.canVoteSite(site)).thenReturn(true);
|
||||
when(site.getKey()).thenReturn("site");
|
||||
when(plugin.getConfigFile()).thenReturn(config);
|
||||
when(config.isPerSiteCoolDownEvents()).thenReturn(true);
|
||||
when(plugin.getVoteSiteManager()).thenReturn(sites);
|
||||
when(sites.getVoteSitesEnabled()).thenReturn(new ArrayList<>(List.of(site)));
|
||||
CoolDownCheck check = new CoolDownCheck(plugin);
|
||||
try {
|
||||
check.check(UUID_VALUE);
|
||||
check.checkPerSite(UUID_VALUE);
|
||||
verify(plugin.getVotingPluginUserManager(), times(2)).getVotingPluginUser(UUID_VALUE);
|
||||
verify(user, times(2)).userDataFetechMode(UserDataFetchMode.NO_CACHE);
|
||||
verify(plugin.getServer().getPluginManager()).callEvent(isA(PlayerVoteCoolDownEndEvent.class));
|
||||
verify(plugin.getServer().getPluginManager()).callEvent(isA(PlayerVoteSiteCoolDownEndEvent.class));
|
||||
verify(user, never()).setPlayerName(anyString());
|
||||
} finally {
|
||||
check.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private void exercise(boolean allSites, String cachedName, String explicitName, String expected) throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
Config config = mock(Config.class);
|
||||
VoteSiteManager sites = mock(VoteSiteManager.class);
|
||||
VoteSite site = mock(VoteSite.class);
|
||||
RewardHandler rewards = mock(RewardHandler.class);
|
||||
SpecialRewardsConfig special = mock(SpecialRewardsConfig.class);
|
||||
Server server = mock(Server.class);
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
YamlConfiguration data = new YamlConfiguration();
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE);
|
||||
when(plugin.getConfigFile()).thenReturn(config);
|
||||
when(config.isPerSiteCoolDownEvents()).thenReturn(true);
|
||||
when(plugin.getVoteSiteManager()).thenReturn(sites);
|
||||
when(plugin.getRewardHandler()).thenReturn(rewards);
|
||||
when(plugin.getSpecialRewardsConfig()).thenReturn(special);
|
||||
when(special.getData()).thenReturn(data);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
when(site.getKey()).thenReturn("site");
|
||||
when(site.getDisplayName()).thenReturn("Site");
|
||||
when(site.getVoteURL(false)).thenReturn("https://example.test");
|
||||
when(site.getSiteData()).thenReturn(data);
|
||||
when(site.isVoteDelayDaily()).thenReturn(true);
|
||||
when(sites.getVoteSites()).thenReturn(new ArrayList<>(List.of(site)));
|
||||
|
||||
VotingPluginUser user = mock(VotingPluginUser.class);
|
||||
Field pluginField = AdvancedCoreUser.class.getDeclaredField("plugin");
|
||||
pluginField.setAccessible(true);
|
||||
pluginField.set(user, plugin);
|
||||
Field dataField = AdvancedCoreUser.class.getDeclaredField("data");
|
||||
dataField.setAccessible(true);
|
||||
UserData userData = new UserData(user);
|
||||
dataField.set(user, userData);
|
||||
when(user.getUserData()).thenReturn(userData);
|
||||
when(user.getPlugin()).thenReturn(plugin);
|
||||
doCallRealMethod().when(user).getPlayerName();
|
||||
doCallRealMethod().when(user).setPlayerName(anyString());
|
||||
doCallRealMethod().when(user).userDataFetechMode(any(UserDataFetchMode.class));
|
||||
doCallRealMethod().when(user).updateTempCacheWithColumns(any());
|
||||
doCallRealMethod().when(user).clearTempCache();
|
||||
if (explicitName != null) user.setPlayerName(explicitName);
|
||||
when(user.getCoolDownCheckSiteList()).thenReturn(new HashMap<>());
|
||||
when(user.canVoteSite(site)).thenReturn(true);
|
||||
when(user.getCoolDownCheck()).thenReturn(allSites);
|
||||
when(user.canVoteAll()).thenReturn(true);
|
||||
when(user.getJavaUUID()).thenReturn(UUID_VALUE);
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(UUID_VALUE, false)).thenReturn(user);
|
||||
com.bencodez.advancedcore.api.user.UserManager storage = plugin.getUserManager();
|
||||
doAnswer(call -> {
|
||||
BiConsumer<UUID, ArrayList<Column>> each = call.getArgument(0);
|
||||
Consumer<Integer> done = call.getArgument(1);
|
||||
ArrayList<Column> columns = new ArrayList<>();
|
||||
if (cachedName != null) columns.add(new Column("PlayerName", new DataValueString(cachedName)));
|
||||
each.accept(UUID_VALUE, columns);
|
||||
done.accept(1);
|
||||
return null;
|
||||
}).when(storage).forEachUserKeys(any(), any());
|
||||
|
||||
CoolDownCheck check = new CoolDownCheck(plugin);
|
||||
AtomicReference<Runnable> deferred = new AtomicReference<>();
|
||||
AtomicReference<String> seen = new AtomicReference<>();
|
||||
doAnswer(call -> {
|
||||
AdvancedCoreUser recipient = call.getArgument(0);
|
||||
deferred.set(() -> seen.set(recipient.getPlayerName()));
|
||||
return null;
|
||||
}).when(rewards).giveReward(any(AdvancedCoreUser.class), any(), anyString(), any(RewardOptions.class));
|
||||
doAnswer(call -> {
|
||||
Event event = call.getArgument(0);
|
||||
if (event instanceof PlayerVoteCoolDownEndEvent) check.onCoolDownEnd((PlayerVoteCoolDownEndEvent) event);
|
||||
if (event instanceof PlayerVoteSiteCoolDownEndEvent) check.onCoolDownEnd((PlayerVoteSiteCoolDownEndEvent) event);
|
||||
return null;
|
||||
}).when(events).callEvent(any(Event.class));
|
||||
try {
|
||||
if (allSites) check.checkAllVoteSites(); else check.checkAllVoteSite(site);
|
||||
assertNull(userData.getTempCache());
|
||||
assertNotNull(deferred.get(), "cooldown listener should dispatch reward");
|
||||
deferred.get().run();
|
||||
assertEquals(expected, seen.get());
|
||||
assertEquals(UUID_VALUE, user.getJavaUUID());
|
||||
verify(user).setCoolDownCheckSite(any());
|
||||
if (allSites) verify(user).setCoolDownCheck(false);
|
||||
} finally {
|
||||
check.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package com.bencodez.votingplugin.tests.cooldown;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.HashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.bencodez.advancedcore.AdvancedCorePlugin;
|
||||
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.simpleapi.sql.data.DataValue;
|
||||
import com.bencodez.simpleapi.sql.data.DataValueString;
|
||||
import com.bencodez.votingplugin.cooldown.CoolDownCheck;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
|
||||
class CoolDownIdentitySnapshotTest {
|
||||
private VotingPluginUser user(String cachedName, String explicitName) throws Exception {
|
||||
VotingPluginUser user = mock(VotingPluginUser.class, CALLS_REAL_METHODS);
|
||||
AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class);
|
||||
when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE);
|
||||
Field pluginField = AdvancedCoreUser.class.getDeclaredField("plugin");
|
||||
pluginField.setAccessible(true);
|
||||
pluginField.set(user, plugin);
|
||||
Field dataField = AdvancedCoreUser.class.getDeclaredField("data");
|
||||
dataField.setAccessible(true);
|
||||
UserData data = new UserData(user);
|
||||
dataField.set(user, data);
|
||||
user.userDataFetechMode(UserDataFetchMode.TEMP_ONLY);
|
||||
HashMap<String, DataValue> cache = new HashMap<>();
|
||||
if (cachedName != null) cache.put("PlayerName", new DataValueString(cachedName));
|
||||
data.setTempCache(cache);
|
||||
if (explicitName != null) user.setPlayerName(explicitName);
|
||||
return user;
|
||||
}
|
||||
|
||||
private void snapshot(VotingPluginUser user) throws Exception {
|
||||
Method capture = CoolDownCheck.class.getDeclaredMethod("capturePlayerName", VotingPluginUser.class);
|
||||
capture.setAccessible(true);
|
||||
capture.invoke(null, user);
|
||||
}
|
||||
|
||||
@Test void delayedRewardRetainsNameAfterTemporaryCacheIsCleared() throws Exception {
|
||||
VotingPluginUser user = user("OfflinePlayer", null);
|
||||
snapshot(user);
|
||||
var admitted = new CountDownLatch(1);
|
||||
var resume = new CountDownLatch(1);
|
||||
var worker = Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
var result = worker.submit(() -> {
|
||||
admitted.countDown();
|
||||
if (!resume.await(5, TimeUnit.SECONDS)) throw new AssertionError("worker not resumed");
|
||||
return user.getPlayerName();
|
||||
});
|
||||
assertTrue(admitted.await(5, TimeUnit.SECONDS));
|
||||
user.clearTempCache();
|
||||
resume.countDown();
|
||||
assertEquals("OfflinePlayer", result.get(5, TimeUnit.SECONDS));
|
||||
} finally {
|
||||
resume.countDown();
|
||||
worker.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void missingNameRemainsMissingAndExplicitNameIsPreserved() throws Exception {
|
||||
VotingPluginUser missing = user(null, null);
|
||||
snapshot(missing);
|
||||
missing.clearTempCache();
|
||||
assertEquals("", missing.getPlayerName());
|
||||
VotingPluginUser online = user("OldName", "OnlineName");
|
||||
snapshot(online);
|
||||
online.clearTempCache();
|
||||
assertEquals("OnlineName", online.getPlayerName());
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.bencodez.votingplugin.tests.cooldown;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.*;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import com.bencodez.advancedcore.api.user.*;
|
||||
import com.bencodez.simpleapi.sql.Column;
|
||||
import com.bencodez.simpleapi.sql.data.DataValueString;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.config.Config;
|
||||
import com.bencodez.votingplugin.cooldown.CoolDownCheck;
|
||||
import com.bencodez.votingplugin.events.PlayerVoteSiteCoolDownEndEvent;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
import com.bencodez.votingplugin.votesites.*;
|
||||
class CoolDownMultipleSitesTest {
|
||||
@org.junit.jupiter.api.Test void dailyBatchPersistsEveryCompletedSite() throws Exception {
|
||||
var id=UUID.randomUUID(); var plugin=mock(VotingPluginMain.class,RETURNS_DEEP_STUBS);
|
||||
var config=mock(Config.class);var sites=mock(VoteSiteManager.class);var site=mock(VoteSite.class);var second=mock(VoteSite.class);
|
||||
var server=mock(Server.class);var events=mock(PluginManager.class);
|
||||
when(plugin.isEnabled()).thenReturn(true);when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE);
|
||||
when(plugin.getConfigFile()).thenReturn(config);when(config.isPerSiteCoolDownEvents()).thenReturn(true);
|
||||
when(plugin.getVoteSiteManager()).thenReturn(sites);when(plugin.getServer()).thenReturn(server);when(server.getPluginManager()).thenReturn(events);
|
||||
when(site.getKey()).thenReturn("site");when(site.isVoteDelayDaily()).thenReturn(true);
|
||||
when(second.getKey()).thenReturn("second");when(second.isVoteDelayDaily()).thenReturn(true);
|
||||
when(sites.getVoteSites()).thenReturn(new ArrayList<>(List.of(site,second)));
|
||||
when(sites.hasVoteSite("site")).thenReturn(true);when(sites.getVoteSite("site",true)).thenReturn(site);
|
||||
var user=mock(VotingPluginUser.class); Field p=VotingPluginUser.class.getDeclaredField("plugin");p.setAccessible(true);p.set(user,plugin);
|
||||
Field ap=AdvancedCoreUser.class.getDeclaredField("plugin");ap.setAccessible(true);ap.set(user,plugin);
|
||||
var data=new UserData(user);when(user.getData()).thenReturn(data);when(user.getUserData()).thenReturn(data);when(user.getPlugin()).thenReturn(plugin);
|
||||
doCallRealMethod().when(user).getCoolDownCheckSiteList();when(user.getCoolDownCheckSitePath()).thenReturn("CoolDownCheck_Sites");
|
||||
doCallRealMethod().when(user).getPlayerName();doCallRealMethod().when(user).setPlayerName(anyString());
|
||||
doCallRealMethod().when(user).userDataFetechMode(any());doCallRealMethod().when(user).getUserDataFetchMode();
|
||||
doCallRealMethod().when(user).updateTempCacheWithColumns(any());doCallRealMethod().when(user).clearTempCache();
|
||||
when(user.getCoolDownCheck()).thenReturn(false);when(user.canVoteAll()).thenReturn(false);when(user.canVoteSite(site)).thenReturn(true);when(user.canVoteSite(second)).thenReturn(true);
|
||||
when(user.getSitesVotedOn()).thenReturn(1);when(user.getJavaUUID()).thenReturn(id);when(user.getNextSiteAvailable()).thenReturn(new NextSite(site,60));
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(id,false)).thenReturn(user);
|
||||
var storage=plugin.getUserManager(); doAnswer(call->{BiConsumer<UUID,ArrayList<Column>> each=call.getArgument(0);Consumer<Integer> done=call.getArgument(1);
|
||||
each.accept(id,new ArrayList<>(List.of(new Column("PlayerName",new DataValueString("OfflinePlayer")),new Column("CoolDownCheck_Sites",new DataValueString("")))));done.accept(1);return null;}).when(storage).forEachUserKeys(any(),any());
|
||||
var writes=new ArrayList<HashMap<String,Boolean>>();
|
||||
doAnswer(call->{writes.add(new HashMap<>(call.getArgument(0)));return null;}).when(user).setCoolDownCheckSite(any());
|
||||
var check=new CoolDownCheck(plugin);
|
||||
check.checkAllVoteSites();
|
||||
assertEquals(2,writes.size());
|
||||
assertEquals(Map.of("site",true),writes.get(0));
|
||||
assertEquals(Map.of("site",true,"second",true),writes.get(1));
|
||||
check.shutdown();
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.bencodez.votingplugin.tests.cooldown;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.*;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import com.bencodez.advancedcore.api.user.*;
|
||||
import com.bencodez.simpleapi.sql.Column;
|
||||
import com.bencodez.simpleapi.sql.data.DataValueString;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.config.Config;
|
||||
import com.bencodez.votingplugin.cooldown.CoolDownCheck;
|
||||
import com.bencodez.votingplugin.events.PlayerVoteSiteCoolDownEndEvent;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
import com.bencodez.votingplugin.votesites.*;
|
||||
class CoolDownPendingCacheTest {
|
||||
@org.junit.jupiter.api.Test void queuedCompletedFlagWinsOverStalePersistedFlag() throws Exception {
|
||||
var id=UUID.randomUUID(); var plugin=mock(VotingPluginMain.class,RETURNS_DEEP_STUBS);
|
||||
var config=mock(Config.class);var sites=mock(VoteSiteManager.class);var site=mock(VoteSite.class);
|
||||
var server=mock(Server.class);var events=mock(PluginManager.class);
|
||||
when(plugin.isEnabled()).thenReturn(true);when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE);
|
||||
when(plugin.getConfigFile()).thenReturn(config);when(config.isPerSiteCoolDownEvents()).thenReturn(true);
|
||||
when(plugin.getVoteSiteManager()).thenReturn(sites);when(plugin.getServer()).thenReturn(server);when(server.getPluginManager()).thenReturn(events);
|
||||
when(site.getKey()).thenReturn("site");when(site.isVoteDelayDaily()).thenReturn(true);
|
||||
when(sites.getVoteSites()).thenReturn(new ArrayList<>(List.of(site)));
|
||||
when(sites.hasVoteSite("site")).thenReturn(true);when(sites.getVoteSite("site",true)).thenReturn(site);
|
||||
var user=mock(VotingPluginUser.class); Field p=VotingPluginUser.class.getDeclaredField("plugin");p.setAccessible(true);p.set(user,plugin);
|
||||
Field ap=AdvancedCoreUser.class.getDeclaredField("plugin");ap.setAccessible(true);ap.set(user,plugin);
|
||||
var data=new UserData(user);when(user.getData()).thenReturn(data);when(user.getUserData()).thenReturn(data);when(user.getPlugin()).thenReturn(plugin);
|
||||
doCallRealMethod().when(user).getCoolDownCheckSiteList();when(user.getCoolDownCheckSitePath()).thenReturn("CoolDownCheck_Sites");
|
||||
doCallRealMethod().when(user).getPlayerName();doCallRealMethod().when(user).setPlayerName(anyString());
|
||||
doCallRealMethod().when(user).userDataFetechMode(any());doCallRealMethod().when(user).getUserDataFetchMode();
|
||||
doCallRealMethod().when(user).updateTempCacheWithColumns(any());doCallRealMethod().when(user).clearTempCache();
|
||||
when(user.getCoolDownCheck()).thenReturn(true);when(user.canVoteAll()).thenReturn(false);when(user.canVoteSite(site)).thenReturn(true);
|
||||
when(user.getSitesVotedOn()).thenReturn(1);when(user.getJavaUUID()).thenReturn(id);when(user.getNextSiteAvailable()).thenReturn(new NextSite(site,60));
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(id,false)).thenReturn(user);
|
||||
var storage=plugin.getUserManager(); doAnswer(call->{BiConsumer<UUID,ArrayList<Column>> each=call.getArgument(0);Consumer<Integer> done=call.getArgument(1);
|
||||
each.accept(id,new ArrayList<>(List.of(new Column("PlayerName",new DataValueString("OfflinePlayer")),new Column("CoolDownCheck_Sites",new DataValueString("site//true")))));done.accept(1);return null;}).when(storage).forEachUserKeys(any(),any());
|
||||
var cacheManager=plugin.getUserManager().getDataManager();
|
||||
var cache=new com.bencodez.advancedcore.api.user.usercache.UserDataCache(cacheManager,id);
|
||||
cache.updateCache(new HashMap<>());
|
||||
when(cacheManager.getPlugin()).thenReturn(plugin);
|
||||
when(cacheManager.getTimer()).thenReturn(mock(ScheduledExecutorService.class));
|
||||
cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeString("CoolDownCheck_Sites", "site//true"),true);
|
||||
var fresh=mock(VotingPluginUser.class);p.set(fresh,plugin);ap.set(fresh,plugin);
|
||||
when(fresh.getPlugin()).thenReturn(plugin);when(fresh.isOnline()).thenReturn(false);when(fresh.isCached()).thenReturn(true);
|
||||
when(fresh.getCache()).thenReturn(cache);when(fresh.getCoolDownCheckSitePath()).thenReturn("CoolDownCheck_Sites");
|
||||
doCallRealMethod().when(fresh).getCoolDownCheckSiteList();doCallRealMethod().when(fresh).userDataFetechMode(any());
|
||||
doCallRealMethod().when(fresh).getUserDataFetchMode();fresh.userDataFetechMode(UserDataFetchMode.DEFAULT);
|
||||
var freshData=new UserData(fresh);when(fresh.getData()).thenReturn(freshData);when(fresh.getUserData()).thenReturn(freshData);
|
||||
var sql=mock(com.bencodez.advancedcore.core.user.storage.SqlUserDataAccess.class);
|
||||
when(sql.getString(any(),eq("CoolDownCheck_Sites"))).thenReturn("site//false");
|
||||
Field sqlField=UserData.class.getDeclaredField("sqlData");sqlField.setAccessible(true);sqlField.set(freshData,sql);
|
||||
when(fresh.canVoteSite(site)).thenReturn(true);
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(id,false)).thenReturn(user,fresh);
|
||||
var check=new CoolDownCheck(plugin);Field f=CoolDownCheck.class.getDeclaredField("timer");f.setAccessible(true);((ScheduledExecutorService) f.get(check)).shutdownNow();var timer=mock(ScheduledExecutorService.class);var scheduled=new AtomicReference<Runnable>();
|
||||
when(timer.schedule(any(Runnable.class),anyLong(),any(TimeUnit.class))).thenAnswer(call->{scheduled.set(call.getArgument(0));return mock(ScheduledFuture.class);});
|
||||
f.set(check,timer);
|
||||
check.checkAllVoteSites();assertNull(data.getTempCache());assertNotNull(scheduled.get());
|
||||
verify(events,never()).callEvent(isA(PlayerVoteSiteCoolDownEndEvent.class));
|
||||
scheduled.get().run();
|
||||
verify(events,never()).callEvent(isA(PlayerVoteSiteCoolDownEndEvent.class));
|
||||
verify(sql,never()).getString(any(),eq("CoolDownCheck_Sites"));
|
||||
assertEquals(UserDataFetchMode.DEFAULT, fresh.getUserDataFetchMode());
|
||||
check.shutdown();
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package com.bencodez.votingplugin.tests.cooldown;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import com.bencodez.advancedcore.api.rewards.RewardHandler;
|
||||
import com.bencodez.advancedcore.api.rewards.RewardOptions;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.config.SpecialRewardsConfig;
|
||||
import com.bencodez.votingplugin.cooldown.CoolDownCheck;
|
||||
import com.bencodez.votingplugin.events.PlayerVoteCoolDownEndEvent;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
|
||||
class CoolDownRewardDiagnosticsTest {
|
||||
@Test void synchronousFailureIsCorrelatedRedactedAndPropagated() {
|
||||
Fixture f = new Fixture(true);
|
||||
IllegalStateException failure = new IllegalStateException("private command and placeholder");
|
||||
doThrow(failure).when(f.handler).giveReward(eq(f.user), eq(f.data), eq("VoteCoolDownEndedReward"), any(RewardOptions.class));
|
||||
try {
|
||||
assertSame(failure, assertThrows(IllegalStateException.class,
|
||||
() -> f.check.onCoolDownEnd(new PlayerVoteCoolDownEndEvent(f.user))));
|
||||
ArgumentCaptor<String> attempted = ArgumentCaptor.forClass(String.class);
|
||||
ArgumentCaptor<String> warning = ArgumentCaptor.forClass(String.class);
|
||||
verify(f.logger).info(attempted.capture());
|
||||
verify(f.logger).warning(warning.capture());
|
||||
var match = java.util.regex.Pattern.compile("occurrence=([0-9a-f-]+)").matcher(attempted.getValue());
|
||||
assertTrue(match.find());
|
||||
assertTrue(warning.getValue().contains("occurrence=" + match.group(1)));
|
||||
assertTrue(warning.getValue().contains("player=" + f.uuid));
|
||||
assertTrue(warning.getValue().contains("site=all"));
|
||||
assertTrue(warning.getValue().contains("outcome=unknown/partial"));
|
||||
assertFalse(warning.getValue().contains("private command"));
|
||||
} finally { f.check.shutdown(); }
|
||||
}
|
||||
|
||||
@Test void absentRewardKeepsLegacyDispatchWithoutDiagnosticNoise() {
|
||||
Fixture f = new Fixture(false);
|
||||
try {
|
||||
f.check.onCoolDownEnd(new PlayerVoteCoolDownEndEvent(f.user));
|
||||
verify(f.handler).giveReward(eq(f.user), eq(f.data), eq("VoteCoolDownEndedReward"), any(RewardOptions.class));
|
||||
verifyNoInteractions(f.logger);
|
||||
} finally { f.check.shutdown(); }
|
||||
}
|
||||
|
||||
@Test void loggerFailureDoesNotSuppressRewardDispatch() {
|
||||
Fixture f = new Fixture(true);
|
||||
doThrow(new IllegalStateException("logger unavailable")).when(f.logger).info(anyString());
|
||||
try {
|
||||
f.check.onCoolDownEnd(new PlayerVoteCoolDownEndEvent(f.user));
|
||||
verify(f.handler).giveReward(eq(f.user), eq(f.data), eq("VoteCoolDownEndedReward"), any(RewardOptions.class));
|
||||
} finally { f.check.shutdown(); }
|
||||
}
|
||||
|
||||
@Test void nestedSiteSectionProducesAdmissionWithoutChangingDispatch() {
|
||||
Fixture f = new Fixture(false);
|
||||
var site = mock(com.bencodez.votingplugin.votesites.VoteSite.class);
|
||||
var section = f.data.createSection("site");
|
||||
section.set("CoolDownEndRewards.Command", "say hello");
|
||||
when(site.getSiteData()).thenReturn(section);
|
||||
when(site.getKey()).thenReturn("example");
|
||||
when(site.getDisplayName()).thenReturn("Example");
|
||||
when(site.getVoteURL(false)).thenReturn("https://example.test");
|
||||
try {
|
||||
f.check.onCoolDownEnd(new com.bencodez.votingplugin.events.PlayerVoteSiteCoolDownEndEvent(f.user, site));
|
||||
verify(f.logger).info(contains("status=attempted"));
|
||||
verify(f.handler).giveReward(eq(f.user), eq(section), eq("CoolDownEndRewards"), any(RewardOptions.class));
|
||||
} finally { f.check.shutdown(); }
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
final UUID uuid = UUID.randomUUID();
|
||||
final VotingPluginMain plugin = mock(VotingPluginMain.class);
|
||||
final VotingPluginUser user = mock(VotingPluginUser.class);
|
||||
final SpecialRewardsConfig special = mock(SpecialRewardsConfig.class);
|
||||
final RewardHandler handler = mock(RewardHandler.class);
|
||||
final Logger logger = mock(Logger.class);
|
||||
final YamlConfiguration data = new YamlConfiguration();
|
||||
final CoolDownCheck check;
|
||||
Fixture(boolean configured) {
|
||||
when(plugin.getSpecialRewardsConfig()).thenReturn(special);
|
||||
when(special.getData()).thenReturn(data);
|
||||
when(plugin.getRewardHandler()).thenReturn(handler);
|
||||
when(plugin.getLogger()).thenReturn(logger);
|
||||
when(user.getUUID()).thenReturn(uuid.toString());
|
||||
when(handler.hasRewards(data, "VoteCoolDownEndedReward")).thenReturn(configured);
|
||||
check = new CoolDownCheck(plugin);
|
||||
}
|
||||
}
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
package com.bencodez.votingplugin.tests.cooldown;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import com.bencodez.advancedcore.api.rewards.RewardHandler;
|
||||
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.simpleapi.sql.Column;
|
||||
import com.bencodez.simpleapi.sql.data.DataValueString;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.config.Config;
|
||||
import com.bencodez.votingplugin.config.SpecialRewardsConfig;
|
||||
import com.bencodez.votingplugin.cooldown.CoolDownCheck;
|
||||
import com.bencodez.votingplugin.events.PlayerVoteCoolDownEndEvent;
|
||||
import com.bencodez.votingplugin.events.PlayerVoteSiteCoolDownEndEvent;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
import com.bencodez.votingplugin.votesites.VoteSite;
|
||||
import com.bencodez.votingplugin.votesites.VoteSiteManager;
|
||||
|
||||
class CoolDownRewardIntegrationTest {
|
||||
@TempDir Path tempDir;
|
||||
private boolean globalsChanged;
|
||||
private com.bencodez.advancedcore.AdvancedCorePlugin oldInstance;
|
||||
private com.bencodez.advancedcore.api.misc.MiscUtils misc;
|
||||
private Field miscPlugin;
|
||||
private Object oldMiscPlugin;
|
||||
private RewardHandler activeRewards;
|
||||
private CoolDownCheck activeCheck;
|
||||
|
||||
private void installGlobals(VotingPluginMain plugin) throws Exception {
|
||||
oldInstance = com.bencodez.advancedcore.AdvancedCorePlugin.getInstance();
|
||||
misc = com.bencodez.advancedcore.api.misc.MiscUtils.getInstance();
|
||||
miscPlugin = misc.getClass().getDeclaredField("plugin");
|
||||
miscPlugin.setAccessible(true);
|
||||
oldMiscPlugin = miscPlugin.get(misc);
|
||||
globalsChanged = true;
|
||||
com.bencodez.advancedcore.AdvancedCorePlugin.setInstance(plugin);
|
||||
miscPlugin.set(misc, plugin);
|
||||
}
|
||||
|
||||
@org.junit.jupiter.api.AfterEach void cleanup() throws Exception {
|
||||
try {
|
||||
if (activeCheck != null) activeCheck.shutdown();
|
||||
} finally {
|
||||
try {
|
||||
if (activeRewards != null) activeRewards.getDelayedTimer().shutdownNow();
|
||||
} finally {
|
||||
if (globalsChanged) {
|
||||
try { miscPlugin.set(misc, oldMiscPlugin); }
|
||||
finally {
|
||||
com.bencodez.advancedcore.AdvancedCorePlugin.setInstance(oldInstance);
|
||||
globalsChanged = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cleanupRestoresNullInstanceAfterPartialSetupFailure() throws Exception {
|
||||
var originalInstance = com.bencodez.advancedcore.AdvancedCorePlugin.getInstance();
|
||||
var originalMisc = com.bencodez.advancedcore.api.misc.MiscUtils.getInstance();
|
||||
Field field = originalMisc.getClass().getDeclaredField("plugin");
|
||||
field.setAccessible(true);
|
||||
Object originalMiscPlugin = field.get(originalMisc);
|
||||
try {
|
||||
com.bencodez.advancedcore.AdvancedCorePlugin.setInstance(null);
|
||||
assertThrows(IllegalStateException.class, () -> {
|
||||
installGlobals(mock(VotingPluginMain.class));
|
||||
throw new IllegalStateException("fixture setup failed");
|
||||
});
|
||||
cleanup();
|
||||
assertNull(com.bencodez.advancedcore.AdvancedCorePlugin.getInstance());
|
||||
assertSame(originalMiscPlugin, field.get(originalMisc));
|
||||
} finally {
|
||||
cleanup();
|
||||
field.set(originalMisc, originalMiscPlugin);
|
||||
com.bencodez.advancedcore.AdvancedCorePlugin.setInstance(originalInstance);
|
||||
}
|
||||
}
|
||||
private static final UUID UUID_VALUE = UUID.fromString("00000000-0000-0000-0000-000000000123");
|
||||
|
||||
@Test void singleSiteCommandListUsesCapturedOfflineName() throws Exception { exercise(false, "OfflinePlayer", false, false, false, false, false); }
|
||||
@Test void allSiteCommandUsesCapturedOfflineName() throws Exception { exercise(true, "OfflinePlayer", false, false, false, false, false); }
|
||||
@Test void missingIdentityRejectsPlayerTargetAndLogsFailure() throws Exception { exercise(false, null, false, true, false, false, false); }
|
||||
@Test void missingIdentityAllowsLiteralCommand() throws Exception { exercise(false, null, true, false, false, false, false); }
|
||||
|
||||
@Test void onlineIdentityUsesPlayerOwnerScheduler() throws Exception { exercise(false, "OldName", false, false, true, false, false); }
|
||||
|
||||
@Test void missingIdentityDoesNotReplayCompletedEffect() throws Exception { exercise(false, null, false, true, false, true, false); }
|
||||
|
||||
@Test void missingOfflineNameCannotTargetUnrelatedPlayer() throws Exception { exercise(false, null, true, true, false, false, true); }
|
||||
|
||||
private void exercise(boolean allSites, String cachedName, boolean literal, boolean reject, boolean online, boolean partial, boolean mixed) throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
Config config = mock(Config.class);
|
||||
VoteSiteManager sites = mock(VoteSiteManager.class);
|
||||
VoteSite site = mock(VoteSite.class);
|
||||
when(plugin.getDataFolder()).thenReturn(tempDir.toFile());
|
||||
RewardHandler rewards = new RewardHandler(plugin);
|
||||
activeRewards = rewards;
|
||||
com.bencodez.advancedcore.api.rewards.builtin.RewardCommands.registerCommand(rewards, plugin);
|
||||
com.bencodez.advancedcore.api.rewards.builtin.RewardCommands.registerCommands(rewards, plugin);
|
||||
java.util.concurrent.atomic.AtomicInteger completed = new java.util.concurrent.atomic.AtomicInteger();
|
||||
if (partial) rewards.getInjectedRewards().add(0, new com.bencodez.advancedcore.api.rewards.injected.RewardInject("Marker") {
|
||||
@Override public Object onRewardRequest(com.bencodez.advancedcore.api.rewards.Reward reward,
|
||||
AdvancedCoreUser recipient, org.bukkit.configuration.ConfigurationSection data,
|
||||
HashMap<String, String> placeholders) { completed.incrementAndGet(); return null; }
|
||||
});
|
||||
SpecialRewardsConfig special = mock(SpecialRewardsConfig.class);
|
||||
Server server = mock(Server.class);
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
YamlConfiguration data = new YamlConfiguration();
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getOptions().isProcessRewards()).thenReturn(true);
|
||||
when(plugin.getOptions().getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd");
|
||||
installGlobals(plugin);
|
||||
var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
java.util.ArrayDeque<Runnable> queued = new java.util.ArrayDeque<>();
|
||||
when(plugin.getBukkitScheduler()).thenReturn(scheduler);
|
||||
doAnswer(call -> { queued.add(call.getArgument(1)); return null; }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class));
|
||||
doAnswer(call -> { queued.add(call.getArgument(1)); return null; }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), any(org.bukkit.entity.Player.class));
|
||||
doAnswer(call -> { queued.add(call.getArgument(1)); return null; }).when(scheduler).runTask(eq(plugin), any(Runnable.class));
|
||||
doAnswer(call -> { queued.add(call.getArgument(1)); return null; }).when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class));
|
||||
|
||||
Logger logger = mock(Logger.class);
|
||||
when(plugin.getLogger()).thenReturn(logger);
|
||||
when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE);
|
||||
when(plugin.getConfigFile()).thenReturn(config);
|
||||
when(config.isPerSiteCoolDownEvents()).thenReturn(true);
|
||||
when(plugin.getVoteSiteManager()).thenReturn(sites);
|
||||
when(plugin.getRewardHandler()).thenReturn(rewards);
|
||||
when(plugin.getSpecialRewardsConfig()).thenReturn(special);
|
||||
when(special.getData()).thenReturn(data);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
when(site.getKey()).thenReturn("site");
|
||||
when(site.getDisplayName()).thenReturn("Site");
|
||||
when(site.getVoteURL(false)).thenReturn("https://example.test");
|
||||
when(site.getSiteData()).thenReturn(data);
|
||||
when(site.isVoteDelayDaily()).thenReturn(true);
|
||||
when(sites.getVoteSites()).thenReturn(new ArrayList<>(List.of(site)));
|
||||
|
||||
VotingPluginUser user = mock(VotingPluginUser.class);
|
||||
Field pluginField = AdvancedCoreUser.class.getDeclaredField("plugin");
|
||||
pluginField.setAccessible(true);
|
||||
pluginField.set(user, plugin);
|
||||
Field dataField = AdvancedCoreUser.class.getDeclaredField("data");
|
||||
dataField.setAccessible(true);
|
||||
UserData userData = new UserData(user);
|
||||
dataField.set(user, userData);
|
||||
when(user.getUserData()).thenReturn(userData);
|
||||
when(user.getUUID()).thenReturn(UUID_VALUE.toString());
|
||||
when(user.endAsyncActionCollection(any())).thenReturn(java.util.concurrent.CompletableFuture.completedFuture(null));
|
||||
when(user.getPlugin()).thenReturn(plugin);
|
||||
doCallRealMethod().when(user).getPlayerName();
|
||||
doCallRealMethod().when(user).setPlayerName(anyString());
|
||||
doCallRealMethod().when(user).userDataFetechMode(any(UserDataFetchMode.class));
|
||||
doCallRealMethod().when(user).updateTempCacheWithColumns(any());
|
||||
doCallRealMethod().when(user).clearTempCache();
|
||||
org.bukkit.entity.Player live = mock(org.bukkit.entity.Player.class);
|
||||
if (mixed) {
|
||||
when(plugin.getOptions().isOnlineMode()).thenReturn(false);
|
||||
doCallRealMethod().when(user).getPlayer();
|
||||
doCallRealMethod().when(user).validatePlayerCommandAvailabilityAsync();
|
||||
}
|
||||
if (online) {
|
||||
when(user.isOnline()).thenReturn(true);
|
||||
when(user.getPlayer()).thenReturn(live);
|
||||
user.setPlayerName("OnlinePlayer");
|
||||
}
|
||||
when(user.getCoolDownCheckSiteList()).thenReturn(new HashMap<>());
|
||||
when(user.canVoteSite(site)).thenReturn(true);
|
||||
when(user.getCoolDownCheck()).thenReturn(allSites);
|
||||
when(user.canVoteAll()).thenReturn(true);
|
||||
when(user.getJavaUUID()).thenReturn(UUID_VALUE);
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(UUID_VALUE, false)).thenReturn(user);
|
||||
com.bencodez.advancedcore.api.user.UserManager storage = plugin.getUserManager();
|
||||
doAnswer(call -> {
|
||||
BiConsumer<UUID, ArrayList<Column>> each = call.getArgument(0);
|
||||
Consumer<Integer> done = call.getArgument(1);
|
||||
ArrayList<Column> columns = new ArrayList<>();
|
||||
if (cachedName != null) columns.add(new Column("PlayerName", new DataValueString(cachedName)));
|
||||
each.accept(UUID_VALUE, columns);
|
||||
done.accept(1);
|
||||
return null;
|
||||
}).when(storage).forEachUserKeys(any(), any());
|
||||
|
||||
data.set("VoteCoolDownEndedReward.ForceOffline", true);
|
||||
data.set("VoteCoolDownEndedReward.Command", literal ? "say ready" : "say %player%");
|
||||
data.set("CoolDownEndRewards.ForceOffline", true);
|
||||
if (partial) data.set("CoolDownEndRewards.Marker", true);
|
||||
if (mixed) {
|
||||
data.set("CoolDownEndRewards.Commands.Console", new ArrayList<>(List.of("say ready")));
|
||||
data.set("CoolDownEndRewards.Commands.Player", new ArrayList<>(List.of("unrelated-action")));
|
||||
} else data.set("CoolDownEndRewards.Commands", new ArrayList<>(List.of(literal ? "say ready" : "say %player%")));
|
||||
CoolDownCheck check = new CoolDownCheck(plugin);
|
||||
activeCheck = check;
|
||||
doAnswer(call -> {
|
||||
Event event = call.getArgument(0);
|
||||
if (event instanceof PlayerVoteCoolDownEndEvent) check.onCoolDownEnd((PlayerVoteCoolDownEndEvent) event);
|
||||
if (event instanceof PlayerVoteSiteCoolDownEndEvent) check.onCoolDownEnd((PlayerVoteSiteCoolDownEndEvent) event);
|
||||
return null;
|
||||
}).when(events).callEvent(any(Event.class));
|
||||
try (var bukkit = mockStatic(org.bukkit.Bukkit.class)) {
|
||||
var console = mock(org.bukkit.command.ConsoleCommandSender.class);
|
||||
var offline = mock(org.bukkit.OfflinePlayer.class);
|
||||
var unrelated = mock(org.bukkit.entity.Player.class);
|
||||
bukkit.when(org.bukkit.Bukkit::getServer).thenReturn(server);
|
||||
bukkit.when(org.bukkit.Bukkit::getPluginManager).thenReturn(events);
|
||||
bukkit.when(org.bukkit.Bukkit::getConsoleSender).thenReturn(console);
|
||||
bukkit.when(() -> org.bukkit.Bukkit.getOfflinePlayer(anyString())).thenReturn(offline);
|
||||
if (online) { when(offline.isOnline()).thenReturn(true); when(offline.getPlayer()).thenReturn(live); }
|
||||
if (mixed) bukkit.when(() -> org.bukkit.Bukkit.getPlayer("")).thenReturn(unrelated);
|
||||
if (allSites) check.checkAllVoteSites(); else check.checkAllVoteSite(site);
|
||||
assertNull(userData.getTempCache());
|
||||
assertFalse(queued.isEmpty(), "real reward must be deferred");
|
||||
verify(server, never()).dispatchCommand(any(), anyString());
|
||||
while (!queued.isEmpty()) queued.remove().run();
|
||||
if (reject) {
|
||||
verify(server, never()).dispatchCommand(any(), anyString());
|
||||
verify(logger).log(eq(java.util.logging.Level.WARNING), contains("Failed to give reward"), any(Throwable.class));
|
||||
var attempted = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||
var failure = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||
verify(logger, atLeastOnce()).info(attempted.capture());
|
||||
verify(logger).warning(failure.capture());
|
||||
String admission = attempted.getAllValues().stream()
|
||||
.filter(value -> value.startsWith("Cooldown reward status=attempted"))
|
||||
.findFirst().orElseThrow();
|
||||
var matcher = java.util.regex.Pattern.compile("occurrence=([0-9a-f-]+)").matcher(admission);
|
||||
assertTrue(matcher.find());
|
||||
assertTrue(failure.getValue().contains("occurrence=" + matcher.group(1)));
|
||||
assertTrue(failure.getValue().contains("player=" + UUID_VALUE));
|
||||
assertTrue(failure.getValue().contains("site=site:site"));
|
||||
assertTrue(failure.getValue().contains("outcome=unknown/partial"));
|
||||
assertTrue(failure.getValue().contains("completedInjections="));
|
||||
if (partial) {
|
||||
var count = java.util.regex.Pattern.compile("completedInjections=([0-9]+)").matcher(failure.getValue());
|
||||
assertTrue(count.find());
|
||||
assertTrue(Integer.parseInt(count.group(1)) >= 1);
|
||||
}
|
||||
assertFalse(failure.getValue().contains("%player%"));
|
||||
assertEquals(partial ? 1 : 0, completed.get());
|
||||
} else {
|
||||
verify(server, times(allSites ? 2 : 1)).dispatchCommand(console, literal ? "say ready" : online ? "say OnlinePlayer" : "say OfflinePlayer");
|
||||
}
|
||||
bukkit.verify(() -> org.bukkit.Bukkit.getOfflinePlayer(""), never());
|
||||
if (mixed) {
|
||||
bukkit.verify(() -> org.bukkit.Bukkit.getPlayer(""), never());
|
||||
verify(unrelated, never()).chat(anyString());
|
||||
}
|
||||
verify(scheduler, atLeastOnce()).executeOrScheduleSync(eq(plugin), any(Runnable.class));
|
||||
verify(scheduler, atLeastOnce()).runTaskAsynchronously(eq(plugin), any(Runnable.class));
|
||||
if (online) verify(scheduler, atLeastOnce()).executeOrScheduleSync(eq(plugin), any(Runnable.class), eq(live));
|
||||
assertEquals(UUID_VALUE, user.getJavaUUID());
|
||||
verify(user).setCoolDownCheckSite(any());
|
||||
if (allSites) verify(user).setCoolDownCheck(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
package com.bencodez.votingplugin.tests.cooldown;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import com.bencodez.advancedcore.api.user.UserData;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.config.Config;
|
||||
import com.bencodez.votingplugin.cooldown.CoolDownCheck;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
import com.bencodez.votingplugin.votesites.NextSite;
|
||||
import com.bencodez.votingplugin.votesites.VoteSite;
|
||||
|
||||
class CoolDownTimerGenerationTest {
|
||||
@Test void replacedTimerDoesNotLoadOrDeliver() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
try {
|
||||
f.check.schedulePerSite(f.user, true);
|
||||
f.check.schedulePerSite(f.user, true);
|
||||
f.tasks.get(0).run();
|
||||
verify(f.plugin.getVotingPluginUserManager(), never()).getVotingPluginUser(f.uuid, false);
|
||||
f.tasks.get(1).run();
|
||||
verify(f.plugin.getVotingPluginUserManager()).getVotingPluginUser(f.uuid, false);
|
||||
Field generations = CoolDownCheck.class.getDeclaredField("perSiteGenerations");
|
||||
generations.setAccessible(true);
|
||||
assertFalse(((java.util.Map<?, ?>) generations.get(f.check)).containsKey(f.uuid));
|
||||
} finally { f.check.shutdown(); }
|
||||
}
|
||||
|
||||
@Test void replacementWhileLoadingStopsOldTimerBeforeSiteCheck() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
try {
|
||||
f.check.schedulePerSite(f.user, true);
|
||||
var manager = f.plugin.getVotingPluginUserManager();
|
||||
doAnswer(call -> { f.check.schedulePerSite(f.user, true); return f.fresh; })
|
||||
.when(manager).getVotingPluginUser(f.uuid, false);
|
||||
f.tasks.get(0).run();
|
||||
verify(f.fresh, never()).getCoolDownCheckSiteList();
|
||||
assertEquals(2, f.tasks.size());
|
||||
} finally { f.check.shutdown(); }
|
||||
}
|
||||
|
||||
@Test void shutdownRetiresAcceptedTimer() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
f.check.schedulePerSite(f.user, true);
|
||||
f.check.shutdown();
|
||||
f.tasks.get(0).run();
|
||||
verify(f.plugin.getVotingPluginUserManager(), never()).getVotingPluginUser(f.uuid, false);
|
||||
f.check.schedulePerSite(f.user, true);
|
||||
assertEquals(1, f.tasks.size());
|
||||
}
|
||||
|
||||
@Test void timerFailureLogsCorrelatedUncertainOutcomeAndPropagates() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
try {
|
||||
f.check.schedulePerSite(f.user, true);
|
||||
var data = f.fresh.getData();
|
||||
when(data.getString(eq("PlayerName"), nullable(com.bencodez.advancedcore.api.user.UserDataFetchMode.class))).thenThrow(new IllegalStateException("private detail"));
|
||||
f.tasks.get(0).run();
|
||||
f.tasks.get(1).run();
|
||||
f.tasks.get(2).run();
|
||||
IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> f.tasks.get(3).run());
|
||||
assertEquals("private detail", thrown.getMessage());
|
||||
verify(f.plugin.getLogger()).warning(argThat((String message) -> message.contains("occurrence=")
|
||||
&& message.contains("player=" + f.uuid) && message.contains("site=site")
|
||||
&& message.contains("time=") && message.contains("outcome is uncertain")
|
||||
&& !message.contains("private detail")));
|
||||
} finally { f.check.shutdown(); }
|
||||
}
|
||||
|
||||
@Test void failedRefreshRetriesThenChecksWithoutReplaying() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
try {
|
||||
var data = f.fresh.getData();
|
||||
when(data.getString(eq("PlayerName"), nullable(com.bencodez.advancedcore.api.user.UserDataFetchMode.class)))
|
||||
.thenThrow(new IllegalStateException("unavailable")).thenReturn("CurrentName");
|
||||
f.check.schedulePerSite(f.user, true);
|
||||
f.tasks.get(0).run();
|
||||
verify(f.fresh, never()).getCoolDownCheckSiteList();
|
||||
assertEquals(2, f.tasks.size());
|
||||
verify(f.timer).schedule(any(Runnable.class), eq(30L), eq(TimeUnit.SECONDS));
|
||||
f.tasks.get(1).run();
|
||||
verify(f.fresh).setPlayerName("CurrentName");
|
||||
verify(f.fresh).getCoolDownCheckSiteList();
|
||||
} finally { f.check.shutdown(); }
|
||||
}
|
||||
|
||||
@Test void replacedOrStoppedRetryCannotLoad() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
try {
|
||||
when(f.fresh.getData().getString(eq("PlayerName"), nullable(com.bencodez.advancedcore.api.user.UserDataFetchMode.class)))
|
||||
.thenThrow(new IllegalStateException("unavailable"));
|
||||
f.check.schedulePerSite(f.user, true);
|
||||
f.tasks.get(0).run();
|
||||
f.check.schedulePerSite(f.user, true);
|
||||
f.tasks.get(1).run();
|
||||
verify(f.plugin.getVotingPluginUserManager(), times(1)).getVotingPluginUser(f.uuid, false);
|
||||
f.check.shutdown();
|
||||
f.tasks.get(2).run();
|
||||
verify(f.plugin.getVotingPluginUserManager(), times(1)).getVotingPluginUser(f.uuid, false);
|
||||
} finally { f.check.shutdown(); }
|
||||
}
|
||||
|
||||
@Test void retiredCacheFailureIsVisibleAndDoesNotChangeSiteFlags() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
try {
|
||||
f.check.schedulePerSite(f.user, true);
|
||||
when(f.fresh.getCoolDownCheckSiteList()).thenThrow(new IllegalStateException("cache retired"));
|
||||
assertThrows(IllegalStateException.class, () -> f.tasks.get(0).run());
|
||||
verify(f.fresh, never()).setCoolDownCheckSite(any());
|
||||
assertEquals(1, f.tasks.size());
|
||||
verify(f.plugin.getLogger()).warning(argThat((String message) -> message.contains("player=" + f.uuid)
|
||||
&& message.contains("site=site") && message.contains("outcome is uncertain")
|
||||
&& !message.contains("cache retired")));
|
||||
} finally { f.check.shutdown(); }
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
final UUID uuid = UUID.randomUUID();
|
||||
final VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
final VotingPluginUser user = mock(VotingPluginUser.class);
|
||||
final VotingPluginUser fresh = mock(VotingPluginUser.class);
|
||||
final VoteSite site = mock(VoteSite.class);
|
||||
final ScheduledExecutorService timer = mock(ScheduledExecutorService.class);
|
||||
final ArrayList<Runnable> tasks = new ArrayList<>();
|
||||
final CoolDownCheck check;
|
||||
Fixture() throws Exception {
|
||||
Config config = mock(Config.class);
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getConfigFile()).thenReturn(config);
|
||||
when(config.isPerSiteCoolDownEvents()).thenReturn(true);
|
||||
when(site.getKey()).thenReturn("site");
|
||||
when(user.getJavaUUID()).thenReturn(uuid);
|
||||
when(user.getPlayerName()).thenReturn("PriorName");
|
||||
when(user.getNextSiteAvailable()).thenReturn(new NextSite(site, 60));
|
||||
when(fresh.getData()).thenReturn(mock(UserData.class));
|
||||
when(fresh.getCoolDownCheckSiteList()).thenReturn(new HashMap<>(java.util.Map.of("site", true)));
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, false)).thenReturn(fresh);
|
||||
doAnswer(call -> { tasks.add(call.getArgument(0)); return mock(ScheduledFuture.class); })
|
||||
.when(timer).schedule(any(Runnable.class), anyLong(), any(TimeUnit.class));
|
||||
check = new CoolDownCheck(plugin);
|
||||
Field field = CoolDownCheck.class.getDeclaredField("timer");
|
||||
field.setAccessible(true);
|
||||
((ScheduledExecutorService) field.get(check)).shutdownNow();
|
||||
field.set(check, timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package com.bencodez.votingplugin.tests.cooldown;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.*;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import com.bencodez.advancedcore.api.user.*;
|
||||
import com.bencodez.simpleapi.sql.Column;
|
||||
import com.bencodez.simpleapi.sql.data.DataValueString;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.config.Config;
|
||||
import com.bencodez.votingplugin.cooldown.CoolDownCheck;
|
||||
import com.bencodez.votingplugin.events.PlayerVoteSiteCoolDownEndEvent;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
import com.bencodez.votingplugin.votesites.*;
|
||||
class CoolDownTimerRefreshTest {
|
||||
@org.junit.jupiter.api.Test void alreadyCompletedSiteIsNotDeliveredAgain() throws Exception { exercise(true, true, "OfflinePlayer", "Renamed", "Renamed"); }
|
||||
@org.junit.jupiter.api.Test void newerVoteIsConsultedBeforeDelivering() throws Exception { exercise(false, false, "OfflinePlayer", "", "OfflinePlayer"); }
|
||||
@org.junit.jupiter.api.Test void missingNameDoesNotInventIdentity() throws Exception { exercise(true, true, null, "", null); }
|
||||
private void exercise(boolean alreadyCompleted, boolean eligible, String scheduledName, String storedName, String expectedName) throws Exception {
|
||||
var id=UUID.randomUUID(); var plugin=mock(VotingPluginMain.class,RETURNS_DEEP_STUBS);
|
||||
var config=mock(Config.class);var sites=mock(VoteSiteManager.class);var site=mock(VoteSite.class);
|
||||
var server=mock(Server.class);var events=mock(PluginManager.class);
|
||||
when(plugin.isEnabled()).thenReturn(true);when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE);
|
||||
when(plugin.getConfigFile()).thenReturn(config);when(config.isPerSiteCoolDownEvents()).thenReturn(true);
|
||||
when(plugin.getVoteSiteManager()).thenReturn(sites);when(plugin.getServer()).thenReturn(server);when(server.getPluginManager()).thenReturn(events);
|
||||
when(site.getKey()).thenReturn("site");when(site.isVoteDelayDaily()).thenReturn(true);
|
||||
when(sites.getVoteSites()).thenReturn(new ArrayList<>(List.of(site)));
|
||||
when(sites.hasVoteSite("site")).thenReturn(true);when(sites.getVoteSite("site",true)).thenReturn(site);
|
||||
var user=mock(VotingPluginUser.class); Field p=VotingPluginUser.class.getDeclaredField("plugin");p.setAccessible(true);p.set(user,plugin);
|
||||
Field ap=AdvancedCoreUser.class.getDeclaredField("plugin");ap.setAccessible(true);ap.set(user,plugin);
|
||||
var data=new UserData(user);when(user.getData()).thenReturn(data);when(user.getUserData()).thenReturn(data);when(user.getPlugin()).thenReturn(plugin);
|
||||
doCallRealMethod().when(user).getCoolDownCheckSiteList();when(user.getCoolDownCheckSitePath()).thenReturn("CoolDownCheck_Sites");
|
||||
doCallRealMethod().when(user).getPlayerName();doCallRealMethod().when(user).setPlayerName(anyString());
|
||||
doCallRealMethod().when(user).userDataFetechMode(any());doCallRealMethod().when(user).getUserDataFetchMode();
|
||||
doCallRealMethod().when(user).updateTempCacheWithColumns(any());doCallRealMethod().when(user).clearTempCache();
|
||||
when(user.getCoolDownCheck()).thenReturn(true);when(user.canVoteAll()).thenReturn(false);when(user.canVoteSite(site)).thenReturn(true);
|
||||
when(user.getSitesVotedOn()).thenReturn(1);when(user.getJavaUUID()).thenReturn(id);when(user.getNextSiteAvailable()).thenReturn(new NextSite(site,60));
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(id,false)).thenReturn(user);
|
||||
var storage=plugin.getUserManager(); doAnswer(call->{BiConsumer<UUID,ArrayList<Column>> each=call.getArgument(0);Consumer<Integer> done=call.getArgument(1);
|
||||
ArrayList<Column> columns=new ArrayList<>(); if(scheduledName != null) columns.add(new Column("PlayerName",new DataValueString(scheduledName))); columns.add(new Column("CoolDownCheck_Sites",new DataValueString("site//true"))); each.accept(id,columns);done.accept(1);return null;}).when(storage).forEachUserKeys(any(),any());
|
||||
var fresh=mock(VotingPluginUser.class);
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(id,false)).thenReturn(user, fresh);
|
||||
var freshData=mock(UserData.class); when(fresh.getData()).thenReturn(freshData);
|
||||
when(freshData.getString(eq("PlayerName"),nullable(UserDataFetchMode.class))).thenReturn(storedName);
|
||||
var freshFlags=new HashMap<String,Boolean>();
|
||||
if (alreadyCompleted) freshFlags.put("site", true);
|
||||
when(fresh.getCoolDownCheckSiteList()).thenReturn(freshFlags);
|
||||
when(fresh.canVoteSite(site)).thenReturn(eligible);
|
||||
var check=new CoolDownCheck(plugin);Field f=CoolDownCheck.class.getDeclaredField("timer");f.setAccessible(true);((ScheduledExecutorService) f.get(check)).shutdownNow();var timer=mock(ScheduledExecutorService.class);var scheduled=new AtomicReference<Runnable>();
|
||||
when(timer.schedule(any(Runnable.class),anyLong(),any(TimeUnit.class))).thenAnswer(call->{scheduled.set(call.getArgument(0));return mock(ScheduledFuture.class);});
|
||||
f.set(check,timer);
|
||||
check.checkAllVoteSites();assertNull(data.getTempCache());assertNotNull(scheduled.get());
|
||||
verify(events,never()).callEvent(isA(PlayerVoteSiteCoolDownEndEvent.class));
|
||||
try (var bukkit=mockStatic(org.bukkit.Bukkit.class)) { scheduled.get().run(); bukkit.verifyNoInteractions(); }
|
||||
verify(events,never()).callEvent(isA(PlayerVoteSiteCoolDownEndEvent.class));
|
||||
verify(plugin.getVotingPluginUserManager(),times(2)).getVotingPluginUser(id,false);
|
||||
verify(plugin.getVotingPluginUserManager(),never()).getVotingPluginUser(id);
|
||||
if (expectedName == null) verify(fresh,never()).setPlayerName(anyString());
|
||||
else verify(fresh).setPlayerName(expectedName);
|
||||
verify(fresh).userDataFetechMode(UserDataFetchMode.NO_CACHE);
|
||||
verify(fresh,never()).setCoolDownCheckSite(any());
|
||||
check.shutdown();
|
||||
}
|
||||
}
|
||||
+123
@@ -1,6 +1,8 @@
|
||||
package com.bencodez.votingplugin.tests.packaging;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
@@ -8,12 +10,14 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.PrintStream;
|
||||
import java.io.StringWriter;
|
||||
import java.net.URL;
|
||||
import java.net.URLClassLoader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.jar.JarFile;
|
||||
import javax.tools.ToolProvider;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
@@ -119,6 +123,125 @@ public class PackagedArtifactTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void packagedJedisUsesTheResolvedClientRatherThanAnEmbeddedProducerCopy() throws Exception {
|
||||
Path selected = Path.of(redis.clients.jedis.Jedis.class.getProtectionDomain()
|
||||
.getCodeSource().getLocation().toURI());
|
||||
try (JarFile dependency = new JarFile(selected.toFile());
|
||||
JarFile artifact = new JarFile(packagedJar().toFile())) {
|
||||
// MultiDbClient differs across the 8.0.1/8.0.2 patch boundary; Jedis alone does not.
|
||||
for (String name : java.util.List.of("redis/clients/jedis/Jedis.class",
|
||||
"redis/clients/jedis/JedisPool.class", "redis/clients/jedis/MultiDbClient.class")) {
|
||||
assertNotNull(dependency.getEntry(name), "Missing resolved client API: " + name);
|
||||
assertNotNull(artifact.getEntry(name), "Missing packaged client API: " + name);
|
||||
assertEquals(1L, artifact.stream().filter(entry -> entry.getName().equals(name)).count(),
|
||||
"Duplicate packaged client: " + name);
|
||||
try (var expected = dependency.getInputStream(dependency.getEntry(name));
|
||||
var actual = artifact.getInputStream(artifact.getEntry(name))) {
|
||||
assertArrayEquals(expected.readAllBytes(), actual.readAllBytes(),
|
||||
"A producer's embedded client must not shadow the resolved dependency: " + name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void packagedSimpleApiRedisHandlerUsesTheSelectedClientWithoutMavenDependencies(@TempDir Path directory)
|
||||
throws Exception {
|
||||
assertRedisHandlerLinks(packagedJar(),
|
||||
"com.bencodez.votingplugin.simpleapi.servercomm.redis.RedisHandler", directory);
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishedProducerArtifactsRetainLegacyRedisApisAndLinkTheirHandlers(@TempDir Path directory) throws Exception {
|
||||
var resources = PackagedArtifactTest.class.getClassLoader()
|
||||
.getResources("com/bencodez/simpleapi/servercomm/redis/RedisHandler.class");
|
||||
java.util.Set<URL> producers = new java.util.LinkedHashSet<>();
|
||||
while (resources.hasMoreElements()) {
|
||||
URL resource = resources.nextElement();
|
||||
assertEquals("jar", resource.getProtocol(), "Producer verification requires actual dependency JARs");
|
||||
var connection = (java.net.JarURLConnection) resource.openConnection();
|
||||
connection.setUseCaches(false);
|
||||
producers.add(connection.getJarFileURL());
|
||||
}
|
||||
assertEquals(2, producers.size(), "Verify both the normal AdvancedCore and direct SimpleAPI artifacts");
|
||||
int index = 0;
|
||||
URL platformSlf4j = org.slf4j.Logger.class.getProtectionDomain().getCodeSource().getLocation();
|
||||
for (URL producer : producers) {
|
||||
try (URLClassLoader loader = new URLClassLoader(new URL[] { producer, platformSlf4j },
|
||||
ClassLoader.getPlatformClassLoader())) {
|
||||
Class<?> endpoint = Class.forName("redis.clients.jedis.HostAndPort", true, loader);
|
||||
Class<?> config = Class.forName("redis.clients.jedis.JedisClientConfig", true, loader);
|
||||
Class.forName("redis.clients.jedis.JedisPooled", true, loader).getConstructor(endpoint, config);
|
||||
Class.forName("redis.clients.jedis.JedisSentineled", true, loader)
|
||||
.getConstructor(String.class, config, java.util.Set.class, config);
|
||||
Class.forName("redis.clients.jedis.UnifiedJedis", true, loader).getConstructor(endpoint, config);
|
||||
}
|
||||
assertRedisHandlerLinks(Path.of(producer.toURI()), "com.bencodez.simpleapi.servercomm.redis.RedisHandler",
|
||||
Files.createDirectory(directory.resolve("producer-" + index++)));
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertRedisHandlerLinks(Path artifact, String handlerName, Path directory) throws Exception {
|
||||
Path source = directory.resolve("PackagedRedisHandlerProbe.java");
|
||||
Files.writeString(source, """
|
||||
public final class PackagedRedisHandlerProbe
|
||||
extends %s {
|
||||
public PackagedRedisHandlerProbe(boolean tls) {
|
||||
super("localhost", 0, "", "", 3, tls);
|
||||
}
|
||||
@Override public void debug(String message) { }
|
||||
public Object listener() {
|
||||
return createEnvelopeListener("synthetic", (channel, envelope) -> { });
|
||||
}
|
||||
}
|
||||
""".formatted(handlerName));
|
||||
var compiler = ToolProvider.getSystemJavaCompiler();
|
||||
assertNotNull(compiler);
|
||||
StringWriter diagnostics = new StringWriter();
|
||||
try (var files = compiler.getStandardFileManager(null, null, StandardCharsets.UTF_8)) {
|
||||
assertTrue(compiler.getTask(diagnostics, files, null,
|
||||
java.util.List.of("--release", "21", "-proc:none", "-classpath", artifact.toString(),
|
||||
"-d", directory.toString()), null,
|
||||
files.getJavaFileObjects(source.toFile())).call(), diagnostics.toString());
|
||||
}
|
||||
URL platformSlf4j = org.slf4j.Logger.class.getProtectionDomain().getCodeSource().getLocation();
|
||||
try (URLClassLoader loader = new URLClassLoader(new URL[] {
|
||||
directory.toUri().toURL(), artifact.toUri().toURL(), platformSlf4j },
|
||||
ClassLoader.getPlatformClassLoader())) {
|
||||
Class<?> probe = Class.forName("PackagedRedisHandlerProbe", true, loader);
|
||||
Class<?> handler = probe.getSuperclass();
|
||||
Class<?> configApi = Class.forName("redis.clients.jedis.JedisClientConfig", true, loader);
|
||||
Class<?> subscriber = Class.forName("redis.clients.jedis.JedisPubSub", true, loader);
|
||||
Class<?> jedis = Class.forName("redis.clients.jedis.Jedis", true, loader);
|
||||
jedis.getMethod("publish", String.class, String.class);
|
||||
jedis.getMethod("subscribe", subscriber, String[].class);
|
||||
var configField = handler.getDeclaredField("clientConfig");
|
||||
configField.setAccessible(true);
|
||||
var poolField = handler.getDeclaredField("publisherPool");
|
||||
poolField.setAccessible(true);
|
||||
for (boolean tls : new boolean[] { false, true }) {
|
||||
Object instance = probe.getConstructor(boolean.class).newInstance(tls);
|
||||
Object pool = poolField.get(instance);
|
||||
try {
|
||||
Object config = configField.get(instance);
|
||||
assertEquals(tls, configApi.getMethod("isSsl").invoke(config));
|
||||
assertEquals(3, configApi.getMethod("getDatabase").invoke(config));
|
||||
if (tls) {
|
||||
var parameters = (javax.net.ssl.SSLParameters) configApi.getMethod("getSslParameters").invoke(config);
|
||||
assertEquals("HTTPS", parameters.getEndpointIdentificationAlgorithm());
|
||||
}
|
||||
assertTrue(subscriber.isInstance(probe.getMethod("listener").invoke(instance)));
|
||||
assertEquals(false, pool.getClass().getMethod("isClosed").invoke(pool));
|
||||
} finally {
|
||||
// Constructors are lazy: no publish, subscribe or connection borrow is performed.
|
||||
handler.getMethod("close").invoke(instance);
|
||||
}
|
||||
assertEquals(true, pool.getClass().getMethod("isClosed").invoke(pool));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void packagedJdkTlsIdentityWorksWithoutExternalCrypto(@TempDir Path directory) throws Exception {
|
||||
URL jar = packagedJar().toUri().toURL();
|
||||
|
||||
+1450
File diff suppressed because it is too large.
Load diff
+201
@@ -0,0 +1,201 @@
|
||||
package com.bencodez.votingplugin.tests.user;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.OptionalInt;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.bencodez.advancedcore.api.user.UserData;
|
||||
import com.bencodez.advancedcore.api.user.UserStorage;
|
||||
import com.bencodez.advancedcore.api.user.usercache.UserDataCache;
|
||||
import com.bencodez.simpleapi.sql.data.DataValueInt;
|
||||
import com.bencodez.simpleapi.sql.data.DataValueString;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.placeholders.PlaceHolders;
|
||||
import com.bencodez.votingplugin.placeholders.PlaceholderCacheLevel;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
|
||||
class VotingPluginUserPointReadCompatibilityTest {
|
||||
private static final UUID USER_ID = UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||
|
||||
@Test
|
||||
void coldSharedMySqlSnapshotReturnsFailClosedZeroAndQueuesOneRecovery() throws Exception {
|
||||
Fixture fixture = fixture("Points");
|
||||
|
||||
assertTrue(fixture.user.getPointsIfLoaded().isEmpty());
|
||||
assertEquals(0, fixture.user.getPoints());
|
||||
assertEquals(0, fixture.user.getPoints());
|
||||
verify(fixture.user, never()).getUserData();
|
||||
verify(fixture.timer, times(1)).execute(any(Runnable.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishedBalanceAndGenuineZeroRemainDistinguishableFromLoading() throws Exception {
|
||||
Fixture fixture = fixture("Points");
|
||||
UserDataCache cache = published(37);
|
||||
fixture.caches.put(USER_ID, cache);
|
||||
|
||||
assertEquals(OptionalInt.of(37), fixture.user.getPointsIfLoaded());
|
||||
assertEquals(37, fixture.user.getPoints());
|
||||
|
||||
cache.updateCache(new HashMap<>(Map.of("Points", new DataValueInt(0))));
|
||||
assertEquals(OptionalInt.of(0), fixture.user.getPointsIfLoaded());
|
||||
assertEquals(0, fixture.user.getPoints());
|
||||
verify(fixture.timer, never()).execute(any(Runnable.class));
|
||||
verify(fixture.user, never()).getUserData();
|
||||
}
|
||||
|
||||
@Test
|
||||
void completeRowWithoutPointColumnUsesHistoricalZeroDefault() throws Exception {
|
||||
Fixture fixture = fixture("Points");
|
||||
UserDataCache cache = new UserDataCache(null, USER_ID);
|
||||
cache.updateCache(new HashMap<>());
|
||||
fixture.caches.put(USER_ID, cache);
|
||||
|
||||
assertEquals(OptionalInt.of(0), fixture.user.getPointsIfLoaded());
|
||||
assertEquals(0, fixture.user.getPoints());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unpublishedSnapshotCannotExposeRetainedOrInvalidatedPointValues() throws Exception {
|
||||
Fixture fixture = fixture("Points");
|
||||
UserDataCache cache = published(28);
|
||||
fixture.caches.put(USER_ID, cache);
|
||||
|
||||
// Retiring even a different column must not make an unverified snapshot readable.
|
||||
cache.invalidateStorageSnapshot("OtherColumn");
|
||||
assertTrue(fixture.user.getPointsIfLoaded().isEmpty());
|
||||
assertEquals(0, fixture.user.getPoints());
|
||||
|
||||
cache.updateCache(new HashMap<>(Map.of("Points", new DataValueInt(19))));
|
||||
assertEquals(OptionalInt.of(19), fixture.user.getPointsIfLoaded());
|
||||
|
||||
cache.invalidateStorageSnapshot("Points");
|
||||
assertEquals(0, fixture.user.getPoints());
|
||||
assertTrue(fixture.user.getPointsIfLoaded().isEmpty());
|
||||
|
||||
cache.updateCache(new HashMap<>(Map.of("Points", new DataValueInt(6))));
|
||||
assertEquals(6, fixture.user.getPoints());
|
||||
verify(fixture.user, never()).getUserData();
|
||||
}
|
||||
|
||||
@Test
|
||||
void pendingMutationCannotExposePreviouslyPublishedBalance() throws Exception {
|
||||
Fixture fixture = fixture("Points");
|
||||
fixture.caches.put(USER_ID, published(55));
|
||||
|
||||
fixture.placeholders.beginSharedPointDebit(USER_ID);
|
||||
try {
|
||||
assertTrue(fixture.user.getPointsIfLoaded().isEmpty());
|
||||
assertEquals(0, fixture.user.getPoints());
|
||||
verify(fixture.user, never()).getUserData();
|
||||
} finally {
|
||||
fixture.placeholders.finishSharedPointDebit(USER_ID, true, true);
|
||||
}
|
||||
|
||||
assertEquals(OptionalInt.of(55), fixture.user.getPointsIfLoaded());
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedPublishedPointValueIsNotTreatedAsKnownZero() throws Exception {
|
||||
Fixture fixture = fixture("Points");
|
||||
UserDataCache cache = new UserDataCache(null, USER_ID);
|
||||
cache.updateCache(new HashMap<>(Map.of("Points", new DataValueString("invalid"))));
|
||||
fixture.caches.put(USER_ID, cache);
|
||||
|
||||
assertTrue(fixture.user.getPointsIfLoaded().isEmpty());
|
||||
assertEquals(0, fixture.user.getPoints());
|
||||
verify(fixture.timer, never()).execute(any(Runnable.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void perServerPointsUseTheirOwnColumn() throws Exception {
|
||||
Fixture fixture = fixture("lobby_Points");
|
||||
UserDataCache cache = new UserDataCache(null, USER_ID);
|
||||
cache.updateCache(new HashMap<>(Map.of(
|
||||
"Points", new DataValueInt(99), "lobby_Points", new DataValueInt(12))));
|
||||
fixture.caches.put(USER_ID, cache);
|
||||
|
||||
assertEquals(OptionalInt.of(12), fixture.user.getPointsIfLoaded());
|
||||
assertEquals(12, fixture.user.getPoints());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mysqlWorkerRetainsTheExistingAuthoritativeReadPath() throws Exception {
|
||||
Fixture fixture = fixture("Points");
|
||||
when(fixture.plugin.getUserManager().getDataManager().mustDeferSharedStorageAccess()).thenReturn(false);
|
||||
UserData data = mock(UserData.class);
|
||||
doReturn(data).when(fixture.user).getUserData();
|
||||
when(data.getInt("Points")).thenReturn(63);
|
||||
|
||||
assertEquals(63, fixture.user.getPoints());
|
||||
verify(data).getInt("Points");
|
||||
verify(fixture.timer, never()).execute(any(Runnable.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonMysqlStorageKeepsItsExistingReadBehavior() throws Exception {
|
||||
Fixture fixture = fixture("Points");
|
||||
when(fixture.plugin.getStorageType()).thenReturn(UserStorage.SQLITE);
|
||||
UserData data = mock(UserData.class);
|
||||
doReturn(data).when(fixture.user).getUserData();
|
||||
when(data.getInt("Points")).thenReturn(18);
|
||||
|
||||
assertEquals(18, fixture.user.getPoints());
|
||||
assertEquals(OptionalInt.of(18), fixture.user.getPointsIfLoaded());
|
||||
verify(data, times(2)).getInt("Points");
|
||||
verify(fixture.timer, never()).execute(any(Runnable.class));
|
||||
}
|
||||
|
||||
private static UserDataCache published(int amount) {
|
||||
UserDataCache cache = new UserDataCache(null, USER_ID);
|
||||
cache.updateCache(new HashMap<>(Map.of("Points", new DataValueInt(amount))));
|
||||
return cache;
|
||||
}
|
||||
|
||||
private static Fixture fixture(String pointColumn) throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
VotingPluginUser user = mock(VotingPluginUser.class, CALLS_REAL_METHODS);
|
||||
Field field = VotingPluginUser.class.getDeclaredField("plugin");
|
||||
field.setAccessible(true);
|
||||
field.set(user, plugin);
|
||||
doReturn(USER_ID.toString()).when(user).getUUID();
|
||||
doReturn(USER_ID).when(user).getJavaUUID();
|
||||
doReturn(pointColumn).when(user).getPointsPath();
|
||||
|
||||
when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL);
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getUserManager().getDataManager().mustDeferSharedStorageAccess()).thenReturn(true);
|
||||
when(plugin.getConfigFile().getPlaceholderCacheLevel()).thenReturn(PlaceholderCacheLevel.AUTOALL);
|
||||
|
||||
ConcurrentHashMap<UUID, UserDataCache> caches = new ConcurrentHashMap<>();
|
||||
when(plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn(caches);
|
||||
ScheduledExecutorService timer = mock(ScheduledExecutorService.class);
|
||||
when(plugin.getTimer()).thenReturn(timer);
|
||||
|
||||
PlaceHolders placeholders = new PlaceHolders(plugin);
|
||||
when(plugin.getPlaceholders()).thenReturn(placeholders);
|
||||
return new Fixture(plugin, user, placeholders, caches, timer);
|
||||
}
|
||||
|
||||
private record Fixture(VotingPluginMain plugin, VotingPluginUser user, PlaceHolders placeholders,
|
||||
ConcurrentHashMap<UUID, UserDataCache> caches, ScheduledExecutorService timer) { }
|
||||
}
|
||||
+58
@@ -58,6 +58,7 @@ import com.bencodez.advancedcore.api.user.UserData;
|
||||
import com.bencodez.advancedcore.api.user.UserDataFetchMode;
|
||||
import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL;
|
||||
import com.bencodez.advancedcore.api.user.usercache.UserDataCache;
|
||||
import com.bencodez.simpleapi.sql.data.DataValueInt;
|
||||
import com.bencodez.simpleapi.sql.mysql.ConnectionManager;
|
||||
import com.bencodez.simpleapi.scheduler.BukkitScheduler;
|
||||
import com.bencodez.simpleapi.folialib.FoliaLib;
|
||||
@@ -108,6 +109,7 @@ class VotingPluginUserPointSchedulingTest {
|
||||
void sharedBulkPointMutationsUseOnePersistenceSubmission() throws Exception {
|
||||
PointFixture fixture = pointFixture();
|
||||
VotingPluginUser second = mock(VotingPluginUser.class);
|
||||
when(second.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002");
|
||||
java.util.List<VotingPluginUser> users = java.util.List.of(fixture.user, second);
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
|
||||
@@ -137,6 +139,7 @@ class VotingPluginUserPointSchedulingTest {
|
||||
void rejectedSharedBulkMutationCompletesEveryUserAsFailed() throws Exception {
|
||||
PointFixture fixture = pointFixture();
|
||||
VotingPluginUser second = mock(VotingPluginUser.class);
|
||||
when(second.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002");
|
||||
Player secondPlayer = mock(Player.class);
|
||||
when(second.getPlayer()).thenReturn(secondPlayer);
|
||||
java.util.List<Boolean> results = new java.util.ArrayList<>();
|
||||
@@ -161,6 +164,7 @@ class VotingPluginUserPointSchedulingTest {
|
||||
void sharedBulkMutationCapturesPlayersBeforePersistenceWork() throws Exception {
|
||||
PointFixture fixture = pointFixture();
|
||||
VotingPluginUser second = mock(VotingPluginUser.class);
|
||||
when(second.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002");
|
||||
Player secondPlayer = mock(Player.class);
|
||||
when(second.getPlayer()).thenReturn(secondPlayer);
|
||||
PluginManager pluginManager = mock(PluginManager.class);
|
||||
@@ -1058,6 +1062,21 @@ class VotingPluginUserPointSchedulingTest {
|
||||
verify(fixture.sql.getConnectionManager(), never()).getConnection();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectedDebitCallbackDoesNotRunAfterDisable() throws Exception {
|
||||
PointFixture fixture = pointFixture();
|
||||
AtomicReference<Boolean> result = new AtomicReference<>();
|
||||
doThrow(new RejectedExecutionException("stopped")).when(fixture.persistence).execute(any(Runnable.class));
|
||||
fixture.user.removePoints(5, result::set);
|
||||
verify(fixture.plugin.getPlaceholders()).finishSharedPointDebit(fixture.user.getJavaUUID(), true, false);
|
||||
ArgumentCaptor<Runnable> entity = ArgumentCaptor.forClass(Runnable.class);
|
||||
verify(fixture.scheduler).runTask(eq(fixture.plugin), entity.capture(), eq(fixture.player));
|
||||
when(fixture.plugin.isEnabled()).thenReturn(false);
|
||||
entity.getValue().run();
|
||||
assertEquals(null, result.get());
|
||||
verify(fixture.sql.getConnectionManager(), never()).getConnection();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sharedRemoveSkipsStaleCachedPointPrecheck() throws Exception {
|
||||
PointFixture fixture = pointFixture();
|
||||
@@ -1361,6 +1380,42 @@ class VotingPluginUserPointSchedulingTest {
|
||||
verify(fixture.sql.getConnectionManager(), org.mockito.Mockito.atLeastOnce()).getConnection();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectedDebitRefreshesPublishedSnapshotBeforeCompletionAndDoesNotLogDatabaseFailure() throws Exception {
|
||||
PointFixture fixture = pointFixture();
|
||||
var uuid = fixture.user.getJavaUUID();
|
||||
var cache = new UserDataCache(null, uuid);
|
||||
cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new DataValueInt(20))));
|
||||
var manager = fixture.plugin.getUserManager().getDataManager();
|
||||
when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(uuid, cache)));
|
||||
doAnswer(call -> {
|
||||
assertFalse(cache.hasPublishedStorageSnapshot());
|
||||
cache.updateSharedSnapshot(new java.util.HashMap<>(java.util.Map.of("Points", new DataValueInt(4))),
|
||||
cache.getSharedSnapshotVersion());
|
||||
return null;
|
||||
}).when(manager).cacheUser(uuid, null);
|
||||
PreparedStatement lookup = mock(PreparedStatement.class);
|
||||
ResultSet missing = mock(ResultSet.class);
|
||||
when(lookup.executeQuery()).thenReturn(missing);
|
||||
when(fixture.connection.prepareStatement(anyString())).thenAnswer(call ->
|
||||
call.<String>getArgument(0).startsWith("SELECT `player_uuid`") ? lookup : fixture.statement);
|
||||
AtomicReference<Boolean> result = new AtomicReference<>();
|
||||
fixture.user.removePoints(16, result::set);
|
||||
verify(fixture.plugin.getPlaceholders()).beginSharedPointDebit(uuid);
|
||||
verify(fixture.sql.getConnectionManager(), never()).getConnection();
|
||||
ArgumentCaptor<Runnable> worker = ArgumentCaptor.forClass(Runnable.class);
|
||||
verify(fixture.persistence).execute(worker.capture());
|
||||
worker.getValue().run();
|
||||
assertEquals(4, cache.snapshotIfPublished().get("Points").getInt());
|
||||
verify(fixture.plugin.getPlaceholders(), org.mockito.Mockito.times(2)).finishSharedPointDebit(uuid, true, true);
|
||||
verify(fixture.plugin.getLogger(), never()).severe(anyString());
|
||||
assertEquals(null, result.get());
|
||||
ArgumentCaptor<Runnable> entity = ArgumentCaptor.forClass(Runnable.class);
|
||||
verify(fixture.scheduler).runTask(eq(fixture.plugin), entity.capture(), eq(fixture.player));
|
||||
entity.getValue().run();
|
||||
assertEquals(Boolean.FALSE, result.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sharedTransferRunsRecipientApprovalOnBukkitSchedulerBeforeSettlement() throws Exception {
|
||||
TransferSchedulingFixture fixture = transferSchedulingFixture();
|
||||
@@ -1985,6 +2040,7 @@ class VotingPluginUserPointSchedulingTest {
|
||||
private static PointFixture pointFixture() throws Exception {
|
||||
PointFixture fixture = new PointFixture();
|
||||
fixture.plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
|
||||
when(fixture.plugin.isEnabled()).thenReturn(true);
|
||||
fixture.persistence = mock(ScheduledExecutorService.class);
|
||||
fixture.scheduler = mock(BukkitScheduler.class);
|
||||
fixture.entityScheduler = configureEntityScheduler(fixture.plugin, fixture.scheduler);
|
||||
@@ -2010,6 +2066,8 @@ class VotingPluginUserPointSchedulingTest {
|
||||
pluginField.setAccessible(true);
|
||||
ReflectiveAccess.set(pluginField, fixture.user, fixture.plugin);
|
||||
doReturn("00000000-0000-0000-0000-000000000001").when(fixture.user).getUUID();
|
||||
doReturn(java.util.UUID.fromString("00000000-0000-0000-0000-000000000001"))
|
||||
.when(fixture.user).getJavaUUID();
|
||||
doReturn("Points").when(fixture.user).getPointsPath();
|
||||
doReturn(fixture.player).when(fixture.user).getPlayer();
|
||||
doReturn(false).when(fixture.user).isCached();
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Reconciling uncertain cooldown rewards
|
||||
|
||||
Cooldown flags record that the cooldown event was raised. They do not prove that every reward effect completed. A reward may contain several effects, and a failure can occur after some effects have already happened.
|
||||
|
||||
For configured cooldown rewards, the server log records `Cooldown reward status=attempted` with an occurrence ID, player UUID, site (or `all`), and time. This means the reward listener attempted dispatch; it is not proof of delivery. An asynchronous terminal failure produces a warning with the same context, `outcome=unknown/partial`, and an injection count when available. That count is a diagnostic, not a list of commands or proof that the other effects did not happen. More than one warning can refer to the same occurrence when a configured reward list has multiple failures. A timer failure has a separate correlation ID and may happen before or after an event.
|
||||
|
||||
Before considering any manual correction:
|
||||
|
||||
1. Preserve the relevant server logs, configuration and reward definitions, and the occurrence ID, UUID, site, and time. Check logs from the server that processed the event.
|
||||
2. For each configured effect, inspect independent records from the affected system. A failed command can have an uncertain outcome; the log alone cannot establish whether it applied. An unavailable or ambiguous record means the effect remains unknown.
|
||||
3. After identifying an independently confirmed missing effect, an authorized operator can issue only that effect and record the correction. Do not reset cooldown flags or replay the complete reward merely because a warning appeared: earlier effects may already have completed.
|
||||
|
||||
These logs are best-effort diagnostics, not a durable reward journal. An event cancelled by another plugin, disabled reward processing, a deferred reward, an internally caught legacy requirement error, logger failure, shutdown, or a crash can leave no terminal warning. A crash between the flag change and the listener's attempted-dispatch log can leave no occurrence entry at all. Missing logs do not establish success or failure, and a logged attempt does not establish the outcome. Keep external transaction evidence for reconciliation; do not assume exactly-once delivery.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Point balances and external shops
|
||||
|
||||
`%votingplugin_points_nocache%` bypasses VotingPlugin's rendered-placeholder
|
||||
cache. It does not guarantee a new MySQL read or reserve points, and another
|
||||
server may change a shared balance immediately after the value is read.
|
||||
|
||||
An external shop that grants a reward before a separate point-removal command
|
||||
has confirmed payment cannot make the two actions atomic. Concurrent clicks or
|
||||
requests on different servers can grant rewards even when later debits fail.
|
||||
Use VotingPlugin's transaction-backed VoteShop when confirmed reservation is
|
||||
required, or use an integration that grants rewards only after an authoritative
|
||||
payment succeeds.
|
||||
|
||||
Point placeholders fail closed while a point mutation's outcome is uncertain.
|
||||
They can remain unavailable even after a reload, a later successful operation or
|
||||
a snapshot refresh: those events do not confirm the original mutation. The
|
||||
current recovery interface does not provide the operation-bound terminal
|
||||
evidence needed to clear that condition automatically. Do not repeat an uncertain
|
||||
debit as a cache workaround.
|
||||
|
||||
## Direct Java API balances
|
||||
|
||||
`VotingPluginUser.getPoints()` remains an `int` for existing integrations such as
|
||||
EconomyShopGUI. When called on a Bukkit/Folia-owned thread with MySQL storage,
|
||||
it reads only a published point snapshot and returns **0** instead of throwing
|
||||
`Shared user data is still loading` when the snapshot is unavailable. This
|
||||
matches the legacy integer default but is a **fail-closed compatibility value**:
|
||||
0 does not establish that the player really has no points.
|
||||
|
||||
New integrations should call `VotingPluginUser.getPointsIfLoaded()` to
|
||||
distinguish a known zero (`OptionalInt.of(0)`) from loading or uncertain state
|
||||
(`OptionalInt.empty()`). In MySQL mode this method is a snapshot-only read,
|
||||
never a blocking SQL lookup; a cache miss schedules bounded worker recovery
|
||||
when that state can safely be refreshed. Other storage types retain their
|
||||
existing read behavior.
|
||||
|
||||
An invalidated snapshot, a pending debit or an indeterminate mutation must
|
||||
not be replaced with a remembered balance. The direct API shares the
|
||||
point-placeholder mutation fence. Display balances are advisory and are not
|
||||
reservations: purchases must use confirmed, transaction-backed debits before
|
||||
granting their rewards. An unchanged shop integration may now reject a click
|
||||
as insufficient funds while the user's points load, but should not crash on
|
||||
the transient read failure. Retry after the snapshot is published.
|
||||
|
||||
Avoid using `UserDataFetchMode.CACHE_ONLY` as a publication check: it may
|
||||
return the integer default even when the shared snapshot is not published,
|
||||
without distinguishing missing data from a genuine zero.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Redis dependency compatibility
|
||||
|
||||
The dependency versions in SimpleAPI, AdvancedCore and VotingPlugin describe different packaging boundaries. Equal version strings alone do not prove that a consumer can link or that a shaded JAR contains the intended implementation.
|
||||
|
||||
- SimpleAPI's full artifact preserves the established Jedis 7 API, including `JedisPooled`, `JedisSentineled` and the public `UnifiedJedis` constructors. Its existing full-artifact test compiles and links a legacy consumer using only that JAR. Jedis 8 removes those APIs; replacing the producer dependency without a migration would break the full-artifact contract.
|
||||
- AdvancedCore excludes the ordinary transitive Jedis dependency from SimpleAPI. This exclusion does not strip classes already embedded in SimpleAPI's full JAR. The published AdvancedCore artifact therefore still contains that producer's Redis implementation.
|
||||
- VotingPlugin filters `redis/clients/**` from both producer JARs and explicitly selects its own Jedis client. These filters must remain together: removing only a Maven transitive dependency does not prevent a fat producer JAR from shadowing the selected client.
|
||||
|
||||
VotingPlugin's package checks verify the selected implementation's class bytes, including an anchor that distinguishes the 8.0.1 and 8.0.2 patch artifacts. They compile and load the relocated SimpleAPI Redis handler using only the downloadable plugin JAR and the platform logging API. The same constructor, listener, TLS configuration and close checks run separately against the actual normal AdvancedCore and SimpleAPI dependency JARs; their legacy constructors remain checked in isolated class loaders. Maven's test dependency classpath cannot supply a missing Redis class to these probes.
|
||||
|
||||
Keep the SimpleAPI legacy exclusion until a separately reviewed compatibility-preserving migration exists. Evaluate each dependency update against its actual public API, supported Java version, resolved artifacts and downstream packaged linkage. A passed constructor/linkage check is not a Redis broker integration test: the probes do not publish, subscribe, borrow a connection or start a service. Native and live transport acceptance remain separate.
|
||||
Reference in new issue
Block a user