mirror of
https://github.com/BenCodez/VotingPlugin.git
synced 2026-11-04 17:10:03 -05:00
Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8fa948ecd | ||
|
|
4b83cc08bd | ||
|
|
b212f5cabb | ||
|
|
e09fa0f039 | ||
|
|
79bbdb1395 | ||
|
|
51f0b5e4a9 | ||
|
|
eb9fc8618b | ||
|
|
86735060bc | ||
|
|
f2eb2152fc | ||
|
|
d2bc2feb8e | ||
|
|
b34229ced0 | ||
|
|
9f56166708 | ||
|
|
7746fc52dc | ||
|
|
ee33a291a6 | ||
|
|
66d1c858c7 | ||
|
|
2e8cb4bd5d | ||
|
|
2a1e4e19ce | ||
|
|
73947ee4cc | ||
|
|
dc127ce300 | ||
|
|
1f27359df7 | ||
|
|
c80e460640 | ||
|
|
c3248a0a85 | ||
|
|
3db2ba2d2a | ||
|
|
bfded64b30 | ||
|
|
8c098ede6d | ||
|
|
6ff81d7919 | ||
|
|
0554c2b954 | ||
|
|
cfd4cf5cf0 | ||
|
|
84a8724ce2 | ||
|
|
032c3834f3 | ||
|
|
bc33141f43 | ||
|
|
0c83e75a16 | ||
|
|
5081f95e63 | ||
|
|
36bf96559e | ||
|
|
63c3c9aecf | ||
|
|
8acf825eaa | ||
|
|
e40bf57093 | ||
|
|
04a83ad54c | ||
|
|
0c512eb8c1 | ||
|
|
de9852a974 | ||
|
|
ad0c58d945 | ||
|
|
dea8c3a8ba | ||
|
|
f79e4c5d36 | ||
|
|
ca591ed39c | ||
|
|
8d3b35fa4f | ||
|
|
d3f7c43550 | ||
|
|
cd531e323b | ||
|
|
ccfe5f1cf1 | ||
|
|
2b59b83123 | ||
|
|
e0c4a1c92f | ||
|
|
1fbc1a1939 | ||
|
|
c6a099fdb1 | ||
|
|
03bbdb4c93 | ||
|
|
4a2dae0e47 | ||
|
|
1ce14cc0ec | ||
|
|
e2f326de2a | ||
|
|
3b4208c595 | ||
|
|
d7b5a89955 | ||
|
|
0518038d91 | ||
|
|
cf8def4c8a | ||
|
|
a9b4af5fc5 | ||
|
|
a89bb5594e | ||
|
|
4409f8fdd9 | ||
|
|
81993afd60 |
No files matched your search
@@ -26,6 +26,8 @@ mvn -B -f VotingPlugin/pom.xml -Dtest=BackendControlConnectorProtocolTest,Contro
|
||||
CI runs `mvn -B -f VotingPlugin/pom.xml package`; see `.github/workflows/maven.yml`. Do not use the `dev` Maven profile in
|
||||
automation because it copies a JAR into a developer-specific server directory.
|
||||
|
||||
All project tests and test-only helpers belong under `VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/`, with package `com.bencodez.votingplugin.tests` or a meaningful subpackage. Keep paths and package declarations aligned, and update cross-test imports, resources, and build selectors when moving tests. Preserve package-phase artifact checks and test discovery. Do not widen production APIs to make tests accessible; use narrowly scoped test-only reflection for non-public seams. Platform API fixtures that must retain an external runtime class identity (such as `net.minecraft.network.chat.Component`) stay in that platform package and must not contain test cases.
|
||||
|
||||
Assign one owner to Maven validation in each worktree. Never run concurrent Maven commands in the same worktree, including focused tests alongside a clean/package build: compilation and cleanup share `target/` and can invalidate each other. Reviewers must inspect source only while that owner builds, or run validation in a separate worktree.
|
||||
|
||||
Keep the downloadable VotingPlugin JAR as small as practical. The 10 MiB size target is advisory:
|
||||
@@ -58,6 +60,10 @@ For compatibility-sensitive changes, add regression coverage that exercises the
|
||||
- `com.bencodez.votingplugin.core` and its subpackages are platform-independent. Classes there must not import Bukkit, Paper, Fabric, Forge, or NeoForge APIs. Keep loader-specific adapters outside this package and enforce the boundary with `CorePlatformIsolationTest`.
|
||||
- `proxy/VotingPluginProxy` and the Bungee/Velocity platform packages own proxy lifecycle and vote routing.
|
||||
- `listeners/` receives Bukkit-side vote/player events; `proxy/cache/` owns proxy pending-vote queues.
|
||||
- `commands/CommandLoader` captures `VoteCommandEnabled`, `VoteCommandName` and `LoadCommandAliases` at startup.
|
||||
Configuration reload must not change the registered player root or generated help/navigation. Preserve exact command
|
||||
ownership, admin roots/aliases and canonical `VotingPlugin.Commands.Vote.*` permissions when disabling or renaming;
|
||||
standalone player aliases retain their `vote...`/`v...` identities. See `docs/player-command-root.md`.
|
||||
- `votesites/` resolves configured service names. Be alert to the distinction between read-only resolution and paths that
|
||||
may auto-create a site.
|
||||
- `user/` owns player totals, points, streaks, last-vote values, and backend offline rewards.
|
||||
|
||||
+16
-9
@@ -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>
|
||||
@@ -316,6 +316,13 @@
|
||||
<enabled>false</enabled>
|
||||
</releases>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>tcoded-releases</id>
|
||||
<url>https://repo.tcoded.com/releases</url>
|
||||
<snapshots>
|
||||
<enabled>false</enabled>
|
||||
</snapshots>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>velocity</id>
|
||||
<url>https://nexus.velocitypowered.com/repository/maven-public/</url>
|
||||
@@ -382,7 +389,7 @@
|
||||
<dependency>
|
||||
<groupId>net.neoforged</groupId>
|
||||
<artifactId>mergetool</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.0.7</version>
|
||||
<classifier>api</classifier>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
@@ -401,12 +408,12 @@
|
||||
<dependency>
|
||||
<groupId>org.yaml</groupId>
|
||||
<artifactId>snakeyaml</artifactId>
|
||||
<version>2.6</version>
|
||||
<version>2.7</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.spigotmc</groupId>
|
||||
<artifactId>spigot-api</artifactId>
|
||||
<version>26.2-R0.1-SNAPSHOT</version>
|
||||
<version>26.3-R0.1-SNAPSHOT</version>
|
||||
<scope>provided</scope>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
@@ -424,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>
|
||||
@@ -475,7 +482,7 @@
|
||||
<dependency>
|
||||
<groupId>redis.clients</groupId>
|
||||
<artifactId>jedis</artifactId>
|
||||
<version>7.5.3</version>
|
||||
<version>8.0.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>me.clip</groupId>
|
||||
@@ -525,7 +532,7 @@
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter-engine</artifactId>
|
||||
<version>5.12.2</version>
|
||||
<version>6.1.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -537,13 +544,13 @@
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter-api</artifactId>
|
||||
<version>5.12.2</version>
|
||||
<version>6.1.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-junit-jupiter</artifactId>
|
||||
<version>5.23.0</version>
|
||||
<version>5.24.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
|
||||
@@ -71,11 +71,9 @@ import com.bencodez.votingplugin.broadcast.BroadcastSettings;
|
||||
import com.bencodez.votingplugin.proxy.BungeeMethod;
|
||||
import com.bencodez.votingplugin.commands.CommandLoader;
|
||||
import com.bencodez.votingplugin.commands.executers.CommandAdminVote;
|
||||
import com.bencodez.votingplugin.commands.executers.CommandVote;
|
||||
import com.bencodez.votingplugin.commands.gui.AdminGUI;
|
||||
import com.bencodez.votingplugin.commands.tabcompleter.AdminVoteTabCompleter;
|
||||
import com.bencodez.votingplugin.commands.tabcompleter.AliasCommandFilterListener;
|
||||
import com.bencodez.votingplugin.commands.tabcompleter.VoteTabCompleter;
|
||||
import com.bencodez.votingplugin.config.BungeeSettings;
|
||||
import com.bencodez.votingplugin.config.Config;
|
||||
import com.bencodez.votingplugin.config.ConfigVoteSites;
|
||||
@@ -87,6 +85,9 @@ import com.bencodez.votingplugin.cooldown.CoolDownCheck;
|
||||
import com.bencodez.votingplugin.control.BackendControlAutoEnrollment;
|
||||
import com.bencodez.votingplugin.control.BackendControlConnector;
|
||||
import com.bencodez.votingplugin.data.ServerData;
|
||||
import com.bencodez.votingplugin.maintenance.AutoCreateVoteSitesReminderService;
|
||||
import com.bencodez.votingplugin.maintenance.AutoCreateVoteSitesReminderService.Settings;
|
||||
import com.bencodez.votingplugin.core.maintenance.AutoCreateVoteSitesReminderState;
|
||||
import com.bencodez.votingplugin.discord.DiscordHandler;
|
||||
import com.bencodez.votingplugin.listeners.BlockBreak;
|
||||
import com.bencodez.votingplugin.integration.votifier.VotifierIntegration;
|
||||
@@ -156,49 +157,6 @@ import lombok.Setter;
|
||||
|
||||
public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
|
||||
private final Object votingNPCLifecycle = new Object();
|
||||
private final java.util.concurrent.atomic.AtomicLong votingNPCConfiguration = new java.util.concurrent.atomic.AtomicLong();
|
||||
private volatile com.bencodez.votingplugin.npc.VotingNPCManager votingNPCManager;
|
||||
|
||||
public com.bencodez.votingplugin.npc.VotingNPCManager getVotingNPCManager() { return votingNPCManager; }
|
||||
|
||||
/** Optional NPC storage is lazy and asynchronous; configuration only schedules owner work. */
|
||||
private void refreshVotingNPCs() {
|
||||
boolean requested = getConfigFile().isVotingNPCsEnabled();
|
||||
long generation = votingNPCConfiguration.incrementAndGet();
|
||||
if (!requested && votingNPCManager == null) return;
|
||||
getBukkitScheduler().runTask(this, () -> {
|
||||
com.bencodez.votingplugin.npc.VotingNPCManager manager;
|
||||
synchronized (votingNPCLifecycle) {
|
||||
if (!isEnabled() || generation != votingNPCConfiguration.get()) return;
|
||||
if (votingNPCManager == null && requested) {
|
||||
try {
|
||||
var created = new com.bencodez.votingplugin.npc.VotingNPCManager(this);
|
||||
Bukkit.getPluginManager().registerEvents(created, this);
|
||||
votingNPCManager = created;
|
||||
} catch (RuntimeException | LinkageError unsupported) {
|
||||
getLogger().warning("Voting NPCs are unavailable on this server; normal voting is unaffected.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
manager = votingNPCManager;
|
||||
}
|
||||
if (manager != null) manager.configure(requested);
|
||||
});
|
||||
}
|
||||
|
||||
private void closeVotingNPCs() {
|
||||
com.bencodez.votingplugin.npc.VotingNPCManager manager;
|
||||
synchronized (votingNPCLifecycle) {
|
||||
votingNPCConfiguration.incrementAndGet();
|
||||
manager = votingNPCManager;
|
||||
votingNPCManager = null;
|
||||
}
|
||||
if (manager != null) {
|
||||
try { manager.close(); } catch (RuntimeException | LinkageError failure) { debug(failure); }
|
||||
}
|
||||
}
|
||||
|
||||
@Getter
|
||||
public static VotingPluginMain plugin;
|
||||
|
||||
@@ -217,6 +175,9 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
@Getter
|
||||
private BroadcastHandler broadcastHandler;
|
||||
|
||||
@Getter
|
||||
private com.bencodez.votingplugin.streaks.VoteStreaksMenu voteStreaksMenu;
|
||||
|
||||
@Getter
|
||||
private BackendProxyHandler backendProxyHandler;
|
||||
private ProcessedVoteCache backendProcessedVoteCache;
|
||||
@@ -256,6 +217,21 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
@Getter
|
||||
private CommandLoader commandLoader;
|
||||
|
||||
/** Runtime command identity is captured at startup; configuration reload does not rename it. */
|
||||
public String getVoteCommandPrefix() {
|
||||
return "/" + (commandLoader == null ? "vote" : commandLoader.getVoteCommandName());
|
||||
}
|
||||
|
||||
public boolean isVoteCommandEnabled() {
|
||||
return commandLoader == null || commandLoader.isVoteCommandEnabled();
|
||||
}
|
||||
|
||||
/** Retarget the built-in invalid-command help hint, including existing Config.yml text. */
|
||||
public String formatVoteCommandMessage(String message) {
|
||||
return message.replace("%votecommand%", getVoteCommandPrefix())
|
||||
.replaceAll("(?i)/vote(?=\\s|$|[!.,])", getVoteCommandPrefix());
|
||||
}
|
||||
|
||||
@Getter
|
||||
private NameMCLikeCheckerTask nameMCLikeCheckerTask;
|
||||
|
||||
@@ -311,6 +287,7 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
|
||||
@Getter
|
||||
private TopVoterState topVoterState;
|
||||
private volatile com.bencodez.votingplugin.topvoter.TopVoterDisplayFilter topVoterDisplayFilter;
|
||||
|
||||
|
||||
@Getter
|
||||
@@ -339,6 +316,8 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
|
||||
@Getter
|
||||
private ScheduledExecutorService voteTimer;
|
||||
private volatile AutoCreateVoteSitesReminderService autoCreateVoteSitesSetupReminder;
|
||||
private ScheduledExecutorService autoCreateVoteSitesReminderWorker;
|
||||
|
||||
@Getter
|
||||
private UserManager votingPluginUserManager;
|
||||
@@ -360,6 +339,9 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
|
||||
private volatile VotingPluginBackgroundTask backgroundTask;
|
||||
private volatile AtomicBoolean basicBungeeUpdateRunning;
|
||||
private final AtomicBoolean unloadInProgress = new AtomicBoolean();
|
||||
private Set<String> completedUnloadSteps;
|
||||
private java.util.concurrent.CompletionStage<Void> signShutdownPublication;
|
||||
|
||||
private synchronized AtomicBoolean basicBungeeUpdateAdmission() {
|
||||
if (basicBungeeUpdateRunning == null) basicBungeeUpdateRunning = new AtomicBoolean();
|
||||
@@ -548,6 +530,84 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
return topVoterState.getLastMonthTopVoters();
|
||||
}
|
||||
|
||||
/** Display-only snapshots; raw getters remain the award/accounting contract. */
|
||||
public LinkedHashMap<TopVoterPlayer, Integer> getTopVoterDisplay(TopVoter top) {
|
||||
return projectTopVoterDisplay(getTopVoter(top));
|
||||
}
|
||||
|
||||
public java.util.concurrent.CompletionStage<LinkedHashMap<TopVoterPlayer, Integer>> getTopVoterDisplayAsync(TopVoter top) {
|
||||
var filter = topVoterDisplayFilter;
|
||||
var raw = new LinkedHashMap<>(getTopVoter(top));
|
||||
return filter == null || !isEnabled() ? java.util.concurrent.CompletableFuture.completedFuture(raw)
|
||||
: filter.projectAsync(raw, configFile != null && configFile.isHideTopVoterIfNotPlayedBefore());
|
||||
}
|
||||
|
||||
private final java.util.concurrent.atomic.AtomicLong topVoterDisplayGeneration = new java.util.concurrent.atomic.AtomicLong();
|
||||
|
||||
public long getTopVoterDisplayGeneration() { return topVoterDisplayGeneration.get(); }
|
||||
|
||||
public LinkedHashMap<TopVoterPlayer, Integer> getLastMonthTopVoterDisplay() {
|
||||
return projectTopVoterDisplay(getLastMonthTopVoter());
|
||||
}
|
||||
|
||||
public LinkedHashMap<TopVoterPlayer, Integer> getPreviousMonthTopVoterDisplay(YearMonth month) {
|
||||
return projectTopVoterDisplay(getPreviousMonthsTopVoters().getOrDefault(month, new LinkedHashMap<>()));
|
||||
}
|
||||
|
||||
private LinkedHashMap<TopVoterPlayer, Integer> projectTopVoterDisplay(LinkedHashMap<TopVoterPlayer, Integer> raw) {
|
||||
var filter = topVoterDisplayFilter;
|
||||
return filter == null || !isEnabled() ? new LinkedHashMap<>(raw)
|
||||
: filter.project(raw, configFile != null && configFile.isHideTopVoterIfNotPlayedBefore());
|
||||
}
|
||||
|
||||
public void initializeTopVoterDisplayFilter() {
|
||||
retireTopVoterDisplayFilter();
|
||||
boolean onlineMode = getOptions().isOnlineMode();
|
||||
var filter = new com.bencodez.votingplugin.topvoter.TopVoterDisplayFilter(row ->
|
||||
com.bencodez.votingplugin.topvoter.TopVoterPresenceIdentity
|
||||
.identities(row, onlineMode, UuidLookup.getInstance()::getCachedUUID),
|
||||
(row, aliases) -> checkTopVoterPresence(aliases, onlineMode), System::nanoTime,
|
||||
this::topVoterDisplayRankingRows);
|
||||
registerEvents(filter);
|
||||
topVoterDisplayFilter = filter;
|
||||
}
|
||||
|
||||
/** Cache capacity follows already loaded rankings, including disjoint historical identities. */
|
||||
private long topVoterDisplayRankingRows() {
|
||||
var state = topVoterState;
|
||||
if (state == null) return 0L;
|
||||
long rows = state.getLastMonthTopVoters().size();
|
||||
for (var board : state.getTopVoters().values()) if (board != null) rows += board.size();
|
||||
for (var board : state.getPreviousMonthsTopVoters().values()) if (board != null) rows += board.size();
|
||||
return rows;
|
||||
}
|
||||
|
||||
private java.util.concurrent.CompletionStage<com.bencodez.advancedcore.api.user.presence.PlayerPresence>
|
||||
checkTopVoterPresence(java.util.List<java.util.UUID> aliases, boolean onlineMode) {
|
||||
boolean completeOnlineHistory = onlineMode || aliases.stream().anyMatch(alias ->
|
||||
alias != null && alias.version() == 4 && alias.variant() == 2);
|
||||
if (!completeOnlineHistory) return checkPlayerPresenceIdentitiesAsync(aliases);
|
||||
try {
|
||||
return checkPlayerPresenceIdentitiesAsync(aliases, true);
|
||||
} catch (NoSuchMethodError | AbstractMethodError unsupported) {
|
||||
// Older SDKs can retain a positive but cannot certify the wider absence scope.
|
||||
return checkPlayerPresenceIdentitiesAsync(aliases).thenApply(evidence ->
|
||||
evidence == com.bencodez.advancedcore.api.user.presence.PlayerPresence.NEVER_JOINED
|
||||
? com.bencodez.advancedcore.api.user.presence.PlayerPresence.UNKNOWN : evidence);
|
||||
}
|
||||
}
|
||||
|
||||
private void retireTopVoterDisplayFilter() {
|
||||
topVoterDisplayGeneration.incrementAndGet();
|
||||
if (discordHandler != null) discordHandler.retireLeaderboardPublications();
|
||||
var filter = topVoterDisplayFilter;
|
||||
topVoterDisplayFilter = null;
|
||||
if (filter != null) {
|
||||
filter.close();
|
||||
org.bukkit.event.HandlerList.unregisterAll(filter);
|
||||
}
|
||||
}
|
||||
|
||||
public LinkedHashMap<YearMonth, LinkedHashMap<TopVoterPlayer, Integer>> getPreviousMonthsTopVoters() {
|
||||
return topVoterState.getPreviousMonthsTopVoters();
|
||||
}
|
||||
@@ -737,6 +797,7 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
|
||||
@Override
|
||||
public void onPostLoad() {
|
||||
serverData.refreshVotePartyDisplay();
|
||||
ensureCommunicationSecret();
|
||||
// auto conversion for Shop.yml
|
||||
if (plugin.getShopFile().isJustCreated()) {
|
||||
@@ -765,6 +826,16 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
voteTester = new VoteTester(plugin);
|
||||
|
||||
loadVoteTimer();
|
||||
BoundedScheduledExecutor setupReminderWorker = new BoundedScheduledExecutor(1, 4);
|
||||
setupReminderWorker.setThreadFactory(runnable -> {
|
||||
Thread worker = new Thread(runnable, "VotingPlugin-SiteSetupReminder");
|
||||
worker.setDaemon(true);
|
||||
return worker;
|
||||
});
|
||||
autoCreateVoteSitesReminderWorker = setupReminderWorker;
|
||||
autoCreateVoteSitesSetupReminder = new AutoCreateVoteSitesReminderService(getDataFolder().toPath(),
|
||||
setupReminderWorker, getLogger(), autoCreateVoteSitesReminderSettings());
|
||||
autoCreateVoteSitesSetupReminder.start();
|
||||
getVotingPluginUserManager().startSharedPointTransferRecovery();
|
||||
|
||||
if (bungeeSettings.isUseBungeecoord()) {
|
||||
@@ -791,7 +862,6 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
checkVotifier();
|
||||
registerEvents();
|
||||
refreshPlaceholderPlayerPresence();
|
||||
refreshVotingNPCs();
|
||||
|
||||
loadVoteBroadcast();
|
||||
|
||||
@@ -815,6 +885,7 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
});
|
||||
|
||||
topVoterState = new TopVoterState();
|
||||
initializeTopVoterDisplayFilter();
|
||||
topVoterHandler = new TopVoterHandler(this);
|
||||
|
||||
new AdminGUI(this).loadHook();
|
||||
@@ -1944,9 +2015,95 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
configFile.saveData();
|
||||
}
|
||||
|
||||
@Getter
|
||||
private volatile com.bencodez.votingplugin.util.ServerDataPublicationFence serverDataPublicationFence;
|
||||
|
||||
private volatile com.bencodez.votingplugin.data.GenerationFencedServerDataFile fencedServerDataFile;
|
||||
|
||||
private java.util.concurrent.CompletableFuture<Void> serverDataFinalPublication;
|
||||
private boolean serverDataFinalPublicationReserved;
|
||||
private boolean serverDataFinalStageAttached;
|
||||
private volatile boolean serverDataEnableCleanupStarted;
|
||||
private volatile boolean serverDataEnableAdmissionRejected;
|
||||
|
||||
@Override
|
||||
public com.bencodez.advancedcore.data.ServerData getServerDataFile() {
|
||||
var file = super.getServerDataFile();
|
||||
var current = fencedServerDataFile;
|
||||
if (current != null && current.wraps(file)) {
|
||||
// The SDK replaces its file after pre-load. A failed new generation has
|
||||
// no file to finalize; never expose the predecessor as its current owner.
|
||||
return current.belongsTo(serverDataPublicationFence) ? current : null;
|
||||
}
|
||||
synchronized (this) {
|
||||
file = super.getServerDataFile();
|
||||
var owner = serverDataPublicationFence;
|
||||
if (file == null || owner == null) return file;
|
||||
current = fencedServerDataFile;
|
||||
if (current != null && current.wraps(file) && !current.belongsTo(owner)) return null;
|
||||
if (current == null || !current.wraps(file)) {
|
||||
current = new com.bencodez.votingplugin.data.GenerationFencedServerDataFile(this, file, owner);
|
||||
fencedServerDataFile = current;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
/** Capture the existing AdvancedCore executors which can still publish server-data state. */
|
||||
public ScheduledExecutorService[] getServerDataWriterExecutors() {
|
||||
var checker = getTimeChecker();
|
||||
return new ScheduledExecutorService[] { getTimer(), getLoginTimer(), getInventoryTimer(),
|
||||
checker == null ? null : checker.getTimer() };
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
synchronized (this) {
|
||||
// Bind a retained SDK file before changing owner. AdvancedCore replaces its
|
||||
// private file only later in loadHook(), after onPreLoad().
|
||||
if (serverDataPublicationFence != null) getServerDataFile();
|
||||
serverDataPublicationFence = com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(
|
||||
getDataFolder().toPath().resolve("ServerData.yml"));
|
||||
try { serverDataPublicationFence.retainForProcessExit(); }
|
||||
catch (RuntimeException | Error registrationFailure) {
|
||||
serverDataEnableAdmissionRejected = true;
|
||||
try { serverDataPublicationFence.retire(); }
|
||||
catch (RuntimeException | Error cleanupFailure) {
|
||||
if (cleanupFailure != registrationFailure) registrationFailure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
throw registrationFailure;
|
||||
}
|
||||
serverDataFinalPublication = new java.util.concurrent.CompletableFuture<>();
|
||||
serverDataFinalPublicationReserved = false;
|
||||
serverDataFinalStageAttached = false;
|
||||
serverDataEnableCleanupStarted = false;
|
||||
serverDataEnableAdmissionRejected = false;
|
||||
}
|
||||
// Failed startup can already own SDK writers; disable must retire them before replacement.
|
||||
try { super.onEnable(); }
|
||||
catch (RuntimeException | Error failure) {
|
||||
// SDK admission can fail before its startup-cleanup try/catch. Such an
|
||||
// attempt admitted no new writers and must not strand an empty plugin owner.
|
||||
// An admitted startup failure already entered our onDisable; preserve its drain.
|
||||
if (!serverDataEnableCleanupStarted) {
|
||||
serverDataEnableAdmissionRejected = true;
|
||||
try {
|
||||
serverDataPublicationFence.retire();
|
||||
serverDataFinalPublication.complete(null);
|
||||
} catch (RuntimeException | Error cleanupFailure) {
|
||||
if (cleanupFailure != failure) failure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPreLoad() {
|
||||
plugin = this;
|
||||
unloadInProgress.set(false);
|
||||
completedUnloadSteps = new java.util.HashSet<>();
|
||||
signShutdownPublication = null;
|
||||
|
||||
setupFiles();
|
||||
if ("SQLITE".equalsIgnoreCase(configFile.getData().getString("DataStorage", "SQLITE"))) {
|
||||
@@ -1988,11 +2145,83 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
if (offlineVoteOwnerHandoffs != null) offlineVoteOwnerHandoffs.close();
|
||||
// The rejected enable owns no SDK shutdown stage to complete a new reservation.
|
||||
if (serverDataEnableAdmissionRejected) return;
|
||||
runShutdownStep("server-data process drain", this::beginServerDataProcessDrain);
|
||||
serverDataEnableCleanupStarted = true;
|
||||
runShutdownStep("Discord leaderboards", () -> {
|
||||
if (discordHandler != null) discordHandler.close();
|
||||
});
|
||||
runShutdownStep("setup reminder", this::stopAutoCreateVoteSitesSetupReminder);
|
||||
runShutdownStep("server-data writer retirement", () -> {
|
||||
if (serverDataPublicationFence != null) {
|
||||
retainServerDataFinalPublication();
|
||||
var coreWriters = getServerDataWriterExecutors();
|
||||
var writers = java.util.Arrays.copyOf(coreWriters, coreWriters.length + 1);
|
||||
writers[coreWriters.length] = voteTimer;
|
||||
serverDataPublicationFence.retainUntilTermination(writers);
|
||||
}
|
||||
});
|
||||
try {
|
||||
shutdownVoteReminders();
|
||||
runShutdownStep("offline vote handoffs", () -> {
|
||||
if (offlineVoteOwnerHandoffs != null) offlineVoteOwnerHandoffs.close();
|
||||
});
|
||||
} finally {
|
||||
super.onDisable();
|
||||
try { shutdownVoteReminders(); }
|
||||
finally {
|
||||
try { super.onDisable(); }
|
||||
finally { runShutdownStep("server-data publication owner", () -> {
|
||||
if (serverDataPublicationFence != null) serverDataPublicationFence.retire();
|
||||
}); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reserve before SDK shutdown can enqueue a final file publication. No YAML lock or wait. */
|
||||
private void retainServerDataFinalPublication() {
|
||||
synchronized (this) {
|
||||
if (serverDataFinalPublicationReserved) return;
|
||||
if (serverDataFinalPublication == null)
|
||||
serverDataFinalPublication = new java.util.concurrent.CompletableFuture<>();
|
||||
serverDataPublicationFence.retainUntilSuccessfulPublication(serverDataFinalPublication);
|
||||
serverDataFinalPublicationReserved = true;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onShutdownComplete(java.util.concurrent.CompletionStage<Void> finalFilesStage) {
|
||||
java.util.concurrent.CompletableFuture<Void> publication;
|
||||
synchronized (this) {
|
||||
publication = serverDataFinalStageAttached ? null : serverDataFinalPublication;
|
||||
serverDataFinalStageAttached = true;
|
||||
}
|
||||
try {
|
||||
if (publication != null) {
|
||||
try {
|
||||
java.util.Objects.requireNonNull(finalFilesStage).whenComplete((ignored, failure) -> {
|
||||
if (failure == null) publication.complete(null);
|
||||
else publication.completeExceptionally(failure);
|
||||
});
|
||||
} catch (Throwable failure) { publication.completeExceptionally(failure); }
|
||||
}
|
||||
} finally { super.onShutdownComplete(finalFilesStage); }
|
||||
}
|
||||
|
||||
private Settings autoCreateVoteSitesReminderSettings() {
|
||||
return new Settings(true, configFile.isAutoCreateVoteSites(),
|
||||
AutoCreateVoteSitesReminderState.DEFAULT_THRESHOLD_MILLIS,
|
||||
AutoCreateVoteSitesReminderState.DEFAULT_REPEAT_MILLIS);
|
||||
}
|
||||
|
||||
private void stopAutoCreateVoteSitesSetupReminder() {
|
||||
AutoCreateVoteSitesReminderService current = autoCreateVoteSitesSetupReminder;
|
||||
autoCreateVoteSitesSetupReminder = null;
|
||||
ScheduledExecutorService worker = autoCreateVoteSitesReminderWorker;
|
||||
autoCreateVoteSitesReminderWorker = null;
|
||||
try {
|
||||
if (current != null) current.closeAsync();
|
||||
} finally {
|
||||
if (worker != null) worker.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2005,49 +2234,107 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
|
||||
@Override
|
||||
public void onUnLoad() {
|
||||
closeVotingNPCs();
|
||||
placeholderPlayerPresence.clear();
|
||||
stopBackendHostedControlLifecycle();
|
||||
stopBackendControlConnectorLifecycle();
|
||||
if (getBackendProxyHandler() != null) {
|
||||
try {
|
||||
getBackendProxyHandler().closeForShutdown();
|
||||
} catch (Exception e) {
|
||||
debug(e);
|
||||
}
|
||||
runShutdownStep("server-data process drain", this::beginServerDataProcessDrain);
|
||||
if (!unloadInProgress.compareAndSet(false, true)) {
|
||||
runShutdownStep("time queue retry", () -> {
|
||||
if (timeQueueHandler != null) timeQueueHandler.saveForShutdown();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (backendOrderedVoteOverflowQueue != null) {
|
||||
backendOrderedVoteOverflowQueue.close();
|
||||
backendOrderedVoteOverflowQueue = null;
|
||||
}
|
||||
if (webhookManager != null) {
|
||||
webhookManager.shutdown();
|
||||
}
|
||||
voteTimer.shutdown();
|
||||
try {
|
||||
voteTimer.awaitTermination(1, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
voteTimer.shutdownNow();
|
||||
if (votifierVoteOverflowQueue != null) {
|
||||
votifierVoteOverflowQueue.close();
|
||||
votifierVoteOverflowQueue = null;
|
||||
}
|
||||
if (timeQueueHandler != null) {
|
||||
timeQueueHandler.save();
|
||||
}
|
||||
if (completedUnloadSteps == null) completedUnloadSteps = new java.util.HashSet<>();
|
||||
runUnloadStep("Discord leaderboards", () -> {
|
||||
if (discordHandler != null) discordHandler.close();
|
||||
});
|
||||
runUnloadStep("top voter display", this::retireTopVoterDisplayFilter);
|
||||
runUnloadStep("streak menu", () -> {
|
||||
if (voteStreaksMenu != null) voteStreaksMenu.close();
|
||||
});
|
||||
runUnloadStep("setup reminder", this::stopAutoCreateVoteSitesSetupReminder);
|
||||
runUnloadStep("player presence", placeholderPlayerPresence::clear);
|
||||
runUnloadStep("backend hosted Control", this::stopBackendHostedControlLifecycle);
|
||||
runUnloadStep("backend Control connector", this::stopBackendControlConnectorLifecycle);
|
||||
runUnloadStep("backend proxy", () -> {
|
||||
if (getBackendProxyHandler() != null) getBackendProxyHandler().closeForShutdown();
|
||||
});
|
||||
runUnloadStep("backend ordered vote queue", () -> {
|
||||
if (backendOrderedVoteOverflowQueue != null) {
|
||||
backendOrderedVoteOverflowQueue.close();
|
||||
backendOrderedVoteOverflowQueue = null;
|
||||
}
|
||||
});
|
||||
runUnloadStep("webhook manager", () -> {
|
||||
if (webhookManager != null) webhookManager.shutdown();
|
||||
});
|
||||
runUnloadStep("vote timer", this::shutdownVoteTimer);
|
||||
runUnloadStep("Votifier vote queue", () -> {
|
||||
if (votifierVoteOverflowQueue != null) {
|
||||
votifierVoteOverflowQueue.close();
|
||||
votifierVoteOverflowQueue = null;
|
||||
}
|
||||
});
|
||||
runShutdownStep("time queue", () -> {
|
||||
if (timeQueueHandler != null) timeQueueHandler.saveForShutdown();
|
||||
});
|
||||
runUnloadStep("vote reminders", this::shutdownVoteReminders);
|
||||
runUnloadStep("cooldown checker", () -> {
|
||||
if (coolDownCheck != null) {
|
||||
coolDownCheck.shutdown();
|
||||
coolDownCheck = null;
|
||||
}
|
||||
});
|
||||
runShutdownStep("signs", this::shutdownSigns);
|
||||
runUnloadStep("event handlers", () -> HandlerList.unregisterAll(this));
|
||||
// Late producers must see rejection until their overflow and handler cleanup finishes.
|
||||
if (completedUnloadSteps.contains("vote timer")) voteTimer = null;
|
||||
if (plugin == this) plugin = null;
|
||||
} finally { unloadInProgress.set(false); }
|
||||
}
|
||||
|
||||
shutdownVoteReminders();
|
||||
private void beginServerDataProcessDrain() {
|
||||
var owner = serverDataPublicationFence;
|
||||
if (owner != null) owner.beginShutdown(System.nanoTime());
|
||||
}
|
||||
|
||||
if (coolDownCheck != null) {
|
||||
coolDownCheck.shutdown();
|
||||
coolDownCheck = null;
|
||||
private void shutdownSigns() {
|
||||
if (signs == null) return;
|
||||
var previous = signShutdownPublication;
|
||||
if (previous != null && !previous.toCompletableFuture().isCompletedExceptionally()) return;
|
||||
signShutdownPublication = java.util.Objects.requireNonNull(signs.storeSignsForShutdown());
|
||||
}
|
||||
|
||||
private void runUnloadStep(String name, Runnable step) {
|
||||
if (!completedUnloadSteps.contains(name) && runShutdownStep(name, step)) completedUnloadSteps.add(name);
|
||||
}
|
||||
|
||||
private boolean runShutdownStep(String name, Runnable step) {
|
||||
try {
|
||||
step.run();
|
||||
return true;
|
||||
} catch (RuntimeException | Error failure) {
|
||||
try {
|
||||
getLogger().log(java.util.logging.Level.WARNING, "Failed to shut down " + name, failure);
|
||||
} catch (RuntimeException | Error ignored) {
|
||||
// Logging must not prevent the next independent cleanup step.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getSigns().storeSigns();
|
||||
HandlerList.unregisterAll(plugin);
|
||||
plugin = null;
|
||||
private void shutdownVoteTimer() {
|
||||
if (voteTimer == null) return;
|
||||
try {
|
||||
voteTimer.shutdown();
|
||||
if (!voteTimer.awaitTermination(1, TimeUnit.SECONDS)) voteTimer.shutdownNow();
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
voteTimer.shutdownNow();
|
||||
} catch (RuntimeException failure) {
|
||||
try {
|
||||
getLogger().log(java.util.logging.Level.WARNING, "Failed to shut down vote timer", failure);
|
||||
} catch (RuntimeException | Error ignored) { }
|
||||
voteTimer.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2058,12 +2345,6 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
commandLoader.loadCommands();
|
||||
commandLoader.loadAliases();
|
||||
|
||||
// /vote, /v
|
||||
getCommand("vote").setExecutor(new CommandVote(this));
|
||||
getCommand("vote").setTabCompleter(new VoteTabCompleter());
|
||||
// getCommand("v").setExecutor(new CommandVote(this));
|
||||
// getCommand("v").setTabCompleter(new VoteTabCompleter());
|
||||
|
||||
// /adminvote, /av
|
||||
getCommand("adminvote").setExecutor(new CommandAdminVote(this));
|
||||
getCommand("adminvote").setTabCompleter(new AdminVoteTabCompleter());
|
||||
@@ -2090,6 +2371,8 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
* Register events.
|
||||
*/
|
||||
private void registerEvents() {
|
||||
voteStreaksMenu = new com.bencodez.votingplugin.streaks.VoteStreaksMenu(this);
|
||||
getServer().getPluginManager().registerEvents(voteStreaksMenu, this);
|
||||
PluginManager pm = getServer().getPluginManager();
|
||||
|
||||
pm.registerEvents(new PlayerJoinEvent(this), this);
|
||||
@@ -2145,15 +2428,19 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
/** Captures Bukkit presence while the lifecycle caller owns platform access. */
|
||||
public void refreshPlaceholderPlayerPresence() {
|
||||
boolean onlineMode = getOptions().isOnlineMode();
|
||||
placeholderPlayerPresence.replace(Bukkit::getOnlinePlayers,
|
||||
player -> placeholderStorageUuid(player, onlineMode));
|
||||
placeholderPlayerPresence.refresh(Bukkit::getOnlinePlayers, (player, capture) ->
|
||||
com.bencodez.votingplugin.util.BukkitCompletionScheduler.run(this, player, () -> {
|
||||
if (!player.isOnline()) return;
|
||||
UUID storageUuid = placeholderPlayerPresence.storageUuid(player);
|
||||
capture.accept(storageUuid == null ? placeholderStorageUuid(player, onlineMode) : storageUuid);
|
||||
}, () -> { }, () -> { }));
|
||||
}
|
||||
|
||||
static UUID placeholderStorageUuid(Player player, boolean onlineMode) {
|
||||
if (onlineMode) return player.getUniqueId();
|
||||
String cachedUuid = UuidLookup.getInstance().getCachedUUID(player.getName());
|
||||
String resolvedUuid = UuidLookup.getInstance().getUUIDWithoutStorage(player.getName());
|
||||
try {
|
||||
return UUID.fromString(cachedUuid);
|
||||
return UUID.fromString(resolvedUuid);
|
||||
} catch (IllegalArgumentException | NullPointerException ignored) {
|
||||
return player.getUniqueId();
|
||||
}
|
||||
@@ -2169,9 +2456,13 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
private void reloadPlugin(boolean userStorage, boolean reconcileHostedControl,
|
||||
boolean updateActiveBackendRuntime) {
|
||||
if (offlineVoteOwnerHandoffs != null) offlineVoteOwnerHandoffs.pause();
|
||||
retireTopVoterDisplayFilter();
|
||||
try {
|
||||
if (voteStreaksMenu != null) voteStreaksMenu.suspend();
|
||||
configFile.reloadData();
|
||||
configFile.loadValues();
|
||||
AutoCreateVoteSitesReminderService setupReminder = autoCreateVoteSitesSetupReminder;
|
||||
if (setupReminder != null) setupReminder.reload(autoCreateVoteSitesReminderSettings());
|
||||
|
||||
configVoteSites.reloadData();
|
||||
|
||||
@@ -2186,6 +2477,7 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
updateAdvancedCoreHook();
|
||||
|
||||
reloadAdvancedCore(userStorage);
|
||||
initializeTopVoterDisplayFilter();
|
||||
// PerServerPoints can switch shared point storage on during a live reload.
|
||||
// Re-evaluate after storage has reloaded; UserManager keeps this lifecycle task unique.
|
||||
getVotingPluginUserManager().startSharedPointTransferRecovery();
|
||||
@@ -2217,11 +2509,11 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
|
||||
loadDirectlyDefined();
|
||||
|
||||
refreshVotingNPCs();
|
||||
if (reconcileHostedControl) restartBackendControlConnector();
|
||||
|
||||
setUpdate(true);
|
||||
} finally {
|
||||
if (voteStreaksMenu != null) voteStreaksMenu.resume();
|
||||
if (offlineVoteOwnerHandoffs != null) offlineVoteOwnerHandoffs.resume();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ import com.bencodez.simpleapi.valuerequest.ValueRequest;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.core.maintenance.MaintenanceConfirmation;
|
||||
import com.bencodez.votingplugin.commands.executers.CommandAliases;
|
||||
import com.bencodez.votingplugin.commands.executers.CommandVote;
|
||||
import com.bencodez.votingplugin.commands.gui.AdminGUI;
|
||||
import com.bencodez.votingplugin.commands.gui.admin.AdminVoteHelp;
|
||||
import com.bencodez.votingplugin.commands.gui.admin.AdminVotePerms;
|
||||
@@ -82,6 +83,7 @@ import com.bencodez.votingplugin.commands.gui.player.VoteTotal;
|
||||
import com.bencodez.votingplugin.commands.gui.player.VoteURL;
|
||||
import com.bencodez.votingplugin.commands.gui.player.VoteURLVoteSite;
|
||||
import com.bencodez.votingplugin.commands.tabcompleter.AliasesTabCompleter;
|
||||
import com.bencodez.votingplugin.commands.tabcompleter.VoteTabCompleter;
|
||||
import com.bencodez.votingplugin.events.PlayerVoteEvent;
|
||||
import com.bencodez.votingplugin.events.PlayerVoteSiteCoolDownEndEvent;
|
||||
import com.bencodez.votingplugin.presets.VoteSitePresetSetupHandler;
|
||||
@@ -115,6 +117,13 @@ public class CommandLoader {
|
||||
private String playerPerm = "VotingPlugin.Player";
|
||||
|
||||
private VotingPluginMain plugin;
|
||||
private final boolean startupVoteCommandEnabled;
|
||||
private final String startupVoteCommandName;
|
||||
private final boolean startupLoadCommandAliases;
|
||||
private boolean playerCommandConfigured;
|
||||
private PluginCommand playerRootCommand;
|
||||
private boolean voteCommandEnabled;
|
||||
private String voteCommandName = "vote";
|
||||
|
||||
/**
|
||||
* Constructs a new CommandLoader.
|
||||
@@ -123,6 +132,131 @@ public class CommandLoader {
|
||||
*/
|
||||
public CommandLoader(VotingPluginMain plugin) {
|
||||
this.plugin = plugin;
|
||||
var config = plugin.getConfigFile();
|
||||
startupVoteCommandEnabled = config == null || config.isVoteCommandEnabled();
|
||||
startupVoteCommandName = config == null ? "vote" : config.getVoteCommandName();
|
||||
startupLoadCommandAliases = config == null || config.isLoadCommandAliases();
|
||||
voteCommandEnabled = startupVoteCommandEnabled;
|
||||
}
|
||||
|
||||
/** The label actually registered at startup, including its namespace on conflict. */
|
||||
public String getVoteCommandName() {
|
||||
return voteCommandName;
|
||||
}
|
||||
|
||||
public boolean isVoteCommandEnabled() {
|
||||
return voteCommandEnabled;
|
||||
}
|
||||
|
||||
/** Runs a generated player navigation action only while its command is available. */
|
||||
public void performVoteCommand(Player player, String arguments) {
|
||||
if (voteCommandEnabled) {
|
||||
player.performCommand(voteCommandName + (arguments.isEmpty() ? "" : " " + arguments));
|
||||
}
|
||||
}
|
||||
|
||||
/** Configure only once: reloading configuration must not change command ownership. */
|
||||
private void configurePlayerCommand() {
|
||||
if (playerCommandConfigured) return;
|
||||
playerCommandConfigured = true;
|
||||
try {
|
||||
CommandMap commandMap = getCommandMap();
|
||||
PluginCommand root = ownedCommand("vote");
|
||||
playerRootCommand = root;
|
||||
if (!startupVoteCommandEnabled) {
|
||||
for (String name : plugin.getDescription().getCommands().keySet()) {
|
||||
if (name.equalsIgnoreCase("vote") || name.toLowerCase(Locale.ROOT).startsWith("vote")) {
|
||||
PluginCommand command = ownedCommand(name);
|
||||
if (command != null) unregisterCommand(commandMap, command);
|
||||
}
|
||||
}
|
||||
voteCommandEnabled = false;
|
||||
return;
|
||||
}
|
||||
if (root == null) {
|
||||
voteCommandEnabled = false;
|
||||
plugin.getLogger().warning("Unable to locate VotingPlugin's player command; player commands were not enabled.");
|
||||
return;
|
||||
}
|
||||
String name = startupVoteCommandName == null ? "" : startupVoteCommandName.trim().toLowerCase(Locale.ROOT);
|
||||
if (!name.matches("[a-z0-9][a-z0-9_-]{0,63}") || isReservedCommandName(name)) {
|
||||
plugin.getLogger().warning("Invalid or reserved VoteCommandName; using vote. Command settings require a restart.");
|
||||
name = "vote";
|
||||
}
|
||||
String namespace = plugin.getDescription().getName().toLowerCase(Locale.ROOT);
|
||||
String fallback = namespace + ":" + name;
|
||||
org.bukkit.command.Command existingFallback = commandMap.getCommand(fallback);
|
||||
org.bukkit.command.Command existingLabel = commandMap.getCommand(name);
|
||||
boolean ownedLabelCollision = existingLabel instanceof PluginCommand owned
|
||||
&& owned.getPlugin() == plugin && owned != root;
|
||||
if (!name.equals("vote") && ((existingFallback != null && existingFallback != root) || ownedLabelCollision)) {
|
||||
plugin.getLogger().warning("VoteCommandName namespace is already owned; keeping vote. Command settings require a restart.");
|
||||
name = "vote";
|
||||
fallback = namespace + ":vote";
|
||||
}
|
||||
if (!name.equals("vote")) {
|
||||
// Bukkit's register writes the fallback even when the bare label collides.
|
||||
// Check it before unregistering or publishing the renamed command.
|
||||
unregisterCommand(commandMap, root);
|
||||
root.setName(name);
|
||||
root.setLabel(name);
|
||||
root.setAliases(List.of());
|
||||
if (existingLabel != null && existingLabel != root) {
|
||||
// SimpleCommandMap may overwrite another command's bare alias. Register
|
||||
// only our free fallback in that case, preserving the alias owner too.
|
||||
root.setLabel(fallback);
|
||||
if (!root.register(commandMap)) throw new IllegalStateException("Player command registration was rejected");
|
||||
Map<String, org.bukkit.command.Command> registrations = getKnownCommands(commandMap);
|
||||
// Paper forwards put/get to Brigadier; HashMap.putIfAbsent can bypass it.
|
||||
if (registrations.get(fallback) != null) throw new IllegalStateException("Player command namespace became occupied");
|
||||
registrations.put(fallback, root);
|
||||
} else {
|
||||
commandMap.register(namespace, root);
|
||||
}
|
||||
}
|
||||
voteCommandName = commandMap.getCommand(name) == root ? name : fallback;
|
||||
voteCommandEnabled = commandMap.getCommand(voteCommandName) == root;
|
||||
if (voteCommandEnabled) {
|
||||
root.setExecutor(new CommandVote(plugin));
|
||||
root.setTabCompleter(new VoteTabCompleter());
|
||||
if (!voteCommandName.equals(name)) {
|
||||
plugin.getLogger().warning("Player command label is already owned; use /" + voteCommandName + " instead.");
|
||||
}
|
||||
} else {
|
||||
plugin.getLogger().warning("Unable to register VotingPlugin's player command without replacing another owner.");
|
||||
}
|
||||
} catch (ReflectiveOperationException | RuntimeException failure) {
|
||||
throw new IllegalStateException("Unable to configure VotingPlugin's player command safely", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isReservedCommandName(String name) {
|
||||
for (var entry : plugin.getDescription().getCommands().entrySet()) {
|
||||
if (entry.getKey().equalsIgnoreCase("vote")) continue;
|
||||
if (entry.getKey().equalsIgnoreCase(name)) return true;
|
||||
Object aliases = entry.getValue().get("aliases");
|
||||
if (aliases instanceof String alias && alias.equalsIgnoreCase(name)) return true;
|
||||
if (aliases instanceof Iterable<?> values) {
|
||||
for (Object alias : values) if (name.equalsIgnoreCase(String.valueOf(alias))) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private PluginCommand ownedCommand(String name) {
|
||||
PluginCommand command = plugin.getCommand(name);
|
||||
if (command != null && command.getPlugin() == plugin) return command;
|
||||
try {
|
||||
// A plugin may have displaced the primary and fallback labels while a short
|
||||
// alias still points at our command. Disable must remove that object too.
|
||||
for (org.bukkit.command.Command candidate : getKnownCommands(getCommandMap()).values()) {
|
||||
if (candidate instanceof PluginCommand owned && owned.getPlugin() == plugin
|
||||
&& owned.getName().equalsIgnoreCase(name)) return owned;
|
||||
}
|
||||
} catch (ReflectiveOperationException failure) {
|
||||
throw new IllegalStateException("Unable to inspect owned command registrations", failure);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void runForCommandSender(CommandSender sender, Runnable task) {
|
||||
@@ -220,7 +354,7 @@ public class CommandLoader {
|
||||
java.util.List<VotingPluginUser> users = new java.util.ArrayList<>();
|
||||
try {
|
||||
for (String uuidString : plugin.getUserManager().getAllUUIDs()) {
|
||||
users.add(plugin.getVotingPluginUserManager().getVotingPluginUser(UUID.fromString(uuidString)));
|
||||
users.add(plugin.getVotingPluginUserManager().getVotingPluginUser(UUID.fromString(uuidString), true));
|
||||
}
|
||||
} catch (Throwable failure) {
|
||||
plugin.debug(failure);
|
||||
@@ -403,26 +537,28 @@ public class CommandLoader {
|
||||
* @return true if player has permission
|
||||
*/
|
||||
public boolean hasPermission(Player player, String cmd) {
|
||||
cmd = cmd.toLowerCase(Locale.ROOT);
|
||||
if (cmd.startsWith("votingplugin:")) {
|
||||
cmd = cmd.substring("votingplugin:".length());
|
||||
}
|
||||
|
||||
if (commands.containsKey(cmd)) {
|
||||
return commands.get(cmd).hasPerm(player);
|
||||
for (var entry : commands.entrySet()) {
|
||||
if (entry.getKey().equalsIgnoreCase(cmd)) return entry.getValue().hasPerm(player);
|
||||
}
|
||||
|
||||
boolean adminCommand = false;
|
||||
String rootLabel = voteCommandName.substring(voteCommandName.indexOf(':') + 1);
|
||||
|
||||
if (cmd.startsWith("vote")) {
|
||||
cmd = cmd.substring("vote".length());
|
||||
} else if (cmd.startsWith("v")) {
|
||||
cmd = cmd.substring(1);
|
||||
} else if (cmd.startsWith("av")) {
|
||||
if (cmd.equals("adminvote") || cmd.startsWith("adminvote ")) {
|
||||
adminCommand = true;
|
||||
cmd = cmd.substring("av".length());
|
||||
} else if (cmd.startsWith("adminvote")) {
|
||||
cmd = cmd.substring("adminvote".length()).trim();
|
||||
} else if (cmd.equals("av") || cmd.startsWith("av ")) {
|
||||
adminCommand = true;
|
||||
cmd = cmd.substring("adminvote".length());
|
||||
cmd = cmd.substring("av".length()).trim();
|
||||
} else if (voteCommandEnabled && (cmd.equals(rootLabel) || cmd.startsWith(rootLabel + " "))) {
|
||||
cmd = cmd.substring(rootLabel.length()).trim();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
// plugin.debug(cmd);
|
||||
if (!adminCommand) {
|
||||
@@ -459,13 +595,11 @@ public class CommandLoader {
|
||||
* @return true if it's a VotingPlugin command
|
||||
*/
|
||||
public boolean isVotingPluginCommand(Player player, String cmd) {
|
||||
if (plugin.getCommand(cmd) != null || cmd.startsWith("votingplugin")) {
|
||||
return true;
|
||||
}
|
||||
for (String str : commands.keySet()) {
|
||||
if (str.equalsIgnoreCase(cmd)) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
org.bukkit.command.Command command = getCommandMap().getCommand(cmd);
|
||||
return command instanceof PluginCommand owned && owned.getPlugin() == plugin;
|
||||
} catch (ReflectiveOperationException failure) {
|
||||
plugin.debug(failure);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -473,9 +607,30 @@ public class CommandLoader {
|
||||
/**
|
||||
* Load admin vote command.
|
||||
*/
|
||||
private void clearDiscordMessageId(CommandSender sender, TopVoter top) {
|
||||
try {
|
||||
plugin.getTimer().execute(() -> {
|
||||
boolean confirmed;
|
||||
try { confirmed = clearDiscordMessageId(top); }
|
||||
catch (RuntimeException failure) { confirmed = false; }
|
||||
String message = confirmed ? "&cCleared discord message ID for " + top.toString()
|
||||
: "&cUnable to confirm the cleared Discord message ID; check storage before restarting.";
|
||||
runForCommandSender(sender, () -> sender.sendMessage(MessageAPI.colorize(message)));
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
runForCommandSender(sender, () -> sender.sendMessage(MessageAPI.colorize(
|
||||
"&cUnable to schedule clearing the Discord message ID; no changes made.")));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean clearDiscordMessageId(TopVoter top) {
|
||||
var discord = plugin.getDiscordHandler();
|
||||
return discord != null ? discord.clearTopVoterMessageId(top)
|
||||
: plugin.getServerData().setTopVoterMessageIdConfirmed(top, 0L);
|
||||
}
|
||||
|
||||
private void loadAdminVoteCommand() {
|
||||
plugin.setAdminVoteCommand(new ArrayList<>());
|
||||
com.bencodez.votingplugin.npc.VotingNPCCommands.register(plugin);
|
||||
|
||||
plugin.getAdminVoteCommand().add(new CommandHandler(plugin, new String[] { "CurrentPluginTime" },
|
||||
"VotingPlugin.Commands.AdminVote.CurrentPluginTime|" + adminPerm, "Current plugin time") {
|
||||
@@ -710,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(
|
||||
@@ -2814,9 +2969,7 @@ public class CommandLoader {
|
||||
public void execute(CommandSender sender, String[] args) {
|
||||
String topVoter = args[1];
|
||||
TopVoter top = TopVoter.getTopVoter(topVoter);
|
||||
plugin.getServerData().setTopVoterMessageId(top, 0);
|
||||
plugin.getDiscordHandler().getTopVoterMessageIds().put(top, 0L);
|
||||
sendMessage(sender, "&cCleared discord message ID for " + top.toString());
|
||||
clearDiscordMessageId(sender, top);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2970,9 +3123,10 @@ public class CommandLoader {
|
||||
public void loadAliases() {
|
||||
commands = new HashMap<>();
|
||||
aliasCommandNames.clear();
|
||||
configurePlayerCommand();
|
||||
|
||||
// Bukkit registers plugin.yml commands before this loader runs.
|
||||
if (!plugin.getConfigFile().isLoadCommandAliases()) {
|
||||
if (!startupLoadCommandAliases) {
|
||||
unregisterOptionalPluginYMLCommands();
|
||||
} else {
|
||||
unregisterDisabledPluginYMLCommands();
|
||||
@@ -2980,7 +3134,7 @@ public class CommandLoader {
|
||||
|
||||
// If false: still wire permissions, but don't wire alias executors/tab
|
||||
// completers.
|
||||
final boolean enableAliases = plugin.getConfigFile().isLoadCommandAliases();
|
||||
final boolean enableAliases = startupLoadCommandAliases;
|
||||
|
||||
// ---------------------------
|
||||
// /vote aliases
|
||||
@@ -3012,16 +3166,18 @@ public class CommandLoader {
|
||||
}
|
||||
registerAdditionalPermissions(Bukkit.getPluginManager(), cmdHandle);
|
||||
|
||||
if (argLength > 0) {
|
||||
if (argLength > 0 && voteCommandEnabled) {
|
||||
String[] args = cmdHandle.getArgs()[0].split("&");
|
||||
|
||||
for (String arg : args) {
|
||||
String commandName = "vote" + arg;
|
||||
// A renamed root may equal a synthetic alias absent from plugin.yml.
|
||||
if (ownedCommand(commandName) == playerRootCommand && playerRootCommand != null) continue;
|
||||
commands.put(commandName, cmdHandle);
|
||||
aliasCommandNames.add(commandName.toLowerCase(Locale.ROOT));
|
||||
|
||||
try {
|
||||
PluginCommand command = plugin.getCommand(commandName);
|
||||
PluginCommand command = ownedCommand(commandName);
|
||||
if (command == null) {
|
||||
plugin.devDebug("Command " + commandName + " not found in plugin.yml");
|
||||
continue;
|
||||
@@ -3095,11 +3251,13 @@ public class CommandLoader {
|
||||
|
||||
for (String arg : args) {
|
||||
String commandName = "adminvote" + arg;
|
||||
// A renamed root may equal a synthetic alias absent from plugin.yml.
|
||||
if (ownedCommand(commandName) == playerRootCommand && playerRootCommand != null) continue;
|
||||
commands.put(commandName, cmdHandle);
|
||||
aliasCommandNames.add(commandName.toLowerCase(Locale.ROOT));
|
||||
|
||||
try {
|
||||
PluginCommand command = plugin.getCommand(commandName);
|
||||
PluginCommand command = ownedCommand(commandName);
|
||||
if (command == null) {
|
||||
plugin.devDebug("Command " + commandName + " not found in plugin.yml");
|
||||
continue;
|
||||
@@ -3165,8 +3323,8 @@ public class CommandLoader {
|
||||
|| commandName.equalsIgnoreCase("av")) {
|
||||
continue;
|
||||
}
|
||||
PluginCommand command = plugin.getCommand(commandName);
|
||||
if (command != null) {
|
||||
PluginCommand command = ownedCommand(commandName);
|
||||
if (command != null && command != playerRootCommand) {
|
||||
unregisterCommand(commandMap, command);
|
||||
}
|
||||
}
|
||||
@@ -3180,8 +3338,8 @@ public class CommandLoader {
|
||||
try {
|
||||
CommandMap commandMap = getCommandMap();
|
||||
for (String commandName : disabledAliasCommandNames) {
|
||||
PluginCommand command = plugin.getCommand(commandName);
|
||||
if (command != null) {
|
||||
PluginCommand command = ownedCommand(commandName);
|
||||
if (command != null && command != playerRootCommand) {
|
||||
unregisterCommand(commandMap, command);
|
||||
}
|
||||
}
|
||||
@@ -3193,22 +3351,7 @@ public class CommandLoader {
|
||||
/** Removes the command and all labels that still point to it from Bukkit's map. */
|
||||
private void unregisterCommand(CommandMap commandMap, PluginCommand command) throws ReflectiveOperationException {
|
||||
command.unregister(commandMap);
|
||||
java.lang.reflect.Field field = null;
|
||||
Class<?> commandMapClass = commandMap.getClass();
|
||||
while (commandMapClass != null && field == null) {
|
||||
try {
|
||||
field = commandMapClass.getDeclaredField("knownCommands");
|
||||
} catch (NoSuchFieldException ignored) {
|
||||
commandMapClass = commandMapClass.getSuperclass();
|
||||
}
|
||||
}
|
||||
if (field == null) {
|
||||
throw new NoSuchFieldException("knownCommands");
|
||||
}
|
||||
field.setAccessible(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, org.bukkit.command.Command> knownCommands =
|
||||
(Map<String, org.bukkit.command.Command>) field.get(commandMap);
|
||||
Map<String, org.bukkit.command.Command> knownCommands = getKnownCommands(commandMap);
|
||||
Set<String> removedLabels = new HashSet<>();
|
||||
for (Map.Entry<String, org.bukkit.command.Command> entry : knownCommands.entrySet()) {
|
||||
if (entry.getValue() == command) {
|
||||
@@ -3237,6 +3380,27 @@ public class CommandLoader {
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads the same Bukkit-owned map used by exact-object unregister. */
|
||||
private Map<String, org.bukkit.command.Command> getKnownCommands(CommandMap commandMap) throws ReflectiveOperationException {
|
||||
java.lang.reflect.Field field = null;
|
||||
Class<?> commandMapClass = commandMap.getClass();
|
||||
while (commandMapClass != null && field == null) {
|
||||
try {
|
||||
field = commandMapClass.getDeclaredField("knownCommands");
|
||||
} catch (NoSuchFieldException ignored) {
|
||||
commandMapClass = commandMapClass.getSuperclass();
|
||||
}
|
||||
}
|
||||
if (field == null) {
|
||||
throw new NoSuchFieldException("knownCommands");
|
||||
}
|
||||
field.setAccessible(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, org.bukkit.command.Command> knownCommands =
|
||||
(Map<String, org.bukkit.command.Command>) field.get(commandMap);
|
||||
return knownCommands;
|
||||
}
|
||||
|
||||
/** Gets Bukkit's command map without depending on a CraftBukkit package name. */
|
||||
private CommandMap getCommandMap() throws ReflectiveOperationException {
|
||||
java.lang.reflect.Method method = plugin.getServer().getClass().getMethod("getCommandMap");
|
||||
@@ -3300,14 +3464,6 @@ public class CommandLoader {
|
||||
* Load tab complete.
|
||||
*/
|
||||
public void loadTabComplete() {
|
||||
TabCompleteHandler.getInstance().addTabCompleteOption(new TabCompleteHandle("(npcid)", new ArrayList<>()) {
|
||||
@Override public void reload() { updateReplacements(); }
|
||||
@Override public void updateReplacements() {
|
||||
var manager = plugin.getVotingNPCManager();
|
||||
setReplace(manager == null ? new ArrayList<>() : new ArrayList<>(manager.definitions().stream()
|
||||
.map(com.bencodez.votingplugin.npc.VotingNPCDefinition::id).toList()));
|
||||
}
|
||||
});
|
||||
ArrayList<String> sites = new ArrayList<>();
|
||||
for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) {
|
||||
sites.add(site.getKey());
|
||||
@@ -4078,6 +4234,9 @@ public class CommandLoader {
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve administrator-defined matches ahead of this new fallback.
|
||||
com.bencodez.votingplugin.streaks.VoteStreaksCommand.register(plugin);
|
||||
|
||||
ArrayList<CommandHandler> avCommands = com.bencodez.advancedcore.command.CommandLoader.getInstance()
|
||||
.getBasicCommands("VotingPlugin");
|
||||
for (CommandHandler cmd : avCommands) {
|
||||
@@ -4144,7 +4303,7 @@ public class CommandLoader {
|
||||
} else if (MessageAPI.startsWithIgnoreCase(slot, "today")) {
|
||||
new VoteToday(plugin, player, user, 1).open();
|
||||
} else if (MessageAPI.startsWithIgnoreCase(slot, "help")) {
|
||||
player.performCommand("vote help");
|
||||
performVoteCommand(player, "help");
|
||||
} else if (MessageAPI.startsWithIgnoreCase(slot, "shop")) {
|
||||
new VoteShop(plugin, player, user).open();
|
||||
} else if (MessageAPI.startsWithIgnoreCase(slot, "lastmonth")) {
|
||||
@@ -4153,6 +4312,8 @@ public class CommandLoader {
|
||||
new VoteBest(plugin, player, user).open();
|
||||
} else if (MessageAPI.startsWithIgnoreCase(slot, "streak")) {
|
||||
new VoteStreak(plugin, player, user).open();
|
||||
} else if (MessageAPI.startsWithIgnoreCase(slot, "streaks")) {
|
||||
plugin.getVoteStreaksMenu().open(player);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ public class CommandAliases implements CommandExecutor {
|
||||
if (adminCommand) {
|
||||
sender.sendMessage(MessageAPI.colorize(plugin.getConfigFile().getFormatInvalidCommandAdminVote()));
|
||||
} else {
|
||||
sender.sendMessage(MessageAPI.colorize(plugin.getConfigFile().getFormatInvalidCommandVote()));
|
||||
sender.sendMessage(MessageAPI.colorize(plugin.formatVoteCommandMessage(plugin.getConfigFile().getFormatInvalidCommandVote())));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ public class CommandVote implements CommandExecutor {
|
||||
}
|
||||
|
||||
// invalid command
|
||||
sender.sendMessage(MessageAPI.colorize(plugin.getConfigFile().getFormatInvalidCommandVote()));
|
||||
sender.sendMessage(MessageAPI.colorize(plugin.formatVoteCommandMessage(plugin.getConfigFile().getFormatInvalidCommandVote())));
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
+8
-8
@@ -77,16 +77,16 @@ public class AdminVotePerms extends GUIHandler {
|
||||
for (CommandHandler handle : plugin.getVoteCommand()) {
|
||||
if (sender instanceof Player) {
|
||||
if (handle.hasPerm(sender)) {
|
||||
msg.add("&6" + handle.getHelpLineCommand("/vote") + " : "
|
||||
msg.add("&6" + handle.getHelpLineCommand(plugin.getVoteCommandPrefix()) + " : "
|
||||
+ handle.getPerm().split(Pattern.quote("|"))[0] + " : &atrue");
|
||||
} else {
|
||||
msg.add("&6" + handle.getHelpLineCommand("/vote") + " : "
|
||||
msg.add("&6" + handle.getHelpLineCommand(plugin.getVoteCommandPrefix()) + " : "
|
||||
+ handle.getPerm().split(Pattern.quote("|"))[0] + " : &cfalse");
|
||||
}
|
||||
} else {
|
||||
msg.add(handle.getHelpLineCommand("/vote") + " : " + handle.getPerm().split(Pattern.quote("|"))[0]);
|
||||
msg.add(handle.getHelpLineCommand(plugin.getVoteCommandPrefix()) + " : " + handle.getPerm().split(Pattern.quote("|"))[0]);
|
||||
}
|
||||
addAdditionalPermissions(msg, sender, handle, "/vote");
|
||||
addAdditionalPermissions(msg, sender, handle, plugin.getVoteCommandPrefix());
|
||||
|
||||
}
|
||||
|
||||
@@ -164,13 +164,13 @@ public class AdminVotePerms extends GUIHandler {
|
||||
|
||||
for (CommandHandler handle : plugin.getVoteCommand()) {
|
||||
if (handle.hasPerm(p)) {
|
||||
msg.add("&6" + handle.getHelpLineCommand("/vote") + " : "
|
||||
msg.add("&6" + handle.getHelpLineCommand(plugin.getVoteCommandPrefix()) + " : "
|
||||
+ handle.getPerm().split(Pattern.quote("|"))[0] + " : &atrue");
|
||||
} else {
|
||||
msg.add("&6" + handle.getHelpLineCommand("/vote") + " : "
|
||||
msg.add("&6" + handle.getHelpLineCommand(plugin.getVoteCommandPrefix()) + " : "
|
||||
+ handle.getPerm().split(Pattern.quote("|"))[0] + " : &cfalse");
|
||||
}
|
||||
addAdditionalPermissions(msg, p, handle, "/vote");
|
||||
addAdditionalPermissions(msg, p, handle, plugin.getVoteCommandPrefix());
|
||||
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ public class AdminVotePerms extends GUIHandler {
|
||||
msg.add(" ");
|
||||
|
||||
for (CommandHandler handle : plugin.getVoteCommand()) {
|
||||
msg.add(handle.getHelpLineCommand("/vote"));
|
||||
msg.add(handle.getHelpLineCommand(plugin.getVoteCommandPrefix()));
|
||||
msg.add(" " + handle.getPerm());
|
||||
msg.add(" " + handle.getHelpMessage());
|
||||
for (String permission : additionalPermissions(handle)) {
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ public class AdminVoteTopPoints extends GUIHandler {
|
||||
LinkedHashMap<TopVoterPlayer, Integer> raw = new LinkedHashMap<>();
|
||||
for (String uuidString : plugin.getVotingPluginUserManager().getAllUUIDs()) {
|
||||
UUID uuid = UUID.fromString(uuidString);
|
||||
VotingPluginUser vpUser = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid);
|
||||
VotingPluginUser vpUser = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, true);
|
||||
vpUser.userDataFetechMode(UserDataFetchMode.NO_CACHE);
|
||||
int points = vpUser.getPoints();
|
||||
if (points <= 0) continue;
|
||||
|
||||
+5
-3
@@ -55,6 +55,7 @@ public class VoteHelp extends GUIHandler {
|
||||
*/
|
||||
public ArrayList<TextComponent> helpText(CommandSender sender) {
|
||||
ArrayList<TextComponent> msg = new ArrayList<>();
|
||||
if (!plugin.isVoteCommandEnabled()) return msg;
|
||||
HashMap<String, TextComponent> unsorted = new HashMap<>();
|
||||
|
||||
boolean requirePerms = plugin.getConfigFile().isFormatCommandsVoteHelpRequirePermission();
|
||||
@@ -70,7 +71,7 @@ public class VoteHelp extends GUIHandler {
|
||||
}
|
||||
for (CommandHandler cmdHandle : plugin.getVoteCommand()) {
|
||||
if (!requirePerms || cmdHandle.hasPerm(sender)) {
|
||||
unsorted.put(cmdHandle.getHelpLineCommand("/vote"), cmdHandle.getHelpLine("/vote",
|
||||
unsorted.put(cmdHandle.getHelpLineCommand(plugin.getVoteCommandPrefix()), cmdHandle.getHelpLine(plugin.getVoteCommandPrefix(),
|
||||
plugin.getConfigFile().getFormatCommandsVoteHelpLine(), hoverColor));
|
||||
}
|
||||
}
|
||||
@@ -92,14 +93,15 @@ public class VoteHelp extends GUIHandler {
|
||||
*/
|
||||
public ArrayList<TextComponent> helpTextLegacy(CommandSender sender) {
|
||||
ArrayList<TextComponent> msg = new ArrayList<>();
|
||||
if (!plugin.isVoteCommandEnabled()) return msg;
|
||||
HashMap<String, TextComponent> unsorted = new HashMap<>();
|
||||
|
||||
boolean requirePerms = plugin.getConfigFile().isFormatCommandsVoteHelpRequirePermission();
|
||||
|
||||
for (CommandHandler cmdHandle : plugin.getVoteCommand()) {
|
||||
if (!requirePerms || cmdHandle.hasPerm(sender)) {
|
||||
unsorted.put(cmdHandle.getHelpLineCommand("/vote"),
|
||||
cmdHandle.getHelpLine("/vote", plugin.getConfigFile().getFormatCommandsVoteHelpLine()));
|
||||
unsorted.put(cmdHandle.getHelpLineCommand(plugin.getVoteCommandPrefix()),
|
||||
cmdHandle.getHelpLine(plugin.getVoteCommandPrefix(), plugin.getConfigFile().getFormatCommandsVoteHelpLine()));
|
||||
}
|
||||
}
|
||||
ArrayList<String> unsortedList = new ArrayList<>();
|
||||
|
||||
+2
-2
@@ -60,7 +60,7 @@ public class VoteToday extends GUIHandler {
|
||||
Player clicked = player.getServer().getPlayer(payload.owner());
|
||||
if (clicked != null) {
|
||||
VotingPluginUser clickedUser = plugin.getVotingPluginUserManager()
|
||||
.getVotingPluginUser(uuid);
|
||||
.getVotingPluginUser(uuid, true);
|
||||
new VoteGUI(plugin, clicked, clickedUser).open(
|
||||
GUIMethod.valueOf(plugin.getGui().getGuiMethodGUI().toUpperCase()));
|
||||
}
|
||||
@@ -161,7 +161,7 @@ public class VoteToday extends GUIHandler {
|
||||
inv.addButton(inv.getNextSlot(), new BInventoryButton(item) {
|
||||
@Override
|
||||
public void onClick(ClickEvent clickEvent) {
|
||||
VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid);
|
||||
VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, true);
|
||||
new VoteGUI(plugin, player, user)
|
||||
.open(GUIMethod.valueOf(plugin.getGui().getGuiMethodGUI().toUpperCase()));
|
||||
}
|
||||
|
||||
+2
-2
@@ -51,7 +51,7 @@ public class VoteTopVoter extends GUIHandler {
|
||||
String topVoter = top.getName();
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
LinkedHashMap<TopVoterPlayer, Integer> topVotes = (LinkedHashMap<TopVoterPlayer, Integer>) plugin.getTopVoter(top)
|
||||
LinkedHashMap<TopVoterPlayer, Integer> topVotes = (LinkedHashMap<TopVoterPlayer, Integer>) plugin.getTopVoterDisplay(top)
|
||||
.clone();
|
||||
|
||||
com.bencodez.simpleapi.dialog.MultiActionDialogBuilder dialog = plugin.getDialogService().multiAction(player)
|
||||
@@ -148,7 +148,7 @@ public class VoteTopVoter extends GUIHandler {
|
||||
String topVoter = top.getName();
|
||||
@SuppressWarnings("unchecked")
|
||||
LinkedHashMap<TopVoterPlayer, Integer> topVotes = (LinkedHashMap<TopVoterPlayer, Integer>) plugin
|
||||
.getTopVoter(top).clone();
|
||||
.getTopVoterDisplay(top).clone();
|
||||
users = topVotes.entrySet();
|
||||
|
||||
ConfigurationSection customization = plugin.getGui().getChestVoteTopCustomization();
|
||||
|
||||
+2
-2
@@ -31,7 +31,7 @@ public class VoteTopVoterLastMonth extends GUIHandler {
|
||||
|
||||
@Override
|
||||
public void onDialog(Player player) {
|
||||
Set<Entry<TopVoterPlayer, Integer>> users = plugin.getLastMonthTopVoter().entrySet();
|
||||
Set<Entry<TopVoterPlayer, Integer>> users = plugin.getLastMonthTopVoterDisplay().entrySet();
|
||||
|
||||
com.bencodez.simpleapi.dialog.MultiActionDialogBuilder dialog = plugin.getDialogService().multiAction(player)
|
||||
.placeholder("player", user.getPlayerName())
|
||||
@@ -91,7 +91,7 @@ public class VoteTopVoterLastMonth extends GUIHandler {
|
||||
public void onChest(Player player) {
|
||||
Set<Entry<TopVoterPlayer, Integer>> users = null;
|
||||
|
||||
users = plugin.getLastMonthTopVoter().entrySet();
|
||||
users = plugin.getLastMonthTopVoterDisplay().entrySet();
|
||||
|
||||
BInventory inv = new BInventory(plugin.getGui().getChestVoteTopName());
|
||||
inv.addPlaceholder("topvoter", "Last Month");
|
||||
|
||||
+2
-2
@@ -60,7 +60,7 @@ public class VoteTopVoterPreviousMonths extends GUIHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
Set<Entry<TopVoterPlayer, Integer>> users = plugin.getPreviousMonthsTopVoters().get(yearMonth).entrySet();
|
||||
Set<Entry<TopVoterPlayer, Integer>> users = plugin.getPreviousMonthTopVoterDisplay(yearMonth).entrySet();
|
||||
|
||||
com.bencodez.simpleapi.dialog.MultiActionDialogBuilder dialog = plugin.getDialogService().multiAction(player)
|
||||
.placeholder("player", user.getPlayerName())
|
||||
@@ -160,7 +160,7 @@ public class VoteTopVoterPreviousMonths extends GUIHandler {
|
||||
player.sendMessage(ChatColor.RED + "Can't open previous months, no data");
|
||||
return;
|
||||
}
|
||||
users = plugin.getPreviousMonthsTopVoters().get(yearMonth).entrySet();
|
||||
users = plugin.getPreviousMonthTopVoterDisplay(yearMonth).entrySet();
|
||||
|
||||
BInventory inv = new BInventory(plugin.getGui().getChestVoteTopName());
|
||||
inv.addPlaceholder("topvoter", yearMonth.toString());
|
||||
|
||||
+1
-1
@@ -177,7 +177,7 @@ public class VoteURL extends GUIHandler {
|
||||
}
|
||||
|
||||
if (plugin.getGui().isChestVoteURLBackButton()) {
|
||||
dialog.button("&eBack", "&7Return to previous menu", payload -> player.performCommand("vote gui"));
|
||||
dialog.button("&eBack", "&7Return to previous menu", payload -> plugin.getCommandLoader().performVoteCommand(player, "gui"));
|
||||
}
|
||||
|
||||
dialog.open();
|
||||
|
||||
@@ -24,9 +24,6 @@ import com.bencodez.votingplugin.topvoter.TopVoter;
|
||||
import lombok.Getter;
|
||||
|
||||
public class Config extends YMLFile {
|
||||
/** Opt-in persistent vanilla voting NPCs; missing configuration preserves legacy behavior. */
|
||||
public boolean isVotingNPCsEnabled() { return getData().getBoolean("VotingNPCs.Enabled", false); }
|
||||
|
||||
|
||||
/** Explicit permission for executable deployment over private-network plaintext HTTP. */
|
||||
public boolean getControlAllowInsecureHttpPluginDeployment() {
|
||||
@@ -525,6 +522,15 @@ public class Config extends YMLFile {
|
||||
@Getter
|
||||
private boolean limitMonthlyVotes = false;
|
||||
|
||||
/** Restart-only player command registration. Missing keys preserve /vote. */
|
||||
@ConfigDataBoolean(path = "VoteCommandEnabled", defaultValue = true)
|
||||
@Getter
|
||||
private boolean voteCommandEnabled = true;
|
||||
|
||||
@ConfigDataString(path = "VoteCommandName", defaultValue = "vote")
|
||||
@Getter
|
||||
private String voteCommandName = "vote";
|
||||
|
||||
@ConfigDataBoolean(path = "LoadCommandAliases")
|
||||
@Getter
|
||||
private boolean loadCommandAliases = true;
|
||||
@@ -803,6 +809,10 @@ public class Config extends YMLFile {
|
||||
@Getter
|
||||
private boolean topVoterAwardsTies = true;
|
||||
|
||||
@ConfigDataBoolean(path = "HideTopVoterIfNotPlayedBefore")
|
||||
@Getter
|
||||
private boolean hideTopVoterIfNotPlayedBefore = false;
|
||||
|
||||
@ConfigDataBoolean(path = "TopVoterIgnorePermission")
|
||||
@Getter
|
||||
private boolean topVoterIgnorePermission = false;
|
||||
|
||||
@@ -26,6 +26,10 @@ import lombok.Getter;
|
||||
*/
|
||||
public class GUI extends YMLFile {
|
||||
|
||||
@ConfigDataString(path = "CHEST.VoteStreaks.Name")
|
||||
@Getter
|
||||
private String chestVoteStreaksName = "&6Vote streaks";
|
||||
|
||||
@ConfigDataString(path = "BOOK.VoteURLBookGUI.AlreadyVotedColor")
|
||||
@Getter
|
||||
private String bookVoteURLBookGUIAlreadyVotedColor = "RED";
|
||||
|
||||
@@ -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);
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package com.bencodez.votingplugin.core.maintenance;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Cumulative active-runtime clock for the owner-facing auto-create setup reminder.
|
||||
* This class performs no persistence or notification. One owned worker must
|
||||
* serialize the entire propose, persist, commit, then notify sequence, including
|
||||
* reload and shutdown checkpoints. Method synchronization alone does not serialize
|
||||
* the external persistence step. Only notify after a successful commit.
|
||||
*
|
||||
* <p>Reconstruct this state from its durable snapshot and a fresh monotonic clock
|
||||
* reading on startup; never persist the process-local clock anchor. Samples must
|
||||
* be less than {@link Long#MAX_VALUE} nanoseconds apart (the normal nanoTime elapsed
|
||||
* time contract). Negative clock readings and signed clock wrap are supported.
|
||||
*/
|
||||
public final class AutoCreateVoteSitesReminderState {
|
||||
|
||||
public static final long NEVER_REMINDED = -1;
|
||||
/** The first reminder is emitted on the first active checkpoint. */
|
||||
public static final long DEFAULT_THRESHOLD_MILLIS = 0;
|
||||
/** Subsequent reminders are bounded to one every 48 active hours. */
|
||||
public static final long DEFAULT_REPEAT_MILLIS = TimeUnit.HOURS.toMillis(48);
|
||||
public static final long MAX_INTERVAL_MILLIS = Long.MAX_VALUE / 1_000_000;
|
||||
private static final long NANOS_PER_MILLI = 1_000_000;
|
||||
|
||||
/** Immutable durable data; a reminder marker must refer to accrued runtime. */
|
||||
public record Snapshot(long totalRuntimeMillis, long lastReminderRuntimeMillis) {
|
||||
public Snapshot {
|
||||
if (totalRuntimeMillis < 0 || lastReminderRuntimeMillis < NEVER_REMINDED
|
||||
|| lastReminderRuntimeMillis > totalRuntimeMillis) {
|
||||
throw new IllegalArgumentException("Invalid reminder runtime snapshot");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A tentative checkpoint, valid only until this state commits any candidate.
|
||||
* Persist {@link #snapshot()} before committing; discard on a failed write and
|
||||
* propose again. Creating or discarding candidates changes no committed state.
|
||||
*/
|
||||
public static final class Candidate {
|
||||
private final Object epoch;
|
||||
private final Snapshot snapshot;
|
||||
private final long checkpointNanos;
|
||||
private final boolean reminderDue;
|
||||
|
||||
private Candidate(Object epoch, Snapshot snapshot, long checkpointNanos, boolean reminderDue) {
|
||||
this.epoch = epoch;
|
||||
this.snapshot = snapshot;
|
||||
this.checkpointNanos = checkpointNanos;
|
||||
this.reminderDue = reminderDue;
|
||||
}
|
||||
|
||||
public Snapshot snapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
public boolean reminderDue() {
|
||||
return reminderDue;
|
||||
}
|
||||
}
|
||||
|
||||
private Snapshot snapshot;
|
||||
private long checkpointNanos;
|
||||
private Object epoch = new Object();
|
||||
|
||||
public AutoCreateVoteSitesReminderState(Snapshot snapshot, long nowNanos) {
|
||||
this.snapshot = Objects.requireNonNull(snapshot, "snapshot");
|
||||
this.checkpointNanos = nowNanos;
|
||||
}
|
||||
|
||||
public synchronized Snapshot snapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accrue runtime regardless of either flag. Both flags are required to propose a
|
||||
* reminder, at the inclusive threshold or repeat boundary. Settings are sampled
|
||||
* for each proposal; changing them does not reset accumulated runtime or markers.
|
||||
* Backward samples accrue nothing and leave the clock anchor unchanged.
|
||||
*/
|
||||
public synchronized Candidate propose(long nowNanos, boolean reminderEnabled, boolean autoCreateEnabled,
|
||||
long thresholdMillis, long repeatMillis) {
|
||||
validateInterval(thresholdMillis);
|
||||
validateInterval(repeatMillis);
|
||||
if (repeatMillis == 0) {
|
||||
throw new IllegalArgumentException("Reminder repeat interval must be positive");
|
||||
}
|
||||
long elapsedNanos = nowNanos - checkpointNanos;
|
||||
long elapsedMillis = elapsedNanos > 0 ? elapsedNanos / NANOS_PER_MILLI : 0;
|
||||
long total = saturatingAdd(snapshot.totalRuntimeMillis(), elapsedMillis);
|
||||
long lastReminder = snapshot.lastReminderRuntimeMillis();
|
||||
boolean due = reminderEnabled && autoCreateEnabled && total >= thresholdMillis
|
||||
&& (lastReminder == NEVER_REMINDED || total - lastReminder >= repeatMillis);
|
||||
Snapshot next = new Snapshot(total, due ? total : lastReminder);
|
||||
// Consume only whole milliseconds so frequent checkpoints do not lose time.
|
||||
long nextCheckpoint = checkpointNanos + elapsedMillis * NANOS_PER_MILLI;
|
||||
return new Candidate(epoch, next, nextCheckpoint, due);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a checkpoint only after its durable write succeeded. A successful
|
||||
* commit invalidates every other candidate from that epoch, including this one.
|
||||
* Foreign, stale, or duplicate candidates are rejected without changing state.
|
||||
*/
|
||||
public synchronized void commit(Candidate candidate) {
|
||||
Objects.requireNonNull(candidate, "candidate");
|
||||
if (candidate.epoch != epoch) {
|
||||
throw new IllegalArgumentException("Candidate is not current for this reminder state");
|
||||
}
|
||||
snapshot = candidate.snapshot;
|
||||
checkpointNanos = candidate.checkpointNanos;
|
||||
epoch = new Object();
|
||||
}
|
||||
|
||||
private static void validateInterval(long millis) {
|
||||
if (millis < 0 || millis > MAX_INTERVAL_MILLIS) {
|
||||
throw new IllegalArgumentException("Reminder interval is outside the supported range");
|
||||
}
|
||||
}
|
||||
|
||||
private static long saturatingAdd(long total, long elapsed) {
|
||||
return elapsed > Long.MAX_VALUE - total ? Long.MAX_VALUE : total + elapsed;
|
||||
}
|
||||
}
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
package com.bencodez.votingplugin.data;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.bukkit.configuration.file.FileConfiguration;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
import com.bencodez.advancedcore.api.time.TimeType;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.util.ServerDataPublicationFence;
|
||||
|
||||
/**
|
||||
* Routes SDK publications through this generation's ownership lease. The SDK's
|
||||
* original file/configuration remains authoritative; no YAML snapshot is copied.
|
||||
*/
|
||||
public final class GenerationFencedServerDataFile extends com.bencodez.advancedcore.data.ServerData {
|
||||
private final com.bencodez.advancedcore.data.ServerData delegate;
|
||||
private final ServerDataPublicationFence fence;
|
||||
private final VotingPluginMain ownerPlugin;
|
||||
|
||||
public GenerationFencedServerDataFile(VotingPluginMain plugin,
|
||||
com.bencodez.advancedcore.data.ServerData delegate, ServerDataPublicationFence fence) {
|
||||
super(plugin);
|
||||
this.ownerPlugin = java.util.Objects.requireNonNull(plugin);
|
||||
this.delegate = java.util.Objects.requireNonNull(delegate);
|
||||
this.fence = java.util.Objects.requireNonNull(fence);
|
||||
}
|
||||
|
||||
public boolean wraps(com.bencodez.advancedcore.data.ServerData file) { return delegate == file; }
|
||||
|
||||
public boolean belongsTo(ServerDataPublicationFence owner) { return fence == owner; }
|
||||
|
||||
/** Checkpoint callers use the same monitor as native final publication. */
|
||||
@Override public Object publicationMonitor() {
|
||||
return java.util.Objects.requireNonNull(delegate.publicationMonitor(), "Server-data publication monitor");
|
||||
}
|
||||
|
||||
private void withOwner(Runnable operation) {
|
||||
try (var lease = fence.acquire()) {
|
||||
synchronized (publicationMonitor()) {
|
||||
try { operation.run(); }
|
||||
catch (RuntimeException | Error failure) {
|
||||
try { refreshVotePartyDisplay(); }
|
||||
catch (RuntimeException | Error observationFailure) {
|
||||
if (observationFailure != failure) failure.addSuppressed(observationFailure);
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
refreshVotePartyDisplay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T withOwnedValue(Supplier<T> operation) {
|
||||
try (var lease = fence.acquire()) {
|
||||
synchronized (publicationMonitor()) { return operation.get(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Override public FileConfiguration getData() { return delegate.getData(); }
|
||||
@Override public File getdFile() { return delegate.getdFile(); }
|
||||
@Override public JavaPlugin getPlugin() { return delegate.getPlugin(); }
|
||||
@Override public boolean isJustCreated() { return delegate.isJustCreated(); }
|
||||
@Override public boolean isFailedToRead() { return delegate.isFailedToRead(); }
|
||||
@Override public boolean isIgnoreCase() { return delegate.isIgnoreCase(); }
|
||||
@Override public void setIgnoreCase(boolean value) {
|
||||
withOwner(() -> delegate.setIgnoreCase(value));
|
||||
}
|
||||
@Override public void createSection(String path) {
|
||||
mutateOrdinaryData(data -> data.createSection(path));
|
||||
}
|
||||
@Override public void setData(FileConfiguration data) {
|
||||
withOwner(() -> delegate.setData(data));
|
||||
}
|
||||
@Override public void setValue(String path, Object value) {
|
||||
mutateOrdinaryData(data -> data.set(path, value));
|
||||
}
|
||||
@Override public void onFileCreation() {
|
||||
withOwner(delegate::onFileCreation);
|
||||
}
|
||||
|
||||
@Override public void setup() {
|
||||
withOwner(delegate::setup);
|
||||
}
|
||||
@Override public void loadValues() {
|
||||
withOwner(delegate::loadValues);
|
||||
}
|
||||
@Override public void reloadData() {
|
||||
withOwner(delegate::reloadData);
|
||||
}
|
||||
private void refreshVotePartyDisplay() {
|
||||
var wrapper = ownerPlugin.getServerData();
|
||||
if (wrapper != null) {
|
||||
synchronized (publicationMonitor()) {
|
||||
if (delegate.getData() != null) wrapper.refreshVotePartyDisplay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void saveData() {
|
||||
withOwner(() -> {
|
||||
refreshVotePartyDisplay();
|
||||
delegate.saveData();
|
||||
});
|
||||
}
|
||||
@Override public void saveDataChecked() {
|
||||
withOwner(() -> {
|
||||
refreshVotePartyDisplay();
|
||||
delegate.saveDataChecked();
|
||||
});
|
||||
}
|
||||
@Override protected void saveTimeChangeTransitionData() { saveDataChecked(); }
|
||||
|
||||
@Override public void mutateOrdinaryData(Consumer<FileConfiguration> mutation) {
|
||||
// Delegate admission, live mutation and saving to the actual SDK owner.
|
||||
// The projection is published inside that owner's callback before I/O.
|
||||
withOwner(() -> delegate.mutateOrdinaryData(data -> applyOwnedMutation(mutation, data)));
|
||||
}
|
||||
|
||||
private void refreshIfLiveMutation(FileConfiguration data) {
|
||||
var wrapper = ownerPlugin.getServerData();
|
||||
if (wrapper == null) return;
|
||||
synchronized (publicationMonitor()) {
|
||||
// Native final publication mutates a staged candidate first. Legacy
|
||||
// adapters may instead mutate the live owner before their save.
|
||||
if (data == delegate.getData()) wrapper.refreshVotePartyDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
private void applyOwnedMutation(Consumer<FileConfiguration> mutation, FileConfiguration data) {
|
||||
try { mutation.accept(data); }
|
||||
catch (RuntimeException | Error failure) {
|
||||
try { refreshIfLiveMutation(data); }
|
||||
catch (RuntimeException | Error observationFailure) {
|
||||
if (observationFailure != failure) failure.addSuppressed(observationFailure);
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
refreshIfLiveMutation(data);
|
||||
}
|
||||
|
||||
private void finishFinalPublication(ServerDataPublicationFence.Lease lease,
|
||||
CompletableFuture<Void> result, Throwable failure) {
|
||||
Throwable outcome = failure;
|
||||
try {
|
||||
// Partial startup may have no display wrapper. In that case, do not
|
||||
// introduce a YAML-observation dependency into final-stage drain.
|
||||
refreshVotePartyDisplay();
|
||||
} catch (Throwable observationFailure) {
|
||||
if (outcome == null) outcome = observationFailure;
|
||||
else if (outcome != observationFailure) outcome.addSuppressed(observationFailure);
|
||||
}
|
||||
// Refresh observes only the actual live owner; it never acknowledges a
|
||||
// failed/cancelled publication or releases its retained generation.
|
||||
if (outcome != null) { result.completeExceptionally(outcome); return; }
|
||||
try { lease.close(); result.complete(null); }
|
||||
catch (Throwable releaseFailure) { result.completeExceptionally(releaseFailure); }
|
||||
}
|
||||
|
||||
/** Retain this handle only for retries of the same accepted mutation. */
|
||||
FinalPublication prepareFinalPublication(Consumer<FileConfiguration> mutation) {
|
||||
return new FinalPublication(mutation);
|
||||
}
|
||||
|
||||
final class FinalPublication {
|
||||
private final Consumer<FileConfiguration> mutation;
|
||||
private ServerDataPublicationFence.Lease lease;
|
||||
private CompletableFuture<Void> attempt;
|
||||
private boolean publicationConfirmed;
|
||||
|
||||
private FinalPublication(Consumer<FileConfiguration> mutation) { this.mutation = mutation; }
|
||||
|
||||
CompletionStage<Void> publish() {
|
||||
CompletableFuture<Void> result;
|
||||
ServerDataPublicationFence.Lease owner;
|
||||
boolean refreshOnly;
|
||||
synchronized (this) {
|
||||
if (attempt != null && !attempt.isCompletedExceptionally())
|
||||
return attempt.minimalCompletionStage();
|
||||
if (lease == null) {
|
||||
try { lease = fence.acquire(); }
|
||||
catch (Throwable failure) { return CompletableFuture.failedFuture(failure); }
|
||||
}
|
||||
owner = lease;
|
||||
result = new CompletableFuture<>();
|
||||
attempt = result;
|
||||
refreshOnly = publicationConfirmed;
|
||||
}
|
||||
// No operation lock is held across the actual owner's YAML or I/O.
|
||||
if (refreshOnly) finish(owner, result, null);
|
||||
else {
|
||||
try {
|
||||
java.util.Objects.requireNonNull(mutation, "mutation");
|
||||
java.util.Objects.requireNonNull(delegate.mutateFinalData(data -> applyOwnedMutation(mutation, data)))
|
||||
.whenComplete((ignored, failure) -> finish(owner, result, failure));
|
||||
} catch (Throwable failure) { finish(owner, result, failure); }
|
||||
}
|
||||
return result.minimalCompletionStage();
|
||||
}
|
||||
|
||||
private void finish(ServerDataPublicationFence.Lease owner,
|
||||
CompletableFuture<Void> result, Throwable failure) {
|
||||
if (failure == null) {
|
||||
synchronized (this) { publicationConfirmed = true; }
|
||||
}
|
||||
// A retry after a display-observation failure must not repeat a
|
||||
// confirmed mutation. Failed publication instead retains this one
|
||||
// lease until this same operation has a confirmed successful retry.
|
||||
finishFinalPublication(owner, result, failure);
|
||||
}
|
||||
}
|
||||
|
||||
@Override public CompletionStage<Void> mutateFinalData(Consumer<FileConfiguration> mutation) {
|
||||
// Ordinary one-shot callers retain failed ownership conservatively.
|
||||
// A caller with an explicit retry contract retains the operation handle.
|
||||
return prepareFinalPublication(mutation).publish();
|
||||
}
|
||||
|
||||
@Override public void setData(String path, Object value) { mutateOrdinaryData(data -> data.set(path, value)); }
|
||||
@Override public void setIgnoreTime(boolean value) { withOwner(() -> delegate.setIgnoreTime(value)); }
|
||||
@Override public void setLastUpdated() { withOwner(delegate::setLastUpdated); }
|
||||
@Override public void setPluginVersion(Plugin plugin) { withOwner(() -> delegate.setPluginVersion(plugin)); }
|
||||
@Override public void setPrevDay(int value) { withOwner(() -> delegate.setPrevDay(value)); }
|
||||
@Override public void setPrevMonth(String value) { withOwner(() -> delegate.setPrevMonth(value)); }
|
||||
@Override public void setPrevWeekDay(int value) { withOwner(() -> delegate.setPrevWeekDay(value)); }
|
||||
|
||||
@Override public TimeChangeTransitionState beginTimeChangeTransition(TimeType type, String period, String marker) {
|
||||
return withOwnedValue(() -> delegate.beginTimeChangeTransition(type, period, marker));
|
||||
}
|
||||
@Override public TimeChangeTransitionState getPendingTimeChangeTransition(TimeType type) {
|
||||
return withOwnedValue(() -> delegate.getPendingTimeChangeTransition(type));
|
||||
}
|
||||
@Override public void completeTimeChangeTransition(TimeChangeTransitionState transition) {
|
||||
withOwner(() -> delegate.completeTimeChangeTransition(transition));
|
||||
}
|
||||
@Override public void failTimeChangeTransition(TimeChangeTransitionState transition) {
|
||||
withOwner(() -> delegate.failTimeChangeTransition(transition));
|
||||
}
|
||||
@Override public boolean isPendingTimeChangeTransition(TimeChangeTransitionState transition) {
|
||||
return withOwnedValue(() -> delegate.isPendingTimeChangeTransition(transition));
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,182 @@ import com.bencodez.votingplugin.signs.SignHandler;
|
||||
import com.bencodez.votingplugin.timequeue.VoteTimeQueue;
|
||||
import com.bencodez.votingplugin.topvoter.TopVoter;
|
||||
|
||||
public class ServerData {
|
||||
public class ServerData {
|
||||
|
||||
/** Immutable live display values, separate from durable publication acknowledgement. */
|
||||
public record VotePartyDisplay(int total, int extraRequired) {
|
||||
public int required(int configured) { return extraRequired > 0 ? configured + extraRequired : configured; }
|
||||
public int needed(int configured) { return required(configured) - total; }
|
||||
}
|
||||
|
||||
private volatile VotePartyDisplay votePartyDisplay = new VotePartyDisplay(0, 0);
|
||||
|
||||
/** Snapshot-only: never acquires a YAML owner or waits for filesystem publication. */
|
||||
public VotePartyDisplay getVotePartyDisplay() { return votePartyDisplay; }
|
||||
|
||||
/** Called during startup before placeholders are registered. */
|
||||
public void refreshVotePartyDisplay() { mutateData(this::captureVotePartyDisplay); }
|
||||
|
||||
private void captureVotePartyDisplay() {
|
||||
var data = getFile().getData().getConfigurationSection("VotingPlugin");
|
||||
votePartyDisplay = data == null ? new VotePartyDisplay(0, 0)
|
||||
: new VotePartyDisplay(data.getInt("VoteParty.Total"), data.getInt("VotePartyExtraRequired"));
|
||||
}
|
||||
|
||||
private VotingPluginMain plugin = VotingPluginMain.plugin;
|
||||
private volatile com.bencodez.advancedcore.data.ServerData generationFile;
|
||||
private final Object generationBinding = new Object();
|
||||
private volatile com.bencodez.votingplugin.util.ServerDataPublicationFence generationFence;
|
||||
|
||||
private com.bencodez.advancedcore.data.ServerData getFile() {
|
||||
var current = generationFile;
|
||||
if (current != null) return current;
|
||||
synchronized (generationBinding) {
|
||||
if (generationFile == null) {
|
||||
var owner = getPublicationFence();
|
||||
// Reject an old lazy wrapper before exposing successor mutable state.
|
||||
try (var lease = owner == null ? null : owner.acquire()) {
|
||||
var file = plugin.getServerDataFile();
|
||||
if (file instanceof GenerationFencedServerDataFile guarded && !guarded.belongsTo(owner))
|
||||
throw new IllegalStateException("Server-data file belongs to another generation");
|
||||
generationFile = file;
|
||||
}
|
||||
}
|
||||
return generationFile;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private record SignCoordinates(String world, int x, int y, int z) {
|
||||
static SignCoordinates capture(Location location) {
|
||||
return new SignCoordinates(location.getWorld().getName(), (int) location.getX(),
|
||||
(int) location.getY(), (int) location.getZ());
|
||||
}
|
||||
void store(ConfigurationSection data, String path) {
|
||||
data.set(path + ".World", world);
|
||||
data.set(path + ".X", x);
|
||||
data.set(path + ".Y", y);
|
||||
data.set(path + ".Z", z);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Detached shutdown payload: no platform object is accessed by its publisher. */
|
||||
public static final class SignSnapshot {
|
||||
private final String count;
|
||||
private final SignCoordinates location;
|
||||
private final SignCoordinates skull;
|
||||
private final String data;
|
||||
private final int position;
|
||||
|
||||
private SignSnapshot(String count, Location location, Location skull, String data, int position) {
|
||||
this.count = count;
|
||||
this.location = SignCoordinates.capture(location);
|
||||
this.skull = skull == null ? null : SignCoordinates.capture(skull);
|
||||
this.data = data;
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
private void store(ConfigurationSection target) {
|
||||
location.store(target, "Signs." + count);
|
||||
target.set("Signs." + count + ".Data", data);
|
||||
target.set("Signs." + count + ".Position", position);
|
||||
if (skull != null) skull.store(target, "Signs." + count + ".Skull");
|
||||
}
|
||||
}
|
||||
|
||||
public static SignSnapshot captureSignSnapshot(String count, Location location, Location skull,
|
||||
String data, int position) {
|
||||
return new SignSnapshot(count, location, skull, data, position);
|
||||
}
|
||||
|
||||
private record ShutdownSignPublication(List<SignSnapshot> snapshot,
|
||||
java.util.function.Supplier<java.util.concurrent.CompletionStage<Void>> publisher) { }
|
||||
private final Object shutdownSignBinding = new Object();
|
||||
private ShutdownSignPublication shutdownSignPublication;
|
||||
|
||||
/** Worker-only: one accepted sign batch, with the same publication owner across retries. */
|
||||
public java.util.concurrent.CompletionStage<Void> publishShutdownSigns(List<SignSnapshot> signs) {
|
||||
var snapshot = List.copyOf(signs);
|
||||
ShutdownSignPublication publication;
|
||||
synchronized (shutdownSignBinding) {
|
||||
if (shutdownSignPublication == null) {
|
||||
var file = getFile();
|
||||
java.util.function.Consumer<org.bukkit.configuration.file.FileConfiguration> mutation = candidate -> {
|
||||
var target = candidate.getConfigurationSection("VotingPlugin");
|
||||
if (target == null) target = candidate.createSection("VotingPlugin");
|
||||
for (var sign : snapshot) sign.store(target);
|
||||
};
|
||||
java.util.function.Supplier<java.util.concurrent.CompletionStage<Void>> publisher;
|
||||
if (file instanceof GenerationFencedServerDataFile guarded) {
|
||||
var retained = guarded.prepareFinalPublication(mutation);
|
||||
publisher = retained::publish;
|
||||
} else publisher = () -> file.mutateFinalData(mutation);
|
||||
shutdownSignPublication = new ShutdownSignPublication(snapshot, publisher);
|
||||
} else if (!shutdownSignPublication.snapshot().equals(snapshot)) {
|
||||
return java.util.concurrent.CompletableFuture.failedFuture(
|
||||
new IllegalStateException("Shutdown sign publication already owns a different batch"));
|
||||
}
|
||||
publication = shutdownSignPublication;
|
||||
}
|
||||
return publication.publisher().get();
|
||||
}
|
||||
|
||||
/** Only YAML work belongs here; platform callbacks run outside the file owner. */
|
||||
private void mutateData(Runnable mutation) {
|
||||
var fence = getPublicationFence();
|
||||
try (var owner = fence == null ? null : fence.acquire()) {
|
||||
synchronized (publicationMonitor()) {
|
||||
mutation.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Admit the complete ordinary mutation before changing live YAML or its display. */
|
||||
private void mutateSavedData(Runnable mutation) {
|
||||
mutateData(() -> getFile().mutateOrdinaryData(ignored -> {
|
||||
try { mutation.run(); }
|
||||
catch (RuntimeException | Error failure) {
|
||||
try { captureVotePartyDisplay(); }
|
||||
catch (RuntimeException | Error observationFailure) {
|
||||
if (observationFailure != failure) failure.addSuppressed(observationFailure);
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
captureVotePartyDisplay();
|
||||
}));
|
||||
}
|
||||
|
||||
/** Store a scalar and save under the same YAML ownership as final SDK snapshots. */
|
||||
public void setInt(String path, int value) {
|
||||
mutateSavedData(() -> {
|
||||
getData().set(path, value);
|
||||
});
|
||||
}
|
||||
|
||||
/** Store a detached string list, preventing later caller mutation of live YAML. */
|
||||
public void setStringList(String path, List<String> values) {
|
||||
var snapshot = values == null ? null : new ArrayList<>(values);
|
||||
mutateSavedData(() -> {
|
||||
getData().set(path, snapshot);
|
||||
});
|
||||
}
|
||||
|
||||
/** All mutations serialize with the actual SDK YAML owner. */
|
||||
private Object publicationMonitor() {
|
||||
var file = getFile();
|
||||
return java.util.Objects.requireNonNull(file.publicationMonitor(), "Server-data publication monitor");
|
||||
}
|
||||
|
||||
private com.bencodez.votingplugin.util.ServerDataPublicationFence getPublicationFence() {
|
||||
var current = generationFence;
|
||||
if (current != null) return current;
|
||||
synchronized (generationBinding) {
|
||||
if (generationFence == null) generationFence = plugin.getServerDataPublicationFence();
|
||||
return generationFence;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new ServerData.
|
||||
@@ -29,6 +202,7 @@ public class ServerData {
|
||||
*/
|
||||
public ServerData(VotingPluginMain plugin) {
|
||||
this.plugin = plugin;
|
||||
this.generationFence = plugin.getServerDataPublicationFence();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,13 +210,15 @@ public class ServerData {
|
||||
*
|
||||
* @param placeholder the placeholder to add
|
||||
*/
|
||||
public void addAutoCachedPlaceholder(String placeholder) {
|
||||
List<String> p = getAutoCachedPlaceholder();
|
||||
|
||||
if (!ArrayUtils.containsIgnoreCase(p, placeholder)) {
|
||||
p.add(placeholder);
|
||||
setAutoCachedPlaceholder(p);
|
||||
}
|
||||
public void addAutoCachedPlaceholder(String placeholder) {
|
||||
mutateData(() -> {
|
||||
List<String> p = getAutoCachedPlaceholder();
|
||||
|
||||
if (!ArrayUtils.containsIgnoreCase(p, placeholder)) {
|
||||
p.add(placeholder);
|
||||
setAutoCachedPlaceholder(p);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,12 +226,14 @@ public class ServerData {
|
||||
*
|
||||
* @param site the service site to add
|
||||
*/
|
||||
public synchronized void addServiceSite(String site) {
|
||||
ArrayList<String> l = getServiceSites();
|
||||
if (!getServiceSites().contains(site)) {
|
||||
l.add(site);
|
||||
}
|
||||
setServiceSites(ArrayUtils.removeDuplicates(l));
|
||||
public void addServiceSite(String site) {
|
||||
mutateData(() -> {
|
||||
ArrayList<String> l = getServiceSites();
|
||||
if (!getServiceSites().contains(site)) {
|
||||
l.add(site);
|
||||
}
|
||||
setServiceSites(ArrayUtils.removeDuplicates(l));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,19 +243,20 @@ public class ServerData {
|
||||
* @param data the data
|
||||
* @param position the position
|
||||
*/
|
||||
public void addSign(Location location, String data, int position) {
|
||||
|
||||
int count = nextSignNumber();
|
||||
|
||||
getData().set("Signs." + count + ".World", location.getWorld().getName());
|
||||
getData().set("Signs." + count + ".X", location.getBlockX());
|
||||
getData().set("Signs." + count + ".Y", location.getBlockY());
|
||||
getData().set("Signs." + count + ".Z", location.getBlockZ());
|
||||
getData().set("Signs." + count + ".Data", data);
|
||||
getData().set("Signs." + count + ".Position", position);
|
||||
saveData();
|
||||
plugin.getSigns().getSigns().add(new SignHandler(plugin, "" + count, getSignLocation("" + count),
|
||||
getSignSkullLocation("" + count), getSignData("" + count), getSignPosition("" + count)));
|
||||
public void addSign(Location location, String data, int position) {
|
||||
var coordinates = new SignCoordinates(location.getWorld().getName(), location.getBlockX(),
|
||||
location.getBlockY(), location.getBlockZ());
|
||||
int[] selected = new int[1];
|
||||
mutateSavedData(() -> {
|
||||
int count = nextSignNumber();
|
||||
selected[0] = count;
|
||||
coordinates.store(getData(), "Signs." + count);
|
||||
getData().set("Signs." + count + ".Data", data);
|
||||
getData().set("Signs." + count + ".Position", position);
|
||||
});
|
||||
String count = Integer.toString(selected[0]);
|
||||
plugin.getSigns().getSigns().add(new SignHandler(plugin, count, getSignLocation(count),
|
||||
getSignSkullLocation(count), getSignData(count), getSignPosition(count)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,11 +265,13 @@ public class ServerData {
|
||||
* @param num the vote number
|
||||
* @param vote the vote time queue entry
|
||||
*/
|
||||
public void addTimeVoted(int num, VoteTimeQueue vote) {
|
||||
getData().set("TimedVoteCache." + num + ".Name", vote.getName());
|
||||
getData().set("TimedVoteCache." + num + ".Service", vote.getService());
|
||||
getData().set("TimedVoteCache." + num + ".Time", vote.getTime());
|
||||
saveData();
|
||||
public void addTimeVoted(int num, VoteTimeQueue vote) {
|
||||
mutateData(() -> {
|
||||
getData().set("TimedVoteCache." + num + ".Name", vote.getName());
|
||||
getData().set("TimedVoteCache." + num + ".Service", vote.getService());
|
||||
getData().set("TimedVoteCache." + num + ".Time", vote.getTime());
|
||||
saveTimedVoteCache();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,16 +279,20 @@ public class ServerData {
|
||||
*
|
||||
* @param ident the vote shop identifier
|
||||
*/
|
||||
public void addVoteShopPurchase(String ident) {
|
||||
setVoteShopPurchases(ident, (getVoteShopPurchases(ident) + 1));
|
||||
public void addVoteShopPurchase(String ident) {
|
||||
mutateData(() -> {
|
||||
setVoteShopPurchases(ident, (getVoteShopPurchases(ident) + 1));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the timed vote cache.
|
||||
*/
|
||||
public void clearTimedVoteCache() {
|
||||
getData().set("TimedVoteCache", null);
|
||||
saveData();
|
||||
public void clearTimedVoteCache() {
|
||||
mutateData(() -> {
|
||||
getData().set("TimedVoteCache", null);
|
||||
saveTimedVoteCache();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,13 +327,15 @@ public class ServerData {
|
||||
*
|
||||
* @return the data
|
||||
*/
|
||||
public ConfigurationSection getData() {
|
||||
ConfigurationSection data = plugin.getServerDataFile().getData().getConfigurationSection("VotingPlugin");
|
||||
if (data == null) {
|
||||
plugin.getServerDataFile().getData().createSection("VotingPlugin");
|
||||
data = plugin.getServerDataFile().getData().getConfigurationSection("VotingPlugin");
|
||||
}
|
||||
return data;
|
||||
public ConfigurationSection getData() {
|
||||
var fence = getPublicationFence();
|
||||
try (var owner = fence == null ? null : fence.acquire()) {
|
||||
synchronized (publicationMonitor()) {
|
||||
ConfigurationSection data = getFile().getData().getConfigurationSection("VotingPlugin");
|
||||
if (data == null) data = getFile().getData().createSection("VotingPlugin");
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,9 +352,10 @@ public class ServerData {
|
||||
*
|
||||
* @return the service sites
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public ArrayList<String> getServiceSites() {
|
||||
return (ArrayList<String>) getData().getList("GottenServiceSites", new ArrayList<>());
|
||||
public ArrayList<String> getServiceSites() {
|
||||
synchronized (publicationMonitor()) {
|
||||
return new ArrayList<>(getData().getStringList("GottenServiceSites"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,9 +364,11 @@ public class ServerData {
|
||||
*
|
||||
* @return persisted service-site observations
|
||||
*/
|
||||
public synchronized List<String> getServiceSitesReadOnly() {
|
||||
ConfigurationSection root = plugin.getServerDataFile().getData().getConfigurationSection("VotingPlugin");
|
||||
return root == null ? List.of() : List.copyOf(root.getStringList("GottenServiceSites"));
|
||||
public List<String> getServiceSitesReadOnly() {
|
||||
synchronized (publicationMonitor()) {
|
||||
ConfigurationSection root = getFile().getData().getConfigurationSection("VotingPlugin");
|
||||
return root == null ? List.of() : List.copyOf(root.getStringList("GottenServiceSites"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -339,8 +529,11 @@ public class ServerData {
|
||||
/**
|
||||
* Reload data.
|
||||
*/
|
||||
public void reloadData() {
|
||||
plugin.getServerDataFile().reloadData();
|
||||
public void reloadData() {
|
||||
mutateData(() -> {
|
||||
getFile().reloadData();
|
||||
captureVotePartyDisplay();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -359,36 +552,164 @@ public class ServerData {
|
||||
* @param top the top voter type
|
||||
* @param messageId the message ID
|
||||
*/
|
||||
public void setTopVoterMessageId(TopVoter top, long messageId) {
|
||||
getData().set("DiscordSRV.TopVoterMessageId." + top.toString(), messageId);
|
||||
saveData();
|
||||
public void setTopVoterMessageId(TopVoter top, long messageId) {
|
||||
mutateSavedData(() -> {
|
||||
getData().set("DiscordSRV.TopVoterMessageId." + top.toString(), messageId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Uses the existing writer and confirms this ID in the saved file. Legacy void saves may
|
||||
* swallow I/O failure; queued/custom writers may also remain unconfirmed at this point.
|
||||
* False is an uncertain outcome, never permission to delete a possibly recorded message.
|
||||
* Call only on the existing worker, under this facade's normal save ordering.
|
||||
*/
|
||||
public synchronized boolean setTopVoterMessageIdConfirmed(TopVoter top, long messageId) {
|
||||
setTopVoterMessageId(top, messageId);
|
||||
try {
|
||||
var saved = new org.bukkit.configuration.file.YamlConfiguration();
|
||||
saved.load(plugin.getServerDataFile().getdFile());
|
||||
return saved.getLong("VotingPlugin.DiscordSRV.TopVoterMessageId." + top.toString(), 0) == messageId;
|
||||
} catch (java.io.IOException | org.bukkit.configuration.InvalidConfigurationException | RuntimeException failure) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the sign.
|
||||
*
|
||||
* @param sign the sign
|
||||
*/
|
||||
public void removeSign(String sign) {
|
||||
getData().set("Signs." + sign + ".World", null);
|
||||
getData().set("Signs." + sign + ".X", null);
|
||||
getData().set("Signs." + sign + ".Y", null);
|
||||
getData().set("Signs." + sign + ".Z", null);
|
||||
getData().set("Signs." + sign + ".Data", null);
|
||||
getData().set("Signs." + sign + ".Position", null);
|
||||
getData().set("Signs." + sign, null);
|
||||
getData().set("Signs." + sign + ".Skull.World", null);
|
||||
getData().set("Signs." + sign + ".Skull.X", null);
|
||||
getData().set("Signs." + sign + ".Skull.Y", null);
|
||||
getData().set("Signs." + sign + ".Skull.Z", null);
|
||||
saveData();
|
||||
public void removeSign(String sign) {
|
||||
mutateSavedData(() -> {
|
||||
getData().set("Signs." + sign + ".World", null);
|
||||
getData().set("Signs." + sign + ".X", null);
|
||||
getData().set("Signs." + sign + ".Y", null);
|
||||
getData().set("Signs." + sign + ".Z", null);
|
||||
getData().set("Signs." + sign + ".Data", null);
|
||||
getData().set("Signs." + sign + ".Position", null);
|
||||
getData().set("Signs." + sign, null);
|
||||
getData().set("Signs." + sign + ".Skull.World", null);
|
||||
getData().set("Signs." + sign + ".Skull.X", null);
|
||||
getData().set("Signs." + sign + ".Skull.Y", null);
|
||||
getData().set("Signs." + sign + ".Skull.Z", null);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save data.
|
||||
*/
|
||||
public synchronized void saveData() {
|
||||
plugin.getServerDataFile().saveData();
|
||||
/** Checkpoint the pending suffix without retiring its live queue entries. */
|
||||
public void replaceTimedVoteCache(java.util.Collection<VoteTimeQueue> pending) {
|
||||
mutateData(() -> {
|
||||
Object previous = getData().get("TimedVoteCache");
|
||||
try {
|
||||
setTimedVoteCache(pending);
|
||||
saveTimedVoteCache();
|
||||
} catch (RuntimeException | Error failure) {
|
||||
getData().set("TimedVoteCache", previous);
|
||||
throw failure;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Publish the recovery suffix and conservative in-flight record in one file save. */
|
||||
public void prepareTimedVoteDispatch(UUID id, VoteTimeQueue vote,
|
||||
java.util.Collection<VoteTimeQueue> pending) {
|
||||
mutateData(() -> {
|
||||
String path = "UncertainTimedVotes." + id;
|
||||
Object previousCache = getData().get("TimedVoteCache");
|
||||
Object previousRecord = getData().get(path);
|
||||
try {
|
||||
setTimedVoteCache(pending);
|
||||
getData().set(path, null);
|
||||
getData().createSection(path, timedVoteFields(vote));
|
||||
saveTimedVoteCache();
|
||||
} catch (RuntimeException | Error failure) {
|
||||
getData().set("TimedVoteCache", previousCache);
|
||||
getData().set(path, previousRecord);
|
||||
throw failure;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Restore an undispatched occurrence and its pending suffix for ordinary restart recovery. */
|
||||
public void abortTimedVoteDispatch(UUID id, java.util.Collection<VoteTimeQueue> pending) {
|
||||
mutateData(() -> {
|
||||
String path = "UncertainTimedVotes." + id;
|
||||
Object previousCache = getData().get("TimedVoteCache");
|
||||
Object previousRecord = getData().get(path);
|
||||
try {
|
||||
setTimedVoteCache(pending);
|
||||
getData().set(path, null);
|
||||
saveTimedVoteCache();
|
||||
} catch (RuntimeException | Error failure) {
|
||||
getData().set("TimedVoteCache", previousCache);
|
||||
getData().set(path, previousRecord);
|
||||
throw failure;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear only a confirmed dispatch record; failed writes retain its live representation. */
|
||||
public void completeTimedVoteDispatch(UUID id) {
|
||||
mutateData(() -> {
|
||||
String path = "UncertainTimedVotes." + id;
|
||||
Object previous = getData().get(path);
|
||||
try {
|
||||
getData().set(path, null);
|
||||
saveTimedVoteCache();
|
||||
} catch (RuntimeException | Error failure) {
|
||||
getData().set(path, previous);
|
||||
throw failure;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void setTimedVoteCache(java.util.Collection<VoteTimeQueue> pending) {
|
||||
java.util.Map<String, Object> rows = new java.util.LinkedHashMap<>();
|
||||
int index = 0;
|
||||
for (VoteTimeQueue vote : pending) rows.put(Integer.toString(index++), timedVoteFields(vote));
|
||||
getData().set("TimedVoteCache", null);
|
||||
getData().createSection("TimedVoteCache", rows);
|
||||
}
|
||||
|
||||
private static java.util.Map<String, Object> timedVoteFields(VoteTimeQueue vote) {
|
||||
java.util.Map<String, Object> fields = new java.util.LinkedHashMap<>();
|
||||
fields.put("Name", vote.getName());
|
||||
fields.put("Service", vote.getService());
|
||||
fields.put("Time", vote.getTime());
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** Retain an ambiguous event delivery without making it eligible for automatic replay. */
|
||||
public void addUncertainTimedVote(java.util.UUID id, VoteTimeQueue vote) {
|
||||
mutateData(() -> {
|
||||
String path = "UncertainTimedVotes." + id;
|
||||
getData().set(path + ".Name", vote.getName());
|
||||
getData().set(path + ".Service", vote.getService());
|
||||
getData().set(path + ".Time", vote.getTime());
|
||||
saveTimedVoteCache();
|
||||
});
|
||||
}
|
||||
|
||||
/** Timed votes must observe write failures before their in-memory queue is retired. */
|
||||
private void saveTimedVoteCache() {
|
||||
var fence = getPublicationFence();
|
||||
try (var owner = fence == null ? null : fence.acquire()) {
|
||||
var file = getFile();
|
||||
if (file instanceof GenerationFencedServerDataFile guarded) guarded.saveDataChecked();
|
||||
else file.getData().save(file.getdFile());
|
||||
} catch (java.io.IOException failure) {
|
||||
throw new java.io.UncheckedIOException("Failed to save timed vote cache", failure);
|
||||
}
|
||||
}
|
||||
|
||||
/** Saves server data through the existing general-purpose file helper. */
|
||||
public void saveData() {
|
||||
mutateData(() -> {
|
||||
// Publish the complete live display projection before entering disk I/O.
|
||||
// A failed save still does not acknowledge durable storage.
|
||||
captureVotePartyDisplay();
|
||||
getFile().saveData();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -396,13 +717,10 @@ public class ServerData {
|
||||
*
|
||||
* @param disabledReminders the list of UUIDs with disabled reminders
|
||||
*/
|
||||
public void saveDisabledReminders(ArrayList<UUID> disabledReminders) {
|
||||
ArrayList<String> uuids = new ArrayList<>();
|
||||
for (UUID uuid : disabledReminders) {
|
||||
uuids.add(uuid.toString());
|
||||
}
|
||||
getData().set("DisabledReminders", uuids);
|
||||
saveData();
|
||||
public void saveDisabledReminders(ArrayList<UUID> disabledReminders) {
|
||||
ArrayList<String> uuids = new ArrayList<>();
|
||||
for (UUID uuid : disabledReminders) uuids.add(uuid.toString());
|
||||
setStringList("DisabledReminders", uuids);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -410,9 +728,8 @@ public class ServerData {
|
||||
*
|
||||
* @param placeholders the list of placeholders
|
||||
*/
|
||||
public void setAutoCachedPlaceholder(List<String> placeholders) {
|
||||
getData().set("AutoCachePlaceholders", placeholders);
|
||||
saveData();
|
||||
public void setAutoCachedPlaceholder(List<String> placeholders) {
|
||||
setStringList("AutoCachePlaceholders", placeholders);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -420,9 +737,8 @@ public class ServerData {
|
||||
*
|
||||
* @param current the current vote count
|
||||
*/
|
||||
public void setBungeeVotePartyCurrent(int current) {
|
||||
getData().set("BungeeVotePartyCurrent", current);
|
||||
saveData();
|
||||
public void setBungeeVotePartyCurrent(int current) {
|
||||
setInt("BungeeVotePartyCurrent", current);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -430,9 +746,8 @@ public class ServerData {
|
||||
*
|
||||
* @param required the required vote count
|
||||
*/
|
||||
public void setBungeeVotePartyRequired(int required) {
|
||||
getData().set("BungeeVotePartyRequired", required);
|
||||
saveData();
|
||||
public void setBungeeVotePartyRequired(int required) {
|
||||
setInt("BungeeVotePartyRequired", required);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -440,9 +755,8 @@ public class ServerData {
|
||||
*
|
||||
* @param list the service sites list
|
||||
*/
|
||||
public void setServiceSites(ArrayList<String> list) {
|
||||
getData().set("GottenServiceSites", list);
|
||||
saveData();
|
||||
public void setServiceSites(ArrayList<String> list) {
|
||||
setStringList("GottenServiceSites", list);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -450,9 +764,10 @@ public class ServerData {
|
||||
*
|
||||
* @param value the converted status
|
||||
*/
|
||||
public void setShopConverted(boolean value) {
|
||||
getData().set("VoteShopConverted", value);
|
||||
saveData();
|
||||
public void setShopConverted(boolean value) {
|
||||
mutateSavedData(() -> {
|
||||
getData().set("VoteShopConverted", value);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -464,21 +779,15 @@ public class ServerData {
|
||||
* @param data the sign data
|
||||
* @param position the sign position
|
||||
*/
|
||||
public void setSign(String count, Location location, Location skullLocation, String data, int position) {
|
||||
|
||||
getData().set("Signs." + count + ".World", location.getWorld().getName());
|
||||
getData().set("Signs." + count + ".X", (int) location.getX());
|
||||
getData().set("Signs." + count + ".Y", (int) location.getY());
|
||||
getData().set("Signs." + count + ".Z", (int) location.getZ());
|
||||
getData().set("Signs." + count + ".Data", data);
|
||||
getData().set("Signs." + count + ".Position", position);
|
||||
if (skullLocation != null) {
|
||||
getData().set("Signs." + count + ".Skull.World", skullLocation.getWorld().getName());
|
||||
getData().set("Signs." + count + ".Skull.X", (int) skullLocation.getX());
|
||||
getData().set("Signs." + count + ".Skull.Y", (int) skullLocation.getY());
|
||||
getData().set("Signs." + count + ".Skull.Z", (int) skullLocation.getZ());
|
||||
}
|
||||
saveData();
|
||||
public void setSign(String count, Location location, Location skullLocation, String data, int position) {
|
||||
var coordinates = SignCoordinates.capture(location);
|
||||
var skull = skullLocation == null ? null : SignCoordinates.capture(skullLocation);
|
||||
mutateSavedData(() -> {
|
||||
coordinates.store(getData(), "Signs." + count);
|
||||
getData().set("Signs." + count + ".Data", data);
|
||||
getData().set("Signs." + count + ".Position", position);
|
||||
if (skull != null) skull.store(getData(), "Signs." + count + ".Skull");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -487,21 +796,20 @@ public class ServerData {
|
||||
* @param count the sign count
|
||||
* @param skullLocation the skull location
|
||||
*/
|
||||
public void setSkullLocation(String count, Location skullLocation) {
|
||||
if (skullLocation != null) {
|
||||
getData().set("Signs." + count + ".Skull.World", skullLocation.getWorld().getName());
|
||||
getData().set("Signs." + count + ".Skull.X", (int) skullLocation.getX());
|
||||
getData().set("Signs." + count + ".Skull.Y", (int) skullLocation.getY());
|
||||
getData().set("Signs." + count + ".Skull.Z", (int) skullLocation.getZ());
|
||||
}
|
||||
public void setSkullLocation(String count, Location skullLocation) {
|
||||
if (skullLocation == null) return;
|
||||
var coordinates = SignCoordinates.capture(skullLocation);
|
||||
mutateData(() -> coordinates.store(getData(), "Signs." + count + ".Skull"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the version.
|
||||
*/
|
||||
public void setVersion() {
|
||||
getData().set("Version", Bukkit.getVersion());
|
||||
saveData();
|
||||
public void setVersion() {
|
||||
String version = Bukkit.getVersion();
|
||||
mutateSavedData(() -> {
|
||||
getData().set("Version", version);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -509,9 +817,10 @@ public class ServerData {
|
||||
*
|
||||
* @param value the extra required votes
|
||||
*/
|
||||
public void setVotePartyExtraRequired(int value) {
|
||||
getData().set("VotePartyExtraRequired", value);
|
||||
saveData();
|
||||
public void setVotePartyExtraRequired(int value) {
|
||||
mutateSavedData(() -> {
|
||||
getData().set("VotePartyExtraRequired", value);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -520,42 +829,45 @@ public class ServerData {
|
||||
* @param ident the vote shop identifier
|
||||
* @param amount the purchase amount
|
||||
*/
|
||||
public void setVoteShopPurchases(String ident, int amount) {
|
||||
getData().set("VoteShopPurchases." + ident, amount);
|
||||
saveData();
|
||||
public void setVoteShopPurchases(String ident, int amount) {
|
||||
mutateSavedData(() -> {
|
||||
getData().set("VoteShopPurchases." + ident, amount);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the last vote party timestamp to today.
|
||||
*/
|
||||
public void updateLastVoteParty() {
|
||||
getData().set("LastVoteParty", plugin.getTimeChecker().getTime().getDayOfYear());
|
||||
saveData();
|
||||
public void updateLastVoteParty() {
|
||||
int day = plugin.getTimeChecker().getTime().getDayOfYear();
|
||||
setInt("LastVoteParty", day);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the last vote party week timestamp to this week.
|
||||
*/
|
||||
public void updateLastVotePartyWeek() {
|
||||
getData().set("LastVotePartyWeek",
|
||||
plugin.getTimeChecker().getTime().get(WeekFields.of(Locale.getDefault()).weekOfYear()));
|
||||
saveData();
|
||||
public void updateLastVotePartyWeek() {
|
||||
int week = plugin.getTimeChecker().getTime().get(WeekFields.of(Locale.getDefault()).weekOfYear());
|
||||
setInt("LastVotePartyWeek", week);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates placeholders to lowercase format.
|
||||
*/
|
||||
public void updatePlaceholders() {
|
||||
boolean data = getData().getBoolean("AutoCacheUpdated", false);
|
||||
if (!data) {
|
||||
List<String> placeholders = getAutoCachedPlaceholder();
|
||||
for (int i = 0; i < placeholders.size(); i++) {
|
||||
placeholders.set(i, placeholders.get(i).toLowerCase());
|
||||
}
|
||||
setAutoCachedPlaceholder(placeholders);
|
||||
getData().set("AutoCacheUpdated", true);
|
||||
saveData();
|
||||
}
|
||||
public void updatePlaceholders() {
|
||||
mutateData(() -> {
|
||||
boolean data = getData().getBoolean("AutoCacheUpdated", false);
|
||||
if (!data) {
|
||||
mutateSavedData(() -> {
|
||||
List<String> placeholders = getAutoCachedPlaceholder();
|
||||
for (int i = 0; i < placeholders.size(); i++) {
|
||||
placeholders.set(i, placeholders.get(i).toLowerCase());
|
||||
}
|
||||
getData().set("AutoCachePlaceholders", placeholders);
|
||||
getData().set("AutoCacheUpdated", true);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,10 +3,8 @@ package com.bencodez.votingplugin.discord;
|
||||
import java.awt.Color;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map.Entry;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
@@ -27,6 +25,7 @@ import github.scarsz.discordsrv.api.events.DiscordReadyEvent;
|
||||
import github.scarsz.discordsrv.dependencies.jda.api.EmbedBuilder;
|
||||
import github.scarsz.discordsrv.dependencies.jda.api.JDA;
|
||||
import github.scarsz.discordsrv.dependencies.jda.api.entities.TextChannel;
|
||||
import github.scarsz.discordsrv.dependencies.jda.api.entities.Message;
|
||||
import github.scarsz.discordsrv.dependencies.jda.api.requests.ErrorResponse;
|
||||
import github.scarsz.discordsrv.dependencies.jda.api.exceptions.ErrorResponseException;
|
||||
import github.scarsz.discordsrv.util.DiscordUtil;
|
||||
@@ -40,8 +39,45 @@ public class DiscordHandler {
|
||||
private final VotingPluginMain plugin;
|
||||
@Getter
|
||||
private final HashMap<TopVoter, Long> topVoterMessageIds = new HashMap<TopVoter, Long>();
|
||||
private final Set<TopVoter> recoveringTopVoters = new HashSet<>();
|
||||
/** Identity, rather than generation alone, owns every request and its callbacks. */
|
||||
private static final class Publication {
|
||||
final long generation;
|
||||
final boolean persistent;
|
||||
boolean sending, editing, recovering, again, completing, retired;
|
||||
long requestedGeneration;
|
||||
final AtomicBoolean sendCompleted = new AtomicBoolean(false);
|
||||
Publication(long generation, boolean persistent, boolean sending) {
|
||||
this.generation = generation; this.requestedGeneration = generation;
|
||||
this.persistent = persistent; this.sending = sending; this.editing = !sending;
|
||||
}
|
||||
}
|
||||
private static final int PENDING = 0, WRITING = 1, RETAINED = 2, DISCARDED = 3, DETACHED = 4;
|
||||
private static final int MAX_OWNED_RECEIPTS = 128;
|
||||
/** External success is owned here before any cancellable worker handoff. */
|
||||
private static final class SentReceipt {
|
||||
final TopVoter top;
|
||||
final Message message;
|
||||
final long id;
|
||||
final long generation;
|
||||
final AtomicBoolean cleanupRequested = new AtomicBoolean(false);
|
||||
int state = PENDING;
|
||||
boolean confirmed;
|
||||
SentReceipt(TopVoter top, Message message, long generation) {
|
||||
this.top = top; this.message = message; this.id = message.getIdLong(); this.generation = generation;
|
||||
}
|
||||
}
|
||||
private final java.util.EnumMap<TopVoter, Publication> publications = new java.util.EnumMap<>(TopVoter.class);
|
||||
/** Guarded by publications; includes uncertain saves until confirmed replacement or explicit detachment. */
|
||||
private final java.util.Set<SentReceipt> ownedReceipts = new java.util.HashSet<>();
|
||||
private final AtomicBoolean closed = new AtomicBoolean(false);
|
||||
private final AtomicBoolean discordReady = new AtomicBoolean(false);
|
||||
private static final class DisplayUpdate {
|
||||
private final long generation;
|
||||
private long requestedGeneration;
|
||||
private boolean again;
|
||||
private DisplayUpdate(long generation) { this.generation = generation; this.requestedGeneration = generation; }
|
||||
}
|
||||
private final java.util.EnumMap<TopVoter, DisplayUpdate> preparing = new java.util.EnumMap<>(TopVoter.class);
|
||||
|
||||
/**
|
||||
* Constructs a new Discord handler.
|
||||
@@ -149,14 +185,347 @@ public class DiscordHandler {
|
||||
* @param top the top voter
|
||||
*/
|
||||
public void updateTopVoterMessageId(TopVoter top) {
|
||||
plugin.extraDebug("Updating Discord Top Voter message for: " + top);
|
||||
prepareTopVoterMessage(top, plugin.getTopVoterDisplayGeneration());
|
||||
}
|
||||
|
||||
long channelId = plugin.getConfigFile().getDiscordSRVTopVoterChannel(top);
|
||||
LinkedHashMap<TopVoterPlayer, Integer> topVoters = plugin.getTopVoter(top);
|
||||
private void prepareTopVoterMessage(TopVoter top, long generation) {
|
||||
DisplayUpdate update;
|
||||
synchronized (preparing) {
|
||||
if (!currentGeneration(generation)) return;
|
||||
DisplayUpdate pending = preparing.get(top);
|
||||
if (pending != null) {
|
||||
pending.again = true;
|
||||
pending.requestedGeneration = generation;
|
||||
return;
|
||||
}
|
||||
update = new DisplayUpdate(generation);
|
||||
preparing.put(top, update);
|
||||
}
|
||||
try {
|
||||
plugin.getTopVoterDisplayAsync(top).whenComplete((snapshot, failure) -> {
|
||||
try {
|
||||
plugin.getTimer().execute(() -> {
|
||||
if (!releasePreparation(top, update)) return;
|
||||
if (!currentGeneration(update.generation)) {
|
||||
retryRequestedPreparation(top, update);
|
||||
return;
|
||||
}
|
||||
// Joins and TTL/scope changes can revoke omissions while publication is queued.
|
||||
// Recheck current evidence without opening another asynchronous deadline.
|
||||
if (update.again || (failure == null && snapshot != null)) {
|
||||
try { publishTopVoterMessage(top, plugin.getTopVoterDisplay(top), update.generation); }
|
||||
catch (RuntimeException unavailable) { warnLeaderboardFailure("preparing a current display for " + top); }
|
||||
} else plugin.getLogger().warning("Unable to prepare Discord Top Voters: " + top);
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
if (releasePreparation(top, update)) {
|
||||
try { if (currentGeneration(update.generation)) warnLeaderboardFailure("preparation worker admission for " + top); }
|
||||
finally { retryRequestedPreparation(top, update); }
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException unavailable) {
|
||||
if (releasePreparation(top, update)) {
|
||||
try { if (currentGeneration(update.generation)) warnLeaderboardFailure("preparing evidence for " + top); }
|
||||
finally { retryRequestedPreparation(top, update); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String title = plugin.getConfigFile().getDiscordSRVTopVoterTitle(top);
|
||||
private boolean releasePreparation(TopVoter top, DisplayUpdate update) {
|
||||
synchronized (preparing) { return preparing.remove(top, update); }
|
||||
}
|
||||
|
||||
private void retryRequestedPreparation(TopVoter top, DisplayUpdate update) {
|
||||
// The retired request's evidence never changes ownership to a replacement filter.
|
||||
if (update.again && update.requestedGeneration != update.generation)
|
||||
prepareTopVoterMessage(top, update.requestedGeneration);
|
||||
}
|
||||
|
||||
void publishTopVoterMessage(TopVoter top, LinkedHashMap<TopVoterPlayer, Integer> prepared) {
|
||||
publishTopVoterMessage(top, prepared, plugin.getTopVoterDisplayGeneration());
|
||||
}
|
||||
|
||||
void publishTopVoterMessage(TopVoter top, LinkedHashMap<TopVoterPlayer, Integer> prepared, long generation) {
|
||||
if (!currentGeneration(generation)) return;
|
||||
boolean newMessage = plugin.getConfigFile().isDiscordSRVTopVoterNewMessageOnUpdate(top);
|
||||
TextChannel channel;
|
||||
try { channel = leaderboardChannel(plugin.getConfigFile().getDiscordSRVTopVoterChannel(top)); }
|
||||
catch (RuntimeException unavailable) { warnLeaderboardFailure("channel lookup for " + top); return; }
|
||||
if (channel == null) { warnLeaderboardFailure("missing channel for " + top); return; }
|
||||
Publication publication;
|
||||
long existingId;
|
||||
synchronized (publications) {
|
||||
if (!currentGeneration(generation)) return;
|
||||
Publication pending = publications.get(top);
|
||||
if (pending != null && (pending.editing || (!pending.retired && pending.generation == generation
|
||||
&& (pending.sending || pending.completing)))) {
|
||||
pending.again = true;
|
||||
pending.requestedGeneration = generation;
|
||||
return;
|
||||
}
|
||||
existingId = topVoterMessageIds.getOrDefault(top, 0L);
|
||||
publication = new Publication(generation, !newMessage, existingId <= 0 || newMessage);
|
||||
publications.put(top, publication);
|
||||
}
|
||||
try {
|
||||
var embed = buildTopVoterEmbed(plugin.getConfigFile().getDiscordSRVTopVoterTitle(top), plugin.getTopVoterDisplay(top)).build();
|
||||
if (!current(top, publication)) { finish(top, publication); return; }
|
||||
if (publication.sending) send(top, channel, publication, embed);
|
||||
else channel.editMessageEmbedsById(existingId, embed).queue(
|
||||
message -> finishEdit(top, publication, existingId),
|
||||
error -> handleMessageUpdateFailure(top, channel, publication, error));
|
||||
} catch (RuntimeException unavailable) { failPublication(top, publication, "preparing or submitting a message"); }
|
||||
}
|
||||
|
||||
private boolean current(TopVoter top, Publication publication) {
|
||||
if (!currentGeneration(publication.generation)) return false;
|
||||
synchronized (publications) { return publications.get(top) == publication && !publication.retired; }
|
||||
}
|
||||
|
||||
/** Never hold the publication lock while waiting for the ServerData save monitor. */
|
||||
private boolean persist(TopVoter top, Publication publication, long id) {
|
||||
var data = plugin.getServerData();
|
||||
synchronized (data) {
|
||||
if (!current(top, publication)) return false;
|
||||
data.setTopVoterMessageId(top, id);
|
||||
}
|
||||
return current(top, publication);
|
||||
}
|
||||
|
||||
private void finish(TopVoter top, Publication publication) {
|
||||
boolean again, prepareAgain;
|
||||
long generation;
|
||||
synchronized (publications) {
|
||||
if (publications.get(top) != publication) return;
|
||||
publications.remove(top);
|
||||
again = publication.again;
|
||||
generation = publication.requestedGeneration;
|
||||
prepareAgain = publication.retired || generation != publication.generation;
|
||||
}
|
||||
// A queued request is independent of whether the previous network operation succeeded.
|
||||
if (again) {
|
||||
if (prepareAgain) prepareTopVoterMessage(top, generation);
|
||||
else dispatchLeaderboard(generation,
|
||||
() -> publishTopVoterMessage(top, plugin.getTopVoterDisplay(top), generation), () -> { });
|
||||
}
|
||||
}
|
||||
|
||||
private void warnLeaderboardFailure(String stage) {
|
||||
// Provider exceptions can contain request URLs or message content. Report the phase only.
|
||||
plugin.getLogger().warning("Unable to publish Discord Top Voters (" + stage
|
||||
+ "). Check the Discord channel, permissions and configuration; a later update can retry.");
|
||||
}
|
||||
|
||||
private void failPublication(TopVoter top, Publication publication, String stage) {
|
||||
try { if (current(top, publication)) warnLeaderboardFailure(stage + " for " + top); }
|
||||
finally { finish(top, publication); }
|
||||
}
|
||||
|
||||
private void send(TopVoter top, TextChannel channel, Publication publication,
|
||||
github.scarsz.discordsrv.dependencies.jda.api.entities.MessageEmbed embed) {
|
||||
if (!current(top, publication)) { finish(top, publication); return; }
|
||||
channel.sendMessageEmbeds(embed).queue(message -> {
|
||||
if (!publication.sendCompleted.compareAndSet(false, true)) return;
|
||||
SentReceipt receipt = new SentReceipt(top, message, publication.generation);
|
||||
boolean admitted;
|
||||
synchronized (publications) {
|
||||
admitted = !closed.get() && (!publication.persistent || ownedReceipts.size() < MAX_OWNED_RECEIPTS);
|
||||
if (admitted && publication.persistent) ownedReceipts.add(receipt);
|
||||
publication.completing = true;
|
||||
}
|
||||
if (!admitted) {
|
||||
discardCreatedMessage(publication, receipt); finish(top, publication); return;
|
||||
}
|
||||
dispatchLeaderboard(publication.generation, () -> {
|
||||
try {
|
||||
if (!current(top, publication)) return;
|
||||
if (publication.persistent) { retainCreatedMessage(top, publication, receipt); return; }
|
||||
if (!current(top, publication)) return;
|
||||
synchronized (publications) {
|
||||
if (publications.get(top) == publication) topVoterMessageIds.put(top, receipt.id);
|
||||
}
|
||||
} catch (RuntimeException failure) { warnLeaderboardFailure("storing a message ID for " + top); }
|
||||
finally { discardCreatedMessage(publication, receipt); finish(top, publication); }
|
||||
}, () -> { discardCreatedMessage(publication, receipt); finish(top, publication); });
|
||||
}, failure -> failPublication(top, publication, "sending a message"));
|
||||
}
|
||||
|
||||
/** Retry an uncertain ID save after a successful edit, without creating another external post. */
|
||||
private void finishEdit(TopVoter top, Publication publication, long id) {
|
||||
synchronized (publications) { publication.editing = false; publication.completing = true; }
|
||||
if (!current(top, publication)) { finish(top, publication); return; }
|
||||
SentReceipt retry;
|
||||
synchronized (publications) {
|
||||
retry = ownedReceipts.stream().filter(receipt -> receipt.top == top && receipt.id == id
|
||||
&& receipt.state == RETAINED && !receipt.confirmed).findFirst().orElse(null);
|
||||
}
|
||||
if (retry == null) { finish(top, publication); return; }
|
||||
dispatchLeaderboard(publication.generation, () -> {
|
||||
try { retainCreatedMessage(top, publication, retry); }
|
||||
finally { discardCreatedMessage(publication, retry); finish(top, publication); }
|
||||
}, () -> finish(top, publication));
|
||||
}
|
||||
|
||||
/** Never hold publication ownership while waiting for the existing save monitor or file I/O. */
|
||||
private void retainCreatedMessage(TopVoter top, Publication publication, SentReceipt receipt) {
|
||||
var obsolete = new java.util.ArrayList<SentReceipt>();
|
||||
var data = plugin.getServerData();
|
||||
boolean uncertain = false;
|
||||
synchronized (data) {
|
||||
if (!current(top, publication)) return;
|
||||
synchronized (publications) {
|
||||
if (!current(top, publication) || (receipt.state != PENDING && receipt.state != RETAINED)) return;
|
||||
receipt.state = WRITING;
|
||||
}
|
||||
try {
|
||||
boolean confirmed = data.setTopVoterMessageIdConfirmed(top, receipt.id);
|
||||
long actual = data.getTopVoterMessageId(top);
|
||||
synchronized (publications) {
|
||||
if (receipt.state == DETACHED) return;
|
||||
// A reentrant newer confirmed write already owns the file. Reconcile only this older post.
|
||||
if (actual != receipt.id && ownedReceipts.stream().anyMatch(other -> other != receipt
|
||||
&& other.top == top && other.id == actual && other.state == RETAINED && other.confirmed)) return;
|
||||
receipt.state = RETAINED;
|
||||
receipt.confirmed = confirmed && actual == receipt.id;
|
||||
uncertain = !receipt.confirmed;
|
||||
if (actual == receipt.id && (publications.get(top) == publication
|
||||
|| topVoterMessageIds.getOrDefault(top, 0L) <= 0)) topVoterMessageIds.put(top, receipt.id);
|
||||
if (receipt.confirmed) {
|
||||
var iterator = ownedReceipts.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
SentReceipt previous = iterator.next();
|
||||
if (previous != receipt && previous.top == top && previous.state == RETAINED) {
|
||||
previous.state = DISCARDED; iterator.remove(); obsolete.add(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException failure) {
|
||||
// The legacy writer may have mutated or saved before throwing; do not delete a possibly recorded post.
|
||||
synchronized (publications) {
|
||||
if (receipt.state == DETACHED) return;
|
||||
receipt.state = RETAINED; receipt.confirmed = false;
|
||||
if (publications.get(top) == publication && topVoterMessageIds.getOrDefault(top, 0L) <= 0)
|
||||
topVoterMessageIds.put(top, receipt.id);
|
||||
}
|
||||
uncertain = true;
|
||||
}
|
||||
}
|
||||
obsolete.forEach(this::removeCreatedMessage);
|
||||
if (uncertain) warnMessageStorage();
|
||||
}
|
||||
|
||||
private void discardCreatedMessage(Publication publication, SentReceipt receipt) {
|
||||
if (!publication.persistent) return; // Append mode deliberately keeps its posts.
|
||||
synchronized (publications) {
|
||||
if (receipt.state != PENDING && receipt.state != WRITING) return;
|
||||
receipt.state = DISCARDED; ownedReceipts.remove(receipt);
|
||||
}
|
||||
removeCreatedMessage(receipt);
|
||||
}
|
||||
|
||||
/** Detach the administrator's old posts; clearing an ID never authorizes deleting those posts. */
|
||||
public boolean clearTopVoterMessageId(TopVoter top) {
|
||||
var data = plugin.getServerData();
|
||||
synchronized (data) {
|
||||
synchronized (publications) {
|
||||
publications.remove(top);
|
||||
synchronized (preparing) { preparing.remove(top); }
|
||||
topVoterMessageIds.put(top, 0L);
|
||||
ownedReceipts.removeIf(receipt -> {
|
||||
if (receipt.top != top || (receipt.state != PENDING && receipt.state != RETAINED
|
||||
&& receipt.state != WRITING)) return false;
|
||||
receipt.state = DETACHED; return true;
|
||||
});
|
||||
}
|
||||
try { if (data.setTopVoterMessageIdConfirmed(top, 0L)) return true; }
|
||||
catch (RuntimeException failure) { /* The outgoing diagnostic remains redacted. */ }
|
||||
}
|
||||
warnMessageStorage(); return false;
|
||||
}
|
||||
|
||||
/** Retire invalidated generations before worker cancellation; close invalidates every generation. */
|
||||
public void retireLeaderboardPublications() {
|
||||
var abandoned = new java.util.ArrayList<SentReceipt>();
|
||||
var restart = new java.util.EnumMap<TopVoter, Long>(TopVoter.class);
|
||||
synchronized (publications) {
|
||||
// An admitted edit can still write on Discord after retirement. Keep its
|
||||
// bounded slot until its network callback rather than submitting overlapping edits.
|
||||
publications.entrySet().removeIf(entry -> {
|
||||
Publication publication = entry.getValue();
|
||||
if (currentGeneration(publication.generation) && !publication.retired) return false;
|
||||
boolean again = publication.again && currentGeneration(publication.requestedGeneration);
|
||||
if (!publication.editing) {
|
||||
if (again) restart.put(entry.getKey(), publication.requestedGeneration);
|
||||
return true;
|
||||
}
|
||||
publication.retired = true;
|
||||
publication.again = again;
|
||||
return false;
|
||||
});
|
||||
synchronized (preparing) {
|
||||
preparing.entrySet().removeIf(entry -> {
|
||||
DisplayUpdate update = entry.getValue();
|
||||
if (currentGeneration(update.generation)) return false;
|
||||
if (update.again && currentGeneration(update.requestedGeneration))
|
||||
restart.put(entry.getKey(), update.requestedGeneration);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
var iterator = ownedReceipts.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
SentReceipt receipt = iterator.next();
|
||||
if (receipt.state == PENDING && !currentGeneration(receipt.generation)) {
|
||||
receipt.state = DISCARDED; iterator.remove(); abandoned.add(receipt);
|
||||
}
|
||||
}
|
||||
}
|
||||
abandoned.forEach(this::removeCreatedMessage);
|
||||
// Old worker completions can be cancelled; transfer explicit requests now,
|
||||
// outside both ownership monitors and without waiting for those callbacks.
|
||||
restart.forEach(this::prepareTopVoterMessage);
|
||||
}
|
||||
|
||||
public void close() {
|
||||
synchronized (publications) {
|
||||
if (!closed.compareAndSet(false, true)) return;
|
||||
}
|
||||
retireLeaderboardPublications();
|
||||
boolean uncertain;
|
||||
synchronized (publications) {
|
||||
uncertain = ownedReceipts.stream().anyMatch(receipt -> receipt.state == WRITING || !receipt.confirmed);
|
||||
}
|
||||
if (uncertain) warnMessageStorage();
|
||||
}
|
||||
|
||||
private void warnMessageStorage() {
|
||||
plugin.getLogger().warning("Unable to confirm Discord Top Voters message ID storage. "
|
||||
+ "The post remains tracked in memory; check storage before restarting and reconcile posts manually.");
|
||||
}
|
||||
|
||||
/** JDA owns asynchronous deletion; cleanup does not depend on the retiring plugin worker. */
|
||||
private void removeCreatedMessage(SentReceipt receipt) {
|
||||
if (!receipt.cleanupRequested.compareAndSet(false, true)) return;
|
||||
try {
|
||||
receipt.message.delete().queue(ignored -> { }, failure -> {
|
||||
if (!(failure instanceof ErrorResponseException response)
|
||||
|| response.getErrorResponse() != ErrorResponse.UNKNOWN_MESSAGE) warnMessageCleanup();
|
||||
});
|
||||
} catch (RuntimeException failure) { warnMessageCleanup(); }
|
||||
}
|
||||
|
||||
private void warnMessageCleanup() {
|
||||
plugin.getLogger().warning("Unable to remove an untracked Discord Top Voters message. "
|
||||
+ "Check Discord permissions and remove the unwanted post manually.");
|
||||
}
|
||||
|
||||
TextChannel leaderboardChannel(long channelId) {
|
||||
return DiscordUtil.getJda().getTextChannelById(channelId);
|
||||
}
|
||||
|
||||
/** Render an inspected display snapshot without accessing the Discord runtime. */
|
||||
static EmbedBuilder buildTopVoterEmbed(String title, LinkedHashMap<TopVoterPlayer, Integer> topVoters) {
|
||||
EmbedBuilder eb = new EmbedBuilder().setTitle(title).setColor(Color.CYAN).setTimestamp(Instant.now());
|
||||
|
||||
if (topVoters == null || topVoters.isEmpty()) {
|
||||
@@ -187,34 +556,7 @@ public class DiscordHandler {
|
||||
eb.setDescription(sb.toString()).setFooter("Top 10 • Updated");
|
||||
}
|
||||
|
||||
TextChannel channel = DiscordUtil.getJda().getTextChannelById(channelId);
|
||||
if (channel == null) {
|
||||
plugin.getLogger().warning("Discord channel ID " + channelId + " not found: " + top);
|
||||
return;
|
||||
}
|
||||
|
||||
long existingId;
|
||||
synchronized (recoveringTopVoters) {
|
||||
if (recoveringTopVoters.contains(top)) {
|
||||
plugin.debug("Skipping Discord Top Voter update while recovery is in progress: " + top);
|
||||
return;
|
||||
}
|
||||
existingId = topVoterMessageIds.getOrDefault(top, 0L);
|
||||
}
|
||||
|
||||
if (existingId <= 0 || newMessage) {
|
||||
channel.sendMessageEmbeds(eb.build()).queue(msg -> {
|
||||
long newId = msg.getIdLong();
|
||||
topVoterMessageIds.put(top, newId);
|
||||
if (!newMessage)
|
||||
plugin.getServerData().setTopVoterMessageId(top, newId);
|
||||
plugin.debug("Posted new Top Voters " + top + " (ID: " + newId + ")");
|
||||
}, err -> plugin.getLogger().warning("Error sending Top Voters " + top + ": " + err.getMessage()));
|
||||
} else {
|
||||
channel.editMessageEmbedsById(existingId, eb.build()).queue(
|
||||
m -> plugin.debug("Edited Top Voters " + top + " (ID: " + existingId + ")"),
|
||||
err -> handleMessageUpdateFailure(top, channel, eb, existingId, err));
|
||||
}
|
||||
return eb;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,41 +566,65 @@ public class DiscordHandler {
|
||||
* condition as recoverable so one deleted message cannot permanently stop
|
||||
* leaderboard updates.
|
||||
*/
|
||||
private void handleMessageUpdateFailure(TopVoter top, TextChannel channel, EmbedBuilder eb, long existingId,
|
||||
Throwable error) {
|
||||
if (plugin.getConfigFile().isDiscordSRVTopVoterAutoRecoverMessageOnFailure()
|
||||
&& error instanceof ErrorResponseException
|
||||
&& ((ErrorResponseException) error).getErrorResponse() == ErrorResponse.UNKNOWN_MESSAGE) {
|
||||
synchronized (recoveringTopVoters) {
|
||||
if (topVoterMessageIds.getOrDefault(top, 0L) != existingId || !recoveringTopVoters.add(top)) {
|
||||
plugin.debug("Skipping stale Discord Top Voter recovery because another update handled: " + top);
|
||||
return;
|
||||
private boolean currentGeneration(long generation) {
|
||||
return !closed.get() && plugin.isEnabled() && plugin.getTopVoterDisplayGeneration() == generation;
|
||||
}
|
||||
|
||||
private void dispatchLeaderboard(long generation, Runnable action, Runnable discarded) {
|
||||
if (!currentGeneration(generation)) { discarded.run(); return; }
|
||||
try {
|
||||
plugin.getTimer().execute(() -> {
|
||||
try {
|
||||
if (currentGeneration(generation)) action.run(); else discarded.run();
|
||||
} catch (RuntimeException failure) {
|
||||
try { if (currentGeneration(generation)) warnLeaderboardFailure("asynchronous publication"); }
|
||||
finally { discarded.run(); }
|
||||
}
|
||||
}
|
||||
plugin.getLogger().warning("Discord Top Voters " + top + " message " + existingId
|
||||
+ " no longer exists; clearing the stored ID and posting a replacement.");
|
||||
synchronized (recoveringTopVoters) {
|
||||
topVoterMessageIds.put(top, 0L);
|
||||
}
|
||||
plugin.getServerData().setTopVoterMessageId(top, 0L);
|
||||
channel.sendMessageEmbeds(eb.build()).queue(msg -> {
|
||||
long newId = msg.getIdLong();
|
||||
synchronized (recoveringTopVoters) {
|
||||
topVoterMessageIds.put(top, newId);
|
||||
plugin.getServerData().setTopVoterMessageId(top, newId);
|
||||
recoveringTopVoters.remove(top);
|
||||
}
|
||||
plugin.debug("Recovered Top Voters " + top + " with replacement message (ID: " + newId + ")");
|
||||
}, sendError -> {
|
||||
synchronized (recoveringTopVoters) {
|
||||
recoveringTopVoters.remove(top);
|
||||
}
|
||||
plugin.getLogger().warning("Error recovering Top Voters " + top + ": " + sendError.getMessage());
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
try { if (currentGeneration(generation)) warnLeaderboardFailure("worker admission"); }
|
||||
finally { discarded.run(); }
|
||||
}
|
||||
}
|
||||
|
||||
private void handleMessageUpdateFailure(TopVoter top, TextChannel channel, Publication publication,
|
||||
Throwable error) {
|
||||
synchronized (publications) { publication.editing = false; }
|
||||
if (!current(top, publication)) { finish(top, publication); return; }
|
||||
if (!plugin.getConfigFile().isDiscordSRVTopVoterAutoRecoverMessageOnFailure()
|
||||
|| !(error instanceof ErrorResponseException response)
|
||||
|| response.getErrorResponse() != ErrorResponse.UNKNOWN_MESSAGE) {
|
||||
failPublication(top, publication, "editing a message");
|
||||
return;
|
||||
}
|
||||
|
||||
plugin.getLogger().warning("Error editing Top Voters " + top + ": " + error.getMessage());
|
||||
synchronized (publications) {
|
||||
if (publications.get(top) != publication || publication.recovering) return;
|
||||
publication.recovering = true;
|
||||
publication.sending = true;
|
||||
}
|
||||
dispatchLeaderboard(publication.generation, () -> {
|
||||
try {
|
||||
if (!persist(top, publication, 0L)) { finish(top, publication); return; }
|
||||
synchronized (publications) {
|
||||
if (publications.get(top) != publication) return;
|
||||
topVoterMessageIds.put(top, 0L);
|
||||
}
|
||||
// Persistence can block while a join, TTL, reload or disable revokes the earlier frame.
|
||||
var replacement = buildTopVoterEmbed(plugin.getConfigFile().getDiscordSRVTopVoterTitle(top),
|
||||
plugin.getTopVoterDisplay(top)).build();
|
||||
send(top, channel, publication, replacement);
|
||||
} catch (RuntimeException failure) { failPublication(top, publication, "recovering a deleted message"); }
|
||||
}, () -> {
|
||||
// Rejected admission has no effects; the same failed edit may be retried.
|
||||
boolean again = false;
|
||||
synchronized (publications) {
|
||||
if (publications.get(top) == publication) {
|
||||
publication.recovering = false; publication.sending = false;
|
||||
again = publication.again;
|
||||
}
|
||||
}
|
||||
if (again) finish(top, publication);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,7 +35,7 @@ public class PlayerJoinEvent implements Listener {
|
||||
public void onPlayerJoin(org.bukkit.event.player.PlayerJoinEvent event) {
|
||||
if (event == null || event.getPlayer() == null) return;
|
||||
Player player = event.getPlayer();
|
||||
plugin.getPlaceholderPlayerPresence().playerOnline(placeholderUuid(player), player);
|
||||
plugin.getPlaceholderPlayerPresence().captureOnline(placeholderUuid(player), player);
|
||||
}
|
||||
|
||||
private UUID placeholderUuid(Player player) {
|
||||
@@ -83,7 +83,7 @@ public class PlayerJoinEvent implements Listener {
|
||||
|
||||
Player player = event.getPlayer();
|
||||
if (player != null) {
|
||||
plugin.getPlaceholderPlayerPresence().playerOnline(user.getJavaUUID(), player);
|
||||
if (!plugin.getPlaceholderPlayerPresence().updateStorageUuid(user.getJavaUUID(), player)) return;
|
||||
}
|
||||
|
||||
if (player != null && plugin.isYmlError()) {
|
||||
@@ -160,17 +160,19 @@ public class PlayerJoinEvent implements Listener {
|
||||
plugin.getBackendProxyHandler().playerOffline(player.getName());
|
||||
}
|
||||
|
||||
final UUID serverUuid = player.getUniqueId();
|
||||
final String playerName = player.getName();
|
||||
plugin.getLoginTimer().execute(new Runnable() {
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
VotingPluginMain.plugin.getAdvancedTab().remove(player.getUniqueId());
|
||||
VotingPluginMain.plugin.getAdvancedTab().remove(serverUuid);
|
||||
|
||||
// Use AdvancedCore UUIDLookup to derive the correct UUID (online/offline aware)
|
||||
String uuid = UuidLookup.getInstance().getUUID(player.getName());
|
||||
String uuid = UuidLookup.getInstance().getUUID(playerName);
|
||||
if (isBlank(uuid)) {
|
||||
// Fallback to online UUID if lookup fails
|
||||
uuid = player.getUniqueId().toString();
|
||||
uuid = serverUuid.toString();
|
||||
}
|
||||
|
||||
VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.bencodez.votingplugin.listeners;
|
||||
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
@@ -135,9 +134,8 @@ public class VotiferEvent implements Listener {
|
||||
plugin.debug("VoteSite: " + voteSite);
|
||||
plugin.debug("IP: " + IP);
|
||||
|
||||
try {
|
||||
plugin.getVoteTimer().submit(() -> processVote(voteSite, voteUsername));
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
if (!com.bencodez.votingplugin.util.VoteTaskAdmission.trySubmit(plugin.getVoteTimer(),
|
||||
() -> processVote(voteSite, voteUsername))) {
|
||||
VotifierVoteOverflowQueue overflow = plugin.getVotifierVoteOverflowQueue();
|
||||
if (overflow == null || !overflow.enqueue(voteUsername, voteSite)) {
|
||||
plugin.getLogger().severe("Votifier vote queue is full; vote was not admitted for "
|
||||
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
package com.bencodez.votingplugin.maintenance;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.LongSupplier;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import com.bencodez.votingplugin.core.maintenance.AutoCreateVoteSitesReminderState;
|
||||
import com.bencodez.votingplugin.core.maintenance.AutoCreateVoteSitesReminderState.Candidate;
|
||||
import com.bencodez.votingplugin.core.maintenance.AutoCreateVoteSitesReminderState.Snapshot;
|
||||
import com.bencodez.votingplugin.util.DurableFiles.PublishedException;
|
||||
|
||||
/** Owner-only console reminder; all file work runs on its bounded background worker. */
|
||||
public final class AutoCreateVoteSitesReminderService {
|
||||
|
||||
public record Settings(boolean enabled, boolean autoCreateEnabled, long afterMillis, long repeatMillis) {
|
||||
public Settings {
|
||||
if (afterMillis < 0 || repeatMillis <= 0
|
||||
|| afterMillis > AutoCreateVoteSitesReminderState.MAX_INTERVAL_MILLIS
|
||||
|| repeatMillis > AutoCreateVoteSitesReminderState.MAX_INTERVAL_MILLIS) {
|
||||
throw new IllegalArgumentException("Reminder intervals must be non-negative and bounded");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface StoreFactory {
|
||||
AutoCreateVoteSitesReminderStore create(Path directory) throws IOException;
|
||||
}
|
||||
|
||||
private final Path directory;
|
||||
private final ScheduledExecutorService executor;
|
||||
private final Logger logger;
|
||||
private final LongSupplier clock;
|
||||
private final StoreFactory storeFactory;
|
||||
private final long startedNanos;
|
||||
private final AtomicBoolean started = new AtomicBoolean();
|
||||
private final AtomicBoolean recoveryRequested = new AtomicBoolean();
|
||||
private record CloseRequest(CompletableFuture<Void> result, long stoppedNanos) { }
|
||||
private final AtomicReference<CloseRequest> closing = new AtomicReference<>();
|
||||
private final Object clockGate = new Object();
|
||||
private volatile Settings settings;
|
||||
private volatile ScheduledFuture<?> task;
|
||||
// Accessed only inside the serialized worker checkpoint, never by gameplay callers.
|
||||
private AutoCreateVoteSitesReminderStore store;
|
||||
private AutoCreateVoteSitesReminderState state;
|
||||
private boolean failed;
|
||||
private boolean checkpointRequired;
|
||||
private boolean waitingReported;
|
||||
|
||||
public AutoCreateVoteSitesReminderService(Path directory, ScheduledExecutorService executor,
|
||||
Logger logger, Settings settings) {
|
||||
this(directory, executor, logger, settings, System::nanoTime, AutoCreateVoteSitesReminderStore::new);
|
||||
}
|
||||
|
||||
AutoCreateVoteSitesReminderService(Path directory, ScheduledExecutorService executor,
|
||||
Logger logger, Settings settings, LongSupplier clock, StoreFactory storeFactory) {
|
||||
this.directory = Objects.requireNonNull(directory);
|
||||
this.executor = Objects.requireNonNull(executor);
|
||||
this.logger = Objects.requireNonNull(logger);
|
||||
this.settings = Objects.requireNonNull(settings);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.storeFactory = Objects.requireNonNull(storeFactory);
|
||||
startedNanos = clock.getAsLong();
|
||||
}
|
||||
|
||||
public void start() {
|
||||
if (closing.get() != null || !started.compareAndSet(false, true)) return;
|
||||
try {
|
||||
task = executor.scheduleWithFixedDelay(this::tick, 0, 60, TimeUnit.SECONDS);
|
||||
if (closing.get() != null) task.cancel(false);
|
||||
} catch (RuntimeException rejected) {
|
||||
logger.warning("AutoCreateVoteSites setup reminder could not start; voting is unaffected.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Reload is a snapshot update, never synchronous file I/O or another periodic task. */
|
||||
public void reload(Settings next) {
|
||||
settings = Objects.requireNonNull(next);
|
||||
recoveryRequested.set(true);
|
||||
}
|
||||
|
||||
private void tick() {
|
||||
long nowNanos;
|
||||
synchronized (clockGate) {
|
||||
if (closing.get() != null) return;
|
||||
nowNanos = clock.getAsLong();
|
||||
}
|
||||
try {
|
||||
checkpoint(nowNanos, false);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
logger.warning("AutoCreateVoteSites setup reminder paused after a checkpoint failure; "
|
||||
+ "repair its checkpoint if necessary and reload or restart. Voting is unaffected.");
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void checkpoint(long nowNanos, boolean finalCheckpoint) throws IOException {
|
||||
CloseRequest stopped = closing.get();
|
||||
if (stopped != null) {
|
||||
if (!finalCheckpoint) return;
|
||||
nowNanos = stopped.stoppedNanos();
|
||||
}
|
||||
if (failed && !recoveryRequested.getAndSet(false)) {
|
||||
if (finalCheckpoint) throw new IOException("Reminder checkpoint remains unavailable");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (store == null) store = storeFactory.create(directory);
|
||||
if (!store.tryAcquireLease()) {
|
||||
if (finalCheckpoint) throw new IOException("Final reminder checkpoint has no storage ownership");
|
||||
if (!waitingReported) {
|
||||
logger.warning("AutoCreateVoteSites setup reminder is waiting for a retiring reminder worker; "
|
||||
+ "voting is unaffected.");
|
||||
waitingReported = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
waitingReported = false;
|
||||
if (state == null || failed) {
|
||||
Snapshot saved;
|
||||
try {
|
||||
saved = store.load();
|
||||
} catch (IOException | RuntimeException unverified) {
|
||||
checkpointRequired = true;
|
||||
throw unverified;
|
||||
}
|
||||
if (saved == null && checkpointRequired) {
|
||||
throw new IOException("Reminder checkpoint was removed while running");
|
||||
}
|
||||
if (saved != null) checkpointRequired = true;
|
||||
state = new AutoCreateVoteSitesReminderState(saved == null ? new Snapshot(0, -1) : saved,
|
||||
state == null && !failed ? startedNanos : nowNanos);
|
||||
failed = false;
|
||||
} else if (checkpointRequired && store.load() == null) {
|
||||
throw new IOException("Reminder checkpoint was removed while running");
|
||||
}
|
||||
recoveryRequested.set(false);
|
||||
Settings current = settings;
|
||||
Candidate candidate = state.propose(nowNanos, !finalCheckpoint && current.enabled(),
|
||||
current.autoCreateEnabled(), current.afterMillis(), current.repeatMillis());
|
||||
try {
|
||||
store.save(candidate.snapshot());
|
||||
} catch (PublishedException uncertain) {
|
||||
checkpointRequired = true;
|
||||
throw uncertain;
|
||||
}
|
||||
checkpointRequired = true;
|
||||
state.commit(candidate);
|
||||
// Do not publish a stale warning if shutdown or configuration changed during I/O.
|
||||
if (candidate.reminderDue() && closing.get() == null && current.equals(settings)) {
|
||||
logger.warning("After voting-site setup, set AutoCreateVoteSites: false in Config.yml.");
|
||||
}
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
failed = true;
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancel promptly; enqueue the final checkpoint without blocking the server thread. */
|
||||
public CompletableFuture<Void> closeAsync() {
|
||||
CloseRequest request;
|
||||
synchronized (clockGate) {
|
||||
CloseRequest existing = closing.get();
|
||||
if (existing != null) return existing.result();
|
||||
request = new CloseRequest(new CompletableFuture<>(), clock.getAsLong());
|
||||
closing.set(request);
|
||||
}
|
||||
CompletableFuture<Void> result = request.result();
|
||||
long stoppedNanos = request.stoppedNanos();
|
||||
ScheduledFuture<?> current = task;
|
||||
if (current != null) current.cancel(false);
|
||||
if (!started.get()) {
|
||||
result.complete(null);
|
||||
return result;
|
||||
}
|
||||
try {
|
||||
executor.execute(() -> {
|
||||
Exception failure = null;
|
||||
try {
|
||||
checkpoint(stoppedNanos, true);
|
||||
} catch (IOException | RuntimeException checkpointFailure) {
|
||||
failure = checkpointFailure;
|
||||
} finally {
|
||||
if (store != null) {
|
||||
try {
|
||||
store.releaseLease();
|
||||
} catch (IOException | RuntimeException cleanupFailure) {
|
||||
if (failure == null) failure = cleanupFailure;
|
||||
else failure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failure == null) result.complete(null);
|
||||
else {
|
||||
logger.warning("AutoCreateVoteSites reminder checkpoint or cleanup did not complete; "
|
||||
+ "inspect its storage before reloading.");
|
||||
result.completeExceptionally(failure);
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
result.completeExceptionally(rejected);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
package com.bencodez.votingplugin.maintenance;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.FileLock;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.OpenOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.nio.file.attribute.PosixFileAttributeView;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import com.bencodez.votingplugin.core.maintenance.AutoCreateVoteSitesReminderState.Snapshot;
|
||||
import com.bencodez.votingplugin.util.DurableFiles;
|
||||
|
||||
/**
|
||||
* A fixed, checksummed active-runtime checkpoint in the plugin's existing data
|
||||
* directory. One reminder worker owns all load/save operations. This store never
|
||||
* creates the directory, repairs corrupt records, or follows a record symlink.
|
||||
*/
|
||||
public final class AutoCreateVoteSitesReminderStore {
|
||||
|
||||
private static final String FILE_NAME = ".auto-create-sites-reminder.dat";
|
||||
private static final byte[] MAGIC = "VPACR001".getBytes(StandardCharsets.US_ASCII);
|
||||
private static final int HEADER_BYTES = 24;
|
||||
private static final int RECORD_BYTES = 56;
|
||||
private final Path directory;
|
||||
private final Path target;
|
||||
private final Publisher publisher;
|
||||
private FileChannel leaseChannel;
|
||||
private FileLock lease;
|
||||
|
||||
@FunctionalInterface
|
||||
interface Publisher {
|
||||
void publish(Path staged, Path target) throws IOException;
|
||||
}
|
||||
|
||||
public AutoCreateVoteSitesReminderStore(Path dataDirectory) throws IOException {
|
||||
this(dataDirectory, DurableFiles::publishStagedFile);
|
||||
}
|
||||
|
||||
AutoCreateVoteSitesReminderStore(Path dataDirectory, Publisher publisher) throws IOException {
|
||||
this.directory = Objects.requireNonNull(dataDirectory, "dataDirectory").toAbsolutePath().normalize();
|
||||
this.target = directory.resolve(FILE_NAME);
|
||||
this.publisher = Objects.requireNonNull(publisher, "publisher");
|
||||
validateDirectory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Nonblocking admission across plugin instances/class loaders in one JVM, held
|
||||
* through final publication. Multiple JVMs sharing the directory are unsupported:
|
||||
* closing another channel may release native locks on some platforms.
|
||||
*/
|
||||
public boolean tryAcquireLease() throws IOException {
|
||||
if (lease != null && lease.isValid()) return true;
|
||||
validateDirectory();
|
||||
Path lockPath = directory.resolve(".auto-create-sites-reminder.lock");
|
||||
try {
|
||||
BasicFileAttributes attributes = Files.readAttributes(lockPath, BasicFileAttributes.class,
|
||||
LinkOption.NOFOLLOW_LINKS);
|
||||
if (!attributes.isRegularFile() || attributes.isSymbolicLink() || attributes.size() != 0) {
|
||||
throw new IOException("Invalid reminder ownership file");
|
||||
}
|
||||
} catch (NoSuchFileException absent) {
|
||||
// Create only this fixed empty metadata file inside the checked data directory.
|
||||
}
|
||||
Set<OpenOption> options = Set.of(StandardOpenOption.CREATE, StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS);
|
||||
FileChannel channel = Files.getFileAttributeView(directory, PosixFileAttributeView.class,
|
||||
LinkOption.NOFOLLOW_LINKS) == null ? FileChannel.open(lockPath, options)
|
||||
: FileChannel.open(lockPath, options,
|
||||
PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
|
||||
boolean acquired = false;
|
||||
try {
|
||||
if (channel.size() != 0) throw new IOException("Invalid reminder ownership file");
|
||||
FileLock candidate;
|
||||
try {
|
||||
candidate = channel.tryLock();
|
||||
} catch (OverlappingFileLockException busy) {
|
||||
return false;
|
||||
}
|
||||
if (candidate == null) return false;
|
||||
leaseChannel = channel;
|
||||
lease = candidate;
|
||||
acquired = true;
|
||||
return true;
|
||||
} finally {
|
||||
if (!acquired) channel.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Closing the channel releases its lock; keep the fixed lock file for successors. */
|
||||
public void releaseLease() throws IOException {
|
||||
FileChannel channel = leaseChannel;
|
||||
leaseChannel = null;
|
||||
lease = null;
|
||||
if (channel != null) channel.close();
|
||||
}
|
||||
|
||||
/** Returns null only when the record is absent from a valid existing directory. */
|
||||
public Snapshot load() throws IOException {
|
||||
validateDirectory();
|
||||
try {
|
||||
return readRecord(target);
|
||||
} catch (NoSuchFileException absent) {
|
||||
// A missing/replaced data directory is a failure, not an absent checkpoint.
|
||||
validateDirectory();
|
||||
if (Files.notExists(target, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return null;
|
||||
}
|
||||
throw absent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a checked same-directory staged record using DurableFiles. Existing
|
||||
* invalid or unreadable records block replacement. PublishedException propagates
|
||||
* unchanged: the new record may already be visible, so no rollback is attempted.
|
||||
*/
|
||||
public void save(Snapshot snapshot) throws IOException {
|
||||
Objects.requireNonNull(snapshot, "snapshot");
|
||||
load();
|
||||
Path staged = createStagedFile();
|
||||
Throwable failure = null;
|
||||
try {
|
||||
ByteBuffer record = ByteBuffer.wrap(encode(snapshot));
|
||||
try (FileChannel channel = FileChannel.open(staged, StandardOpenOption.WRITE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING, LinkOption.NOFOLLOW_LINKS)) {
|
||||
while (record.hasRemaining()) {
|
||||
if (channel.write(record) <= 0) {
|
||||
throw new IOException("Could not write reminder checkpoint");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!snapshot.equals(readRecord(staged))) {
|
||||
throw new IOException("Staged reminder checkpoint did not match");
|
||||
}
|
||||
// Recheck after staging, before replacing any existing destination.
|
||||
load();
|
||||
publisher.publish(staged, target);
|
||||
} catch (IOException | RuntimeException | Error primary) {
|
||||
failure = primary;
|
||||
throw primary;
|
||||
} finally {
|
||||
try {
|
||||
Files.deleteIfExists(staged);
|
||||
} catch (IOException | RuntimeException cleanup) {
|
||||
if (failure != null) {
|
||||
failure.addSuppressed(cleanup);
|
||||
} else {
|
||||
throw new DurableFiles.PublishedException(cleanup instanceof IOException io ? io
|
||||
: new IOException("Reminder staging cleanup failed after publication", cleanup));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateDirectory() throws IOException {
|
||||
BasicFileAttributes attributes = Files.readAttributes(directory, BasicFileAttributes.class,
|
||||
LinkOption.NOFOLLOW_LINKS);
|
||||
if (!attributes.isDirectory() || attributes.isSymbolicLink()) {
|
||||
throw new IOException("Reminder data directory must be an existing directory");
|
||||
}
|
||||
}
|
||||
|
||||
private Path createStagedFile() throws IOException {
|
||||
if (Files.getFileAttributeView(directory, PosixFileAttributeView.class, LinkOption.NOFOLLOW_LINKS) != null) {
|
||||
return Files.createTempFile(directory, ".auto-create-sites-reminder-", ".tmp",
|
||||
PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
|
||||
}
|
||||
return Files.createTempFile(directory, ".auto-create-sites-reminder-", ".tmp");
|
||||
}
|
||||
|
||||
private static Snapshot readRecord(Path recordPath) throws IOException {
|
||||
BasicFileAttributes attributes = Files.readAttributes(recordPath, BasicFileAttributes.class,
|
||||
LinkOption.NOFOLLOW_LINKS);
|
||||
if (!attributes.isRegularFile() || attributes.isSymbolicLink() || attributes.size() != RECORD_BYTES) {
|
||||
throw new IOException("Invalid reminder checkpoint file");
|
||||
}
|
||||
ByteBuffer record = ByteBuffer.allocate(RECORD_BYTES);
|
||||
try (FileChannel channel = FileChannel.open(recordPath, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (channel.size() != RECORD_BYTES) {
|
||||
throw new IOException("Invalid reminder checkpoint length");
|
||||
}
|
||||
while (record.hasRemaining()) {
|
||||
if (channel.read(record) <= 0) {
|
||||
throw new IOException("Incomplete reminder checkpoint");
|
||||
}
|
||||
}
|
||||
if (channel.size() != RECORD_BYTES || channel.read(ByteBuffer.allocate(1)) != -1) {
|
||||
throw new IOException("Trailing reminder checkpoint data");
|
||||
}
|
||||
}
|
||||
return decode(record.array());
|
||||
}
|
||||
|
||||
private static byte[] encode(Snapshot snapshot) {
|
||||
ByteBuffer record = ByteBuffer.allocate(RECORD_BYTES).order(ByteOrder.BIG_ENDIAN);
|
||||
record.put(MAGIC).putLong(snapshot.totalRuntimeMillis()).putLong(snapshot.lastReminderRuntimeMillis());
|
||||
record.put(digest(Arrays.copyOf(record.array(), HEADER_BYTES)));
|
||||
return record.array();
|
||||
}
|
||||
|
||||
private static Snapshot decode(byte[] record) throws IOException {
|
||||
if (!Arrays.equals(MAGIC, Arrays.copyOf(record, MAGIC.length))) {
|
||||
throw new IOException("Invalid reminder checkpoint schema");
|
||||
}
|
||||
byte[] expected = digest(Arrays.copyOf(record, HEADER_BYTES));
|
||||
if (!MessageDigest.isEqual(expected, Arrays.copyOfRange(record, HEADER_BYTES, RECORD_BYTES))) {
|
||||
throw new IOException("Invalid reminder checkpoint checksum");
|
||||
}
|
||||
ByteBuffer header = ByteBuffer.wrap(record).order(ByteOrder.BIG_ENDIAN);
|
||||
header.position(MAGIC.length);
|
||||
try {
|
||||
return new Snapshot(header.getLong(), header.getLong());
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
throw new IOException("Invalid reminder checkpoint counters", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] digest(byte[] header) {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256").digest(header);
|
||||
} catch (NoSuchAlgorithmException unavailable) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", unavailable);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
package com.bencodez.votingplugin.npc;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import com.bencodez.advancedcore.api.command.CommandHandler;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
|
||||
|
||||
/** Normal /av command handlers; no separate command map or player GUI registry. */
|
||||
public final class VotingNPCCommands {
|
||||
private static final String PERMISSION = "VotingPlugin.Commands.AdminVote.NPC";
|
||||
private VotingNPCCommands() { }
|
||||
|
||||
public static void register(VotingPluginMain plugin) {
|
||||
add(plugin, new String[] { "NPC", "Create", "(string)" }, false);
|
||||
add(plugin, new String[] { "NPC", "Create", "(string)", "(list)" }, false);
|
||||
add(plugin, new String[] { "NPC", "List" }, true);
|
||||
add(plugin, new String[] { "NPC", "Inspect", "(npcid)" }, true);
|
||||
add(plugin, new String[] { "NPC", "Remove", "(npcid)" }, true);
|
||||
}
|
||||
|
||||
private static void add(VotingPluginMain plugin, String[] pattern, boolean console) {
|
||||
plugin.getAdminVoteCommand().add(new CommandHandler(plugin, pattern, PERMISSION + "|VotingPlugin.Admin",
|
||||
"Manage saved vanilla voting NPCs", console) {
|
||||
@Override public void execute(CommandSender sender, String[] args) {
|
||||
// CommandHandler invokes execute asynchronously. Capture location,
|
||||
// permission and messages only after reaching the sender's owner.
|
||||
String[] captured = args.clone();
|
||||
onSender(plugin, sender, () -> executeOwned(plugin, sender, captured));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static boolean allowed(CommandSender sender) {
|
||||
return (!(sender instanceof Player player) || player.isOnline())
|
||||
&& (sender.hasPermission(PERMISSION) || sender.hasPermission("VotingPlugin.Admin"));
|
||||
}
|
||||
|
||||
private static void onSender(VotingPluginMain plugin, CommandSender sender, Runnable action) {
|
||||
Player player = sender instanceof Player candidate ? candidate : null;
|
||||
BukkitCompletionScheduler.run(plugin, player, action, player == null ? action : () -> { }, () -> { });
|
||||
}
|
||||
|
||||
private static void executeOwned(VotingPluginMain plugin, CommandSender sender, String[] args) {
|
||||
if (!allowed(sender)) return;
|
||||
VotingNPCManager manager = plugin.getVotingNPCManager();
|
||||
if (manager == null || !manager.available()) {
|
||||
sender.sendMessage("Voting NPCs are disabled, unsupported, or waiting for valid storage. Check VotingNPCs.Enabled in Config.yml and reload.");
|
||||
return;
|
||||
}
|
||||
switch (args[1].toLowerCase(java.util.Locale.ROOT)) {
|
||||
case "create":
|
||||
if (!(sender instanceof Player player)) { sender.sendMessage("Create a voting NPC from in game."); return; }
|
||||
try {
|
||||
Location at = player.getLocation();
|
||||
String name = args.length > 3 ? String.join(" ", Arrays.copyOfRange(args, 3, args.length)) : "&aVote";
|
||||
VotingNPCDefinition definition = new VotingNPCDefinition(args[2], UUID.randomUUID(), at.getWorld().getUID(),
|
||||
at.getX(), at.getY(), at.getZ(), at.getYaw(), at.getPitch(), name);
|
||||
confirm(plugin, sender, manager, manager.create(definition),
|
||||
ignored -> "Saved voting NPC '" + definition.id() + "'. Its villager appears when the area is loaded and available.");
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Use a lowercase ID of 1-40 letters, digits, underscores or hyphens, and a name of 1-128 characters without controls.");
|
||||
}
|
||||
break;
|
||||
case "remove":
|
||||
confirm(plugin, sender, manager, manager.remove(args[2]), removed -> removed
|
||||
? "Removed saved voting NPC '" + args[2] + "'; exact entity cleanup has been requested."
|
||||
: "No voting NPC has that ID.");
|
||||
break;
|
||||
case "inspect":
|
||||
manager.definitions().stream().filter(value -> value.id().equals(args[2])).findFirst().ifPresentOrElse(value ->
|
||||
sender.sendMessage("Voting NPC " + value.id() + ": world " + value.world() + ", "
|
||||
+ value.x() + ", " + value.y() + ", " + value.z() + "; " + manager.runtimeState(value.id())),
|
||||
() -> sender.sendMessage("No voting NPC has that ID."));
|
||||
break;
|
||||
case "list":
|
||||
sender.sendMessage("Saved voting NPCs: " + manager.definitions().size() + "/64; tracked runtime slots: " + manager.runtimeCount());
|
||||
for (var value : manager.definitions()) sender.sendMessage(value.id() + ": " + manager.runtimeState(value.id()));
|
||||
break;
|
||||
default:
|
||||
sender.sendMessage("Use /av npc create <id> [name], list, inspect <id>, or remove <id>.");
|
||||
}
|
||||
}
|
||||
|
||||
private static String failureMessage(VotingNPCManager manager, Throwable failure) {
|
||||
while ((failure instanceof java.util.concurrent.CompletionException
|
||||
|| failure instanceof java.util.concurrent.ExecutionException) && failure.getCause() != null) {
|
||||
failure = failure.getCause();
|
||||
}
|
||||
if (manager.available()) {
|
||||
if (failure instanceof VotingNPCRegistry.ValidationFailure validation) {
|
||||
return switch (validation.reason) {
|
||||
case DUPLICATE_ID -> "That voting NPC ID already exists. Use a different ID or remove it first.";
|
||||
case DUPLICATE_IDENTITY -> "That voting NPC identity already exists. No definition was changed.";
|
||||
case LIMIT -> "The 64 voting NPC limit has been reached. Remove an NPC before creating another.";
|
||||
};
|
||||
}
|
||||
if (failure instanceof java.util.concurrent.RejectedExecutionException) {
|
||||
return "Voting NPC storage is busy. Wait for pending changes to finish before retrying.";
|
||||
}
|
||||
}
|
||||
return "Voting NPC change was not confirmed. Check storage and reload before retrying; no success is assumed.";
|
||||
}
|
||||
|
||||
private static <T> void confirm(VotingPluginMain plugin, CommandSender sender, VotingNPCManager manager, CompletionStage<T> completion,
|
||||
java.util.function.Function<T, String> message) {
|
||||
completion.whenComplete((value, failure) -> onSender(plugin, sender, () -> {
|
||||
if (!allowed(sender)) return;
|
||||
sender.sendMessage(failure == null ? message.apply(value)
|
||||
: failureMessage(manager, failure));
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package com.bencodez.votingplugin.npc;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Durable definition; it contains no live world, player, entity or reward state. */
|
||||
public record VotingNPCDefinition(String id, UUID identity, UUID world, double x, double y, double z,
|
||||
float yaw, float pitch, String name) {
|
||||
private static final Pattern ID = Pattern.compile("[a-z0-9][a-z0-9_-]{0,39}");
|
||||
|
||||
public VotingNPCDefinition {
|
||||
if (id == null || !ID.matcher(id).matches()) throw new IllegalArgumentException("Invalid voting NPC ID");
|
||||
Objects.requireNonNull(identity, "identity");
|
||||
Objects.requireNonNull(world, "world");
|
||||
if (!Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z)
|
||||
|| Math.abs(x) > 30_000_000 || Math.abs(z) > 30_000_000
|
||||
|| !Float.isFinite(yaw) || !Float.isFinite(pitch)) {
|
||||
throw new IllegalArgumentException("Invalid voting NPC position");
|
||||
}
|
||||
if (name == null || name.isEmpty() || name.length() > 128
|
||||
|| name.codePoints().anyMatch(Character::isISOControl)) {
|
||||
throw new IllegalArgumentException("Invalid voting NPC name");
|
||||
}
|
||||
}
|
||||
|
||||
public int chunkX() { return Math.floorDiv((int) Math.floor(x), 16); }
|
||||
public int chunkZ() { return Math.floorDiv((int) Math.floor(z), 16); }
|
||||
}
|
||||
@@ -1,450 +0,0 @@
|
||||
package com.bencodez.votingplugin.npc;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.entity.Villager;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityCombustEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntityInteractEvent;
|
||||
import org.bukkit.event.entity.EntityTargetEvent;
|
||||
import org.bukkit.event.entity.EntityTeleportEvent;
|
||||
import org.bukkit.event.entity.EntityTransformEvent;
|
||||
import org.bukkit.event.player.PlayerInteractAtEntityEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEntityEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.event.vehicle.VehicleEnterEvent;
|
||||
import org.bukkit.event.world.ChunkLoadEvent;
|
||||
import org.bukkit.event.world.ChunkUnloadEvent;
|
||||
import org.bukkit.event.world.WorldLoadEvent;
|
||||
import org.bukkit.event.world.WorldUnloadEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
|
||||
|
||||
/** Persistent definitions, transient public villagers, and no vote/reward accounting. */
|
||||
public final class VotingNPCManager implements Listener, AutoCloseable {
|
||||
private final VotingPluginMain plugin;
|
||||
private final VotingNPCScheduler scheduler;
|
||||
private final Consumer<Player> open;
|
||||
private final Map<String, RuntimeNPC> resources = new ConcurrentHashMap<>();
|
||||
private final Set<String> capacityDemand = ConcurrentHashMap.newKeySet();
|
||||
private final AtomicBoolean capacityWakeQueued = new AtomicBoolean();
|
||||
private final Map<UUID, RuntimeNPC> entities = new ConcurrentHashMap<>();
|
||||
private final Set<ChunkKey> unloadingChunks = ConcurrentHashMap.newKeySet();
|
||||
private final Set<UUID> unloadingWorlds = ConcurrentHashMap.newKeySet();
|
||||
private final Map<UUID, Long> recentClicks = new LinkedHashMap<>();
|
||||
private volatile VotingNPCRegistry registry;
|
||||
private volatile boolean enabled;
|
||||
private volatile boolean closed;
|
||||
private volatile long configuration;
|
||||
private final AtomicBoolean warned = new AtomicBoolean();
|
||||
|
||||
private record ChunkKey(UUID world, int x, int z) { }
|
||||
private static final class RuntimeNPC {
|
||||
final VotingNPCDefinition definition;
|
||||
final Location anchor;
|
||||
final long configuration;
|
||||
boolean closed, cleanupQueued, reconstructRequested;
|
||||
Villager entity;
|
||||
UUID entityId;
|
||||
RuntimeNPC(VotingNPCDefinition definition, World world, long configuration) {
|
||||
this.definition = definition;
|
||||
this.configuration = configuration;
|
||||
anchor = new Location(world, definition.x(), definition.y(), definition.z(), definition.yaw(), definition.pitch());
|
||||
}
|
||||
}
|
||||
|
||||
public VotingNPCManager(VotingPluginMain plugin) {
|
||||
this(plugin, new VotingNPCScheduler(plugin), null, player -> openVotingSites(plugin, player));
|
||||
}
|
||||
|
||||
static void openVotingSites(VotingPluginMain plugin, Player player) {
|
||||
// Reuse the normal command's permissions, asynchronous user loading,
|
||||
// configured GUI, cooldown display, and URL handling; no root alias.
|
||||
for (var handler : plugin.getVoteCommand()) {
|
||||
if (handler.runCommand(player, new String[] { "URL" })) return;
|
||||
}
|
||||
player.sendMessage("Voting-site selection is unavailable.");
|
||||
}
|
||||
|
||||
VotingNPCManager(VotingPluginMain plugin, VotingNPCScheduler scheduler, VotingNPCRegistry registry, Consumer<Player> open) {
|
||||
this.plugin = plugin;
|
||||
this.scheduler = scheduler;
|
||||
this.registry = registry;
|
||||
this.open = open;
|
||||
}
|
||||
|
||||
public static boolean supported() {
|
||||
try {
|
||||
Entity.class.getMethod("setPersistent", boolean.class);
|
||||
Entity.class.getMethod("setGravity", boolean.class);
|
||||
Entity.class.getMethod("setInvulnerable", boolean.class);
|
||||
LivingEntity.class.getMethod("setAI", boolean.class);
|
||||
LivingEntity.class.getMethod("setCollidable", boolean.class);
|
||||
World.class.getMethod("spawn", Location.class, Class.class, Consumer.class);
|
||||
return true;
|
||||
} catch (ReflectiveOperationException | LinkageError unavailable) { return false; }
|
||||
}
|
||||
|
||||
/** Configuration callers do no file I/O and do not wait for storage. */
|
||||
public void configure(boolean requested) {
|
||||
VotingNPCRegistry owner;
|
||||
CompletionStage<List<VotingNPCDefinition>> loading;
|
||||
long generation;
|
||||
boolean activate;
|
||||
synchronized (this) {
|
||||
if (closed) return;
|
||||
generation = ++configuration;
|
||||
activate = requested && supported();
|
||||
enabled = activate;
|
||||
if (!activate) { owner = null; loading = null; }
|
||||
else {
|
||||
if (registry == null) {
|
||||
registry = new VotingNPCRegistry(plugin.getDataFolder().toPath().resolve("VotingNPCs.json"));
|
||||
loading = registry.initialLoad();
|
||||
} else loading = registry.reload();
|
||||
owner = registry;
|
||||
}
|
||||
}
|
||||
if (!activate) {
|
||||
for (RuntimeNPC npc : resources.values()) retire(npc);
|
||||
if (requested) report(new IllegalStateException("Vanilla voting NPC API is unavailable"));
|
||||
return;
|
||||
}
|
||||
loading.whenComplete((definitions, failure) -> {
|
||||
synchronized (VotingNPCManager.this) {
|
||||
if (closed || !enabled || generation != configuration || registry != owner) return;
|
||||
}
|
||||
if (failure != null) {
|
||||
for (RuntimeNPC npc : resources.values()) retire(npc);
|
||||
report(failure);
|
||||
} else {
|
||||
warned.set(false);
|
||||
reconcile();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public boolean available() {
|
||||
VotingNPCRegistry owner = registry;
|
||||
return !closed && enabled && owner != null && owner.available();
|
||||
}
|
||||
|
||||
public List<VotingNPCDefinition> definitions() {
|
||||
VotingNPCRegistry owner = registry;
|
||||
return owner == null ? List.of() : owner.snapshot();
|
||||
}
|
||||
|
||||
/** Returns durable definition completion; runtime creation can still await a loaded chunk. */
|
||||
public CompletionStage<VotingNPCDefinition> create(VotingNPCDefinition definition) {
|
||||
VotingNPCRegistry owner = registry;
|
||||
if (!available()) return CompletableFuture.failedFuture(new IllegalStateException("Enable VotingNPCs and load valid storage first"));
|
||||
CompletionStage<VotingNPCDefinition> completion = owner.create(definition);
|
||||
completion.whenComplete((value, failure) -> mutationFinished(owner, failure));
|
||||
return completion;
|
||||
}
|
||||
|
||||
public CompletionStage<Boolean> remove(String id) {
|
||||
VotingNPCRegistry owner = registry;
|
||||
if (!available()) return CompletableFuture.failedFuture(new IllegalStateException("Enable VotingNPCs and load valid storage first"));
|
||||
CompletionStage<Boolean> completion = owner.remove(id);
|
||||
completion.whenComplete((value, failure) -> mutationFinished(owner, failure));
|
||||
return completion;
|
||||
}
|
||||
|
||||
private void mutationFinished(VotingNPCRegistry owner, Throwable failure) {
|
||||
if (owner != registry || closed) return;
|
||||
if (failure == null) reconcile();
|
||||
else if (!owner.available()) {
|
||||
for (RuntimeNPC npc : resources.values()) retire(npc);
|
||||
report(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean current(RuntimeNPC npc) {
|
||||
return available() && definitions().contains(npc.definition);
|
||||
}
|
||||
|
||||
private void reconcile() {
|
||||
retryPendingCleanup();
|
||||
capacityDemand.retainAll(definitions().stream().map(VotingNPCDefinition::id).toList());
|
||||
List<VotingNPCDefinition> snapshot = definitions();
|
||||
Set<ChunkKey> chunks = new HashSet<>();
|
||||
Set<UUID> worlds = new HashSet<>();
|
||||
for (var value : snapshot) { chunks.addAll(occupiedChunks(value)); worlds.add(value.world()); }
|
||||
unloadingChunks.retainAll(chunks);
|
||||
unloadingWorlds.retainAll(worlds);
|
||||
for (RuntimeNPC npc : resources.values()) if (!current(npc)) retire(npc);
|
||||
if (!available()) return;
|
||||
scheduler.global(() -> {
|
||||
if (!available()) return;
|
||||
for (var value : definitions()) ensure(value);
|
||||
}, this::report);
|
||||
}
|
||||
|
||||
/** Same conservative half-block bounds used for creation ownership and chunk availability. */
|
||||
private static Set<ChunkKey> occupiedChunks(VotingNPCDefinition value) {
|
||||
Set<ChunkKey> chunks = new HashSet<>();
|
||||
for (double dx : new double[] { -.5, .5 }) for (double dz : new double[] { -.5, .5 }) {
|
||||
chunks.add(new ChunkKey(value.world(), (int) Math.floor(value.x() + dx) >> 4,
|
||||
(int) Math.floor(value.z() + dz) >> 4));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
private boolean unloading(VotingNPCDefinition value) {
|
||||
return occupiedChunks(value).stream().anyMatch(unloadingChunks::contains);
|
||||
}
|
||||
|
||||
private void ensure(VotingNPCDefinition value) {
|
||||
if (!available() || !definitions().contains(value) || unloadingWorlds.contains(value.world()) || unloading(value)) return;
|
||||
World world = Bukkit.getWorld(value.world());
|
||||
if (world == null) return;
|
||||
RuntimeNPC npc = new RuntimeNPC(value, world, configuration);
|
||||
RuntimeNPC previous;
|
||||
synchronized (resources) {
|
||||
previous = resources.get(value.id());
|
||||
if (previous == null) {
|
||||
if (resources.size() >= VotingNPCStore.MAX_NPCS) {
|
||||
if (capacityDemand.size() < VotingNPCStore.MAX_NPCS) capacityDemand.add(value.id());
|
||||
return;
|
||||
}
|
||||
resources.put(value.id(), npc);
|
||||
capacityDemand.remove(value.id());
|
||||
}
|
||||
}
|
||||
if (previous != null) {
|
||||
synchronized (previous) { if (previous.closed) previous.reconstructRequested = true; }
|
||||
return;
|
||||
}
|
||||
scheduler.region(npc.anchor, () -> spawn(npc), failure -> { retire(npc); report(failure); });
|
||||
}
|
||||
|
||||
private boolean loadedArea(RuntimeNPC npc) {
|
||||
World world = npc.anchor.getWorld();
|
||||
if (Bukkit.getWorld(npc.definition.world()) != world || unloadingWorlds.contains(npc.definition.world())
|
||||
|| unloading(npc.definition)) return false;
|
||||
for (double dx : new double[] { -.5, .5 }) for (double dz : new double[] { -.5, .5 }) {
|
||||
Location edge = npc.anchor.clone().add(dx, 0, dz);
|
||||
if (!scheduler.owns(edge) || !world.isChunkLoaded(edge.getBlockX() >> 4, edge.getBlockZ() >> 4)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void spawn(RuntimeNPC npc) {
|
||||
try {
|
||||
synchronized (npc) {
|
||||
if (npc.closed || !current(npc) || !loadedArea(npc)) {
|
||||
boolean capacityReleased = !npc.closed && !current(npc);
|
||||
forget(npc, capacityReleased);
|
||||
return;
|
||||
}
|
||||
if (npc.entity != null) return;
|
||||
Villager spawned = npc.anchor.getWorld().spawn(npc.anchor, Villager.class, villager -> {
|
||||
npc.entity = villager;
|
||||
npc.entityId = villager.getUniqueId();
|
||||
entities.put(npc.entityId, npc);
|
||||
villager.setPersistent(false);
|
||||
villager.setAI(false);
|
||||
villager.setGravity(false);
|
||||
villager.setInvulnerable(true);
|
||||
villager.setSilent(true);
|
||||
villager.setCollidable(false);
|
||||
villager.setCanPickupItems(false);
|
||||
villager.setRemoveWhenFarAway(false);
|
||||
villager.setCustomName(ChatColor.translateAlternateColorCodes('&', npc.definition.name()));
|
||||
villager.setCustomNameVisible(true);
|
||||
});
|
||||
if (spawned != npc.entity || !spawned.isValid() || !current(npc) || npc.closed) {
|
||||
throw new IllegalStateException("Voting NPC spawn was cancelled or superseded");
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException | LinkageError failure) { retire(npc); report(failure); }
|
||||
}
|
||||
|
||||
private void retire(RuntimeNPC npc) {
|
||||
Villager entity;
|
||||
synchronized (npc) {
|
||||
npc.closed = true;
|
||||
entity = npc.entity;
|
||||
if (entity == null) { forget(npc, false); return; }
|
||||
if (npc.cleanupQueued) return;
|
||||
npc.cleanupQueued = true;
|
||||
}
|
||||
scheduler.entity(entity, () -> {
|
||||
try { entity.remove(); forget(npc); }
|
||||
catch (RuntimeException | LinkageError failure) { cleanupFailed(npc, failure); }
|
||||
}, () -> forget(npc), failure -> cleanupFailed(npc, failure));
|
||||
}
|
||||
|
||||
private void cleanupFailed(RuntimeNPC npc, Throwable failure) {
|
||||
synchronized (npc) {
|
||||
npc.cleanupQueued = false;
|
||||
npc.reconstructRequested = true;
|
||||
}
|
||||
// Keep the exact identity and capacity reservation. Rejected scheduling
|
||||
// is not successful removal and must not permit duplicate reconstruction.
|
||||
report(failure);
|
||||
}
|
||||
|
||||
private void retryPendingCleanup() {
|
||||
for (RuntimeNPC npc : resources.values()) {
|
||||
synchronized (npc) {
|
||||
if (!npc.closed || !npc.reconstructRequested || npc.cleanupQueued || npc.entity == null) continue;
|
||||
}
|
||||
retire(npc);
|
||||
}
|
||||
}
|
||||
|
||||
private void forget(RuntimeNPC npc) { forget(npc, false); }
|
||||
|
||||
private void forget(RuntimeNPC npc, boolean capacityReleased) {
|
||||
boolean reconstruct, removed;
|
||||
synchronized (npc) {
|
||||
reconstruct = npc.entity != null && (npc.reconstructRequested || npc.configuration != configuration
|
||||
|| definitions().stream().anyMatch(value -> value.id().equals(npc.definition.id()) && !value.equals(npc.definition)));
|
||||
if (npc.entityId != null) entities.remove(npc.entityId, npc);
|
||||
npc.entity = null;
|
||||
removed = resources.remove(npc.definition.id(), npc);
|
||||
}
|
||||
// A successor sharing this configured ID must wait for exact predecessor
|
||||
// cleanup. Missing/unloaded spawn attempts do not recursively retry.
|
||||
if (reconstruct && available()) scheduler.global(() -> {
|
||||
for (var value : definitions()) if (value.id().equals(npc.definition.id())) ensure(value);
|
||||
}, this::report);
|
||||
if (removed && !capacityDemand.isEmpty() && available()
|
||||
&& capacityWakeQueued.compareAndSet(false, true)) {
|
||||
scheduler.global(() -> {
|
||||
capacityWakeQueued.set(false);
|
||||
for (var value : definitions()) if (capacityDemand.contains(value.id())) ensure(value);
|
||||
}, failure -> {
|
||||
capacityWakeQueued.set(false);
|
||||
report(failure);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void report(Throwable failure) {
|
||||
if (warned.compareAndSet(false, true)) {
|
||||
try {
|
||||
plugin.getLogger().warning("Voting NPCs could not complete an operation; definitions are retained. Check API support and VotingNPCs.json, then reload. Cause: "
|
||||
+ failure.getClass().getSimpleName());
|
||||
} catch (RuntimeException ignored) { /* Reporting must not prevent independent cleanup. */ }
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
|
||||
public void interact(PlayerInteractEntityEvent event) {
|
||||
RuntimeNPC npc = entities.get(event.getRightClicked().getUniqueId());
|
||||
if (npc == null) return;
|
||||
boolean cancelled = event.isCancelled();
|
||||
event.setCancelled(true); // Only this exact owned entity; never trading.
|
||||
if (cancelled || event.getHand() != EquipmentSlot.HAND || !current(npc)) return;
|
||||
synchronized (npc) { if (npc.closed) { retire(npc); return; } }
|
||||
Player player = event.getPlayer();
|
||||
long now = System.nanoTime();
|
||||
synchronized (recentClicks) {
|
||||
recentClicks.entrySet().removeIf(entry -> now - entry.getValue() >= 300_000_000L);
|
||||
if (recentClicks.containsKey(player.getUniqueId()) || recentClicks.size() >= 256) return;
|
||||
recentClicks.put(player.getUniqueId(), now);
|
||||
}
|
||||
BukkitCompletionScheduler.run(plugin, player, () -> {
|
||||
if (!player.isOnline() || !current(npc) || !player.getWorld().getUID().equals(npc.definition.world())
|
||||
|| player.getLocation().distanceSquared(npc.anchor) > 36) return;
|
||||
synchronized (npc) { if (npc.closed) return; }
|
||||
open.accept(player);
|
||||
}, () -> { }, () -> { });
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
|
||||
public void interactAt(PlayerInteractAtEntityEvent event) { interact(event); }
|
||||
private boolean owned(Entity entity) { return entity != null && entities.containsKey(entity.getUniqueId()); }
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
|
||||
public void damage(EntityDamageEvent event) { if (owned(event.getEntity())) event.setCancelled(true); }
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
|
||||
public void combust(EntityCombustEvent event) { if (owned(event.getEntity())) event.setCancelled(true); }
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
|
||||
public void teleport(EntityTeleportEvent event) { if (owned(event.getEntity())) event.setCancelled(true); }
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
|
||||
public void transform(EntityTransformEvent event) { if (owned(event.getEntity())) event.setCancelled(true); }
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
|
||||
public void target(EntityTargetEvent event) { if (owned(event.getEntity()) || owned(event.getTarget())) event.setCancelled(true); }
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
|
||||
public void blockInteract(EntityInteractEvent event) { if (owned(event.getEntity())) event.setCancelled(true); }
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
|
||||
public void vehicle(VehicleEnterEvent event) { if (owned(event.getEntered())) event.setCancelled(true); }
|
||||
@EventHandler public void quit(PlayerQuitEvent event) { synchronized (recentClicks) { recentClicks.remove(event.getPlayer().getUniqueId()); } }
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void chunkLoad(ChunkLoadEvent event) {
|
||||
UUID world = event.getWorld().getUID();
|
||||
unloadingChunks.remove(new ChunkKey(world, event.getChunk().getX(), event.getChunk().getZ()));
|
||||
retryPendingCleanup();
|
||||
ChunkKey chunk = new ChunkKey(world, event.getChunk().getX(), event.getChunk().getZ());
|
||||
for (var value : definitions()) if (occupiedChunks(value).contains(chunk)) ensure(value);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void chunkUnload(ChunkUnloadEvent event) {
|
||||
ChunkKey chunk = new ChunkKey(event.getWorld().getUID(), event.getChunk().getX(), event.getChunk().getZ());
|
||||
if (definitions().stream().noneMatch(value -> occupiedChunks(value).contains(chunk))) return;
|
||||
unloadingChunks.add(chunk);
|
||||
for (RuntimeNPC npc : resources.values()) if (occupiedChunks(npc.definition).contains(chunk)) retire(npc);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void worldLoad(WorldLoadEvent event) {
|
||||
UUID world = event.getWorld().getUID();
|
||||
if (definitions().stream().noneMatch(value -> value.world().equals(world))) return;
|
||||
unloadingWorlds.remove(world);
|
||||
reconcile();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void worldUnload(WorldUnloadEvent event) {
|
||||
UUID world = event.getWorld().getUID();
|
||||
if (definitions().stream().noneMatch(value -> value.world().equals(world))) return;
|
||||
unloadingWorlds.add(world);
|
||||
for (RuntimeNPC npc : resources.values()) if (npc.definition.world().equals(world)) retire(npc);
|
||||
}
|
||||
|
||||
public int runtimeCount() { return resources.size(); }
|
||||
|
||||
public String runtimeState(String id) {
|
||||
RuntimeNPC npc = resources.get(id);
|
||||
if (npc == null) return "waiting for a loaded, available area";
|
||||
synchronized (npc) {
|
||||
if (npc.closed) return "cleanup pending";
|
||||
return npc.entity == null ? "creation scheduled" : "villager created";
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
synchronized (this) { if (closed) return; closed = true; enabled = false; ++configuration; }
|
||||
VotingNPCRegistry owner = registry;
|
||||
if (owner != null) owner.close();
|
||||
for (RuntimeNPC npc : new ArrayList<>(resources.values())) retire(npc);
|
||||
synchronized (recentClicks) { recentClicks.clear(); }
|
||||
}
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
package com.bencodez.votingplugin.npc;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.FileLock;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* One bounded, daemon file owner. Completion means the definition was persisted,
|
||||
* not that a world entity has spawned. Callbacks have no player/region ownership.
|
||||
*/
|
||||
final class VotingNPCRegistry implements AutoCloseable {
|
||||
enum Rejection { DUPLICATE_ID, DUPLICATE_IDENTITY, LIMIT }
|
||||
static final class ValidationFailure extends IllegalArgumentException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
final Rejection reason;
|
||||
ValidationFailure(Rejection reason) { super("Voting NPC request rejected"); this.reason = reason; }
|
||||
}
|
||||
|
||||
private final Object admission = new Object();
|
||||
private final ArrayBlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(128);
|
||||
private final CountDownLatch stopped = new CountDownLatch(1);
|
||||
private final VotingNPCStore store;
|
||||
private final Path lockPath;
|
||||
private final CompletableFuture<List<VotingNPCDefinition>> initial;
|
||||
private volatile Map<String, VotingNPCDefinition> definitions = Map.of();
|
||||
private volatile boolean available;
|
||||
private volatile boolean closed;
|
||||
// Only the file worker accesses these resources.
|
||||
private FileChannel lockChannel;
|
||||
private FileLock lease;
|
||||
private boolean storageReady;
|
||||
|
||||
VotingNPCRegistry(Path file) {
|
||||
this(file, new VotingNPCStore(file));
|
||||
}
|
||||
|
||||
VotingNPCRegistry(Path file, VotingNPCStore store) {
|
||||
this.store = store;
|
||||
lockPath = file.toAbsolutePath().normalize().resolveSibling(file.getFileName() + ".lock");
|
||||
initial = submit(false, this::readDefinitions);
|
||||
Thread thread = new Thread(this::work, "VotingPlugin-NPC-storage");
|
||||
thread.setDaemon(true);
|
||||
try { thread.start(); }
|
||||
catch (RuntimeException | Error failure) {
|
||||
closed = true;
|
||||
initial.completeExceptionally(failure);
|
||||
stopped.countDown();
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
CompletionStage<List<VotingNPCDefinition>> initialLoad() { return initial; }
|
||||
boolean available() { return !closed && available; }
|
||||
List<VotingNPCDefinition> snapshot() { return List.copyOf(definitions.values()); }
|
||||
|
||||
CompletionStage<List<VotingNPCDefinition>> reload() {
|
||||
synchronized (admission) {
|
||||
if (closed) return rejected("Voting NPC registry is closed");
|
||||
var completion = submit(false, this::readDefinitions);
|
||||
// A rejected reload changes nothing. The worker publishes readiness
|
||||
// under this same short lock, after admission has finished.
|
||||
if (!completion.isCompletedExceptionally()) available = false;
|
||||
return completion;
|
||||
}
|
||||
}
|
||||
|
||||
CompletionStage<VotingNPCDefinition> create(VotingNPCDefinition definition) {
|
||||
return submit(true, () -> {
|
||||
if (definition == null) throw new IllegalArgumentException("Voting NPC definition is required");
|
||||
if (definitions.containsKey(definition.id())) throw new ValidationFailure(Rejection.DUPLICATE_ID);
|
||||
if (definitions.values().stream().anyMatch(value -> value.identity().equals(definition.identity()))) {
|
||||
throw new ValidationFailure(Rejection.DUPLICATE_IDENTITY);
|
||||
}
|
||||
if (definitions.size() >= VotingNPCStore.MAX_NPCS) throw new ValidationFailure(Rejection.LIMIT);
|
||||
var next = new LinkedHashMap<>(definitions);
|
||||
next.put(definition.id(), definition);
|
||||
publish(next);
|
||||
return definition;
|
||||
});
|
||||
}
|
||||
|
||||
CompletionStage<Boolean> remove(String id) {
|
||||
return submit(true, () -> {
|
||||
if (!definitions.containsKey(id)) return false;
|
||||
var next = new LinkedHashMap<>(definitions);
|
||||
next.remove(id);
|
||||
publish(next);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private List<VotingNPCDefinition> readDefinitions() throws IOException {
|
||||
try {
|
||||
ownFile();
|
||||
List<VotingNPCDefinition> loaded = store.load();
|
||||
var next = new LinkedHashMap<String, VotingNPCDefinition>();
|
||||
for (VotingNPCDefinition value : loaded) next.put(value.id(), value);
|
||||
definitions = java.util.Collections.unmodifiableMap(next);
|
||||
storageReady = true;
|
||||
synchronized (admission) { available = true; }
|
||||
return loaded;
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
storageReady = false;
|
||||
synchronized (admission) { available = false; }
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private void publish(LinkedHashMap<String, VotingNPCDefinition> next) throws IOException {
|
||||
// The worker holds the process-independent lease through shutdown drain.
|
||||
try { store.save(List.copyOf(next.values())); }
|
||||
catch (IOException | RuntimeException | Error failure) {
|
||||
// A failed write may already be visible. Neither later admitted
|
||||
// mutations nor a new request may overwrite it from the old map.
|
||||
storageReady = false;
|
||||
synchronized (admission) { available = false; }
|
||||
throw failure;
|
||||
}
|
||||
definitions = java.util.Collections.unmodifiableMap(next);
|
||||
}
|
||||
|
||||
private <T> CompletableFuture<T> submit(boolean needsReady, Callable<T> action) {
|
||||
synchronized (admission) {
|
||||
if (closed || (needsReady && !available)) return rejected("Voting NPC storage is not available");
|
||||
var completion = new CompletableFuture<T>();
|
||||
Runnable task = () -> {
|
||||
try {
|
||||
if (needsReady && !storageReady) throw new RejectedExecutionException("Reload valid voting NPC storage before editing");
|
||||
completion.complete(action.call());
|
||||
}
|
||||
catch (Throwable failure) { completion.completeExceptionally(failure); }
|
||||
};
|
||||
if (!queue.offer(task)) completion.completeExceptionally(new RejectedExecutionException("Voting NPC storage queue is full"));
|
||||
return completion;
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> CompletableFuture<T> rejected(String message) {
|
||||
return CompletableFuture.failedFuture(new RejectedExecutionException(message));
|
||||
}
|
||||
|
||||
private void ownFile() throws IOException {
|
||||
if (lease != null && lease.isValid()) return;
|
||||
if (!Files.isDirectory(lockPath.getParent().toRealPath(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Voting NPC data directory is unavailable");
|
||||
}
|
||||
FileChannel candidate = FileChannel.open(lockPath, StandardOpenOption.CREATE,
|
||||
StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
|
||||
try {
|
||||
FileLock acquired;
|
||||
try { acquired = candidate.tryLock(); }
|
||||
catch (OverlappingFileLockException overlap) { throw new IOException("Voting NPC file still has an active owner", overlap); }
|
||||
if (acquired == null) throw new IOException("Voting NPC file still has an active owner");
|
||||
lockChannel = candidate;
|
||||
lease = acquired;
|
||||
} finally {
|
||||
if (lockChannel != candidate) candidate.close();
|
||||
}
|
||||
}
|
||||
|
||||
private void work() {
|
||||
try {
|
||||
while (!closed || !queue.isEmpty()) {
|
||||
Runnable task;
|
||||
try { task = queue.take(); }
|
||||
catch (InterruptedException interrupted) { continue; }
|
||||
task.run();
|
||||
}
|
||||
} finally {
|
||||
available = false;
|
||||
try {
|
||||
if (lockChannel != null) lockChannel.close();
|
||||
} catch (IOException ignored) {
|
||||
// Closing this channel releases its OS lease; never do this I/O
|
||||
// under an admission or executor lock on a gameplay caller.
|
||||
} finally { stopped.countDown(); }
|
||||
}
|
||||
}
|
||||
|
||||
/** Stops admission without blocking or interrupting an accepted file write. */
|
||||
@Override public void close() {
|
||||
synchronized (admission) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
queue.offer(() -> { }); // Wake an idle worker; a full queue already wakes it.
|
||||
}
|
||||
}
|
||||
|
||||
/** Test/owner-worker observation only; gameplay callers must never wait here. */
|
||||
boolean awaitClosed(long time, TimeUnit unit) throws InterruptedException { return stopped.await(time, unit); }
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package com.bencodez.votingplugin.npc;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Entity;
|
||||
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
|
||||
|
||||
/** World creation uses a region; subsequent cleanup follows the actual entity. */
|
||||
class VotingNPCScheduler {
|
||||
private final VotingPluginMain plugin;
|
||||
private final Method locationOwner;
|
||||
private final Method entityOwner;
|
||||
|
||||
VotingNPCScheduler(VotingPluginMain plugin) {
|
||||
this.plugin = plugin;
|
||||
Method location = null, entity = null;
|
||||
try {
|
||||
Class.forName("io.papermc.paper.threadedregions.RegionizedServer");
|
||||
location = Bukkit.class.getMethod("isOwnedByCurrentRegion", Location.class);
|
||||
entity = Bukkit.class.getMethod("isOwnedByCurrentRegion", Entity.class);
|
||||
} catch (ClassNotFoundException ordinaryServer) {
|
||||
// Spigot/Paper have a single world-owner thread.
|
||||
} catch (ReflectiveOperationException unavailable) {
|
||||
throw new IllegalStateException("Voting NPC region ownership API is unavailable", unavailable);
|
||||
}
|
||||
locationOwner = location;
|
||||
entityOwner = entity;
|
||||
}
|
||||
|
||||
boolean owns(Location location) { return owns(locationOwner, location); }
|
||||
boolean owns(Entity entity) { return owns(entityOwner, entity); }
|
||||
|
||||
private boolean owns(Method method, Object value) {
|
||||
if (method == null) return Bukkit.isPrimaryThread();
|
||||
try { return Boolean.TRUE.equals(method.invoke(null, value)); }
|
||||
catch (ReflectiveOperationException failure) { throw new IllegalStateException("Cannot verify voting NPC ownership", failure); }
|
||||
}
|
||||
|
||||
void global(Runnable action, Consumer<Throwable> failure) {
|
||||
try {
|
||||
plugin.getBukkitScheduler().getFoliaLib().getImpl().runNextTick(ignored -> action.run())
|
||||
.whenComplete((ignored, failed) -> { if (failed != null) failure.accept(failed); });
|
||||
} catch (RuntimeException | LinkageError rejected) { failure.accept(rejected); }
|
||||
}
|
||||
|
||||
void region(Location anchor, Runnable action, Consumer<Throwable> failure) {
|
||||
try {
|
||||
if (owns(anchor)) action.run();
|
||||
else plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtLocation(anchor, ignored -> action.run())
|
||||
.whenComplete((ignored, failed) -> { if (failed != null) failure.accept(failed); });
|
||||
} catch (RuntimeException | LinkageError rejected) { failure.accept(rejected); }
|
||||
}
|
||||
|
||||
void entity(Entity entity, Runnable action, Runnable retired, Consumer<Throwable> failure) {
|
||||
AtomicBoolean executed = new AtomicBoolean();
|
||||
Runnable run = () -> { if (executed.compareAndSet(false, true)) action.run(); };
|
||||
Runnable gone = () -> { if (executed.compareAndSet(false, true)) retired.run(); };
|
||||
try {
|
||||
if (owns(entity)) { run.run(); return; }
|
||||
plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtEntityWithFallback(entity,
|
||||
ignored -> run.run(), gone).whenComplete((status, failed) -> {
|
||||
if (failed != null) failure.accept(failed);
|
||||
else if (status instanceof Enum<?> result && "ENTITY_RETIRED".equals(result.name())) gone.run();
|
||||
else if (!BukkitCompletionScheduler.isSuccessfulEntityTaskResult(status)) {
|
||||
// Scheduler retirement does not prove the entity was removed.
|
||||
failure.accept(new IllegalStateException("Voting NPC entity scheduler is unavailable"));
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException | LinkageError rejected) { failure.accept(rejected); }
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
package com.bencodez.votingplugin.npc;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.StringReader;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import com.google.gson.stream.JsonReader;
|
||||
import com.google.gson.stream.JsonToken;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
import com.bencodez.votingplugin.util.DurableFiles;
|
||||
|
||||
/** Single storage-worker owner. Never call this file boundary on a gameplay thread. */
|
||||
class VotingNPCStore {
|
||||
static final int MAX_NPCS = 64;
|
||||
private static final int MAX_BYTES = 128 * 1024;
|
||||
private static final Set<String> FIELDS = Set.of("id", "identity", "world", "x", "y", "z", "yaw", "pitch", "name");
|
||||
private final Path file;
|
||||
private boolean initialized;
|
||||
private boolean uncertain;
|
||||
|
||||
VotingNPCStore(Path file) { this.file = file.toAbsolutePath().normalize(); }
|
||||
|
||||
synchronized List<VotingNPCDefinition> load() throws IOException {
|
||||
initialized = false;
|
||||
checkParent();
|
||||
BasicFileAttributes attributes;
|
||||
try { attributes = Files.readAttributes(file, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); }
|
||||
catch (NoSuchFileException missing) {
|
||||
if (uncertain) throw new IOException("Uncertain voting NPC definitions are missing", missing);
|
||||
initialized = true;
|
||||
return List.of();
|
||||
}
|
||||
if (!attributes.isRegularFile() || attributes.isSymbolicLink() || attributes.size() > MAX_BYTES) {
|
||||
throw new IOException("Voting NPC definitions are not a bounded regular file");
|
||||
}
|
||||
byte[] bytes;
|
||||
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
bytes = input.readNBytes(MAX_BYTES + 1);
|
||||
}
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Voting NPC definitions exceed limit");
|
||||
String text;
|
||||
try { text = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(bytes)).toString(); }
|
||||
catch (CharacterCodingException invalid) { throw new IOException("Invalid voting NPC encoding", invalid); }
|
||||
List<VotingNPCDefinition> result = decode(text);
|
||||
uncertain = false;
|
||||
initialized = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
synchronized void save(List<VotingNPCDefinition> definitions) throws IOException {
|
||||
if (!initialized || uncertain) throw new IOException("Load valid voting NPC definitions before editing");
|
||||
validate(definitions);
|
||||
byte[] bytes = encode(definitions).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Voting NPC definitions exceed limit");
|
||||
checkParent();
|
||||
if (Files.isSymbolicLink(file)) throw new IOException("Voting NPC definitions cannot be a symbolic link");
|
||||
Path staged = Files.createTempFile(file.getParent(), ".voting-npcs-", ".tmp");
|
||||
boolean published = false;
|
||||
Throwable primary = null;
|
||||
try {
|
||||
try (FileChannel output = FileChannel.open(staged, StandardOpenOption.WRITE)) {
|
||||
ByteBuffer buffer = ByteBuffer.wrap(bytes);
|
||||
while (buffer.hasRemaining()) output.write(buffer);
|
||||
output.force(true);
|
||||
}
|
||||
Files.move(staged, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
published = true;
|
||||
forceDirectory(file.getParent());
|
||||
} catch (IOException | RuntimeException | Error failure) {
|
||||
// A visible replacement followed by a failed directory sync is not a
|
||||
// rollback. Fence later mutations rather than overwrite it from old state.
|
||||
if (published) uncertain = true;
|
||||
primary = failure;
|
||||
throw failure;
|
||||
} finally {
|
||||
try { Files.deleteIfExists(staged); }
|
||||
catch (IOException cleanupFailure) {
|
||||
if (primary != null) primary.addSuppressed(cleanupFailure);
|
||||
else {
|
||||
if (published) uncertain = true;
|
||||
throw cleanupFailure;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void checkParent() throws IOException {
|
||||
// Operators may place the data directory below a directory alias. Only
|
||||
// the definitions and lease files themselves must remain non-links.
|
||||
if (!Files.isDirectory(file.getParent().toRealPath(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Voting NPC data directory is unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
/** Kept as a narrow I/O seam so failure-after-publication can be tested. */
|
||||
void forceDirectory(Path directory) throws IOException {
|
||||
DurableFiles.forceDirectory(directory);
|
||||
}
|
||||
|
||||
private static void validate(List<VotingNPCDefinition> values) throws IOException {
|
||||
if (values == null || values.size() > MAX_NPCS) throw new IOException("Voting NPC definition limit exceeded");
|
||||
Set<String> ids = new HashSet<>();
|
||||
Set<UUID> identities = new HashSet<>();
|
||||
for (VotingNPCDefinition value : values) {
|
||||
if (value == null || !ids.add(value.id()) || !identities.add(value.identity())) {
|
||||
throw new IOException("Duplicate or missing voting NPC identity");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String encode(List<VotingNPCDefinition> values) throws IOException {
|
||||
var text = new java.io.StringWriter();
|
||||
try (JsonWriter writer = new JsonWriter(text)) {
|
||||
writer.setIndent(" ");
|
||||
writer.beginObject().name("version").value(1).name("npcs").beginArray();
|
||||
for (VotingNPCDefinition value : values) {
|
||||
writer.beginObject().name("id").value(value.id()).name("identity").value(value.identity().toString())
|
||||
.name("world").value(value.world().toString()).name("x").value(value.x()).name("y").value(value.y())
|
||||
.name("z").value(value.z()).name("yaw").value(value.yaw()).name("pitch").value(value.pitch())
|
||||
.name("name").value(value.name()).endObject();
|
||||
}
|
||||
writer.endArray().endObject();
|
||||
}
|
||||
return text.toString() + "\n";
|
||||
}
|
||||
|
||||
private static List<VotingNPCDefinition> decode(String text) throws IOException {
|
||||
try (JsonReader reader = new JsonReader(new StringReader(text))) {
|
||||
Set<String> keys = new HashSet<>();
|
||||
List<VotingNPCDefinition> values = null;
|
||||
reader.beginObject();
|
||||
while (reader.hasNext()) {
|
||||
String key = reader.nextName();
|
||||
if (!keys.add(key)) throw new IOException("Duplicate voting NPC document field");
|
||||
if (key.equals("version")) {
|
||||
if (reader.peek() != JsonToken.NUMBER || !reader.nextString().equals("1")) {
|
||||
throw new IOException("Unsupported voting NPC document version");
|
||||
}
|
||||
} else if (key.equals("npcs")) {
|
||||
values = new ArrayList<>();
|
||||
reader.beginArray();
|
||||
while (reader.hasNext()) {
|
||||
if (values.size() >= MAX_NPCS) throw new IOException("Voting NPC definition limit exceeded");
|
||||
values.add(readDefinition(reader));
|
||||
}
|
||||
reader.endArray();
|
||||
} else throw new IOException("Unknown voting NPC document field");
|
||||
}
|
||||
reader.endObject();
|
||||
if (!keys.equals(Set.of("version", "npcs")) || reader.peek() != JsonToken.END_DOCUMENT) {
|
||||
throw new IOException("Incomplete or trailing voting NPC document");
|
||||
}
|
||||
validate(values);
|
||||
return List.copyOf(values);
|
||||
} catch (IllegalArgumentException | IllegalStateException invalid) {
|
||||
throw new IOException("Invalid voting NPC definitions", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static VotingNPCDefinition readDefinition(JsonReader reader) throws IOException {
|
||||
var fields = new java.util.HashMap<String, String>();
|
||||
reader.beginObject();
|
||||
while (reader.hasNext()) {
|
||||
String field = reader.nextName();
|
||||
if (!FIELDS.contains(field) || fields.containsKey(field)) throw new IOException("Unknown or duplicate voting NPC field");
|
||||
boolean numeric = Set.of("x", "y", "z", "yaw", "pitch").contains(field);
|
||||
if (reader.peek() != (numeric ? JsonToken.NUMBER : JsonToken.STRING)) throw new IOException("Invalid voting NPC field type");
|
||||
fields.put(field, reader.nextString());
|
||||
}
|
||||
reader.endObject();
|
||||
if (!fields.keySet().equals(FIELDS)) throw new IOException("Missing voting NPC field");
|
||||
return new VotingNPCDefinition(fields.get("id"), uuid(fields.get("identity")), uuid(fields.get("world")),
|
||||
Double.parseDouble(fields.get("x")), Double.parseDouble(fields.get("y")), Double.parseDouble(fields.get("z")),
|
||||
Float.parseFloat(fields.get("yaw")), Float.parseFloat(fields.get("pitch")), fields.get("name"));
|
||||
}
|
||||
|
||||
private static UUID uuid(String text) {
|
||||
UUID value = UUID.fromString(text);
|
||||
if (!value.toString().equals(text)) throw new IllegalArgumentException("Noncanonical voting NPC identity");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+240
-33
@@ -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);
|
||||
@@ -205,6 +304,12 @@ public class PlaceHolders {
|
||||
return "no player";
|
||||
}
|
||||
|
||||
// These ranks use bounded snapshot projections; persistent result caches outlive presence evidence.
|
||||
if (plugin.getConfigFile().isHideTopVoterIfNotPlayedBefore() && switch (identifier.toLowerCase(Locale.ROOT)) {
|
||||
case "top_all_position", "top_month_position", "top_lastmonth_position", "top_week_position",
|
||||
"top_daily_position" -> true;
|
||||
default -> false;
|
||||
}) useCache = false;
|
||||
VotingPluginUser user = resolvePlaceholderUser(p);
|
||||
if (useCache) {
|
||||
if (!cachedPlaceholders.contains(identifier)) {
|
||||
@@ -492,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"));
|
||||
|
||||
@@ -501,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"));
|
||||
|
||||
@@ -609,7 +716,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(VotingPluginUser user, String identifier) {
|
||||
int num = 1;
|
||||
UUID toMatch = UUID.fromString(user.getUUID());
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.AllTime).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.AllTime).entrySet()) {
|
||||
if (entry.getKey().getUuid().equals(toMatch)) {
|
||||
return "" + num;
|
||||
}
|
||||
@@ -627,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";
|
||||
@@ -705,7 +814,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(String identifier) {
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.AllTime).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.AllTime).entrySet()) {
|
||||
if (num == number) {
|
||||
return "" + entry.getValue().intValue();
|
||||
}
|
||||
@@ -724,7 +833,7 @@ public class PlaceHolders {
|
||||
}
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.AllTime).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.AllTime).entrySet()) {
|
||||
if (num == number) {
|
||||
return entry.getKey().getPlayerName();
|
||||
}
|
||||
@@ -740,7 +849,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(VotingPluginUser user, String identifier) {
|
||||
int num = 1;
|
||||
UUID toMatch = UUID.fromString(user.getUUID());
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Monthly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Monthly).entrySet()) {
|
||||
if (entry.getKey().getUuid().equals(toMatch)) {
|
||||
return "" + num;
|
||||
}
|
||||
@@ -757,7 +866,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(VotingPluginUser user, String identifier) {
|
||||
int num = 1;
|
||||
UUID toMatch = UUID.fromString(user.getUUID());
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getLastMonthTopVoter().entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getLastMonthTopVoterDisplay().entrySet()) {
|
||||
if (entry.getKey().getUuid().equals(toMatch)) {
|
||||
return "" + num;
|
||||
}
|
||||
@@ -775,7 +884,7 @@ public class PlaceHolders {
|
||||
}
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getLastMonthTopVoter().entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getLastMonthTopVoterDisplay().entrySet()) {
|
||||
if (num == number) {
|
||||
return entry.getKey().getPlayerName();
|
||||
}
|
||||
@@ -791,7 +900,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(String identifier) {
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getLastMonthTopVoter().entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getLastMonthTopVoterDisplay().entrySet()) {
|
||||
if (num == number) {
|
||||
return "" + entry.getValue().intValue();
|
||||
}
|
||||
@@ -812,7 +921,7 @@ public class PlaceHolders {
|
||||
}
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Monthly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Monthly).entrySet()) {
|
||||
if (num == number) {
|
||||
return entry.getKey().getPlayerName();
|
||||
}
|
||||
@@ -828,7 +937,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(String identifier) {
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Monthly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Monthly).entrySet()) {
|
||||
if (num == number) {
|
||||
return "" + entry.getValue().intValue();
|
||||
}
|
||||
@@ -844,7 +953,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(VotingPluginUser user, String identifier) {
|
||||
int num = 1;
|
||||
UUID toMatch = UUID.fromString(user.getUUID());
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Weekly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Weekly).entrySet()) {
|
||||
if (entry.getKey().getUuid().equals(toMatch)) {
|
||||
return "" + num;
|
||||
}
|
||||
@@ -863,7 +972,7 @@ public class PlaceHolders {
|
||||
}
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Weekly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Weekly).entrySet()) {
|
||||
if (num == number) {
|
||||
return entry.getKey().getPlayerName();
|
||||
}
|
||||
@@ -879,7 +988,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(String identifier) {
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Weekly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Weekly).entrySet()) {
|
||||
if (num == number) {
|
||||
return "" + entry.getValue().intValue();
|
||||
}
|
||||
@@ -895,7 +1004,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(VotingPluginUser user, String identifier) {
|
||||
int num = 1;
|
||||
UUID toMatch = UUID.fromString(user.getUUID());
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Daily).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Daily).entrySet()) {
|
||||
if (entry.getKey().getUuid().equals(toMatch)) {
|
||||
return "" + num;
|
||||
}
|
||||
@@ -914,7 +1023,7 @@ public class PlaceHolders {
|
||||
}
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Daily).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Daily).entrySet()) {
|
||||
if (num == number) {
|
||||
return entry.getKey().getPlayerName();
|
||||
}
|
||||
@@ -938,7 +1047,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(String identifier) {
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Daily).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Daily).entrySet()) {
|
||||
if (num == number) {
|
||||
return "" + entry.getValue().intValue();
|
||||
}
|
||||
@@ -954,7 +1063,7 @@ public class PlaceHolders {
|
||||
|
||||
@Override
|
||||
public String placeholderRequest(String identifier) {
|
||||
return Integer.toString(plugin.getVoteParty().getTotalVotes());
|
||||
return Integer.toString(plugin.getServerData().getVotePartyDisplay().total());
|
||||
}
|
||||
}.withDescription("Current amount of voteparty votes"));
|
||||
|
||||
@@ -962,7 +1071,8 @@ public class PlaceHolders {
|
||||
|
||||
@Override
|
||||
public String placeholderRequest(String identifier) {
|
||||
return Integer.toString(plugin.getVoteParty().getNeededVotes());
|
||||
return Integer.toString(plugin.getServerData().getVotePartyDisplay()
|
||||
.needed(plugin.getSpecialRewardsConfig().getVotePartyVotesRequired()));
|
||||
}
|
||||
}.withDescription("Voteparty votes needed"));
|
||||
|
||||
@@ -970,7 +1080,8 @@ public class PlaceHolders {
|
||||
|
||||
@Override
|
||||
public String placeholderRequest(String identifier) {
|
||||
return Integer.toString(plugin.getVoteParty().getVotesRequired());
|
||||
return Integer.toString(plugin.getServerData().getVotePartyDisplay()
|
||||
.required(plugin.getSpecialRewardsConfig().getVotePartyVotesRequired()));
|
||||
}
|
||||
}.withDescription("Amount of votes needed for voteparty"));
|
||||
|
||||
@@ -1407,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;
|
||||
@@ -1427,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;
|
||||
@@ -1436,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,
|
||||
@@ -1460,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
|
||||
@@ -1505,11 +1676,12 @@ public class PlaceHolders {
|
||||
/**
|
||||
* Update placeholders for specific user.
|
||||
* @param user the voting plugin user
|
||||
* @param login whether this is a login update
|
||||
* @param login whether to include uncached entries, such as a login or worker warmup
|
||||
*/
|
||||
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<>();
|
||||
@@ -1551,6 +1723,8 @@ public class PlaceHolders {
|
||||
* Reload placeholders.
|
||||
*/
|
||||
public void reload() {
|
||||
pointFence.invalidateLifecycle();
|
||||
pointRecoveryWorkers.clear();
|
||||
platformUpdateGenerations.clear();
|
||||
plugin.refreshPlaceholderPlayerPresence();
|
||||
cacheLevel = plugin.getConfigFile().getPlaceholderCacheLevel();
|
||||
@@ -1584,14 +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();
|
||||
onUpdate(user, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+106
-2
@@ -7,6 +7,8 @@ import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.bukkit.entity.Player;
|
||||
@@ -19,6 +21,97 @@ import org.bukkit.entity.Player;
|
||||
public final class PlaceholderPlayerPresence {
|
||||
private final AtomicReference<Map<UUID, Player>> onlinePlayers = new AtomicReference<>(Map.of());
|
||||
private final Object lifecycleLock = new Object();
|
||||
private final Map<Player, LiveIdentity> identities = new IdentityHashMap<>();
|
||||
private final Map<Player, Object> refreshReservations = new IdentityHashMap<>();
|
||||
|
||||
/** Immutable identity; lookup callers never need to access its Bukkit owner. */
|
||||
public record LiveIdentity(UUID serverUuid, String playerName, UUID storageUuid) { }
|
||||
|
||||
/** Capture only on this player's owning thread (join or entity scheduler). */
|
||||
public void captureOnline(UUID storageUuid, Player player) {
|
||||
if (storageUuid == null || player == null) return;
|
||||
LiveIdentity identity = new LiveIdentity(player.getUniqueId(), player.getName(), storageUuid);
|
||||
synchronized (lifecycleLock) {
|
||||
playerOnline(storageUuid, player);
|
||||
identities.put(player, identity);
|
||||
}
|
||||
}
|
||||
|
||||
/** Rekey only the current owner. Delayed login cannot resurrect a retired session. */
|
||||
public boolean updateStorageUuid(UUID uuid, Player player) {
|
||||
if (uuid == null || player == null) return false;
|
||||
synchronized (lifecycleLock) {
|
||||
if (storageUuid(player) == null) return false;
|
||||
Player currentOwner = onlinePlayers.get().get(uuid);
|
||||
if (currentOwner != null && currentOwner != player) return false;
|
||||
LiveIdentity identity = identities.get(player);
|
||||
playerOnline(uuid, player);
|
||||
if (identity != null) identities.put(player,
|
||||
new LiveIdentity(identity.serverUuid(), identity.playerName(), uuid));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshot-only lookup, with exact storage identities taking precedence over aliases. */
|
||||
public LiveIdentity liveIdentity(UUID uuid) {
|
||||
if (uuid == null) return null;
|
||||
synchronized (lifecycleLock) {
|
||||
Player exact = onlinePlayers.get().get(uuid);
|
||||
if (exact != null) return identities.get(exact);
|
||||
LiveIdentity found = null;
|
||||
for (LiveIdentity identity : identities.values()) {
|
||||
if (!uuid.equals(identity.serverUuid())) continue;
|
||||
if (found != null) return null; // Uncertain or ambiguous: preserve the supplied UUID.
|
||||
found = identity;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh membership without reading another region's players. Each scheduled
|
||||
* capture is fenced by its reservation; quit, join, another refresh and clear
|
||||
* invalidate it before it can publish stale identity data. The capture consumer
|
||||
* must run on that player's owning thread.
|
||||
*/
|
||||
public void refresh(Supplier<? extends Collection<? extends Player>> playersSupplier,
|
||||
BiConsumer<Player, Consumer<UUID>> scheduleCapture) {
|
||||
Map<Player, Object> reservations = new IdentityHashMap<>();
|
||||
synchronized (lifecycleLock) {
|
||||
Collection<? extends Player> players = playersSupplier.get();
|
||||
Map<Player, Boolean> members = new IdentityHashMap<>();
|
||||
if (players != null) for (Player player : players) {
|
||||
if (player != null) members.put(player, Boolean.TRUE);
|
||||
}
|
||||
Map<UUID, Player> retained = new HashMap<>(onlinePlayers.get());
|
||||
retained.entrySet().removeIf(entry -> !members.containsKey(entry.getValue()));
|
||||
onlinePlayers.set(Map.copyOf(retained));
|
||||
retainCurrentIdentities();
|
||||
refreshReservations.clear();
|
||||
members.keySet().forEach(player -> {
|
||||
Object reservation = new Object();
|
||||
refreshReservations.put(player, reservation);
|
||||
reservations.put(player, reservation);
|
||||
});
|
||||
}
|
||||
reservations.forEach((player, reservation) -> scheduleCapture.accept(player, storageUuid -> {
|
||||
synchronized (lifecycleLock) {
|
||||
if (refreshReservations.get(player) != reservation) return;
|
||||
refreshReservations.remove(player);
|
||||
if (storageUuid == null || !player.isOnline()) return;
|
||||
Player currentOwner = onlinePlayers.get().get(storageUuid);
|
||||
if (currentOwner != null && currentOwner != player) return;
|
||||
UUID serverUuid = player.getUniqueId();
|
||||
if (identities.entrySet().stream().anyMatch(entry -> entry.getKey() != player
|
||||
&& serverUuid.equals(entry.getValue().serverUuid()))) return;
|
||||
captureOnline(storageUuid, player);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private void retainCurrentIdentities() {
|
||||
identities.entrySet().removeIf(entry -> onlinePlayers.get().get(entry.getValue().storageUuid()) != entry.getKey());
|
||||
}
|
||||
|
||||
public boolean isOnline(UUID uuid) {
|
||||
return uuid != null && onlinePlayers.get().containsKey(uuid);
|
||||
@@ -46,24 +139,31 @@ public final class PlaceholderPlayerPresence {
|
||||
public void playerOnline(UUID uuid, Player player) {
|
||||
if (uuid == null || player == null) return;
|
||||
synchronized (lifecycleLock) {
|
||||
refreshReservations.remove(player);
|
||||
Player displacedOwner = onlinePlayers.get().get(uuid);
|
||||
if (displacedOwner != null && displacedOwner != player) refreshReservations.remove(displacedOwner);
|
||||
update(current -> {
|
||||
Map<UUID, Player> next = new HashMap<>(current);
|
||||
next.entrySet().removeIf(entry -> entry.getValue() == player && !entry.getKey().equals(uuid));
|
||||
next.put(uuid, player);
|
||||
return Map.copyOf(next);
|
||||
});
|
||||
retainCurrentIdentities();
|
||||
}
|
||||
}
|
||||
|
||||
public void playerOffline(UUID uuid) {
|
||||
if (uuid == null) return;
|
||||
synchronized (lifecycleLock) {
|
||||
Player owner = onlinePlayers.get().get(uuid);
|
||||
if (owner != null) refreshReservations.remove(owner);
|
||||
update(current -> {
|
||||
if (!current.containsKey(uuid)) return current;
|
||||
Map<UUID, Player> next = new HashMap<>(current);
|
||||
next.remove(uuid);
|
||||
return Map.copyOf(next);
|
||||
});
|
||||
retainCurrentIdentities();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,11 +171,13 @@ public final class PlaceholderPlayerPresence {
|
||||
public boolean playerOffline(UUID uuid, Player expectedOwner) {
|
||||
if (uuid == null || expectedOwner == null) return false;
|
||||
synchronized (lifecycleLock) {
|
||||
refreshReservations.remove(expectedOwner);
|
||||
Map<UUID, Player> current = onlinePlayers.get();
|
||||
if (current.get(uuid) != expectedOwner) return false;
|
||||
Map<UUID, Player> next = new HashMap<>(current);
|
||||
next.remove(uuid);
|
||||
onlinePlayers.set(Map.copyOf(next));
|
||||
retainCurrentIdentities();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -113,6 +215,8 @@ public final class PlaceholderPlayerPresence {
|
||||
}
|
||||
}
|
||||
onlinePlayers.set(Map.copyOf(next));
|
||||
refreshReservations.clear();
|
||||
retainCurrentIdentities();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,11 +226,11 @@ public final class PlaceholderPlayerPresence {
|
||||
if (players != null) players.forEach((uuid, player) -> {
|
||||
if (uuid != null && player != null) next.put(uuid, player);
|
||||
});
|
||||
synchronized (lifecycleLock) { onlinePlayers.set(Map.copyOf(next)); }
|
||||
synchronized (lifecycleLock) { onlinePlayers.set(Map.copyOf(next)); refreshReservations.clear(); retainCurrentIdentities(); }
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
synchronized (lifecycleLock) { onlinePlayers.set(Map.of()); }
|
||||
synchronized (lifecycleLock) { onlinePlayers.set(Map.of()); identities.clear(); refreshReservations.clear(); }
|
||||
}
|
||||
|
||||
/** Publish an offline-only result atomically with player lifecycle capture. */
|
||||
|
||||
+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();
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -123,8 +123,7 @@ public class ServiceSiteHandler {
|
||||
|
||||
public void saveToServerData() {
|
||||
List<String> out = toListFormat(sitesRef.get());
|
||||
plugin.getServerData().getData().set(SERVERDATA_PATH, out);
|
||||
plugin.getServerData().saveData();
|
||||
plugin.getServerData().setStringList(SERVERDATA_PATH, out);
|
||||
}
|
||||
|
||||
public void refreshFromWebOnce(boolean logMore) {
|
||||
|
||||
@@ -12,7 +12,6 @@ import org.bukkit.block.Skull;
|
||||
import org.bukkit.block.data.Directional;
|
||||
import org.bukkit.inventory.meta.SkullMeta;
|
||||
|
||||
import com.bencodez.advancedcore.api.misc.MiscUtils;
|
||||
import com.bencodez.simpleapi.array.ArrayUtils;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.topvoter.TopVoter;
|
||||
@@ -25,14 +24,14 @@ public class SignHandler {
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
private String data;
|
||||
private volatile String data;
|
||||
|
||||
/** The lines. */
|
||||
private ArrayList<String> lines;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
private Location location;
|
||||
private volatile Location location;
|
||||
|
||||
private String playerName;
|
||||
|
||||
@@ -48,7 +47,7 @@ public class SignHandler {
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
private Location skullLocation;
|
||||
private volatile Location skullLocation;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@@ -56,6 +55,88 @@ public class SignHandler {
|
||||
|
||||
private int votes;
|
||||
|
||||
private record Render(ArrayList<String> lines, String name, UUID identity, int votes) { }
|
||||
private record DisplayUpdate(long generation, Location location, String data) { }
|
||||
private DisplayUpdate preparing;
|
||||
private long skullSequence;
|
||||
private static final int MAX_DISCOVERED_SKULLS = 28;
|
||||
private final java.util.Set<Location> displayedSkulls = new java.util.LinkedHashSet<>();
|
||||
private SkullFrame lastSkullFrame;
|
||||
private record SkullFrame(long sequence, long generation, Location origin, Location selected, String data,
|
||||
String name, UUID identity) { }
|
||||
|
||||
private synchronized SkullFrame skullFrame(String name, UUID identity) {
|
||||
var frame = new SkullFrame(++skullSequence, plugin.getTopVoterDisplayGeneration(), location.clone(),
|
||||
skullLocation == null ? null : skullLocation.clone(), data, name, identity);
|
||||
if (lastSkullFrame == null || lastSkullFrame.generation() != frame.generation()
|
||||
|| !java.util.Objects.equals(lastSkullFrame.origin(), frame.origin())
|
||||
|| !java.util.Objects.equals(lastSkullFrame.selected(), frame.selected())
|
||||
|| !java.util.Objects.equals(lastSkullFrame.data(), frame.data())) displayedSkulls.clear();
|
||||
lastSkullFrame = frame;
|
||||
return frame;
|
||||
}
|
||||
|
||||
/** Remember successful automatic publications, never arbitrary neighboring skulls. */
|
||||
private synchronized void rememberSkull(SkullFrame frame, Location target) {
|
||||
if (lastSkullFrame == frame && frame.selected() == null && displayedSkulls.size() < MAX_DISCOVERED_SKULLS)
|
||||
displayedSkulls.add(target.clone());
|
||||
}
|
||||
|
||||
private synchronized void forgetSkull(SkullFrame frame, Location target) {
|
||||
if (lastSkullFrame == frame) displayedSkulls.remove(target);
|
||||
}
|
||||
|
||||
private synchronized boolean assignedSkull(SkullFrame frame, Location target) {
|
||||
return lastSkullFrame == frame && (java.util.Objects.equals(frame.selected(), target)
|
||||
|| displayedSkulls.contains(target));
|
||||
}
|
||||
|
||||
private synchronized ArrayList<Location> assignedSkulls(SkullFrame frame) {
|
||||
if (lastSkullFrame != frame) return new ArrayList<>();
|
||||
return new ArrayList<>(frame.selected() == null ? displayedSkulls : java.util.List.of(frame.selected()));
|
||||
}
|
||||
|
||||
private boolean current(SkullFrame frame) {
|
||||
if (!plugin.isEnabled() || plugin.getTopVoterDisplayGeneration() != frame.generation()) return false;
|
||||
synchronized (this) {
|
||||
return skullSequence == frame.sequence() && java.util.Objects.equals(location, frame.origin())
|
||||
&& java.util.Objects.equals(skullLocation, frame.selected())
|
||||
&& java.util.Objects.equals(data, frame.data());
|
||||
}
|
||||
}
|
||||
|
||||
/** Only coordinates cross regions; every block access occurs inside its own location task. */
|
||||
private void onOwner(SkullFrame frame, Location target, Runnable action) {
|
||||
try {
|
||||
plugin.getBukkitScheduler().runTask(plugin, () -> {
|
||||
if (current(frame)) action.run();
|
||||
}, target);
|
||||
} catch (RuntimeException rejected) { plugin.debug("Unable to schedule skull display"); }
|
||||
}
|
||||
|
||||
private void updateSkull(SkullFrame frame, Location target, Runnable absent) {
|
||||
onOwner(frame, target, () -> {
|
||||
Block block = target.getBlock();
|
||||
if (!(block.getState() instanceof Skull)) {
|
||||
forgetSkull(frame, target);
|
||||
if (absent != null) absent.run();
|
||||
return;
|
||||
}
|
||||
String name = frame.name(); UUID identity = frame.identity();
|
||||
// Queued region work may outlive the evidence used by the sign render.
|
||||
TopVoter period = displayPeriod();
|
||||
if (period != null && position > 0 && plugin.getConfigFile().isHideTopVoterIfNotPlayedBefore()) {
|
||||
var rows = new ArrayList<>(plugin.getTopVoterDisplay(period).keySet());
|
||||
if (rows.size() >= position) {
|
||||
name = rows.get(position - 1).getPlayerName(); identity = rows.get(position - 1).getUuid();
|
||||
} else { name = "No Player"; identity = null; }
|
||||
}
|
||||
if (!current(frame)) return;
|
||||
if (!checkSkull(block, target, name, identity, frame) && absent != null) absent.run();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new SignHandler.
|
||||
*
|
||||
@@ -81,19 +162,28 @@ public class SignHandler {
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
private boolean checkSkull(Block block) {
|
||||
if (block.getState() instanceof Skull) {
|
||||
if (!playerName.equals("No Player")) {
|
||||
Skull skull = (Skull) block.getState();
|
||||
private boolean checkSkull(Block block, Location target, String name, UUID identity, SkullFrame frame) {
|
||||
if (block.getState() instanceof Skull skull) {
|
||||
if (name.isEmpty() || name.equalsIgnoreCase("No Player")) {
|
||||
if (plugin.getConfigFile().isHideTopVoterIfNotPlayedBefore()) {
|
||||
if (!current(frame) || !assignedSkull(frame, target)) return true;
|
||||
skull.setOwnerProfile(null);
|
||||
if (current(frame) && skull.update(true, false)) forgetSkull(frame, target);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (!name.equals("No Player")) {
|
||||
try {
|
||||
SkullMeta meta = (SkullMeta) plugin.getSkullCacheHandler().getSkull(uuid, playerName).getItemMeta();
|
||||
skull.setOwnerProfile(meta.getOwnerProfile());
|
||||
skull.update(true, false);
|
||||
SkullMeta meta = (SkullMeta) plugin.getSkullCacheHandler().getSkull(identity, name).getItemMeta();
|
||||
var profile = java.util.Objects.requireNonNull(meta.getOwnerProfile(), "Skull profile unavailable");
|
||||
if (!current(frame)) return true;
|
||||
skull.setOwnerProfile(profile);
|
||||
if (current(frame) && skull.update(true, false)) rememberSkull(frame, target);
|
||||
} catch (Exception e) {
|
||||
plugin.debug("Failed to set skull for " + playerName);
|
||||
plugin.debug("Failed to set skull for " + name);
|
||||
plugin.debug(e);
|
||||
skull.setOwner(playerName);
|
||||
skull.update(true, false);
|
||||
if (!current(frame)) return true;
|
||||
if (skull.setOwner(name) && current(frame) && skull.update(true, false)) rememberSkull(frame, target);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -106,18 +196,24 @@ public class SignHandler {
|
||||
* Check skulls.
|
||||
*/
|
||||
public void checkSkulls() {
|
||||
if (noNameAvailable()) {
|
||||
return;
|
||||
}
|
||||
if (skullLocation != null) {
|
||||
checkSkull(skullLocation.getBlock());
|
||||
return;
|
||||
}
|
||||
Render rendered = currentRender();
|
||||
checkSkulls(rendered.name(), rendered.identity());
|
||||
}
|
||||
|
||||
Location loc = location;
|
||||
private void checkSkulls(String name, UUID identity) {
|
||||
if ((name.equalsIgnoreCase("No Player") || name.isEmpty())
|
||||
&& !plugin.getConfigFile().isHideTopVoterIfNotPlayedBefore()) {
|
||||
return;
|
||||
}
|
||||
SkullFrame frame = skullFrame(name, identity);
|
||||
if (frame.selected() != null) {
|
||||
updateSkull(frame, frame.selected(), null);
|
||||
return;
|
||||
}
|
||||
Location loc = frame.origin();
|
||||
Location loc1 = new Location(loc.getWorld(), loc.getBlockX() - 1, loc.getBlockY() - 1, loc.getBlockZ() - 1);
|
||||
Location loc2 = new Location(loc.getWorld(), loc.getBlockX() + 1, loc.getBlockY() + 1, loc.getBlockZ() + 1);
|
||||
updateSkulls(loc1, loc2);
|
||||
updateSkulls(loc1, loc2, frame);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,6 +286,12 @@ public class SignHandler {
|
||||
/**
|
||||
* Store sign.
|
||||
*/
|
||||
/** Capture platform state on the caller before shutdown transfers publication ownership. */
|
||||
public com.bencodez.votingplugin.data.ServerData.SignSnapshot captureShutdownSnapshot() {
|
||||
return com.bencodez.votingplugin.data.ServerData.captureSignSnapshot(
|
||||
sign, location, skullLocation, data, position);
|
||||
}
|
||||
|
||||
public void storeSign() {
|
||||
plugin.getServerData().setSign(sign, location, skullLocation, data, position);
|
||||
}
|
||||
@@ -198,95 +300,131 @@ public class SignHandler {
|
||||
* Update lines.
|
||||
*/
|
||||
public void updateLines() {
|
||||
lines = new ArrayList<>();
|
||||
checkValidSign();
|
||||
if (position != 0) {
|
||||
String line1 = plugin.getConfigFile().getFormatSignTopVoterSignLine1();
|
||||
String line2 = plugin.getConfigFile().getFormatSignTopVoterSignLine2();
|
||||
String line3 = plugin.getConfigFile().getFormatSignTopVoterSignLine3();
|
||||
String line4 = plugin.getConfigFile().getFormatSignTopVoterSignLine4();
|
||||
lines.add(line1);
|
||||
lines.add(line2);
|
||||
lines.add(line3);
|
||||
lines.add(line4);
|
||||
|
||||
ArrayList<TopVoterPlayer> users = null;
|
||||
if (data.equalsIgnoreCase("all")) {
|
||||
users = new ArrayList<>(plugin.getTopVoter(TopVoter.AllTime).keySet());
|
||||
} else if (data.equalsIgnoreCase("monthly")) {
|
||||
users = new ArrayList<>(plugin.getTopVoter(TopVoter.Monthly).keySet());
|
||||
} else if (data.equalsIgnoreCase("weekly")) {
|
||||
users = new ArrayList<>(plugin.getTopVoter(TopVoter.Weekly).keySet());
|
||||
} else if (data.equalsIgnoreCase("daily")) {
|
||||
users = new ArrayList<>(plugin.getTopVoter(TopVoter.Daily).keySet());
|
||||
}
|
||||
|
||||
if (users != null && users.size() >= position) {
|
||||
TopVoterPlayer user = users.get(position - 1);
|
||||
playerName = user.getPlayerName();
|
||||
uuid = user.getUuid();
|
||||
|
||||
votes = 0;
|
||||
if (data.equalsIgnoreCase("all")) {
|
||||
votes = plugin.getTopVoter(TopVoter.AllTime).get(user);
|
||||
} else if (data.equalsIgnoreCase("monthly")) {
|
||||
votes = plugin.getTopVoter(TopVoter.Monthly).get(user);
|
||||
} else if (data.equalsIgnoreCase("weekly")) {
|
||||
votes = plugin.getTopVoter(TopVoter.Weekly).get(user);
|
||||
} else if (data.equalsIgnoreCase("daily")) {
|
||||
votes = plugin.getTopVoter(TopVoter.Daily).get(user);
|
||||
}
|
||||
|
||||
for (int j = 0; j < lines.size(); j++) {
|
||||
lines.set(j, lines.get(j).replace("%votes%", "" + votes).replace("%player%", playerName));
|
||||
}
|
||||
} else {
|
||||
playerName = "No Player";
|
||||
votes = 0;
|
||||
for (int j = 0; j < lines.size(); j++) {
|
||||
lines.set(j, lines.get(j).replace("%votes%", "" + votes).replace("%player%", playerName));
|
||||
}
|
||||
}
|
||||
|
||||
for (int j = 0; j < lines.size(); j++) {
|
||||
lines.set(j, lines.get(j).replace("%SiteName%", data).replace("%position%", "" + position));
|
||||
}
|
||||
|
||||
lines = ArrayUtils.colorize(lines);
|
||||
|
||||
}
|
||||
|
||||
Render rendered = renderLines();
|
||||
synchronized (this) { applyRender(rendered); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Update sign.
|
||||
*
|
||||
* @param delay the delay
|
||||
*/
|
||||
private TopVoter displayPeriod() {
|
||||
if ("all".equalsIgnoreCase(data)) return TopVoter.AllTime;
|
||||
if ("monthly".equalsIgnoreCase(data)) return TopVoter.Monthly;
|
||||
if ("weekly".equalsIgnoreCase(data)) return TopVoter.Weekly;
|
||||
if ("daily".equalsIgnoreCase(data)) return TopVoter.Daily;
|
||||
return null;
|
||||
}
|
||||
|
||||
private Render renderLines() {
|
||||
var rendered = new ArrayList<String>();
|
||||
checkValidSign();
|
||||
if (position == 0) {
|
||||
synchronized (this) { return new Render(rendered, playerName, uuid, votes); }
|
||||
}
|
||||
rendered.add(plugin.getConfigFile().getFormatSignTopVoterSignLine1());
|
||||
rendered.add(plugin.getConfigFile().getFormatSignTopVoterSignLine2());
|
||||
rendered.add(plugin.getConfigFile().getFormatSignTopVoterSignLine3());
|
||||
rendered.add(plugin.getConfigFile().getFormatSignTopVoterSignLine4());
|
||||
TopVoter period = displayPeriod();
|
||||
java.util.Map<TopVoterPlayer, Integer> board = period == null ? java.util.Map.of()
|
||||
: plugin.getTopVoterDisplay(period);
|
||||
var users = new ArrayList<>(board.keySet());
|
||||
String name = "No Player";
|
||||
UUID identity = null;
|
||||
int amount = 0;
|
||||
if (users.size() >= position) {
|
||||
TopVoterPlayer row = users.get(position - 1);
|
||||
name = row.getPlayerName(); identity = row.getUuid(); amount = board.get(row);
|
||||
}
|
||||
for (int index = 0; index < rendered.size(); index++) {
|
||||
rendered.set(index, rendered.get(index).replace("%votes%", Integer.toString(amount))
|
||||
.replace("%player%", name).replace("%SiteName%", data)
|
||||
.replace("%position%", Integer.toString(position)));
|
||||
}
|
||||
return new Render(ArrayUtils.colorize(rendered), name, identity, amount);
|
||||
}
|
||||
|
||||
/** Called under this monitor; never call storage/provider APIs from this scope. */
|
||||
private void applyRender(Render rendered) {
|
||||
lines = rendered.lines(); playerName = rendered.name(); uuid = rendered.identity(); votes = rendered.votes();
|
||||
}
|
||||
|
||||
private Render currentRender() {
|
||||
synchronized (this) { return new Render(new ArrayList<>(lines), playerName, uuid, votes); }
|
||||
}
|
||||
|
||||
/** Update the sign on its owning scheduler, waiting once for admitted presence evidence when enabled. */
|
||||
public void updateSign(int delay) {
|
||||
plugin.getBukkitScheduler().runTaskLater(plugin, new Runnable() {
|
||||
TopVoter period = displayPeriod();
|
||||
if (position != 0 && period != null && plugin.getConfigFile() != null
|
||||
&& plugin.getConfigFile().isHideTopVoterIfNotPlayedBefore()) {
|
||||
prepareDisplay(period, delay);
|
||||
return;
|
||||
}
|
||||
plugin.getBukkitScheduler().runTaskLater(plugin, () -> writeSign(currentRender(), getLocation()),
|
||||
delay, getLocation());
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
@Override
|
||||
public void run() {
|
||||
private void prepareDisplay(TopVoter period, int delay) {
|
||||
long generation = plugin.getTopVoterDisplayGeneration();
|
||||
Location target = getLocation().clone();
|
||||
DisplayUpdate update;
|
||||
synchronized (this) {
|
||||
if (preparing != null && preparing.generation() == generation
|
||||
&& preparing.location().equals(target) && java.util.Objects.equals(preparing.data(), data)) return;
|
||||
update = new DisplayUpdate(generation, target, data);
|
||||
preparing = update;
|
||||
}
|
||||
try {
|
||||
var ready = plugin.getTopVoterDisplayAsync(period);
|
||||
if (ready == null) schedulePreparedDisplay(update, delay);
|
||||
else ready.whenComplete((ignored, failure) -> schedulePreparedDisplay(update, delay));
|
||||
} catch (RuntimeException unavailable) {
|
||||
schedulePreparedDisplay(update, delay);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean current(DisplayUpdate update) {
|
||||
return plugin.isEnabled() && plugin.getTopVoterDisplayGeneration() == update.generation()
|
||||
&& java.util.Objects.equals(update.location(), getLocation())
|
||||
&& java.util.Objects.equals(update.data(), data);
|
||||
}
|
||||
|
||||
private void schedulePreparedDisplay(DisplayUpdate update, int delay) {
|
||||
synchronized (this) { if (preparing != update) return; }
|
||||
try {
|
||||
plugin.getBukkitScheduler().runTaskLater(plugin, () -> {
|
||||
try {
|
||||
BlockState state = getLocation().getBlock().getState();
|
||||
if (state instanceof Sign) {
|
||||
Sign s = (Sign) state;
|
||||
|
||||
for (int j = 0; j < lines.size() && j < 4; j++) {
|
||||
s.setLine(j, lines.get(j));
|
||||
}
|
||||
s.update();
|
||||
checkSkulls();
|
||||
synchronized (SignHandler.this) { if (preparing != update) return; }
|
||||
if (!current(update)) return;
|
||||
// Resolve current evidence outside the sign monitor. Completion may reenter from the SDK worker.
|
||||
Render rendered = renderLines();
|
||||
if (!current(update)) return;
|
||||
synchronized (SignHandler.this) {
|
||||
if (preparing != update) return;
|
||||
applyRender(rendered);
|
||||
}
|
||||
|
||||
} catch (Exception ex) {
|
||||
plugin.debug("Failed to update sign at " + getLocation().toString());
|
||||
VotingPluginMain.plugin.debug(ex);
|
||||
writeSign(rendered, update.location());
|
||||
} finally {
|
||||
synchronized (SignHandler.this) { if (preparing == update) preparing = null; }
|
||||
}
|
||||
}, delay, update.location());
|
||||
} catch (RuntimeException rejected) {
|
||||
synchronized (this) { if (preparing == update) preparing = null; }
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
private void writeSign(Render rendered, Location target) {
|
||||
try {
|
||||
BlockState state = target.getBlock().getState();
|
||||
if (state instanceof Sign signState) {
|
||||
for (int index = 0; index < rendered.lines().size() && index < 4; index++)
|
||||
signState.setLine(index, rendered.lines().get(index));
|
||||
signState.update();
|
||||
checkSkulls(rendered.name(), rendered.identity());
|
||||
}
|
||||
}, delay, getLocation());
|
||||
} catch (Exception failure) {
|
||||
plugin.debug("Failed to update sign display");
|
||||
plugin.debug(failure);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -296,19 +434,38 @@ public class SignHandler {
|
||||
* @param loc2 the second location
|
||||
*/
|
||||
public void updateSkulls(Location loc1, Location loc2) {
|
||||
BlockState state = getLocation().getBlock().getState();
|
||||
if (state instanceof Sign && state.getBlockData() instanceof Directional) {
|
||||
Directional s = (Directional) state.getBlockData();
|
||||
Block b = location.getBlock().getRelative(s.getFacing());
|
||||
Block above = b.getRelative(BlockFace.UP);
|
||||
if (checkSkull(above)) {
|
||||
return;
|
||||
}
|
||||
Render rendered = currentRender();
|
||||
updateSkulls(loc1, loc2, rendered.name(), rendered.identity());
|
||||
}
|
||||
|
||||
}
|
||||
private void updateSkulls(Location loc1, Location loc2, String name, UUID identity) {
|
||||
updateSkulls(loc1, loc2, skullFrame(name, identity));
|
||||
}
|
||||
|
||||
for (Block block : MiscUtils.getInstance().getRegionBlocks(location.getWorld(), loc1, loc2)) {
|
||||
checkSkull(block);
|
||||
private void updateSkulls(Location loc1, Location loc2, SkullFrame frame) {
|
||||
if (frame.name().isEmpty() || frame.name().equalsIgnoreCase("No Player")) {
|
||||
if (plugin.getConfigFile().isHideTopVoterIfNotPlayedBefore())
|
||||
for (Location target : assignedSkulls(frame)) updateSkull(frame, target, null);
|
||||
return;
|
||||
}
|
||||
Runnable nearby = () -> {
|
||||
for (int x = Math.min(loc1.getBlockX(), loc2.getBlockX()); x <= Math.max(loc1.getBlockX(), loc2.getBlockX()); x++)
|
||||
for (int y = Math.min(loc1.getBlockY(), loc2.getBlockY()); y <= Math.max(loc1.getBlockY(), loc2.getBlockY()); y++)
|
||||
for (int z = Math.min(loc1.getBlockZ(), loc2.getBlockZ()); z <= Math.max(loc1.getBlockZ(), loc2.getBlockZ()); z++) {
|
||||
if (!current(frame)) return;
|
||||
updateSkull(frame, new Location(frame.origin().getWorld(), x, y, z), null);
|
||||
}
|
||||
};
|
||||
onOwner(frame, frame.origin(), () -> {
|
||||
BlockState state = frame.origin().getBlock().getState();
|
||||
if (state instanceof Sign && state.getBlockData() instanceof Directional direction) {
|
||||
BlockFace facing = direction.getFacing();
|
||||
Location preferred = new Location(frame.origin().getWorld(),
|
||||
frame.origin().getBlockX() + facing.getModX(),
|
||||
frame.origin().getBlockY() + facing.getModY() + 1,
|
||||
frame.origin().getBlockZ() + facing.getModZ());
|
||||
updateSkull(frame, preferred, nearby);
|
||||
} else nearby.run();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,69 @@ public class Signs {
|
||||
}
|
||||
}
|
||||
|
||||
private final Object shutdownLock = new Object();
|
||||
private ShutdownSigns shutdownSigns;
|
||||
private java.util.concurrent.CompletableFuture<Void> shutdownAttempt;
|
||||
|
||||
private record ShutdownSigns(com.bencodez.votingplugin.data.ServerData data,
|
||||
java.util.List<com.bencodez.votingplugin.data.ServerData.SignSnapshot> snapshot,
|
||||
com.bencodez.votingplugin.util.ServerDataPublicationFence.Lease lease) { }
|
||||
|
||||
/** No YAML lock or disk wait on the lifecycle caller; failures retain the original payload. */
|
||||
public java.util.concurrent.CompletionStage<Void> storeSignsForShutdown() {
|
||||
synchronized (shutdownLock) {
|
||||
if (shutdownAttempt != null && !shutdownAttempt.isCompletedExceptionally())
|
||||
return shutdownAttempt.minimalCompletionStage();
|
||||
if (shutdownSigns == null) {
|
||||
var snapshot = new java.util.ArrayList<com.bencodez.votingplugin.data.ServerData.SignSnapshot>();
|
||||
for (var sign : java.util.List.copyOf(getSigns())) snapshot.add(sign.captureShutdownSnapshot());
|
||||
if (snapshot.isEmpty()) {
|
||||
shutdownAttempt = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
return shutdownAttempt.minimalCompletionStage();
|
||||
}
|
||||
var data = java.util.Objects.requireNonNull(plugin.getServerData());
|
||||
var fence = java.util.Objects.requireNonNull(plugin.getServerDataPublicationFence());
|
||||
shutdownSigns = new ShutdownSigns(data, java.util.List.copyOf(snapshot), fence.acquire());
|
||||
}
|
||||
var publication = shutdownSigns;
|
||||
var attempt = new java.util.concurrent.CompletableFuture<Void>();
|
||||
shutdownAttempt = attempt;
|
||||
try {
|
||||
startShutdownWorker(() -> {
|
||||
try {
|
||||
java.util.Objects.requireNonNull(publication.data().publishShutdownSigns(publication.snapshot()))
|
||||
.whenComplete((ignored, failure) -> finishShutdownSigns(publication, attempt, failure));
|
||||
} catch (Throwable failure) { finishShutdownSigns(publication, attempt, failure); }
|
||||
});
|
||||
} catch (RuntimeException | Error failure) {
|
||||
finishShutdownSigns(publication, attempt, failure);
|
||||
throw failure;
|
||||
}
|
||||
return attempt.minimalCompletionStage();
|
||||
}
|
||||
}
|
||||
|
||||
private void finishShutdownSigns(ShutdownSigns publication,
|
||||
java.util.concurrent.CompletableFuture<Void> attempt, Throwable failure) {
|
||||
if (failure == null) {
|
||||
try { publication.lease().close(); attempt.complete(null); }
|
||||
catch (Throwable releaseFailure) { attempt.completeExceptionally(releaseFailure); }
|
||||
} else {
|
||||
// Neither rejected admission nor failed I/O proves durability or writer drain.
|
||||
// Keep the same detached payload and lease for an explicit repeated-unload attempt.
|
||||
try { plugin.getLogger().warning("Sign shutdown persistence failed; its payload remains owned. Recovery is not confirmed."); }
|
||||
catch (RuntimeException | Error ignored) { }
|
||||
attempt.completeExceptionally(failure);
|
||||
}
|
||||
}
|
||||
|
||||
/** Admission seam for deterministic rejection/cancellation tests. */
|
||||
protected void startShutdownWorker(Runnable task) {
|
||||
var worker = new Thread(task, "VotingPlugin-sign-shutdown");
|
||||
worker.setDaemon(true);
|
||||
worker.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update signs.
|
||||
*/
|
||||
|
||||
+3
-2
@@ -146,8 +146,9 @@ public class VoteParty implements Listener {
|
||||
public void commandVoteParty(CommandSender sender) {
|
||||
if (plugin.getSpecialRewardsConfig().isVotePartyEnabled()) {
|
||||
ArrayList<String> msg = plugin.getConfigFile().getFormatCommandsVoteParty();
|
||||
int votesRequired = getVotesRequired();
|
||||
int votes = getTotalVotes();
|
||||
var display = plugin.getServerData().getVotePartyDisplay();
|
||||
int votesRequired = display.required(plugin.getSpecialRewardsConfig().getVotePartyVotesRequired());
|
||||
int votes = display.total();
|
||||
int neededVotes = votesRequired - votes;
|
||||
HashMap<String, String> placeholders = new HashMap<>();
|
||||
placeholders.put("votesrequired", "" + votesRequired);
|
||||
|
||||
+1
-1
@@ -120,7 +120,7 @@ public final class VotePartyRewardHandler {
|
||||
return triggeringUser;
|
||||
}
|
||||
VotingPluginUser user = onlinePlayer == null
|
||||
? plugin.getVotingPluginUserManager().getVotingPluginUser(uuid)
|
||||
? plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, true)
|
||||
: plugin.getVotingPluginUserManager().getVotingPluginUser(onlinePlayer);
|
||||
user.userDataFetechMode(UserDataFetchMode.NO_CACHE);
|
||||
return user;
|
||||
|
||||
+2
-4
@@ -24,8 +24,7 @@ public final class VotePartyState {
|
||||
}
|
||||
|
||||
public void setTotalVotes(int value) {
|
||||
plugin.getServerData().getData().set(TOTAL_PATH, value);
|
||||
plugin.getServerData().saveData();
|
||||
plugin.getServerData().setInt(TOTAL_PATH, value);
|
||||
}
|
||||
|
||||
public List<String> getVotedUsers() {
|
||||
@@ -34,8 +33,7 @@ public final class VotePartyState {
|
||||
}
|
||||
|
||||
public void setVotedUsers(List<String> users) {
|
||||
plugin.getServerData().getData().set(VOTED_PATH, users);
|
||||
plugin.getServerData().saveData();
|
||||
plugin.getServerData().setStringList(VOTED_PATH, users);
|
||||
}
|
||||
|
||||
public int getVotesRequired() {
|
||||
|
||||
+21
@@ -40,6 +40,7 @@ public class VoteStreakHandler {
|
||||
private final Map<String, VoteStreakDefinition> byId = new LinkedHashMap<>();
|
||||
private final Map<String, VoteStreakDefinition> byProgressGroup = new LinkedHashMap<>();
|
||||
private final List<VoteStreakDefinition> ordered = new ArrayList<>();
|
||||
private volatile List<VoteStreakDefinition> displayDefinitions = List.of();
|
||||
|
||||
public VoteStreakHandler(VotingPluginMain plugin) {
|
||||
this.plugin = plugin;
|
||||
@@ -56,6 +57,26 @@ public class VoteStreakHandler {
|
||||
// VoteStreak definitions live in specialrewards.yml now (Option B)
|
||||
ConfigurationSection root = plugin.getSpecialRewardsConfig().getData();
|
||||
new VoteStreakConfigLoader().load(root);
|
||||
displayDefinitions = ordered.stream().filter(VoteStreakDefinition::isEnabled).toList();
|
||||
}
|
||||
|
||||
/** Worker-only, observational view. Does not advance periods, save state or execute rewards. */
|
||||
public com.bencodez.votingplugin.streaks.VoteStreaksSnapshot snapshotForDisplay(VotingPluginUser user) {
|
||||
var definitions = displayDefinitions;
|
||||
var periods = List.of(
|
||||
new com.bencodez.votingplugin.streaks.VoteStreaksSnapshot.Period(VoteStreakType.DAILY, user.getDayVoteStreak(), user.getBestDayVoteStreak()),
|
||||
new com.bencodez.votingplugin.streaks.VoteStreaksSnapshot.Period(VoteStreakType.WEEKLY, user.getWeekVoteStreak(), user.getBestWeekVoteStreak()),
|
||||
new com.bencodez.votingplugin.streaks.VoteStreaksSnapshot.Period(VoteStreakType.MONTHLY, user.getMonthVoteStreak(), user.getBestMonthVoteStreak()));
|
||||
var states = new LinkedHashMap<String, StreakState>();
|
||||
var rows = new ArrayList<com.bencodez.votingplugin.streaks.VoteStreaksSnapshot.Milestone>();
|
||||
for (var definition : definitions) {
|
||||
if (rows.size() >= com.bencodez.votingplugin.streaks.VoteStreaksSnapshot.MAX_MILESTONES) break;
|
||||
var state = states.computeIfAbsent(getColumnName(definition), ignored -> readState(user, definition));
|
||||
String label = definition.getId().codePoints().limit(80).collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append).toString();
|
||||
rows.add(new com.bencodez.votingplugin.streaks.VoteStreaksSnapshot.Milestone(label, definition.getType(), state.streakCount,
|
||||
state.bestStreakCount, definition.getRequiredAmount(), definition.getVotesRequired(), definition.isRecurring()));
|
||||
}
|
||||
return new com.bencodez.votingplugin.streaks.VoteStreaksSnapshot(periods, rows, definitions.size() > rows.size());
|
||||
}
|
||||
|
||||
public List<VoteStreakDefinition> getDefinitions() {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.bencodez.votingplugin.streaks;
|
||||
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import com.bencodez.advancedcore.api.command.CommandHandler;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
|
||||
public final class VoteStreaksCommand {
|
||||
private VoteStreaksCommand() { }
|
||||
public static void register(VotingPluginMain plugin) {
|
||||
plugin.getVoteCommand().add(new CommandHandler(plugin, new String[] { "Streaks" },
|
||||
VoteStreaksMenu.PERMISSION + "|VotingPlugin.Player", "View your vote streak progression", false) {
|
||||
@Override public void execute(CommandSender sender, String[] args) {
|
||||
if (sender instanceof Player player && plugin.getVoteStreaksMenu() != null) {
|
||||
plugin.getVoteStreaksMenu().open(player);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package com.bencodez.votingplugin.streaks;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.event.inventory.InventoryCloseEvent;
|
||||
import org.bukkit.event.inventory.InventoryDragEvent;
|
||||
import org.bukkit.event.inventory.InventoryOpenEvent;
|
||||
import org.bukkit.event.player.PlayerChangedWorldEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.InventoryHolder;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.ItemMeta;
|
||||
|
||||
import com.bencodez.simpleapi.messages.MessageAPI;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
|
||||
|
||||
/** One isolated native inventory view; no registrations in existing production GUI managers. */
|
||||
public final class VoteStreaksMenu implements Listener, AutoCloseable {
|
||||
public static final String PERMISSION = "VotingPlugin.Commands.Vote.Streaks";
|
||||
private static final int MAX_SESSIONS = 128, MAX_LOADS = 16;
|
||||
private final VotingPluginMain plugin;
|
||||
private final Consumer<Runnable> worker;
|
||||
private final BiFunction<UUID, String, VoteStreaksSnapshot> snapshot;
|
||||
private final Map<UUID, Session> sessions = new ConcurrentHashMap<>();
|
||||
private final AtomicInteger loading = new AtomicInteger();
|
||||
private final AtomicInteger reloadDepth = new AtomicInteger();
|
||||
private volatile boolean closed;
|
||||
|
||||
public VoteStreaksMenu(VotingPluginMain plugin) {
|
||||
this(plugin, task -> plugin.getUserManager().getDataManager().getTimer().execute(task), (uuid, name) ->
|
||||
plugin.getVoteStreakHandler().snapshotForDisplay(plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, name)));
|
||||
}
|
||||
VoteStreaksMenu(VotingPluginMain plugin, Consumer<Runnable> worker, BiFunction<UUID, String, VoteStreaksSnapshot> snapshot) {
|
||||
this.plugin = plugin; this.worker = worker; this.snapshot = snapshot;
|
||||
}
|
||||
private static final class Session {
|
||||
final Player player;
|
||||
final UUID owner;
|
||||
final String name;
|
||||
final Inventory previous;
|
||||
final boolean ownsPrevious;
|
||||
final long created = System.nanoTime();
|
||||
volatile Holder view;
|
||||
Session(Player player, VoteStreaksMenu manager) {
|
||||
this.player = player; owner = player.getUniqueId(); name = player.getName();
|
||||
previous = player.getOpenInventory().getTopInventory();
|
||||
Holder holder = manager.holder(previous);
|
||||
ownsPrevious = holder != null && holder.session.owner.equals(owner);
|
||||
}
|
||||
}
|
||||
static final class Holder implements InventoryHolder {
|
||||
final VoteStreaksMenu manager;
|
||||
final Session session;
|
||||
final VoteStreaksSnapshot snapshot;
|
||||
final int page;
|
||||
Inventory inventory;
|
||||
Holder(VoteStreaksMenu manager, Session session, VoteStreaksSnapshot snapshot, int page) {
|
||||
this.manager = manager; this.session = session; this.snapshot = snapshot; this.page = snapshot.clampPage(page);
|
||||
}
|
||||
@Override public Inventory getInventory() { return inventory; }
|
||||
}
|
||||
private static boolean permitted(Player player) {
|
||||
return player.isOnline() && (player.hasPermission(PERMISSION) || player.hasPermission("VotingPlugin.Player"));
|
||||
}
|
||||
private void onPlayer(Player player, Runnable work, Runnable rejected) {
|
||||
BukkitCompletionScheduler.run(plugin, player, work, rejected, rejected);
|
||||
}
|
||||
public void open(Player player) {
|
||||
onPlayer(player, () -> begin(player), () -> { });
|
||||
}
|
||||
private void begin(Player player) {
|
||||
if (closed || reloadDepth.get() != 0 || !permitted(player)) return;
|
||||
if (loading.incrementAndGet() > MAX_LOADS) {
|
||||
loading.decrementAndGet(); player.sendMessage("Vote streaks are busy. Please try again shortly."); return;
|
||||
}
|
||||
Session session;
|
||||
synchronized (sessions) {
|
||||
if (closed || reloadDepth.get() != 0 || (sessions.size() >= MAX_SESSIONS && !sessions.containsKey(player.getUniqueId()))) {
|
||||
loading.decrementAndGet(); player.sendMessage("Vote streaks are unavailable. Please try again shortly."); return;
|
||||
}
|
||||
session = new Session(player, this); sessions.put(session.owner, session);
|
||||
}
|
||||
try {
|
||||
worker.accept(() -> {
|
||||
try {
|
||||
if (!current(session)) return;
|
||||
VoteStreaksSnapshot data = snapshot.apply(session.owner, session.name);
|
||||
onPlayer(player, () -> {
|
||||
if (!current(session)) return;
|
||||
if (!permitted(player) || System.nanoTime() - session.created > 30_000_000_000L
|
||||
|| player.getOpenInventory().getTopInventory() != session.previous) {
|
||||
retire(session); return;
|
||||
}
|
||||
renderSafely(session, data, 0);
|
||||
}, () -> retire(session));
|
||||
} catch (RuntimeException failure) {
|
||||
onPlayer(player, () -> {
|
||||
if (retire(session) && player.isOnline())
|
||||
player.sendMessage("Unable to read vote streaks. Please try again later.");
|
||||
}, () -> retire(session));
|
||||
} finally { loading.decrementAndGet(); }
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
loading.decrementAndGet(); retire(session);
|
||||
player.sendMessage("Vote streaks are unavailable. Please try again later.");
|
||||
}
|
||||
}
|
||||
private boolean current(Session session) { return !closed && sessions.get(session.owner) == session; }
|
||||
private Holder holder(Inventory inventory) {
|
||||
return inventory != null && inventory.getHolder() instanceof Holder holder && holder.manager == this ? holder : null;
|
||||
}
|
||||
private void renderSafely(Session session, VoteStreaksSnapshot data, int page) {
|
||||
try { render(session, data, page); }
|
||||
catch (RuntimeException failure) {
|
||||
if (sessions.remove(session.owner, session)) {
|
||||
closeView(session);
|
||||
if (session.player.isOnline()) session.player.sendMessage("Unable to open vote streaks. Please try again later.");
|
||||
}
|
||||
}
|
||||
}
|
||||
private void navigate(Holder holder, int page) {
|
||||
onPlayer(holder.session.player, () -> {
|
||||
if (current(holder.session) && holder.session.view == holder
|
||||
&& holder.session.player.getOpenInventory().getTopInventory() == holder.inventory)
|
||||
renderSafely(holder.session, holder.snapshot, page);
|
||||
}, () -> sessions.remove(holder.session.owner, holder.session));
|
||||
}
|
||||
private void render(Session session, VoteStreaksSnapshot data, int page) {
|
||||
if (!current(session) || !permitted(session.player)) return;
|
||||
Holder holder = new Holder(this, session, data, page);
|
||||
String title = plugin.getGui().getChestVoteStreaksName();
|
||||
holder.inventory = Bukkit.createInventory(holder, 54, MessageAPI.colorize(title));
|
||||
for (int i = 0; i < data.periods().size(); i++) {
|
||||
var period = data.periods().get(i); String unit = VoteStreaksSnapshot.unit(period.type());
|
||||
holder.inventory.setItem(11 + i * 2, item(Material.CLOCK, "&6" + period.type() + " streak",
|
||||
List.of("&7Current: &e" + period.current() + " " + unit, "&7Best: &e" + period.best() + " " + unit)));
|
||||
}
|
||||
List<VoteStreaksSnapshot.Milestone> milestones = data.page(holder.page);
|
||||
for (int i = 0; i < milestones.size(); i++) {
|
||||
var milestone = milestones.get(i); String unit = VoteStreaksSnapshot.unit(milestone.type());
|
||||
var lore = new ArrayList<String>();
|
||||
lore.add("&7Current: &e" + milestone.current() + " " + unit);
|
||||
lore.add("&7Best: &e" + milestone.best() + " " + unit);
|
||||
lore.add("&7Milestone: &e" + milestone.threshold() + " " + unit);
|
||||
lore.add("&7Votes per period: &e" + milestone.votesRequired());
|
||||
lore.add(milestone.recurring() ? "&7Repeats every " + milestone.threshold() + " " + unit + "; next: " + milestone.nextThreshold()
|
||||
: milestone.current() >= milestone.threshold() ? "&aCurrent streak meets this milestone" : "&7Upcoming milestone");
|
||||
lore.add("&7Progress only; this does not claim rewards.");
|
||||
holder.inventory.setItem(20 + i, item(Material.PAPER, "&e" + milestone.id(), lore));
|
||||
}
|
||||
if (milestones.isEmpty()) holder.inventory.setItem(22, item(Material.PAPER, "&7No enabled streak milestones",
|
||||
List.of("&7Configure VoteStreaks in SpecialRewards.yml.")));
|
||||
holder.inventory.setItem(45, item(Material.ARROW, "&ePrevious", List.of()));
|
||||
holder.inventory.setItem(49, item(Material.BARRIER, "&cClose", List.of()));
|
||||
holder.inventory.setItem(50, item(Material.CLOCK, "&eRefresh", List.of("&7Read current streaks again.")));
|
||||
holder.inventory.setItem(53, item(Material.ARROW, "&eNext", List.of("&7Page " + (holder.page + 1) + "/" + data.pages(),
|
||||
data.truncated() ? "&7Showing the first 512 enabled milestones." : "&7Read-only streak progression.")));
|
||||
session.view = holder;
|
||||
if (!current(session) || !permitted(session.player)) return;
|
||||
session.player.openInventory(holder.inventory);
|
||||
if (!current(session) && session.player.getOpenInventory().getTopInventory() == holder.inventory)
|
||||
session.player.closeInventory();
|
||||
if (session.player.getOpenInventory().getTopInventory() != holder.inventory) sessions.remove(session.owner, session);
|
||||
}
|
||||
private static ItemStack item(Material material, String name, List<String> lore) {
|
||||
ItemStack item = new ItemStack(material); ItemMeta meta = item.getItemMeta();
|
||||
if (meta != null) {
|
||||
meta.setDisplayName(MessageAPI.colorize(name));
|
||||
meta.setLore(lore.stream().map(MessageAPI::colorize).toList()); item.setItemMeta(meta);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
|
||||
public void inventoryOpen(InventoryOpenEvent event) {
|
||||
if (!(event.getPlayer() instanceof Player player)) return;
|
||||
Holder holder = holder(event.getInventory());
|
||||
if (holder == null) return;
|
||||
if (holder.session.player != player || !current(holder.session) || holder.session.view != holder || !permitted(player)) {
|
||||
event.setCancelled(true); return;
|
||||
}
|
||||
if (event.isCancelled()) sessions.remove(holder.session.owner, holder.session);
|
||||
}
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void unrelatedInventoryOpen(InventoryOpenEvent event) {
|
||||
if (!event.isCancelled() && holder(event.getInventory()) == null && event.getPlayer() instanceof Player player)
|
||||
sessions.remove(player.getUniqueId());
|
||||
}
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
|
||||
public void click(InventoryClickEvent event) {
|
||||
Holder holder = holder(event.getView().getTopInventory());
|
||||
if (holder == null) return;
|
||||
boolean cancelled = event.isCancelled(); event.setCancelled(true);
|
||||
if (cancelled || event.getWhoClicked() != holder.session.player || !current(holder.session)
|
||||
|| holder.session.view != holder || !permitted(holder.session.player)) return;
|
||||
// Only ordinary top-inventory clicks navigate; shift/number/drop actions cannot move previews.
|
||||
if (event.getRawSlot() < 0 || event.getRawSlot() >= 54 || !event.isLeftClick() || event.isShiftClick()) return;
|
||||
switch (event.getRawSlot()) {
|
||||
case 45 -> navigate(holder, holder.page - 1);
|
||||
case 53 -> navigate(holder, holder.page + 1);
|
||||
case 49 -> { sessions.remove(holder.session.owner, holder.session); closeView(holder.session); }
|
||||
case 50 -> begin(holder.session.player);
|
||||
default -> { }
|
||||
}
|
||||
}
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
|
||||
public void drag(InventoryDragEvent event) {
|
||||
if (holder(event.getView().getTopInventory()) != null) event.setCancelled(true);
|
||||
}
|
||||
@EventHandler public void inventoryClose(InventoryCloseEvent event) {
|
||||
Holder holder = holder(event.getInventory());
|
||||
if (holder != null && holder.session.view == holder) sessions.remove(holder.session.owner, holder.session);
|
||||
else if (event.getPlayer() instanceof Player player) {
|
||||
Session pending = sessions.get(player.getUniqueId());
|
||||
if (pending != null && pending.view == null && pending.previous == event.getInventory()) sessions.remove(pending.owner, pending);
|
||||
}
|
||||
}
|
||||
@EventHandler public void quit(PlayerQuitEvent event) { sessions.remove(event.getPlayer().getUniqueId()); }
|
||||
@EventHandler public void worldChange(PlayerChangedWorldEvent event) { end(event.getPlayer()); }
|
||||
@EventHandler public void death(PlayerDeathEvent event) { end(event.getEntity()); }
|
||||
private void end(Player player) { Session session = sessions.remove(player.getUniqueId()); if (session != null) closeView(session); }
|
||||
private boolean retire(Session session) {
|
||||
if (!sessions.remove(session.owner, session)) return false;
|
||||
closeView(session); return true;
|
||||
}
|
||||
private void closeView(Session session) {
|
||||
if (session.view == null && !session.ownsPrevious) return;
|
||||
Runnable cleanup = () -> {
|
||||
// A queued cleanup must never close an inventory adopted by a newer request.
|
||||
Session replacement = sessions.get(session.owner);
|
||||
if (replacement != null && replacement != session) return;
|
||||
Inventory top = session.player.getOpenInventory().getTopInventory();
|
||||
if ((session.view != null && top == session.view.inventory)
|
||||
|| (session.ownsPrevious && top == session.previous)) session.player.closeInventory();
|
||||
};
|
||||
// Bukkit rejects scheduling after disable. Its primary thread already owns this interaction.
|
||||
if (closed && ownsForShutdown(Bukkit.class, session.player)) cleanup.run();
|
||||
else onPlayer(session.player, cleanup, () -> { });
|
||||
}
|
||||
/** Public Folia ownership also recognizes its stopped-region shutdown owner. */
|
||||
static boolean ownsForShutdown(Class<?> platform, Player player) {
|
||||
try {
|
||||
return Boolean.TRUE.equals(platform.getMethod("isOwnedByCurrentRegion", org.bukkit.entity.Entity.class).invoke(null, player));
|
||||
} catch (NoSuchMethodException legacy) {
|
||||
try {
|
||||
Class.forName("io.papermc.paper.threadedregions.RegionizedServer", false, VoteStreaksMenu.class.getClassLoader());
|
||||
return false; // A regional server without its ownership API must fail closed.
|
||||
} catch (ClassNotFoundException ordinaryServer) { return Bukkit.isPrimaryThread(); }
|
||||
catch (LinkageError unavailable) { return false; }
|
||||
} catch (ReflectiveOperationException | LinkageError unavailable) { return false; }
|
||||
}
|
||||
public void suspend() { reloadDepth.incrementAndGet(); reset(); }
|
||||
public void resume() { reloadDepth.updateAndGet(value -> Math.max(0, value - 1)); }
|
||||
public void reset() {
|
||||
List<Session> old;
|
||||
synchronized (sessions) { old = List.copyOf(sessions.values()); sessions.clear(); }
|
||||
old.forEach(this::closeView);
|
||||
}
|
||||
@Override public void close() { synchronized (sessions) { closed = true; } reset(); }
|
||||
int sessionCount() { return sessions.size(); }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.bencodez.votingplugin.streaks;
|
||||
|
||||
import java.util.List;
|
||||
import com.bencodez.votingplugin.specialrewards.votestreak.VoteStreakType;
|
||||
|
||||
/** Read-only observations of existing state; never evidence that a reward was delivered. */
|
||||
public record VoteStreaksSnapshot(List<Period> periods, List<Milestone> milestones, boolean truncated) {
|
||||
public static final int MAX_MILESTONES = 512;
|
||||
public static final int PAGE_SIZE = 5;
|
||||
public VoteStreaksSnapshot {
|
||||
periods = List.copyOf(periods);
|
||||
milestones = List.copyOf(milestones);
|
||||
if (periods.size() != 3 || milestones.size() > MAX_MILESTONES) throw new IllegalArgumentException("Invalid streak snapshot size");
|
||||
}
|
||||
public record Period(VoteStreakType type, int current, int best) {
|
||||
public Period { current = Math.max(0, current); best = Math.max(current, best); }
|
||||
}
|
||||
public record Milestone(String id, VoteStreakType type, int current, int best,
|
||||
int threshold, int votesRequired, boolean recurring) {
|
||||
public Milestone {
|
||||
current = Math.max(0, current); best = Math.max(current, best);
|
||||
threshold = Math.max(1, threshold); votesRequired = Math.max(1, votesRequired);
|
||||
}
|
||||
public long nextThreshold() {
|
||||
return recurring ? ((long) current / threshold + 1L) * threshold : threshold;
|
||||
}
|
||||
}
|
||||
public int pages() { return Math.max(1, (milestones.size() + PAGE_SIZE - 1) / PAGE_SIZE); }
|
||||
public int clampPage(int page) { return Math.max(0, Math.min(page, pages() - 1)); }
|
||||
public List<Milestone> page(int page) {
|
||||
int start = clampPage(page) * PAGE_SIZE;
|
||||
return milestones.subList(start, Math.min(start + PAGE_SIZE, milestones.size()));
|
||||
}
|
||||
public static String unit(VoteStreakType type) {
|
||||
return switch (type) { case DAILY -> "days"; case WEEKLY -> "weeks"; case MONTHLY -> "months"; };
|
||||
}
|
||||
}
|
||||
+254
-31
@@ -3,10 +3,15 @@ package com.bencodez.votingplugin.timequeue;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Queue;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.FutureTask;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.event.EventHandler;
|
||||
@@ -24,11 +29,24 @@ import lombok.Getter;
|
||||
* The TimeQueueHandler class manages time-based vote queue processing.
|
||||
*/
|
||||
public class TimeQueueHandler implements Listener {
|
||||
private static final long TICKS_PER_SECOND = 20L;
|
||||
@Getter
|
||||
private Queue<VoteTimeQueue> timeChangeQueue = new ConcurrentLinkedQueue<>();
|
||||
|
||||
private VotingPluginMain plugin;
|
||||
private final Object persistenceLock = new Object();
|
||||
private volatile boolean stopping;
|
||||
private final AtomicReference<FutureTask<Void>> shutdownSave = new AtomicReference<>();
|
||||
// A failed task is retryable, but cannot release its unsaved suffix to a successor.
|
||||
private final AtomicReference<ShutdownPublication> shutdownPublicationOwner = new AtomicReference<>();
|
||||
private record ShutdownPublication(com.bencodez.votingplugin.util.ServerDataPublicationFence.Lease lease,
|
||||
java.util.concurrent.ScheduledExecutorService producers,
|
||||
java.util.concurrent.ScheduledExecutorService[] coreWriters) { }
|
||||
private final AtomicBoolean processing = new AtomicBoolean();
|
||||
private VoteTimeQueue dispatchingVote;
|
||||
private UUID dispatchId;
|
||||
private boolean dispatchEnded;
|
||||
private boolean dispatchStarted;
|
||||
private int persistedShutdownVotes;
|
||||
private final AtomicBoolean retryPending = new AtomicBoolean();
|
||||
private final AtomicInteger retryAttempts = new AtomicInteger();
|
||||
|
||||
@@ -49,8 +67,13 @@ public class TimeQueueHandler implements Listener {
|
||||
* @param voteSiteName the vote site name
|
||||
*/
|
||||
public void addVote(String voteUsername, String voteSiteName) {
|
||||
timeChangeQueue.add(new VoteTimeQueue(voteUsername, voteSiteName,
|
||||
LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()));
|
||||
synchronized (persistenceLock) {
|
||||
timeChangeQueue.add(new VoteTimeQueue(voteUsername, voteSiteName,
|
||||
LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()));
|
||||
// A vote task can outlive shutdownNow(). Persist its late admission
|
||||
// through the same owner rather than appending to an already-saved queue.
|
||||
if (stopping) persistPendingVotes();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,17 +99,11 @@ public class TimeQueueHandler implements Listener {
|
||||
}
|
||||
|
||||
private void scheduleQueueProcessing(long delay, TimeUnit unit) {
|
||||
boolean admitted = VoteTaskAdmission.trySchedule(plugin.getVoteTimer(), () -> {
|
||||
// Clear only after the bounded executor has admitted the task. If it is
|
||||
// rejected, shutdown persistence can still recover the in-memory queue.
|
||||
plugin.getServerData().clearTimedVoteCache();
|
||||
processQueue();
|
||||
}, delay, unit);
|
||||
synchronized (persistenceLock) { if (stopping) return; }
|
||||
boolean admitted = VoteTaskAdmission.trySchedule(plugin.getVoteTimer(), this::processQueue, delay, unit);
|
||||
if (!admitted) {
|
||||
plugin.getLogger().warning("Unable to schedule time-queue processing because vote processing is busy; queued votes were retained.");
|
||||
scheduleRetry();
|
||||
} else {
|
||||
retryAttempts.set(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,15 +111,14 @@ public class TimeQueueHandler implements Listener {
|
||||
if (timeChangeQueue.isEmpty() || !retryPending.compareAndSet(false, true)) return;
|
||||
int attempt = Math.min(6, retryAttempts.getAndIncrement());
|
||||
long delaySeconds = Math.min(60L, 1L << attempt);
|
||||
long delayTicks = delaySeconds * TICKS_PER_SECOND;
|
||||
try {
|
||||
plugin.getBukkitScheduler().runTaskLaterAsynchronously(plugin, () -> {
|
||||
retryPending.set(false);
|
||||
if (!timeChangeQueue.isEmpty()) scheduleQueueProcessing(0, TimeUnit.SECONDS);
|
||||
}, delayTicks);
|
||||
}, delaySeconds);
|
||||
} catch (RuntimeException rejected) {
|
||||
retryPending.set(false);
|
||||
plugin.getLogger().warning("Unable to queue a time-queue retry; pending votes remain persisted for recovery.");
|
||||
plugin.getLogger().warning("Unable to queue a time-queue retry; pending votes remain owned for recovery.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,32 +126,239 @@ public class TimeQueueHandler implements Listener {
|
||||
* Processes all votes in the queue.
|
||||
*/
|
||||
public void processQueue() {
|
||||
while (getTimeChangeQueue().size() > 0) {
|
||||
VoteTimeQueue vote = getTimeChangeQueue().remove();
|
||||
PlayerVoteEvent voteEvent = new PlayerVoteEvent(
|
||||
plugin.getVoteSiteManager().getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, vote.getService()), true), vote.getName(),
|
||||
vote.getService(), true);
|
||||
voteEvent.setTime(vote.getTime());
|
||||
plugin.getServer().getPluginManager().callEvent(voteEvent);
|
||||
|
||||
if (voteEvent.isCancelled()) {
|
||||
plugin.debug("Vote cancelled");
|
||||
continue;
|
||||
if (!processing.compareAndSet(false, true)) return;
|
||||
boolean retry = false;
|
||||
try {
|
||||
synchronized (persistenceLock) {
|
||||
if (stopping) return;
|
||||
if (dispatchingVote != null) {
|
||||
if (!dispatchEnded) return;
|
||||
retainUncertainDispatch();
|
||||
clearDispatch();
|
||||
}
|
||||
persistedShutdownVotes = 0;
|
||||
}
|
||||
while (true) {
|
||||
VoteTimeQueue vote;
|
||||
synchronized (persistenceLock) {
|
||||
if (stopping || dispatchingVote != null) return;
|
||||
vote = timeChangeQueue.peek();
|
||||
}
|
||||
if (vote == null) return;
|
||||
PlayerVoteEvent voteEvent = new PlayerVoteEvent(
|
||||
plugin.getVoteSiteManager().getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, vote.getService()), true), vote.getName(),
|
||||
vote.getService(), true);
|
||||
voteEvent.setTime(vote.getTime());
|
||||
synchronized (persistenceLock) {
|
||||
if (stopping) return;
|
||||
if (timeChangeQueue.peek() != vote) continue;
|
||||
dispatchingVote = vote;
|
||||
if (vote.getVoteId() == null) vote.setVoteId(UUID.randomUUID());
|
||||
dispatchId = vote.getVoteId();
|
||||
dispatchEnded = false;
|
||||
try {
|
||||
plugin.getServerData().prepareTimedVoteDispatch(dispatchId, vote,
|
||||
timeChangeQueue.stream().filter(pending -> pending != vote).toList());
|
||||
} catch (RuntimeException | Error failure) {
|
||||
clearDispatch();
|
||||
throw failure;
|
||||
}
|
||||
// A preparation resumed after stop is still eligible for normal recovery.
|
||||
if (stopping) return;
|
||||
dispatchStarted = true;
|
||||
}
|
||||
try {
|
||||
plugin.getServer().getPluginManager().callEvent(voteEvent);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
synchronized (persistenceLock) {
|
||||
dispatchEnded = true;
|
||||
try {
|
||||
retainUncertainDispatch();
|
||||
clearDispatch();
|
||||
if (!stopping) checkpointPendingVotes();
|
||||
} catch (RuntimeException | Error persistenceFailure) {
|
||||
if (failure != persistenceFailure) failure.addSuppressed(persistenceFailure);
|
||||
}
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
synchronized (persistenceLock) {
|
||||
dispatchEnded = true;
|
||||
if (!stopping) plugin.getServerData().completeTimedVoteDispatch(dispatchId);
|
||||
timeChangeQueue.remove(vote);
|
||||
clearDispatch();
|
||||
if (voteEvent.isCancelled() && !stopping) checkpointPendingVotes();
|
||||
}
|
||||
if (voteEvent.isCancelled()) {
|
||||
plugin.debug("Vote cancelled");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException | Error failure) {
|
||||
retry = !stopping;
|
||||
throw failure;
|
||||
} finally {
|
||||
processing.set(false);
|
||||
if (retry) scheduleRetry();
|
||||
else retryAttempts.set(0);
|
||||
}
|
||||
}
|
||||
|
||||
private void clearDispatch() {
|
||||
dispatchingVote = null;
|
||||
dispatchId = null;
|
||||
dispatchEnded = false;
|
||||
dispatchStarted = false;
|
||||
}
|
||||
|
||||
private void checkpointPendingVotes() {
|
||||
plugin.getServerData().replaceTimedVoteCache(
|
||||
timeChangeQueue.stream().filter(vote -> vote != dispatchingVote).toList());
|
||||
persistedShutdownVotes = 0;
|
||||
}
|
||||
|
||||
/** Uncertain event delivery is retained for review, never automatically replayed. */
|
||||
private void retainUncertainDispatch() {
|
||||
if (dispatchingVote == null || !timeChangeQueue.contains(dispatchingVote)) return;
|
||||
plugin.getServerData().addUncertainTimedVote(dispatchId, dispatchingVote);
|
||||
timeChangeQueue.remove(dispatchingVote);
|
||||
plugin.getLogger().warning("Timed vote event delivery is uncertain; retained in ServerData.yml VotingPlugin.UncertainTimedVotes for manual review.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves pending votes to server data.
|
||||
*/
|
||||
public void save() {
|
||||
if (!timeChangeQueue.isEmpty()) {
|
||||
int num = 0;
|
||||
for (VoteTimeQueue vote : timeChangeQueue) {
|
||||
plugin.getServerData().addTimeVoted(num, vote);
|
||||
num++;
|
||||
if (stopping) {
|
||||
saveForShutdown();
|
||||
return;
|
||||
}
|
||||
synchronized (persistenceLock) {
|
||||
if (stopping) persistPendingVotes();
|
||||
else checkpointPendingVotes();
|
||||
}
|
||||
}
|
||||
|
||||
/** Seal processing and retain one persistence owner without an unbounded lifecycle wait. */
|
||||
public void saveForShutdown() {
|
||||
stopping = true;
|
||||
FutureTask<Void> save;
|
||||
while (true) {
|
||||
save = shutdownSave.get();
|
||||
if (save != null) {
|
||||
if (!save.isDone()) break;
|
||||
shutdownSave.compareAndSet(save, null);
|
||||
continue;
|
||||
}
|
||||
FutureTask<Void> candidate = new FutureTask<Void>(() -> {
|
||||
try {
|
||||
var publication = java.util.Objects.requireNonNull(shutdownPublicationOwner.get());
|
||||
try { synchronized (persistenceLock) { persistPendingVotes(); } }
|
||||
finally {
|
||||
// Publication failure does not terminate previously admitted producers.
|
||||
awaitRetiringProducers(publication.producers());
|
||||
if (publication.coreWriters() != null)
|
||||
for (var writer : publication.coreWriters()) awaitWriterTermination(writer);
|
||||
}
|
||||
synchronized (persistenceLock) { persistPendingVotes(); }
|
||||
if (publication.lease() != null) publication.lease().close();
|
||||
shutdownPublicationOwner.compareAndSet(publication, null);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
try {
|
||||
plugin.getLogger().warning("Timed vote shutdown persistence failed; pending votes remain owned for retry. Recovery is not confirmed.");
|
||||
} catch (RuntimeException | Error ignored) { }
|
||||
throw failure;
|
||||
}
|
||||
return null;
|
||||
}) {
|
||||
@Override protected void done() {
|
||||
// Late admission failures must be retryable after this owner completes.
|
||||
shutdownSave.compareAndSet(this, null);
|
||||
}
|
||||
};
|
||||
FutureTask<Void> existing = shutdownSave.compareAndExchange(null, candidate);
|
||||
if (existing == null) {
|
||||
save = candidate;
|
||||
try {
|
||||
if (shutdownPublicationOwner.get() == null) {
|
||||
// Unload can clear these fields even while their original work survives.
|
||||
// Keep the snapshot with its lease through every rejected or failed attempt.
|
||||
var producers = plugin.getVoteTimer();
|
||||
var coreWriters = plugin.getServerDataWriterExecutors();
|
||||
var retainedWriters = coreWriters == null ? null : coreWriters.clone();
|
||||
var fence = plugin.getServerDataPublicationFence();
|
||||
shutdownPublicationOwner.set(new ShutdownPublication(
|
||||
fence == null ? null : fence.acquire(), producers, retainedWriters));
|
||||
}
|
||||
startShutdownWorker(candidate);
|
||||
}
|
||||
catch (RuntimeException | Error failure) {
|
||||
// Even a rejected worker retains the queue owner for an explicit retry.
|
||||
candidate.cancel(false);
|
||||
shutdownSave.compareAndSet(candidate, null);
|
||||
throw failure;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
try {
|
||||
save.get(1, TimeUnit.SECONDS);
|
||||
shutdownSave.compareAndSet(save, null);
|
||||
} catch (TimeoutException pending) {
|
||||
plugin.getLogger().warning("Timed vote shutdown persistence is still pending; its worker retains ownership. Recovery is not yet confirmed.");
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
plugin.getLogger().warning("Timed vote shutdown persistence wait was interrupted; its worker retains ownership.");
|
||||
} catch (ExecutionException failure) {
|
||||
shutdownSave.compareAndSet(save, null);
|
||||
Throwable cause = failure.getCause();
|
||||
if (cause instanceof Error error) throw error;
|
||||
if (cause instanceof RuntimeException runtime) throw runtime;
|
||||
throw new IllegalStateException("Timed vote shutdown persistence failed", cause);
|
||||
}
|
||||
}
|
||||
|
||||
/** Kept separate so worker-admission failure can be exercised without replacing persistence. */
|
||||
protected void startShutdownWorker(Runnable task) {
|
||||
Thread worker = new Thread(task, "VotingPlugin-timed-vote-shutdown");
|
||||
worker.setDaemon(true);
|
||||
worker.start();
|
||||
}
|
||||
|
||||
private void awaitRetiringProducers(java.util.concurrent.ScheduledExecutorService producers) {
|
||||
if (producers == null || !producers.isShutdown()) return;
|
||||
awaitWriterTermination(producers);
|
||||
}
|
||||
|
||||
private void awaitWriterTermination(java.util.concurrent.ScheduledExecutorService producers) {
|
||||
if (producers == null) return;
|
||||
boolean interrupted = false;
|
||||
try {
|
||||
while (!producers.isTerminated()) {
|
||||
try { producers.awaitTermination(100, TimeUnit.MILLISECONDS); }
|
||||
catch (InterruptedException ignored) { interrupted = true; }
|
||||
}
|
||||
} finally { if (interrupted) Thread.currentThread().interrupt(); }
|
||||
}
|
||||
|
||||
/** Remove only a successfully saved prefix; failures retain the same next slot for retry. */
|
||||
private void persistPendingVotes() {
|
||||
VoteTimeQueue vote;
|
||||
while ((vote = timeChangeQueue.peek()) != null) {
|
||||
if (vote == dispatchingVote) {
|
||||
if (dispatchStarted) retainUncertainDispatch();
|
||||
else {
|
||||
var pending = timeChangeQueue.stream().toList();
|
||||
plugin.getServerData().abortTimedVoteDispatch(dispatchId, pending);
|
||||
timeChangeQueue.removeAll(pending);
|
||||
persistedShutdownVotes = pending.size();
|
||||
clearDispatch();
|
||||
}
|
||||
} else {
|
||||
plugin.getServerData().addTimeVoted(persistedShutdownVotes, vote);
|
||||
persistedShutdownVotes++;
|
||||
timeChangeQueue.remove(vote);
|
||||
}
|
||||
}
|
||||
timeChangeQueue.clear();
|
||||
}
|
||||
}
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
package com.bencodez.votingplugin.topvoter;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.function.LongSupplier;
|
||||
import com.bencodez.advancedcore.api.user.presence.PlayerPresence;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
|
||||
/** Bounded display snapshots only; raw rankings and award inputs stay untouched. */
|
||||
public final class TopVoterDisplayFilter implements AutoCloseable, Listener {
|
||||
private static final int MAX_PENDING = 128;
|
||||
private static final int MAX_CACHED = 8192;
|
||||
private static final long MAX_AGE = java.util.concurrent.TimeUnit.SECONDS.toNanos(30);
|
||||
private record Identity(UUID uuid, String name) { }
|
||||
private static final class Evidence {
|
||||
private PlayerPresence value = PlayerPresence.UNKNOWN;
|
||||
private long completed;
|
||||
private boolean pending = true;
|
||||
private Set<UUID> identities = Set.of();
|
||||
private long joinGeneration;
|
||||
private TopVoterPlayer snapshot;
|
||||
private List<UUID> aliases;
|
||||
private final CompletableFuture<Void> settled = new CompletableFuture<>();
|
||||
}
|
||||
private final Function<TopVoterPlayer, List<UUID>> identities;
|
||||
private final BiFunction<TopVoterPlayer, List<UUID>, CompletionStage<PlayerPresence>> lookup;
|
||||
private final LongSupplier clock;
|
||||
private final LongSupplier rankingRowBudget;
|
||||
private volatile int cacheLimit = MAX_CACHED;
|
||||
private final LinkedHashMap<Identity, Evidence> cache = new LinkedHashMap<>(16, 0.75f, true);
|
||||
private int pending;
|
||||
private boolean closed;
|
||||
private long joinGeneration;
|
||||
|
||||
public TopVoterDisplayFilter(Function<TopVoterPlayer, CompletionStage<PlayerPresence>> lookup) {
|
||||
this(lookup, System::nanoTime);
|
||||
}
|
||||
|
||||
public TopVoterDisplayFilter(Function<TopVoterPlayer, CompletionStage<PlayerPresence>> lookup, LongSupplier clock) {
|
||||
this(player -> List.of(player.getUuid()), (player, aliases) -> lookup.apply(player), clock);
|
||||
Objects.requireNonNull(lookup);
|
||||
}
|
||||
|
||||
/** Check the captured cache-only scope asynchronously; revalidate it before using negative evidence. */
|
||||
public TopVoterDisplayFilter(Function<TopVoterPlayer, List<UUID>> identities,
|
||||
BiFunction<TopVoterPlayer, List<UUID>, CompletionStage<PlayerPresence>> lookup) {
|
||||
this(identities, lookup, System::nanoTime);
|
||||
}
|
||||
|
||||
public TopVoterDisplayFilter(Function<TopVoterPlayer, List<UUID>> identities,
|
||||
BiFunction<TopVoterPlayer, List<UUID>, CompletionStage<PlayerPresence>> lookup, LongSupplier clock) {
|
||||
this(identities, lookup, clock, () -> 0L);
|
||||
}
|
||||
|
||||
/** The owner supplies the row count already retained in current and historical ranking maps. */
|
||||
public TopVoterDisplayFilter(Function<TopVoterPlayer, List<UUID>> identities,
|
||||
BiFunction<TopVoterPlayer, List<UUID>, CompletionStage<PlayerPresence>> lookup, LongSupplier clock,
|
||||
LongSupplier rankingRowBudget) {
|
||||
this.identities = Objects.requireNonNull(identities);
|
||||
this.lookup = Objects.requireNonNull(lookup);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.rankingRowBudget = Objects.requireNonNull(rankingRowBudget);
|
||||
}
|
||||
|
||||
/** Pending, failed, missing or stale evidence remains visible until a checked result arrives. */
|
||||
public LinkedHashMap<TopVoterPlayer, Integer> project(Map<TopVoterPlayer, Integer> rankings, boolean enabled) {
|
||||
if (enabled) reserveRankingRows(rankings.size());
|
||||
var visible = new LinkedHashMap<TopVoterPlayer, Integer>();
|
||||
for (var entry : rankings.entrySet()) {
|
||||
if (!enabled || evidence(entry.getKey()) != PlayerPresence.NEVER_JOINED)
|
||||
visible.put(entry.getKey(), entry.getValue());
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
private void reserveRankingRows(int rows) {
|
||||
long requested = rows;
|
||||
// Read owner state outside the evidence monitor. A racing replacement may leave
|
||||
// a temporary unknown budget; keep the previous bound until the next refresh.
|
||||
try { requested = Math.max(requested, rankingRowBudget.getAsLong()); }
|
||||
catch (RuntimeException unavailable) { requested = Math.max(requested, cacheLimit); }
|
||||
synchronized (cache) {
|
||||
cacheLimit = (int) Math.min(Integer.MAX_VALUE, Math.max(MAX_CACHED, requested));
|
||||
trimCompletedEvidence(cacheLimit);
|
||||
}
|
||||
}
|
||||
|
||||
/** Called under cache; in-flight requests retain their bounded admission until completion. */
|
||||
private void trimCompletedEvidence(int limit) {
|
||||
var oldest = cache.entrySet().iterator();
|
||||
while (cache.size() > limit && oldest.hasNext()) {
|
||||
if (!oldest.next().getValue().pending) oldest.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/** Await admitted checks without blocking their worker; unavailable rows stay visible. */
|
||||
public CompletionStage<LinkedHashMap<TopVoterPlayer, Integer>> projectAsync(
|
||||
Map<TopVoterPlayer, Integer> rankings, boolean enabled) {
|
||||
var snapshot = new LinkedHashMap<>(rankings);
|
||||
if (!enabled) return CompletableFuture.completedFuture(snapshot);
|
||||
project(snapshot, true);
|
||||
var captured = new LinkedHashMap<Identity, Evidence>();
|
||||
var waiting = new java.util.ArrayList<CompletableFuture<Void>>();
|
||||
synchronized (cache) {
|
||||
for (TopVoterPlayer player : snapshot.keySet()) {
|
||||
if (player == null || player.getUuid() == null) continue;
|
||||
Identity key = new Identity(player.getUuid(), player.getPlayerName());
|
||||
Evidence record = cache.get(key);
|
||||
if (record != null) {
|
||||
captured.put(key, record);
|
||||
if (record.pending) waiting.add(record.settled);
|
||||
}
|
||||
}
|
||||
}
|
||||
return CompletableFuture.allOf(waiting.toArray(CompletableFuture[]::new))
|
||||
.completeOnTimeout(null, 5, java.util.concurrent.TimeUnit.SECONDS).thenApply(ignored -> {
|
||||
var visible = new LinkedHashMap<TopVoterPlayer, Integer>();
|
||||
for (var entry : snapshot.entrySet()) {
|
||||
TopVoterPlayer player = entry.getKey();
|
||||
Identity key = player == null ? null : new Identity(player.getUuid(), player.getPlayerName());
|
||||
Evidence record = captured.get(key);
|
||||
if (record == null || cachedValue(key, record) != PlayerPresence.NEVER_JOINED)
|
||||
visible.put(player, entry.getValue());
|
||||
}
|
||||
return visible;
|
||||
});
|
||||
}
|
||||
|
||||
private PlayerPresence evidence(TopVoterPlayer player) {
|
||||
if (player == null || player.getUuid() == null) return PlayerPresence.UNKNOWN;
|
||||
Identity key = new Identity(player.getUuid(), player.getPlayerName());
|
||||
Evidence record;
|
||||
boolean reuse;
|
||||
synchronized (cache) {
|
||||
if (closed) return PlayerPresence.UNKNOWN;
|
||||
record = cache.get(key);
|
||||
reuse = record != null && (record.pending || clock.getAsLong() - record.completed < MAX_AGE);
|
||||
if (!reuse) {
|
||||
if (pending >= MAX_PENDING) return PlayerPresence.UNKNOWN;
|
||||
if (record != null) cache.remove(key);
|
||||
if (cache.size() >= cacheLimit) trimCompletedEvidence(cacheLimit - 1);
|
||||
if (cache.size() >= cacheLimit) return PlayerPresence.UNKNOWN;
|
||||
record = new Evidence(); cache.put(key, record); pending++;
|
||||
record.joinGeneration = joinGeneration;
|
||||
record.snapshot = new TopVoterPlayer(key.uuid(), key.name(), player.getLastVoteTime());
|
||||
}
|
||||
}
|
||||
if (reuse) return cachedValue(key, record);
|
||||
Evidence admitted = record;
|
||||
try {
|
||||
List<UUID> aliases = resolveScope(admitted.snapshot);
|
||||
if (aliases == null) {
|
||||
finish(key, admitted, PlayerPresence.UNKNOWN);
|
||||
return PlayerPresence.UNKNOWN;
|
||||
}
|
||||
var checked = new java.util.HashSet<UUID>();
|
||||
for (UUID alias : aliases) if (alias != null) checked.add(alias);
|
||||
boolean valid;
|
||||
synchronized (cache) {
|
||||
valid = !closed && cache.get(key) == admitted && admitted.joinGeneration == joinGeneration;
|
||||
if (valid) {
|
||||
admitted.identities = Set.copyOf(checked);
|
||||
admitted.aliases = aliases;
|
||||
} else cache.remove(key, admitted);
|
||||
}
|
||||
if (!valid) {
|
||||
finish(key, admitted, PlayerPresence.UNKNOWN);
|
||||
return PlayerPresence.UNKNOWN;
|
||||
}
|
||||
CompletionStage<PlayerPresence> request = lookup.apply(admitted.snapshot, aliases);
|
||||
if (request == null) finish(key, admitted, PlayerPresence.UNKNOWN);
|
||||
else request.whenComplete((result, failure) -> finish(key, admitted,
|
||||
failure == null && result != null ? result : PlayerPresence.UNKNOWN));
|
||||
} catch (RuntimeException unavailable) {
|
||||
finish(key, admitted, PlayerPresence.UNKNOWN);
|
||||
}
|
||||
return cachedValue(key, admitted);
|
||||
}
|
||||
|
||||
private List<UUID> resolveScope(TopVoterPlayer snapshot) {
|
||||
List<UUID> resolved = identities.apply(snapshot);
|
||||
if (resolved == null || resolved.isEmpty() || resolved.size() > 8) return null;
|
||||
// Preserve null uncertainty markers and detach the provider from mutable cache data.
|
||||
return java.util.Collections.unmodifiableList(new java.util.ArrayList<>(resolved));
|
||||
}
|
||||
|
||||
private boolean matchesScope(Evidence record) {
|
||||
try {
|
||||
return record.aliases != null && record.aliases.equals(resolveScope(record.snapshot));
|
||||
} catch (RuntimeException unavailable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private PlayerPresence cachedValue(Identity key, Evidence record) {
|
||||
PlayerPresence value;
|
||||
synchronized (cache) {
|
||||
if (closed || cache.get(key) != record || record.pending
|
||||
|| clock.getAsLong() - record.completed >= MAX_AGE) return PlayerPresence.UNKNOWN;
|
||||
value = record.value;
|
||||
}
|
||||
// The resolver is cache-only, but must never run under the evidence-cache monitor.
|
||||
if (value == PlayerPresence.NEVER_JOINED && !matchesScope(record)) {
|
||||
synchronized (cache) { cache.remove(key, record); }
|
||||
return PlayerPresence.UNKNOWN;
|
||||
}
|
||||
synchronized (cache) {
|
||||
return !closed && cache.get(key) == record && !record.pending
|
||||
&& clock.getAsLong() - record.completed < MAX_AGE ? record.value : PlayerPresence.UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
private void finish(Identity key, Evidence record, PlayerPresence result) {
|
||||
boolean currentScope = result != PlayerPresence.NEVER_JOINED || matchesScope(record);
|
||||
synchronized (cache) {
|
||||
if (!record.pending) return;
|
||||
record.pending = false; pending--;
|
||||
if (!closed && cache.get(key) == record) {
|
||||
if (!currentScope) cache.remove(key, record);
|
||||
else { record.value = result; record.completed = clock.getAsLong(); }
|
||||
}
|
||||
trimCompletedEvidence(cacheLimit);
|
||||
}
|
||||
// Completion may invoke projection callbacks, which resolve aliases outside this monitor.
|
||||
record.settled.complete(null);
|
||||
}
|
||||
|
||||
/** A newly observed join revokes matching negative snapshots and pending stale completions. */
|
||||
public void joined(UUID uuid, String name) {
|
||||
var invalidated = new java.util.ArrayList<Evidence>();
|
||||
synchronized (cache) {
|
||||
joinGeneration++;
|
||||
cache.entrySet().removeIf(entry -> {
|
||||
boolean matches = Objects.equals(entry.getKey().uuid(), uuid)
|
||||
|| (uuid != null && entry.getValue().identities.contains(uuid))
|
||||
|| (name != null && name.equalsIgnoreCase(entry.getKey().name()));
|
||||
if (matches) invalidated.add(entry.getValue());
|
||||
return matches;
|
||||
});
|
||||
}
|
||||
for (Evidence record : invalidated) record.settled.complete(null);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST)
|
||||
public void onJoin(PlayerJoinEvent event) {
|
||||
joined(event.getPlayer().getUniqueId(), event.getPlayer().getName());
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
List<Evidence> retiring;
|
||||
synchronized (cache) {
|
||||
closed = true;
|
||||
retiring = new java.util.ArrayList<>(cache.values());
|
||||
cache.clear();
|
||||
}
|
||||
for (Evidence record : retiring) record.settled.complete(null);
|
||||
}
|
||||
}
|
||||
+28
-18
@@ -113,11 +113,12 @@ public class TopVoterHandler implements Listener {
|
||||
*/
|
||||
public String[] getTopVotersWeekly() {
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
ArrayList<TopVoterPlayer> users = new ArrayList<>(plugin.getTopVoter(TopVoter.Weekly).keySet());
|
||||
var board = plugin.getTopVoterDisplay(TopVoter.Weekly);
|
||||
ArrayList<TopVoterPlayer> users = new ArrayList<>(board.keySet());
|
||||
for (int i = 0; i < users.size(); i++) {
|
||||
String line = plugin.getConfigFile().getFormatCommandsVoteTopLine().replace("%num%", "" + (i + 1))
|
||||
.replace("%player%", users.get(i).getPlayerName())
|
||||
.replace("%votes%", "" + plugin.getTopVoter(TopVoter.Weekly).get(users.get(i)));
|
||||
.replace("%votes%", "" + board.get(users.get(i)));
|
||||
msg.add(line);
|
||||
}
|
||||
msg = ArrayUtils.colorize(msg);
|
||||
@@ -638,11 +639,12 @@ public class TopVoterHandler implements Listener {
|
||||
* @return the string[]
|
||||
*/
|
||||
public String[] topVoterAllTime(int page) {
|
||||
int pagesize = plugin.getConfigFile().getFormatPageSize();
|
||||
page = Math.max(1, page);
|
||||
int pagesize = Math.max(1, plugin.getConfigFile().getFormatPageSize());
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
ArrayList<String> topVoters = new ArrayList<>();
|
||||
int count = 1;
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.AllTime).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.AllTime).entrySet()) {
|
||||
String line = plugin.getConfigFile().getFormatCommandsVoteTopLine();
|
||||
line = line.replace("%num%", "" + count);
|
||||
line = line.replace("%player%", entry.getKey().getPlayerName());
|
||||
@@ -662,7 +664,8 @@ public class TopVoterHandler implements Listener {
|
||||
title = title.replace("%Top%", plugin.getConfigFile().getFormatTopVoterAllTime());
|
||||
msg.add(MessageAPI.colorize(title));
|
||||
|
||||
for (int i = (page - 1) * pagesize; (i < topVoters.size()) && (i < (((page - 1) * pagesize) + 10)); i++) {
|
||||
long start = (long) (page - 1) * pagesize;
|
||||
for (int i = (int) Math.min(start, topVoters.size()); i < topVoters.size() && i < start + pagesize; i++) {
|
||||
msg.add(topVoters.get(i));
|
||||
}
|
||||
|
||||
@@ -676,11 +679,12 @@ public class TopVoterHandler implements Listener {
|
||||
* @return array of formatted top voter lines
|
||||
*/
|
||||
public String[] topVoterDaily(int page) {
|
||||
int pagesize = plugin.getConfigFile().getFormatPageSize();
|
||||
page = Math.max(1, page);
|
||||
int pagesize = Math.max(1, plugin.getConfigFile().getFormatPageSize());
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
ArrayList<String> topVoters = new ArrayList<>();
|
||||
int count = 1;
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Daily).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Daily).entrySet()) {
|
||||
String line = plugin.getConfigFile().getFormatCommandsVoteTopLine();
|
||||
line = line.replace("%num%", "" + count);
|
||||
line = line.replace("%player%", entry.getKey().getPlayerName());
|
||||
@@ -700,7 +704,8 @@ public class TopVoterHandler implements Listener {
|
||||
title = title.replace("%Top%", plugin.getConfigFile().getFormatTopVoterDaily());
|
||||
msg.add(MessageAPI.colorize(title));
|
||||
|
||||
for (int i = (page - 1) * pagesize; (i < topVoters.size()) && (i < (((page - 1) * pagesize) + 10)); i++) {
|
||||
long start = (long) (page - 1) * pagesize;
|
||||
for (int i = (int) Math.min(start, topVoters.size()); i < topVoters.size() && i < start + pagesize; i++) {
|
||||
msg.add(topVoters.get(i));
|
||||
}
|
||||
|
||||
@@ -714,11 +719,12 @@ public class TopVoterHandler implements Listener {
|
||||
* @return array of formatted top voter lines
|
||||
*/
|
||||
public String[] topVoterMonthly(int page) {
|
||||
int pagesize = plugin.getConfigFile().getFormatPageSize();
|
||||
page = Math.max(1, page);
|
||||
int pagesize = Math.max(1, plugin.getConfigFile().getFormatPageSize());
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
ArrayList<String> topVoters = new ArrayList<>();
|
||||
int count = 1;
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Monthly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Monthly).entrySet()) {
|
||||
String line = plugin.getConfigFile().getFormatCommandsVoteTopLine();
|
||||
line = line.replace("%num%", "" + count);
|
||||
line = line.replace("%player%", entry.getKey().getPlayerName());
|
||||
@@ -738,7 +744,8 @@ public class TopVoterHandler implements Listener {
|
||||
title = title.replace("%Top%", plugin.getConfigFile().getFormatTopVoterMonthly());
|
||||
msg.add(MessageAPI.colorize(title));
|
||||
|
||||
for (int i = (page - 1) * pagesize; (i < topVoters.size()) && (i < (((page - 1) * pagesize) + 10)); i++) {
|
||||
long start = (long) (page - 1) * pagesize;
|
||||
for (int i = (int) Math.min(start, topVoters.size()); i < topVoters.size() && i < start + pagesize; i++) {
|
||||
msg.add(topVoters.get(i));
|
||||
}
|
||||
|
||||
@@ -752,7 +759,7 @@ public class TopVoterHandler implements Listener {
|
||||
*/
|
||||
public String[] topVotersAllTime() {
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
List<Entry<TopVoterPlayer, Integer>> list = new LinkedList<>(plugin.getTopVoter(TopVoter.AllTime).entrySet());
|
||||
List<Entry<TopVoterPlayer, Integer>> list = new LinkedList<>(plugin.getTopVoterDisplay(TopVoter.AllTime).entrySet());
|
||||
int i = 0;
|
||||
for (Entry<TopVoterPlayer, Integer> entry : list) {
|
||||
String line = "%num%: %player%, %votes%";
|
||||
@@ -778,7 +785,8 @@ public class TopVoterHandler implements Listener {
|
||||
*/
|
||||
public String[] topVotersDaily() {
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
ArrayList<TopVoterPlayer> users = new ArrayList<>(plugin.getTopVoter(TopVoter.Daily).keySet());
|
||||
var board = plugin.getTopVoterDisplay(TopVoter.Daily);
|
||||
ArrayList<TopVoterPlayer> users = new ArrayList<>(board.keySet());
|
||||
for (int i = 0; i < users.size(); i++) {
|
||||
String line = "%num%: %player%, %votes%";
|
||||
line = line.replace("%num%", "" + (i + 1));
|
||||
@@ -787,7 +795,7 @@ public class TopVoterHandler implements Listener {
|
||||
} catch (Exception ex) {
|
||||
VotingPluginMain.plugin.debug(ex);
|
||||
}
|
||||
line = line.replace("%votes%", "" + plugin.getTopVoter(TopVoter.Monthly).get(users.get(i)));
|
||||
line = line.replace("%votes%", "" + board.get(users.get(i)));
|
||||
msg.add(line);
|
||||
}
|
||||
|
||||
@@ -801,7 +809,7 @@ public class TopVoterHandler implements Listener {
|
||||
*/
|
||||
public String[] topVotersMonthly() {
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
List<Entry<TopVoterPlayer, Integer>> list = new LinkedList<>(plugin.getTopVoter(TopVoter.Monthly).entrySet());
|
||||
List<Entry<TopVoterPlayer, Integer>> list = new LinkedList<>(plugin.getTopVoterDisplay(TopVoter.Monthly).entrySet());
|
||||
int i = 0;
|
||||
for (Entry<TopVoterPlayer, Integer> entry : list) {
|
||||
String line = "%num%: %player%, %votes%";
|
||||
@@ -827,11 +835,12 @@ public class TopVoterHandler implements Listener {
|
||||
* @return array of formatted top voter lines
|
||||
*/
|
||||
public String[] topVoterWeekly(int page) {
|
||||
int pagesize = plugin.getConfigFile().getFormatPageSize();
|
||||
page = Math.max(1, page);
|
||||
int pagesize = Math.max(1, plugin.getConfigFile().getFormatPageSize());
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
ArrayList<String> topVoters = new ArrayList<>();
|
||||
int count = 1;
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Weekly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Weekly).entrySet()) {
|
||||
String line = plugin.getConfigFile().getFormatCommandsVoteTopLine();
|
||||
line = line.replace("%num%", "" + count);
|
||||
line = line.replace("%player%", entry.getKey().getPlayerName());
|
||||
@@ -851,7 +860,8 @@ public class TopVoterHandler implements Listener {
|
||||
title = title.replace("%Top%", plugin.getConfigFile().getFormatTopVoterWeekly());
|
||||
msg.add(MessageAPI.colorize(title));
|
||||
|
||||
for (int i = (page - 1) * pagesize; (i < topVoters.size()) && (i < (((page - 1) * pagesize) + 10)); i++) {
|
||||
long start = (long) (page - 1) * pagesize;
|
||||
for (int i = (int) Math.min(start, topVoters.size()); i < topVoters.size() && i < start + pagesize; i++) {
|
||||
msg.add(topVoters.get(i));
|
||||
}
|
||||
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.bencodez.votingplugin.topvoter;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
|
||||
/** UUID-only evidence scopes; never resolves a profile or loads plugin/server storage. */
|
||||
public final class TopVoterPresenceIdentity {
|
||||
private TopVoterPresenceIdentity() { }
|
||||
|
||||
/** A null alias explicitly prevents uncertain migration evidence becoming negative. */
|
||||
public static List<UUID> identities(TopVoterPlayer row, boolean onlineMode, Function<String, String> cachedUuid) {
|
||||
var identities = new ArrayList<UUID>();
|
||||
if (row == null || row.getUuid() == null) { identities.add(null); return identities; }
|
||||
identities.add(row.getUuid());
|
||||
String name = row.getPlayerName();
|
||||
if (name == null || name.isBlank() || name.length() > 64 || !name.equals(name.trim())) {
|
||||
identities.add(null); return identities;
|
||||
}
|
||||
UUID offline = UUID.nameUUIDFromBytes(("OfflinePlayer:" + name).getBytes(StandardCharsets.UTF_8));
|
||||
if (!identities.contains(offline)) identities.add(offline);
|
||||
UUID current = null;
|
||||
try {
|
||||
String known = cachedUuid.apply(name);
|
||||
if (known != null && !known.isBlank()) {
|
||||
current = UUID.fromString(known);
|
||||
if (!current.toString().equalsIgnoreCase(known)) { identities.add(null); return identities; }
|
||||
}
|
||||
} catch (RuntimeException unavailable) { identities.add(null); return identities; }
|
||||
if (current != null && !identities.contains(current)) identities.add(current);
|
||||
// This validates known aliases; online historical completeness is checked
|
||||
// separately against retained server history by the asynchronous provider.
|
||||
boolean resolvable = onlineMode ? row.getUuid().version() == 4 && row.getUuid().variant() == 2
|
||||
&& (current == null || current.equals(row.getUuid()))
|
||||
: offline.equals(row.getUuid()) || offline.equals(current);
|
||||
if (!resolvable) identities.add(null);
|
||||
return identities;
|
||||
}
|
||||
}
|
||||
+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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ package com.bencodez.votingplugin.user;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.UUID;
|
||||
import java.util.UUID;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import com.bencodez.votingplugin.placeholders.PlaceholderPlayerPresence.LiveIdentity;
|
||||
|
||||
import org.bukkit.OfflinePlayer;
|
||||
import org.bukkit.entity.Player;
|
||||
@@ -22,7 +26,8 @@ import com.bencodez.votingplugin.topvoter.TopVoter;
|
||||
* Manages VotingPlugin user operations and data.
|
||||
*/
|
||||
public class UserManager {
|
||||
private VotingPluginMain plugin;
|
||||
private VotingPluginMain plugin;
|
||||
private final Set<UUID> warnedOfflineUuidPlayers = ConcurrentHashMap.newKeySet();
|
||||
private boolean sharedPointTransferRecoveryScheduled;
|
||||
|
||||
/**
|
||||
@@ -257,18 +262,50 @@ public class UserManager {
|
||||
* Gets a VotingPluginUser by UUID.
|
||||
* @param uuid the player UUID
|
||||
* @return the VotingPluginUser
|
||||
* @deprecated UUID-only lookups can be ambiguous in offline mode because Minecraft
|
||||
* preserves name capitalization, while VotingPlugin derives its
|
||||
* offline UUID from the player name lowercased with Locale.ROOT.
|
||||
* Prefer {@link #getVotingPluginUser(UUID, String)} when both UUID
|
||||
* and player name are available; supplying the name allows the
|
||||
* existing offline UUID normalization. Use
|
||||
* {@link #getVotingPluginUser(Player)} for an online player,
|
||||
* {@link #getVotingPluginUser(OfflinePlayer)} when an offline
|
||||
* player object with a known name is available, or
|
||||
* {@link #getVotingPluginUser(String)} when only the name is known.
|
||||
*/
|
||||
@Deprecated
|
||||
@SuppressWarnings("deprecation")
|
||||
public VotingPluginUser getVotingPluginUser(UUID uuid) {
|
||||
return new VotingPluginUser(plugin, uuid);
|
||||
LiveIdentity identity = offlineLiveIdentity(uuid);
|
||||
if (identity == null) return new VotingPluginUser(plugin, uuid);
|
||||
VotingPluginUser user = new VotingPluginUser(plugin, identity.storageUuid(), false);
|
||||
user.setPlayerName(identity.playerName());
|
||||
warnOfflineUuidCorrection(uuid, identity);
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a VotingPluginUser by UUID with optional name loading.
|
||||
* Gets a VotingPluginUser by exact storage UUID with optional name loading.
|
||||
* This overload preserves the supplied key for storage iteration, recovery
|
||||
* and administrative operations, even when loadName is true.
|
||||
* @param uuid the player UUID
|
||||
* @param loadName whether to load the player name
|
||||
* @return the VotingPluginUser
|
||||
* @deprecated UUID-only lookups can be ambiguous in offline mode because Minecraft
|
||||
* preserves name capitalization, while VotingPlugin derives its
|
||||
* offline UUID from the player name lowercased with Locale.ROOT.
|
||||
* Prefer {@link #getVotingPluginUser(UUID, String)} when both UUID
|
||||
* and player name are available; supplying the name allows the
|
||||
* existing offline UUID normalization. Use
|
||||
* {@link #getVotingPluginUser(Player)} for an online player,
|
||||
* {@link #getVotingPluginUser(OfflinePlayer)} when an offline
|
||||
* player object with a known name is available, or
|
||||
* {@link #getVotingPluginUser(String)} when only the name is known.
|
||||
* Retain this overload when the exact stored UUID and explicit
|
||||
* name-loading behavior are required; name-aware alternatives
|
||||
* may normalize the supplied identity in offline mode.
|
||||
*/
|
||||
@Deprecated
|
||||
@SuppressWarnings("deprecation")
|
||||
public VotingPluginUser getVotingPluginUser(UUID uuid, boolean loadName) {
|
||||
return new VotingPluginUser(plugin, uuid, loadName);
|
||||
@@ -285,6 +322,24 @@ public class UserManager {
|
||||
return new VotingPluginUser(plugin, uuid, playerName);
|
||||
}
|
||||
|
||||
/** Resolve only immutable live identity captured by the presence lifecycle. */
|
||||
private LiveIdentity offlineLiveIdentity(UUID uuid) {
|
||||
if (plugin.getOptions().isOnlineMode()) return null;
|
||||
var presence = plugin.getPlaceholderPlayerPresence();
|
||||
LiveIdentity identity = presence == null ? null : presence.liveIdentity(uuid);
|
||||
if (identity == null || identity.serverUuid() == null || identity.storageUuid() == null
|
||||
|| identity.playerName() == null || identity.playerName().isBlank()) return null;
|
||||
return identity;
|
||||
}
|
||||
|
||||
private void warnOfflineUuidCorrection(UUID uuid, LiveIdentity identity) {
|
||||
if (!uuid.equals(identity.storageUuid()) && warnedOfflineUuidPlayers.add(identity.storageUuid())) {
|
||||
plugin.getLogger().warning("Offline UUID compatibility workaround used for " + identity.playerName()
|
||||
+ ". Server UUID: " + identity.serverUuid() + " -> VotingPlugin UUID: " + identity.storageUuid()
|
||||
+ ". Applied for third-party compatibility: a plugin may be using the server UUID instead of VotingPlugin's UUID.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Purges old players with no voting data.
|
||||
*/
|
||||
|
||||
+2108
-1964
File diff suppressed because it is too large.
Load diff
+217
@@ -0,0 +1,217 @@
|
||||
package com.bencodez.votingplugin.util;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Locale;
|
||||
import java.util.Properties;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
/** Memory-only admission for participating plugin generations sharing one server-data path in a JVM. */
|
||||
public final class ServerDataPublicationFence {
|
||||
private static final String PREFIX = "com.bencodez.votingplugin.ServerDataPublicationFence.owner.";
|
||||
private static final int MAX_OWNERS = 1024;
|
||||
private final Properties registry;
|
||||
private final String key;
|
||||
private final String generation;
|
||||
private final AtomicBoolean retirementStarted = new AtomicBoolean();
|
||||
private final java.util.concurrent.CompletableFuture<Void> retirementCompleted = new java.util.concurrent.CompletableFuture<>();
|
||||
|
||||
private final CompletableFuture<Long> processShutdownStarted = new CompletableFuture<>();
|
||||
private final CompletableFuture<Void> processExitCompletion = new CompletableFuture<>();
|
||||
private final AtomicBoolean processHookRemoved = new AtomicBoolean();
|
||||
private volatile Runtime processRuntime;
|
||||
private volatile Thread processHook;
|
||||
|
||||
/** Opt in during startup, before the JVM can begin invoking shutdown hooks. */
|
||||
public synchronized void retainForProcessExit() {
|
||||
if (processHook != null) return;
|
||||
var runtime = Runtime.getRuntime();
|
||||
var hook = new Thread(this::awaitProcessExit, "VotingPlugin-ServerData-Exit");
|
||||
runtime.addShutdownHook(hook);
|
||||
processRuntime = runtime;
|
||||
processHook = hook;
|
||||
processExitCompletion.whenComplete((ignored, failure) -> removeProcessHook());
|
||||
}
|
||||
|
||||
/** The first disable entry fixes the deadline; retries cannot renew it. No caller wait. */
|
||||
public void beginShutdown(long startedNanos) {
|
||||
if (processShutdownStarted.complete(startedNanos) && processHook != null) {
|
||||
CompletableFuture.delayedExecutor(remainingProcessWait(startedNanos), TimeUnit.NANOSECONDS)
|
||||
.execute(this::removeProcessHook);
|
||||
}
|
||||
}
|
||||
|
||||
private static long remainingProcessWait(long startedNanos) {
|
||||
long elapsed = Math.max(0L, System.nanoTime() - startedNanos);
|
||||
return Math.max(0L, TimeUnit.SECONDS.toNanos(15) - elapsed);
|
||||
}
|
||||
|
||||
private void awaitProcessExit() {
|
||||
long exitStarted = System.nanoTime();
|
||||
try {
|
||||
long disableStarted = processShutdownStarted.get(remainingProcessWait(exitStarted), TimeUnit.NANOSECONDS);
|
||||
long remaining = Math.min(remainingProcessWait(exitStarted), remainingProcessWait(disableStarted));
|
||||
if (remaining > 0) processExitCompletion.get(remaining, TimeUnit.NANOSECONDS);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (ExecutionException | TimeoutException pendingOrExpired) {
|
||||
// Process exit is bounded. Timeout does not acknowledge publication or release a lease.
|
||||
}
|
||||
}
|
||||
|
||||
private void removeProcessHook() {
|
||||
var hook = processHook;
|
||||
if (hook == null || !processHookRemoved.compareAndSet(false, true)) return;
|
||||
try { processRuntime.removeShutdownHook(hook); }
|
||||
catch (IllegalStateException stopping) { /* The JVM is already invoking its hooks. */ }
|
||||
}
|
||||
|
||||
private ServerDataPublicationFence(Properties registry, String key, String generation) {
|
||||
this.registry = registry;
|
||||
this.key = key;
|
||||
this.generation = generation;
|
||||
}
|
||||
|
||||
/** Runs before platform initialization and performs no filesystem operation or wait for storage. */
|
||||
public static ServerDataPublicationFence beginGeneration(Path file) {
|
||||
String identity = file.toAbsolutePath().normalize().toString();
|
||||
if (DurableFiles.isWindowsName(System.getProperty("os.name", ""))) identity = identity.toLowerCase(Locale.ROOT);
|
||||
String key;
|
||||
try {
|
||||
key = PREFIX + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(identity.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException unavailable) { throw new IllegalStateException(unavailable); }
|
||||
Properties registry = System.getProperties();
|
||||
synchronized (registry) {
|
||||
Object value = registry.get(key);
|
||||
if (value != null) {
|
||||
State previous = state(value);
|
||||
if (previous.users != 0 || !previous.retiring)
|
||||
throw new IllegalStateException("Previous server-data generation still owns storage work; replacement was not started");
|
||||
} else if (registry.keySet().stream().filter(k -> k instanceof String s && s.startsWith(PREFIX)).count() >= MAX_OWNERS) {
|
||||
throw new IllegalStateException("Server-data publication owner capacity is unavailable");
|
||||
}
|
||||
String generation = UUID.randomUUID().toString();
|
||||
registry.setProperty(key, generation + ":0:false");
|
||||
return new ServerDataPublicationFence(registry, key, generation);
|
||||
}
|
||||
}
|
||||
|
||||
/** Hold through the entire operation, including a blocked write or admitted shutdown handoff. */
|
||||
public Lease acquire() {
|
||||
synchronized (registry) {
|
||||
State current = current();
|
||||
if (current.users >= MAX_OWNERS) throw new IllegalStateException("Server-data publication admission is unavailable");
|
||||
registry.setProperty(key, generation + ":" + (current.users + 1) + ":" + current.retiring);
|
||||
return new Lease(this);
|
||||
}
|
||||
}
|
||||
|
||||
/** Existing operations/retries retain ownership; a successor requires zero outstanding leases. */
|
||||
public void retire() {
|
||||
boolean drained = false;
|
||||
synchronized (registry) {
|
||||
State current = state(registry.get(key));
|
||||
if (generation.equals(current.generation)) {
|
||||
registry.setProperty(key, generation + ":" + current.users + ":true");
|
||||
drained = current.users == 0;
|
||||
}
|
||||
}
|
||||
// Completion callbacks may remove a JVM hook; never run them under the registry monitor.
|
||||
if (drained) processExitCompletion.complete(null);
|
||||
}
|
||||
|
||||
/** Reserve before asynchronous admission; a failed stage never proves drain. */
|
||||
public void retainUntilSuccessfulPublication(java.util.concurrent.CompletionStage<Void> publication) {
|
||||
java.util.Objects.requireNonNull(publication);
|
||||
Lease lease = acquire();
|
||||
publication.whenComplete((ignored, failure) -> {
|
||||
if (failure == null) lease.close();
|
||||
});
|
||||
}
|
||||
|
||||
/** Reserve before disable and release only after existing SDK writers actually terminate. */
|
||||
public java.util.concurrent.CompletionStage<Void> retainUntilTermination(java.util.concurrent.ScheduledExecutorService[] writers) {
|
||||
var snapshot = writers.clone();
|
||||
if (!retirementStarted.compareAndSet(false, true)) return retirementCompleted.minimalCompletionStage();
|
||||
Lease lease;
|
||||
try { lease = acquire(); }
|
||||
catch (RuntimeException | Error failure) { retirementStarted.set(false); throw failure; }
|
||||
try {
|
||||
Thread guardian = new Thread(() -> {
|
||||
boolean interrupted = false;
|
||||
try (lease) {
|
||||
for (var writer : snapshot) {
|
||||
if (writer == null) continue;
|
||||
while (!writer.isTerminated()) {
|
||||
try { writer.awaitTermination(100, java.util.concurrent.TimeUnit.MILLISECONDS); }
|
||||
catch (InterruptedException ignored) { interrupted = true; }
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException | Error failure) {
|
||||
retirementCompleted.completeExceptionally(failure);
|
||||
return;
|
||||
} finally { if (interrupted) Thread.currentThread().interrupt(); }
|
||||
retirementCompleted.complete(null);
|
||||
}, "VotingPlugin-server-data-retirement");
|
||||
guardian.setDaemon(true);
|
||||
guardian.start();
|
||||
} catch (RuntimeException | Error failure) {
|
||||
try { lease.close(); }
|
||||
catch (RuntimeException | Error cleanupFailure) { if (cleanupFailure != failure) failure.addSuppressed(cleanupFailure); }
|
||||
finally { retirementStarted.set(false); }
|
||||
throw failure;
|
||||
}
|
||||
return retirementCompleted.minimalCompletionStage();
|
||||
}
|
||||
|
||||
private State current() {
|
||||
State current = state(registry.get(key));
|
||||
if (registry != System.getProperties() || !generation.equals(current.generation))
|
||||
throw new IllegalStateException("Server-data publication belongs to another generation");
|
||||
return current;
|
||||
}
|
||||
|
||||
private static State state(Object value) {
|
||||
if (!(value instanceof String text) || text.length() > 64)
|
||||
throw new IllegalStateException("Server-data publication ownership is invalid");
|
||||
String[] fields = text.split(":", -1);
|
||||
try {
|
||||
if (fields.length != 3 || !UUID.fromString(fields[0]).toString().equals(fields[0])
|
||||
|| !(fields[2].equals("true") || fields[2].equals("false"))) throw new IllegalArgumentException();
|
||||
int users = Integer.parseInt(fields[1]);
|
||||
if (users < 0 || users > MAX_OWNERS || !Integer.toString(users).equals(fields[1])) throw new IllegalArgumentException();
|
||||
return new State(fields[0], users, Boolean.parseBoolean(fields[2]));
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
throw new IllegalStateException("Server-data publication ownership is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private record State(String generation, int users, boolean retiring) { }
|
||||
|
||||
public static final class Lease implements AutoCloseable {
|
||||
private final ServerDataPublicationFence owner;
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
private Lease(ServerDataPublicationFence owner) { this.owner = owner; }
|
||||
@Override public void close() {
|
||||
boolean drained;
|
||||
synchronized (owner.registry) {
|
||||
if (closed.get()) return;
|
||||
State current = owner.current();
|
||||
if (current.users <= 0) throw new IllegalStateException("Server-data publication ownership is invalid");
|
||||
owner.registry.setProperty(owner.key, owner.generation + ":" + (current.users - 1) + ":" + current.retiring);
|
||||
closed.set(true);
|
||||
drained = current.retiring && current.users == 1;
|
||||
}
|
||||
if (drained) owner.processExitCompletion.complete(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,12 @@ public final class VoteTaskAdmission {
|
||||
/**
|
||||
* Attempts to submit a one-shot vote task without running it on the caller.
|
||||
*
|
||||
* @param executor bounded vote executor
|
||||
* @param executor bounded vote executor, or null before startup/after shutdown
|
||||
* @param task task to submit
|
||||
* @return true when the task was admitted
|
||||
*/
|
||||
public static boolean trySubmit(ScheduledExecutorService executor, Runnable task) {
|
||||
if (executor == null) return false;
|
||||
try {
|
||||
executor.submit(task);
|
||||
return true;
|
||||
@@ -29,13 +30,14 @@ public final class VoteTaskAdmission {
|
||||
/**
|
||||
* Attempts to schedule a one-shot vote task without blocking the caller.
|
||||
*
|
||||
* @param executor bounded vote executor
|
||||
* @param executor bounded vote executor, or null before startup/after shutdown
|
||||
* @param task task to schedule
|
||||
* @param delay delay before execution
|
||||
* @param unit delay unit
|
||||
* @return true when the task was admitted
|
||||
*/
|
||||
public static boolean trySchedule(ScheduledExecutorService executor, Runnable task, long delay, TimeUnit unit) {
|
||||
if (executor == null) return false;
|
||||
try {
|
||||
executor.schedule(task, delay, unit);
|
||||
return true;
|
||||
|
||||
+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. */
|
||||
|
||||
@@ -188,6 +188,7 @@ DebugLevel: NONE
|
||||
GiveDefaultPermission: true
|
||||
|
||||
# If true, plugin will automatically generate votesites upon getting a vote
|
||||
# Disable after setup; a small periodic console reminder warns while enabled.
|
||||
AutoCreateVoteSites: true
|
||||
|
||||
# Geyser/Bedrock player support
|
||||
@@ -211,6 +212,14 @@ UseVoteGUIMainCommand: false
|
||||
# Does not backup MySQL data
|
||||
CreateBackups: true
|
||||
|
||||
# Player root command and standalone player aliases; admin commands remain available.
|
||||
# These settings require a server restart; /av reload does not change registration.
|
||||
VoteCommandEnabled: true
|
||||
# Root label without a slash or namespace; 1-64 letters, digits, underscores or hyphens.
|
||||
# Reserved/invalid names use vote. A foreign bare-name conflict uses votingplugin:<name>.
|
||||
# Standalone aliases such as /votegui and /vgui keep their existing names.
|
||||
VoteCommandName: vote
|
||||
|
||||
# Load command aliases, such as /avgui
|
||||
# Requires restart to take effect
|
||||
LoadCommandAliases: true
|
||||
@@ -442,6 +451,14 @@ VoteLogging:
|
||||
# Tracks and displays top voters by time period
|
||||
###########################################
|
||||
|
||||
# Hide only confirmed never-joined identities from top voter displays.
|
||||
# Missing, unreadable or ambiguous history stays visible. Awards are unchanged.
|
||||
# Presence checks are asynchronous; pending snapshots stay visible until refreshed.
|
||||
# Evidence expires after 30 seconds. GUI, chat and placeholder views can show
|
||||
# previously hidden rows while fresh checks are pending; stale negatives are not reused.
|
||||
# At most 128 checks run at once, so a large expired board may need several refreshes.
|
||||
HideTopVoterIfNotPlayedBefore: false
|
||||
|
||||
# Top voter blacklist
|
||||
# Hide these names from top voter lists
|
||||
BlackList:
|
||||
@@ -1256,9 +1273,3 @@ DisableUpdateChecking: false
|
||||
# Disable checking permissions on tab complete
|
||||
# Could increase performance slightly, but probably not
|
||||
DisableAdvancedTab: false
|
||||
|
||||
# Optional public vanilla villagers opening the existing voting-site menu.
|
||||
# Enable and /av reload, then /av npc create <id> [name]. No Citizens required.
|
||||
# Definitions live in VotingNPCs.json; disabling keeps them but removes live NPCs.
|
||||
VotingNPCs:
|
||||
Enabled: false
|
||||
@@ -294,6 +294,10 @@ CHEST:
|
||||
Lore: '&c&lBest: &c%Best%'
|
||||
Amount: 1
|
||||
|
||||
# Separate read-only /vote streaks progression menu.
|
||||
VoteStreaks:
|
||||
Name: '&6Vote streaks'
|
||||
|
||||
VoteStreak:
|
||||
Name: 'VoteStreak: %player%'
|
||||
BackButton: false
|
||||
|
||||
@@ -170,8 +170,6 @@ permissions:
|
||||
description: Allows mod commands
|
||||
VotingPlugin.Admin:
|
||||
description: Allows admincommands
|
||||
children:
|
||||
VotingPlugin.Commands.AdminVote: true
|
||||
VotingPlugin.TopVoter.Ignore:
|
||||
description: Allows you to be exempt from top voter
|
||||
VotingPlugin.NoRemind:
|
||||
@@ -198,8 +196,8 @@ permissions:
|
||||
description: Remind if player can vote on all votesites, rather than any
|
||||
default: false
|
||||
|
||||
VotingPlugin.Commands.AdminVote.NPC:
|
||||
description: Create, inspect, list and remove saved vanilla voting NPCs
|
||||
VotingPlugin.Commands.Vote.Streaks:
|
||||
description: View your read-only vote streak progression
|
||||
default: op
|
||||
children:
|
||||
VotingPlugin.Commands.AdminVote: true
|
||||
VotingPlugin.Commands.Vote: true
|
||||
@@ -1,203 +0,0 @@
|
||||
package com.bencodez.votingplugin.npc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import com.bencodez.advancedcore.api.command.CommandHandler;
|
||||
import com.bencodez.simpleapi.scheduler.BukkitScheduler;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
|
||||
class VotingNPCCommandsTest {
|
||||
private static class Fixture {
|
||||
final VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
final BukkitScheduler scheduler = mock(BukkitScheduler.class);
|
||||
final VotingNPCManager manager = mock(VotingNPCManager.class);
|
||||
final ArrayList<CommandHandler> handlers = new ArrayList<>();
|
||||
final LinkedBlockingQueue<Runnable> tasks = new LinkedBlockingQueue<>();
|
||||
final LinkedBlockingQueue<Runnable> asynchronous = new LinkedBlockingQueue<>();
|
||||
Fixture() {
|
||||
when(plugin.getAdminVoteCommand()).thenReturn(handlers);
|
||||
when(plugin.getVotingNPCManager()).thenReturn(manager);
|
||||
when(plugin.getBukkitScheduler()).thenReturn(scheduler);
|
||||
when(manager.available()).thenReturn(true);
|
||||
doAnswer(inv -> { tasks.add(inv.getArgument(1)); return null; })
|
||||
.when(scheduler).runTask(eq(plugin), any(Runnable.class), any(Entity.class));
|
||||
doAnswer(inv -> { tasks.add(inv.getArgument(1)); return null; })
|
||||
.when(scheduler).runTask(eq(plugin), any(Runnable.class));
|
||||
doAnswer(inv -> { asynchronous.add(inv.getArgument(1)); return null; })
|
||||
.when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class));
|
||||
VotingNPCCommands.register(plugin);
|
||||
}
|
||||
Player player() {
|
||||
Player player = mock(Player.class);
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
when(player.hasPermission("VotingPlugin.Commands.AdminVote.NPC")).thenReturn(true);
|
||||
World world = mock(World.class); when(world.getUID()).thenReturn(new UUID(1, 0));
|
||||
// Location holds its world weakly; retain it as a real online player does.
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
when(player.getLocation()).thenReturn(new Location(world, 4, 64, 4));
|
||||
return player;
|
||||
}
|
||||
CommandHandler command(String action, int arguments) {
|
||||
return handlers.stream().filter(value -> value.getArgs()[1].equals(action)
|
||||
&& value.getArgs().length == arguments).findFirst().orElseThrow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void usesOnlyDedicatedNpcPatternsAndExplicitAdminPermission() {
|
||||
var f = new Fixture();
|
||||
assertEquals(List.of(List.of("NPC", "Create", "(string)"), List.of("NPC", "Create", "(string)", "(list)"),
|
||||
List.of("NPC", "List"), List.of("NPC", "Inspect", "(npcid)"), List.of("NPC", "Remove", "(npcid)")),
|
||||
f.handlers.stream().map(value -> List.of(value.getArgs())).toList());
|
||||
for (var handler : f.handlers) assertEquals("VotingPlugin.Commands.AdminVote.NPC|VotingPlugin.Admin", handler.getPerm());
|
||||
}
|
||||
|
||||
@Test void metadataGrantsTheAdminRootGateWithoutGrantingOtherAdminLeaves() throws Exception {
|
||||
org.bukkit.plugin.PluginDescriptionFile description;
|
||||
try (var input = getClass().getResourceAsStream("/plugin.yml")) {
|
||||
assertNotNull(input); description = new org.bukkit.plugin.PluginDescriptionFile(input);
|
||||
}
|
||||
var permissions = description.getPermissions().stream().collect(java.util.stream.Collectors.toMap(
|
||||
permission -> permission.getName().toLowerCase(java.util.Locale.ROOT), java.util.function.Function.identity()));
|
||||
var plugin = mock(VotingPluginMain.class); when(plugin.isEnabled()).thenReturn(true);
|
||||
var pm = mock(org.bukkit.plugin.PluginManager.class);
|
||||
when(pm.getDefaultPermissions(false)).thenReturn(java.util.Set.of());
|
||||
when(pm.getPermission(anyString())).thenAnswer(call -> permissions.get(((String)call.getArgument(0)).toLowerCase(java.util.Locale.ROOT)));
|
||||
try (var bukkit = mockStatic(org.bukkit.Bukkit.class)) {
|
||||
bukkit.when(org.bukkit.Bukkit::getPluginManager).thenReturn(pm);
|
||||
var server = mock(org.bukkit.Server.class); when(server.getPluginManager()).thenReturn(pm);
|
||||
bukkit.when(org.bukkit.Bukkit::getServer).thenReturn(server);
|
||||
for (String grant : List.of("VotingPlugin.Commands.AdminVote.NPC", "VotingPlugin.Admin")) {
|
||||
var permissible = new org.bukkit.permissions.PermissibleBase(mock(org.bukkit.permissions.ServerOperator.class));
|
||||
permissible.addAttachment(plugin, grant, true);
|
||||
CommandSender sender = mock(CommandSender.class);
|
||||
when(sender.hasPermission(anyString())).thenAnswer(call -> permissible.hasPermission((String)call.getArgument(0)));
|
||||
var root = new org.bukkit.command.Command("av") {
|
||||
@Override public boolean execute(CommandSender sender, String label, String[] args) { return true; }
|
||||
};
|
||||
root.setPermission("VotingPlugin.Commands.AdminVote");
|
||||
assertTrue(root.testPermissionSilent(sender), grant);
|
||||
if (grant.endsWith(".NPC")) {
|
||||
assertFalse(permissible.hasPermission("VotingPlugin.Commands.AdminVote.Edit.Config"));
|
||||
assertFalse(permissible.hasPermission("VotingPlugin.Admin"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void capturesPlayerStateOnlyOnOwnerAndSuccessOnlyAfterDurableCompletion() {
|
||||
var f = new Fixture(); Player player = f.player();
|
||||
var completion = new CompletableFuture<VotingNPCDefinition>();
|
||||
when(f.manager.create(any())).thenReturn(completion);
|
||||
f.command("Create", 4).execute(player, new String[] { "NPC", "Create", "voting", "Vote", "here" });
|
||||
verify(player, never()).getLocation(); verify(f.manager, never()).create(any());
|
||||
f.tasks.remove().run();
|
||||
var definition = ArgumentCaptor.forClass(VotingNPCDefinition.class); verify(f.manager).create(definition.capture());
|
||||
assertEquals("Vote here", definition.getValue().name()); assertEquals("voting", definition.getValue().id());
|
||||
verify(player, never()).sendMessage(anyString());
|
||||
completion.complete(definition.getValue());
|
||||
verify(player, never()).sendMessage(anyString());
|
||||
f.tasks.remove().run();
|
||||
verify(player).sendMessage(contains("Saved voting NPC 'voting'"));
|
||||
}
|
||||
|
||||
@Test void delayedPermissionRevocationAndDisabledFeaturePreventMutation() {
|
||||
var f = new Fixture(); Player player = f.player();
|
||||
var handler = f.command("Create", 3);
|
||||
handler.execute(player, new String[] { "NPC", "Create", "voting" });
|
||||
when(player.hasPermission("VotingPlugin.Commands.AdminVote.NPC")).thenReturn(false);
|
||||
f.tasks.remove().run(); verify(f.manager, never()).create(any());
|
||||
when(player.hasPermission("VotingPlugin.Commands.AdminVote.NPC")).thenReturn(true);
|
||||
when(f.manager.available()).thenReturn(false);
|
||||
handler.execute(player, new String[] { "NPC", "Create", "voting" }); f.tasks.remove().run();
|
||||
verify(f.manager, never()).create(any()); verify(player).sendMessage(contains("VotingNPCs.Enabled"));
|
||||
}
|
||||
|
||||
@Test void rejectedStorageDoesNotSendSuccessAndDoesNotLeakRawFailure() {
|
||||
var f = new Fixture(); Player player = f.player();
|
||||
when(f.manager.remove("voting")).thenReturn(CompletableFuture.failedFuture(new IllegalStateException("Synthetic private diagnostic")));
|
||||
f.command("Remove", 3).execute(player, new String[] { "NPC", "Remove", "voting" });
|
||||
f.tasks.remove().run(); f.tasks.remove().run();
|
||||
verify(player).sendMessage(contains("was not confirmed"));
|
||||
verify(player, never()).sendMessage(contains("Synthetic private"));
|
||||
verify(player, never()).sendMessage(startsWith("Removed saved"));
|
||||
}
|
||||
|
||||
@Test void validationRejectionsHaveFixedActionableMessagesAndUnavailableStorageStaysConservative() {
|
||||
for (var reason : VotingNPCRegistry.Rejection.values()) {
|
||||
var f = new Fixture(); Player player = f.player();
|
||||
when(f.manager.create(any())).thenReturn(CompletableFuture.failedFuture(
|
||||
new java.util.concurrent.CompletionException(new VotingNPCRegistry.ValidationFailure(reason))));
|
||||
f.command("Create", 3).execute(player, new String[] { "NPC", "Create", "voting" });
|
||||
f.tasks.remove().run(); f.tasks.remove().run();
|
||||
String expected = switch (reason) {
|
||||
case DUPLICATE_ID -> "ID already exists";
|
||||
case DUPLICATE_IDENTITY -> "identity already exists";
|
||||
case LIMIT -> "64 voting NPC limit";
|
||||
};
|
||||
verify(player).sendMessage(contains(expected));
|
||||
verify(player, never()).sendMessage(contains("reload before retrying"));
|
||||
}
|
||||
var f = new Fixture(); Player player = f.player();
|
||||
var pending = new CompletableFuture<VotingNPCDefinition>();
|
||||
when(f.manager.create(any())).thenReturn(pending);
|
||||
f.command("Create", 3).execute(player, new String[] { "NPC", "Create", "voting" }); f.tasks.remove().run();
|
||||
when(f.manager.available()).thenReturn(false);
|
||||
pending.completeExceptionally(new java.util.concurrent.RejectedExecutionException("Synthetic")); f.tasks.remove().run();
|
||||
verify(player).sendMessage(contains("reload before retrying"));
|
||||
verify(player, never()).sendMessage(contains("busy"));
|
||||
}
|
||||
|
||||
@Test void healthyQueueSaturationDoesNotTellTheAdministratorToReloadStorage() {
|
||||
var f = new Fixture(); Player player = f.player();
|
||||
when(f.manager.create(any())).thenReturn(CompletableFuture.failedFuture(
|
||||
new java.util.concurrent.RejectedExecutionException("Synthetic queue rejection")));
|
||||
f.command("Create", 3).execute(player, new String[] { "NPC", "Create", "voting" });
|
||||
f.tasks.remove().run(); f.tasks.remove().run();
|
||||
verify(player).sendMessage(contains("busy"));
|
||||
verify(player, never()).sendMessage(contains("reload before retrying"));
|
||||
verify(player, never()).sendMessage(contains("Synthetic"));
|
||||
}
|
||||
|
||||
@Test void invalidDefinitionAndConsoleCreationDoNotMutate() {
|
||||
var f = new Fixture(); Player player = f.player();
|
||||
f.command("Create", 3).execute(player, new String[] { "NPC", "Create", "bad id" }); f.tasks.remove().run();
|
||||
verify(f.manager, never()).create(any()); verify(player).sendMessage(contains("lowercase ID"));
|
||||
CommandSender console = mock(CommandSender.class); when(console.hasPermission("VotingPlugin.Admin")).thenReturn(true);
|
||||
f.command("Create", 3).execute(console, new String[] { "NPC", "Create", "voting" }); f.tasks.remove().run();
|
||||
verify(console).sendMessage("Create a voting NPC from in game."); verify(f.manager, never()).create(any());
|
||||
}
|
||||
|
||||
@Test void bothCreationFormsMatchRealCommandDispatchWithStandardFrameworkPlaceholders() {
|
||||
var tabs = new com.bencodez.simpleapi.command.TabCompleteHandler();
|
||||
// These are the standard placeholders AdvancedCore registers before commands.
|
||||
tabs.addTabCompleteOption("(String)", new ArrayList<>());
|
||||
tabs.addTabCompleteOption("(List)", new ArrayList<>());
|
||||
try (var tabSingleton = mockStatic(com.bencodez.simpleapi.command.TabCompleteHandler.class)) {
|
||||
tabSingleton.when(com.bencodez.simpleapi.command.TabCompleteHandler::getInstance).thenReturn(tabs);
|
||||
var f = new Fixture(); Player player = f.player();
|
||||
when(f.manager.create(any())).thenReturn(new CompletableFuture<>());
|
||||
assertTrue(f.command("Create", 3).runCommand(player, new String[] { "npc", "create", "new-voting" }));
|
||||
f.asynchronous.remove().run(); f.tasks.remove().run();
|
||||
assertTrue(f.command("Create", 4).runCommand(player, new String[] { "npc", "create", "another", "Vote", "here" }));
|
||||
f.asynchronous.remove().run(); f.tasks.remove().run();
|
||||
var values = ArgumentCaptor.forClass(VotingNPCDefinition.class); verify(f.manager, times(2)).create(values.capture());
|
||||
assertEquals(List.of("new-voting", "another"), values.getAllValues().stream().map(VotingNPCDefinition::id).toList());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,556 +0,0 @@
|
||||
package com.bencodez.votingplugin.npc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.AdditionalMatchers.aryEq;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.entity.Villager;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEntityEvent;
|
||||
import org.bukkit.event.world.ChunkLoadEvent;
|
||||
import org.bukkit.event.world.ChunkUnloadEvent;
|
||||
import org.bukkit.event.world.WorldLoadEvent;
|
||||
import org.bukkit.event.world.WorldUnloadEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import com.bencodez.simpleapi.scheduler.BukkitScheduler;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
|
||||
class VotingNPCManagerTest {
|
||||
@TempDir Path directory;
|
||||
private static final UUID WORLD = new UUID(1, 0);
|
||||
|
||||
private static VotingNPCDefinition definition(int identity) {
|
||||
return new VotingNPCDefinition("voting", new UUID(0, identity), WORLD, 8, 64, 8, 0, 0, "&aVote");
|
||||
}
|
||||
|
||||
private static VotingNPCDefinition definitionAt(int identity, double x) {
|
||||
return new VotingNPCDefinition("npc-" + identity, new UUID(0, identity), WORLD, x, 64, 8, 0, 0, "&aVote");
|
||||
}
|
||||
|
||||
private static <T> T await(CompletionStage<T> stage) throws Exception {
|
||||
return stage.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static class Scheduler extends VotingNPCScheduler {
|
||||
final LinkedBlockingQueue<Runnable> global = new LinkedBlockingQueue<>();
|
||||
final LinkedBlockingQueue<Runnable> regions = new LinkedBlockingQueue<>();
|
||||
final LinkedBlockingQueue<Runnable> entities = new LinkedBlockingQueue<>();
|
||||
String owner = "none";
|
||||
boolean rejectCleanup;
|
||||
Scheduler(VotingPluginMain plugin) { super(plugin); }
|
||||
@Override boolean owns(Location value) { return owner.equals("region"); }
|
||||
@Override boolean owns(Entity value) { return owner.equals("entity"); }
|
||||
@Override void global(Runnable action, Consumer<Throwable> failed) { global.add(action); }
|
||||
@Override void region(Location anchor, Runnable action, Consumer<Throwable> failed) { regions.add(action); }
|
||||
@Override void entity(Entity entity, Runnable action, Runnable retired, Consumer<Throwable> failed) {
|
||||
if (rejectCleanup) failed.accept(new IllegalStateException("Synthetic rejection"));
|
||||
else entities.add(action);
|
||||
}
|
||||
void runNext(LinkedBlockingQueue<Runnable> queue, String phase) throws Exception {
|
||||
Runnable action = queue.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(action, "Expected scheduled " + phase + " work");
|
||||
owner = phase;
|
||||
try { action.run(); } finally { owner = "none"; }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void duplicateChunkNotificationsDoNotReconstructAnotherEntityAndOrdinaryEntitiesAreIgnored() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
|
||||
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var scheduler = new Scheduler(plugin);
|
||||
List<Villager> spawned = installSpawner(world, scheduler);
|
||||
var registry = new VotingNPCRegistry(directory.resolve("npcs.json")); await(registry.initialLoad());
|
||||
await(registry.create(definition(1)));
|
||||
var manager = new VotingNPCManager(plugin, scheduler, registry, player -> fail("Unexpected menu"));
|
||||
try {
|
||||
manager.configure(true);
|
||||
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
|
||||
Chunk chunk = chunk(world);
|
||||
manager.chunkLoad(new ChunkLoadEvent(chunk, false));
|
||||
manager.chunkLoad(new ChunkLoadEvent(chunk, false));
|
||||
assertEquals(1, spawned.size()); assertEquals(1, manager.runtimeCount());
|
||||
verify(spawned.getFirst()).setPersistent(false);
|
||||
verify(spawned.getFirst()).setAI(false);
|
||||
verify(spawned.getFirst()).setCustomName("\u00a7aVote");
|
||||
Player player = mock(Player.class); when(player.getUniqueId()).thenReturn(new UUID(0, 100));
|
||||
Villager unrelated = mock(Villager.class); when(unrelated.getUniqueId()).thenReturn(new UUID(0, 99));
|
||||
var interact = new PlayerInteractEntityEvent(player, unrelated, EquipmentSlot.HAND);
|
||||
manager.interact(interact); assertFalse(interact.isCancelled());
|
||||
var damage = mock(EntityDamageEvent.class); when(damage.getEntity()).thenReturn(unrelated);
|
||||
manager.damage(damage); verify(damage, never()).setCancelled(anyBoolean());
|
||||
var protectedDamage = mock(EntityDamageEvent.class); when(protectedDamage.getEntity()).thenReturn(spawned.getFirst());
|
||||
manager.damage(protectedDamage); verify(protectedDamage).setCancelled(true);
|
||||
var hostileTarget = new org.bukkit.event.entity.EntityTargetEvent(unrelated, spawned.getFirst(),
|
||||
org.bukkit.event.entity.EntityTargetEvent.TargetReason.CLOSEST_ENTITY);
|
||||
manager.target(hostileTarget);
|
||||
assertTrue(hostileTarget.isCancelled(), "ordinary mobs must not target a tracked NPC");
|
||||
var normalTarget = new org.bukkit.event.entity.EntityTargetEvent(unrelated, player,
|
||||
org.bukkit.event.entity.EntityTargetEvent.TargetReason.CLOSEST_ENTITY);
|
||||
manager.target(normalTarget); assertFalse(normalTarget.isCancelled());
|
||||
var clearedTarget = new org.bukkit.event.entity.EntityTargetEvent(unrelated, null,
|
||||
org.bukkit.event.entity.EntityTargetEvent.TargetReason.FORGOT_TARGET);
|
||||
manager.target(clearedTarget); assertFalse(clearedTarget.isCancelled());
|
||||
var witch = mock(org.bukkit.entity.Witch.class);
|
||||
var transform = new org.bukkit.event.entity.EntityTransformEvent(spawned.getFirst(), List.of(witch),
|
||||
org.bukkit.event.entity.EntityTransformEvent.TransformReason.LIGHTNING);
|
||||
var unrelatedTransform = new org.bukkit.event.entity.EntityTransformEvent(unrelated, List.of(witch),
|
||||
org.bukkit.event.entity.EntityTransformEvent.TransformReason.LIGHTNING);
|
||||
// Discover actual listener methods, as event registration does;
|
||||
// absence of a transformation handler must fail this regression.
|
||||
for (var method : VotingNPCManager.class.getDeclaredMethods()) {
|
||||
if (method.isAnnotationPresent(org.bukkit.event.EventHandler.class) && method.getParameterCount() == 1
|
||||
&& method.getParameterTypes()[0].isInstance(transform)) {
|
||||
method.invoke(manager, transform); method.invoke(manager, unrelatedTransform);
|
||||
}
|
||||
}
|
||||
assertTrue(transform.isCancelled(), "lightning must not replace a tracked villager with an untracked entity");
|
||||
assertFalse(unrelatedTransform.isCancelled());
|
||||
} finally {
|
||||
manager.close(); scheduler.runNext(scheduler.entities, "entity");
|
||||
assertEquals(0, manager.runtimeCount()); assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void freedFullCapacityWakesOnePendingDifferentId() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
|
||||
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
|
||||
var registry = new VotingNPCRegistry(directory.resolve("capacity.json")); await(registry.initialLoad());
|
||||
for (int i = 1; i <= VotingNPCStore.MAX_NPCS; i++) await(registry.create(definitionAt(i, i * 2)));
|
||||
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
|
||||
try {
|
||||
manager.configure(true); scheduler.runNext(scheduler.global, "global");
|
||||
for (int i = 0; i < VotingNPCStore.MAX_NPCS; i++) scheduler.runNext(scheduler.regions, "region");
|
||||
assertEquals(VotingNPCStore.MAX_NPCS, manager.runtimeCount());
|
||||
await(manager.remove("npc-1")); await(manager.create(definitionAt(1000, 2000)));
|
||||
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.global, "global");
|
||||
scheduler.runNext(scheduler.entities, "entity");
|
||||
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
|
||||
assertEquals(VotingNPCStore.MAX_NPCS, manager.runtimeCount());
|
||||
assertEquals(VotingNPCStore.MAX_NPCS + 1, spawned.size());
|
||||
} finally {
|
||||
manager.close(); while (!scheduler.entities.isEmpty()) scheduler.runNext(scheduler.entities, "entity");
|
||||
assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unloadedCurrentReservationWakesDifferentPendingIdWithoutRetryingItself() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
|
||||
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
|
||||
var registry = new VotingNPCRegistry(directory.resolve("unloaded-current-capacity.json")); await(registry.initialLoad());
|
||||
for (int i = 1; i < VotingNPCStore.MAX_NPCS; i++) await(registry.create(definitionAt(i, i * 2)));
|
||||
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
|
||||
try {
|
||||
manager.configure(true); scheduler.runNext(scheduler.global, "global");
|
||||
for (int i = 1; i < VotingNPCStore.MAX_NPCS; i++) scheduler.runNext(scheduler.regions, "region");
|
||||
await(manager.create(definitionAt(64, 128)));
|
||||
scheduler.runNext(scheduler.global, "global");
|
||||
assertEquals(64, manager.runtimeCount()); assertEquals(1, scheduler.regions.size());
|
||||
await(manager.remove("npc-1")); await(manager.create(definitionAt(1000, 2000)));
|
||||
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.global, "global");
|
||||
assertEquals(1, scheduler.entities.size()); assertTrue(scheduler.global.isEmpty());
|
||||
assertEquals(1, scheduler.regions.size());
|
||||
// The existing queued reservation is now unloaded; the new target remains loaded.
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenAnswer(call -> (Integer) call.getArgument(0) != 8);
|
||||
scheduler.runNext(scheduler.regions, "region");
|
||||
assertEquals(63, manager.runtimeCount());
|
||||
assertEquals(1, scheduler.global.size(), "Released capacity must wake the different waiting ID");
|
||||
scheduler.runNext(scheduler.global, "global");
|
||||
assertEquals(1, scheduler.regions.size(), "Only the pending ID may retry, not the unloaded reservation");
|
||||
scheduler.runNext(scheduler.regions, "region");
|
||||
assertEquals(64, spawned.size()); assertEquals(64, manager.runtimeCount());
|
||||
assertTrue(scheduler.global.isEmpty()); assertTrue(scheduler.regions.isEmpty());
|
||||
verify(world).spawn(argThat(anchor -> anchor.getX() == 2000), eq(Villager.class), any(Consumer.class));
|
||||
} finally {
|
||||
manager.close(); while (!scheduler.entities.isEmpty()) scheduler.runNext(scheduler.entities, "entity");
|
||||
assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void successorWaitsForExactPredecessorCleanupAndCleanupRejectionRetainsOwnership() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
|
||||
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
|
||||
var registry = new VotingNPCRegistry(directory.resolve("npcs.json")); await(registry.initialLoad());
|
||||
await(registry.create(definition(1)));
|
||||
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
|
||||
try {
|
||||
manager.configure(true); scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
|
||||
scheduler.rejectCleanup = true;
|
||||
assertTrue(await(manager.remove("voting")));
|
||||
await(manager.create(definition(2)));
|
||||
// Process both durable mutation notifications while old removal is rejected.
|
||||
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.global, "global");
|
||||
assertEquals(1, spawned.size()); assertEquals(1, manager.runtimeCount());
|
||||
verify(spawned.getFirst(), never()).remove();
|
||||
// A rejected cleanup keeps ownership; repeated load notifications may
|
||||
// request a retry, but must never create a successor or duplicate task.
|
||||
manager.chunkLoad(new ChunkLoadEvent(chunk(world), false));
|
||||
manager.chunkLoad(new ChunkLoadEvent(chunk(world), false));
|
||||
assertEquals(1, spawned.size()); assertEquals(1, manager.runtimeCount());
|
||||
scheduler.rejectCleanup = false;
|
||||
manager.chunkLoad(new ChunkLoadEvent(chunk(world), false));
|
||||
scheduler.runNext(scheduler.entities, "entity");
|
||||
verify(spawned.getFirst()).remove();
|
||||
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
|
||||
assertEquals(2, spawned.size()); assertEquals(1, manager.runtimeCount());
|
||||
} finally {
|
||||
scheduler.rejectCleanup = false; manager.close();
|
||||
if (!scheduler.entities.isEmpty()) scheduler.runNext(scheduler.entities, "entity");
|
||||
assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void transientUnloadCleanupRetriesAtLoadAndCoalescesUntilRemovalFinishes() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
|
||||
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
|
||||
var registry = new VotingNPCRegistry(directory.resolve("npcs.json")); await(registry.initialLoad());
|
||||
await(registry.create(definition(1)));
|
||||
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
|
||||
try {
|
||||
manager.configure(true); scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
|
||||
Chunk chunk = chunk(world); scheduler.rejectCleanup = true;
|
||||
manager.chunkUnload(new ChunkUnloadEvent(chunk));
|
||||
assertEquals(1, manager.runtimeCount()); assertTrue(scheduler.global.isEmpty());
|
||||
assertTrue(scheduler.entities.isEmpty(), "rejection must not create an autonomous retry loop");
|
||||
scheduler.rejectCleanup = false;
|
||||
manager.chunkLoad(new ChunkLoadEvent(chunk, false));
|
||||
manager.chunkLoad(new ChunkLoadEvent(chunk, false));
|
||||
assertEquals(1, scheduler.entities.size(), "a queued exact predecessor cleanup must coalesce");
|
||||
assertEquals(1, spawned.size());
|
||||
scheduler.runNext(scheduler.entities, "entity"); verify(spawned.getFirst()).remove();
|
||||
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
|
||||
assertEquals(2, spawned.size()); assertEquals(1, manager.runtimeCount());
|
||||
assertTrue(scheduler.entities.isEmpty()); assertTrue(scheduler.global.isEmpty());
|
||||
} finally {
|
||||
scheduler.rejectCleanup = false; manager.close();
|
||||
if (!scheduler.entities.isEmpty()) scheduler.runNext(scheduler.entities, "entity");
|
||||
assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void queuedRemovedReservationWakesPendingCapacityWithoutEntity() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
|
||||
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
|
||||
var registry = new VotingNPCRegistry(directory.resolve("queued-capacity.json")); await(registry.initialLoad());
|
||||
for (int i = 1; i <= VotingNPCStore.MAX_NPCS; i++) await(registry.create(definitionAt(i, i * 2)));
|
||||
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
|
||||
try {
|
||||
manager.configure(true); scheduler.runNext(scheduler.global, "global");
|
||||
await(manager.remove("npc-1")); await(manager.create(definitionAt(1000, 2000)));
|
||||
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.global, "global");
|
||||
for (int i = 0; i <= VotingNPCStore.MAX_NPCS; i++) scheduler.runNext(scheduler.regions, "region");
|
||||
assertTrue(scheduler.regions.isEmpty()); assertTrue(scheduler.global.isEmpty());
|
||||
assertEquals(VotingNPCStore.MAX_NPCS, manager.runtimeCount());
|
||||
assertEquals(VotingNPCStore.MAX_NPCS, spawned.size());
|
||||
} finally {
|
||||
manager.close(); while (!scheduler.entities.isEmpty()) scheduler.runNext(scheduler.entities, "entity");
|
||||
assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unloadedQueuedReservationWithPendingCapacityDoesNotRetry() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
|
||||
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false);
|
||||
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
|
||||
var registry = new VotingNPCRegistry(directory.resolve("unloaded-capacity.json")); await(registry.initialLoad());
|
||||
for (int i = 1; i <= VotingNPCStore.MAX_NPCS; i++) await(registry.create(definitionAt(i, i * 2)));
|
||||
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
|
||||
try {
|
||||
manager.configure(true); scheduler.runNext(scheduler.global, "global");
|
||||
await(manager.remove("npc-1")); await(manager.create(definitionAt(1000, 2000)));
|
||||
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.global, "global");
|
||||
for (int i = 0; i <= VotingNPCStore.MAX_NPCS; i++) scheduler.runNext(scheduler.regions, "region");
|
||||
assertEquals(0, spawned.size()); assertTrue(scheduler.global.isEmpty());
|
||||
assertTrue(scheduler.regions.isEmpty()); assertEquals(0, manager.runtimeCount());
|
||||
} finally { manager.close(); assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS)); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unloadedChunksNeverSpawnOrLoadAndLaterLoadReconstructsOnce() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
|
||||
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
|
||||
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
|
||||
var registry = new VotingNPCRegistry(directory.resolve("npcs.json")); await(registry.initialLoad());
|
||||
await(registry.create(definition(1)));
|
||||
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
|
||||
try {
|
||||
manager.configure(true); scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
|
||||
assertTrue(spawned.isEmpty()); assertEquals(0, manager.runtimeCount());
|
||||
verify(world, never()).getChunkAt(anyInt(), anyInt()); verify(world, never()).loadChunk(anyInt(), anyInt());
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
manager.chunkLoad(new ChunkLoadEvent(chunk(world), false)); scheduler.runNext(scheduler.regions, "region");
|
||||
assertEquals(1, spawned.size());
|
||||
manager.chunkUnload(new ChunkUnloadEvent(chunk(world))); scheduler.runNext(scheduler.entities, "entity");
|
||||
assertTrue(scheduler.global.isEmpty(), "unload must not immediately retry reconstruction");
|
||||
assertTrue(scheduler.regions.isEmpty());
|
||||
manager.chunkLoad(new ChunkLoadEvent(chunk(world), false)); scheduler.runNext(scheduler.regions, "region");
|
||||
assertEquals(2, spawned.size());
|
||||
} finally { manager.close(); scheduler.runNext(scheduler.entities, "entity"); assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS)); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void partialSpawnFailureRemovesOnlyTheCapturedEntityAndCloseInvalidatesQueuedSpawns() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
|
||||
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
|
||||
var registry = new VotingNPCRegistry(directory.resolve("npcs.json")); await(registry.initialLoad());
|
||||
await(registry.create(definition(1)));
|
||||
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
|
||||
try {
|
||||
manager.configure(true); scheduler.runNext(scheduler.global, "global");
|
||||
// Close before a deferred creation runs: no entity may be created.
|
||||
manager.configure(false); scheduler.runNext(scheduler.regions, "region");
|
||||
assertTrue(spawned.isEmpty()); assertEquals(0, manager.runtimeCount());
|
||||
manager.configure(true); scheduler.runNext(scheduler.global, "global");
|
||||
doAnswer(invocation -> {
|
||||
Villager entity = mock(Villager.class); when(entity.getUniqueId()).thenReturn(new UUID(0, 17));
|
||||
doThrow(new IllegalStateException("Synthetic entity property failure")).when(entity).setAI(false);
|
||||
spawned.add(entity);
|
||||
Consumer<Villager> initializer = invocation.getArgument(2); initializer.accept(entity); return entity;
|
||||
}).when(world).spawn(any(Location.class), eq(Villager.class), any(Consumer.class));
|
||||
scheduler.runNext(scheduler.regions, "region");
|
||||
assertEquals(1, manager.runtimeCount());
|
||||
scheduler.runNext(scheduler.entities, "entity");
|
||||
verify(spawned.getFirst()).remove(); assertEquals(0, manager.runtimeCount());
|
||||
assertTrue(scheduler.global.isEmpty(), "failed spawn must not cause an automatic retry loop");
|
||||
} finally { manager.close(); assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS)); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void occupiedNeighbourUnloadRetiresAndReloadReconstructsOnceAtBothCoordinateSigns() throws Exception {
|
||||
for (double position : new double[] {15.75, -.25}) {
|
||||
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
|
||||
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
|
||||
var registry = new VotingNPCRegistry(directory.resolve("boundary-" + (position > 0) + ".json"));
|
||||
await(registry.initialLoad());
|
||||
var definition = new VotingNPCDefinition("voting", new UUID(0, 1), WORLD, position, 64, position, 0, 0, "Vote");
|
||||
await(registry.create(definition));
|
||||
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
|
||||
try {
|
||||
manager.configure(true); scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
|
||||
manager.chunkUnload(new ChunkUnloadEvent(atChunk(world, 7, 7)));
|
||||
assertTrue(scheduler.entities.isEmpty(), "unrelated chunk unload must leave NPC alone");
|
||||
int neighbour = definition.chunkX() + 1;
|
||||
manager.chunkUnload(new ChunkUnloadEvent(atChunk(world, neighbour, neighbour)));
|
||||
assertEquals(1, scheduler.entities.size(), "occupied adjacent chunk must trigger entity-owned cleanup");
|
||||
// Bukkit may still report the unloading chunk as loaded during its event.
|
||||
manager.chunkLoad(new ChunkLoadEvent(atChunk(world, definition.chunkX(), definition.chunkZ()), false));
|
||||
scheduler.runNext(scheduler.entities, "entity");
|
||||
assertEquals(0, manager.runtimeCount()); assertTrue(scheduler.regions.isEmpty());
|
||||
manager.chunkLoad(new ChunkLoadEvent(atChunk(world, neighbour, neighbour), false));
|
||||
manager.chunkLoad(new ChunkLoadEvent(atChunk(world, neighbour, neighbour), false));
|
||||
scheduler.runNext(scheduler.regions, "region");
|
||||
assertEquals(2, spawned.size()); assertEquals(1, manager.runtimeCount());
|
||||
verify(world, never()).getChunkAt(anyInt(), anyInt()); verify(world, never()).loadChunk(anyInt(), anyInt());
|
||||
} finally {
|
||||
manager.close(); if (!scheduler.entities.isEmpty()) scheduler.runNext(scheduler.entities, "entity");
|
||||
assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void neighbourUnloadFenceSurvivesReconciliationAndBlocksDeferredCreation() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
|
||||
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
|
||||
var registry = new VotingNPCRegistry(directory.resolve("pending-boundary.json")); await(registry.initialLoad());
|
||||
await(registry.create(new VotingNPCDefinition("voting", new UUID(0, 1), WORLD, 15.75, 64, 15.75, 0, 0, "Vote")));
|
||||
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
|
||||
try {
|
||||
manager.configure(true); scheduler.runNext(scheduler.global, "global");
|
||||
manager.chunkUnload(new ChunkUnloadEvent(atChunk(world, 1, 1)));
|
||||
scheduler.runNext(scheduler.regions, "region");
|
||||
assertTrue(spawned.isEmpty(), "a deferred creation cannot cross an occupied chunk unload");
|
||||
assertEquals(0, manager.runtimeCount());
|
||||
await(manager.create(new VotingNPCDefinition("other", new UUID(0, 2), WORLD, 40, 64, 40, 0, 0, "Vote")));
|
||||
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
|
||||
assertEquals(1, spawned.size(), "unrelated mutation must not erase the first NPC's occupied-chunk fence");
|
||||
assertTrue(scheduler.regions.isEmpty());
|
||||
} finally {
|
||||
manager.close(); while (!scheduler.entities.isEmpty()) scheduler.runNext(scheduler.entities, "entity");
|
||||
assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Chunk atChunk(World world, int x, int z) {
|
||||
Chunk chunk = mock(Chunk.class); when(chunk.getWorld()).thenReturn(world);
|
||||
when(chunk.getX()).thenReturn(x); when(chunk.getZ()).thenReturn(z); return chunk;
|
||||
}
|
||||
|
||||
@Test void disabledFeatureDoesNotReadStorageOrRegisterAnyRuntimeWork() {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
var scheduler = new Scheduler(plugin);
|
||||
var manager = new VotingNPCManager(plugin, scheduler, null, ignored -> fail("Disabled menu"));
|
||||
manager.configure(false);
|
||||
assertFalse(manager.available()); assertEquals(0, manager.runtimeCount());
|
||||
assertTrue(scheduler.global.isEmpty()); assertTrue(scheduler.regions.isEmpty());
|
||||
verify(plugin, never()).getDataFolder();
|
||||
manager.close(); manager.close();
|
||||
}
|
||||
|
||||
@Test void worldUnloadPreservesDefinitionsAndAWorldLoadAfterCleanupRecreatesOnce() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
|
||||
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
|
||||
var registry = new VotingNPCRegistry(directory.resolve("npcs.json")); await(registry.initialLoad());
|
||||
await(registry.create(definition(1)));
|
||||
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
|
||||
try {
|
||||
manager.configure(true); scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
|
||||
manager.worldUnload(new WorldUnloadEvent(world));
|
||||
// A load before delayed cleanup must record demand, not spawn a duplicate.
|
||||
manager.worldLoad(new WorldLoadEvent(world)); scheduler.runNext(scheduler.global, "global");
|
||||
assertEquals(1, spawned.size());
|
||||
scheduler.runNext(scheduler.entities, "entity"); scheduler.runNext(scheduler.global, "global");
|
||||
scheduler.runNext(scheduler.regions, "region");
|
||||
assertEquals(2, spawned.size()); assertEquals(List.of(definition(1)), manager.definitions());
|
||||
} finally { manager.close(); scheduler.runNext(scheduler.entities, "entity"); assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS)); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cancelledAndOffhandClicksDoNotOpenAndRemovalInvalidatesAQueuedPlayerCallback() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
var bukkitScheduler = mock(BukkitScheduler.class); when(plugin.getBukkitScheduler()).thenReturn(bukkitScheduler);
|
||||
var playerCallbacks = new LinkedBlockingQueue<Runnable>();
|
||||
doAnswer(invocation -> { playerCallbacks.add(invocation.getArgument(1)); return null; })
|
||||
.when(bukkitScheduler).runTask(eq(plugin), any(Runnable.class), any(Entity.class));
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
|
||||
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
|
||||
var registry = new VotingNPCRegistry(directory.resolve("npcs.json")); await(registry.initialLoad());
|
||||
await(registry.create(definition(1)));
|
||||
List<Player> opened = new ArrayList<>();
|
||||
var manager = new VotingNPCManager(plugin, scheduler, registry, opened::add);
|
||||
try {
|
||||
manager.configure(true); scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
|
||||
Player player = mock(Player.class); when(player.getUniqueId()).thenReturn(new UUID(0, 50));
|
||||
when(player.isOnline()).thenReturn(true); when(player.getWorld()).thenReturn(world);
|
||||
when(player.getLocation()).thenReturn(new Location(world, 8, 64, 9));
|
||||
var cancelled = new PlayerInteractEntityEvent(player, spawned.getFirst(), EquipmentSlot.HAND); cancelled.setCancelled(true);
|
||||
manager.interact(cancelled);
|
||||
manager.interact(new PlayerInteractEntityEvent(player, spawned.getFirst(), EquipmentSlot.OFF_HAND));
|
||||
assertTrue(playerCallbacks.isEmpty());
|
||||
var click = new PlayerInteractEntityEvent(player, spawned.getFirst(), EquipmentSlot.HAND);
|
||||
manager.interact(click); assertTrue(click.isCancelled());
|
||||
assertEquals(1, playerCallbacks.size());
|
||||
playerCallbacks.remove().run();
|
||||
assertEquals(List.of(player), opened, "healthy main-hand selection opens on the player callback");
|
||||
Player second = mock(Player.class); when(second.getUniqueId()).thenReturn(new UUID(0, 51));
|
||||
when(second.isOnline()).thenReturn(true); when(second.getWorld()).thenReturn(world);
|
||||
when(second.getLocation()).thenReturn(new Location(world, 8, 64, 9));
|
||||
manager.interact(new PlayerInteractEntityEvent(second, spawned.getFirst(), EquipmentSlot.HAND));
|
||||
playerCallbacks.remove().run();
|
||||
assertEquals(List.of(player, second), opened, "one player's selection must not block another player");
|
||||
Player third = mock(Player.class); when(third.getUniqueId()).thenReturn(new UUID(0, 52));
|
||||
when(third.isOnline()).thenReturn(true); when(third.getWorld()).thenReturn(world);
|
||||
when(third.getLocation()).thenReturn(new Location(world, 8, 64, 9));
|
||||
manager.interact(new PlayerInteractEntityEvent(third, spawned.getFirst(), EquipmentSlot.HAND));
|
||||
await(manager.remove("voting"));
|
||||
playerCallbacks.remove().run(); assertEquals(List.of(player, second), opened);
|
||||
} finally { manager.close(); scheduler.runNext(scheduler.entities, "entity"); assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS)); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void productionMenuEntryDelegatesToTheExistingUrlCommandRatherThanAnotherMenuOrRewardPath() {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
var unrelated = mock(com.bencodez.advancedcore.api.command.CommandHandler.class);
|
||||
var url = mock(com.bencodez.advancedcore.api.command.CommandHandler.class);
|
||||
var later = mock(com.bencodez.advancedcore.api.command.CommandHandler.class);
|
||||
var player = mock(Player.class);
|
||||
when(plugin.getVoteCommand()).thenReturn(new ArrayList<>(List.of(unrelated, url, later)));
|
||||
when(url.runCommand(eq(player), aryEq(new String[] { "URL" }))).thenReturn(true);
|
||||
VotingNPCManager.openVotingSites(plugin, player);
|
||||
verify(unrelated).runCommand(eq(player), aryEq(new String[] { "URL" }));
|
||||
verify(url).runCommand(eq(player), aryEq(new String[] { "URL" }));
|
||||
verifyNoInteractions(later);
|
||||
verify(player, never()).performCommand(anyString());
|
||||
}
|
||||
|
||||
private static Chunk chunk(World world) {
|
||||
Chunk chunk = mock(Chunk.class); when(chunk.getWorld()).thenReturn(world); when(chunk.getX()).thenReturn(0); when(chunk.getZ()).thenReturn(0); return chunk;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<Villager> installSpawner(World world, Scheduler scheduler) {
|
||||
List<Villager> spawned = new ArrayList<>();
|
||||
doAnswer(invocation -> {
|
||||
assertEquals("region", scheduler.owner);
|
||||
Villager entity = mock(Villager.class); when(entity.getUniqueId()).thenReturn(new UUID(2, spawned.size() + 1));
|
||||
when(entity.isValid()).thenReturn(true);
|
||||
Consumer<Villager> initializer = invocation.getArgument(2); initializer.accept(entity);
|
||||
doAnswer(removal -> { assertEquals("entity", scheduler.owner); return null; }).when(entity).remove();
|
||||
spawned.add(entity); return entity;
|
||||
}).when(world).spawn(any(Location.class), eq(Villager.class), any(Consumer.class));
|
||||
return spawned;
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
package com.bencodez.votingplugin.npc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class VotingNPCRegistryTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
private VotingNPCDefinition definition(int id) {
|
||||
return new VotingNPCDefinition("npc-" + id, new UUID(0, id), new UUID(1, 0),
|
||||
0.5, 64, 0.5, 0, 0, "Vote");
|
||||
}
|
||||
|
||||
private static <T> T await(CompletionStage<T> stage) throws Exception {
|
||||
return stage.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static void close(VotingNPCRegistry registry) throws InterruptedException {
|
||||
registry.close();
|
||||
assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
|
||||
}
|
||||
|
||||
private static void waitOn(CountDownLatch latch) throws IOException {
|
||||
try {
|
||||
if (!latch.await(5, TimeUnit.SECONDS)) throw new IOException("Synthetic worker latch timed out");
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Synthetic worker interrupted", interrupted);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void linkedDirectoryAliasesShareTheSameExclusiveOwnerAndLeafLockLinksAreRejected() throws Exception {
|
||||
Path real = Files.createDirectory(directory.resolve("real"));
|
||||
Path linked = Files.createSymbolicLink(directory.resolve("linked"), real);
|
||||
Path file = linked.resolve("npcs.json");
|
||||
var first = new VotingNPCRegistry(file);
|
||||
try {
|
||||
await(first.initialLoad()); await(first.create(definition(1)));
|
||||
var alias = new VotingNPCRegistry(real.resolve("npcs.json"));
|
||||
try { assertThrows(ExecutionException.class, () -> await(alias.initialLoad())); }
|
||||
finally { close(alias); }
|
||||
} finally { close(first); }
|
||||
var recovered = new VotingNPCRegistry(file);
|
||||
try { assertEquals(List.of(definition(1)), await(recovered.initialLoad())); }
|
||||
finally { close(recovered); }
|
||||
Path lock = linked.resolve("other.json.lock");
|
||||
Path unrelated = real.resolve("unrelated"); Files.writeString(unrelated, "unchanged");
|
||||
Files.createSymbolicLink(lock, unrelated);
|
||||
var rejected = new VotingNPCRegistry(linked.resolve("other.json"));
|
||||
try { assertThrows(ExecutionException.class, () -> await(rejected.initialLoad())); }
|
||||
finally { close(rejected); }
|
||||
assertEquals("unchanged", Files.readString(unrelated));
|
||||
}
|
||||
|
||||
@Test void orderedDefinitionsAreDurableAndDuplicateRequestsDoNotOverwrite() throws Exception {
|
||||
Path file = directory.resolve("npcs.json");
|
||||
var registry = new VotingNPCRegistry(file);
|
||||
try {
|
||||
assertEquals(List.of(), await(registry.initialLoad()));
|
||||
var first = registry.create(definition(1));
|
||||
var duplicate = registry.create(definition(1));
|
||||
var second = registry.create(definition(2));
|
||||
assertEquals(definition(1), await(first));
|
||||
assertInstanceOf(IllegalArgumentException.class,
|
||||
assertThrows(ExecutionException.class, () -> await(duplicate)).getCause());
|
||||
await(second);
|
||||
assertEquals(List.of(definition(1), definition(2)), new VotingNPCStore(file).load());
|
||||
assertTrue(await(registry.remove("npc-1")));
|
||||
assertFalse(await(registry.remove("npc-1")));
|
||||
} finally { close(registry); }
|
||||
var restarted = new VotingNPCRegistry(file);
|
||||
try { assertEquals(List.of(definition(2)), await(restarted.initialLoad())); }
|
||||
finally { close(restarted); }
|
||||
}
|
||||
|
||||
@Test void failedReloadRetainsLastSnapshotButCannotPublishUntilRepaired() throws Exception {
|
||||
Path file = directory.resolve("npcs.json");
|
||||
var registry = new VotingNPCRegistry(file);
|
||||
try {
|
||||
await(registry.initialLoad()); await(registry.create(definition(1)));
|
||||
byte[] healthy = Files.readAllBytes(file);
|
||||
Files.writeString(file, "{invalid");
|
||||
assertThrows(ExecutionException.class, () -> await(registry.reload()));
|
||||
assertFalse(registry.available());
|
||||
assertEquals(List.of(definition(1)), registry.snapshot());
|
||||
assertThrows(ExecutionException.class, () -> await(registry.create(definition(2))));
|
||||
assertEquals("{invalid", Files.readString(file));
|
||||
Files.write(file, healthy);
|
||||
assertEquals(List.of(definition(1)), await(registry.reload()));
|
||||
await(registry.create(definition(2)));
|
||||
} finally { close(registry); }
|
||||
}
|
||||
|
||||
@Test void invalidInitialFileDoesNotAppearReadyOrGetReplaced() throws Exception {
|
||||
Path file = directory.resolve("npcs.json"); Files.writeString(file, "{invalid");
|
||||
var registry = new VotingNPCRegistry(file);
|
||||
try {
|
||||
assertThrows(ExecutionException.class, () -> await(registry.initialLoad()));
|
||||
assertFalse(registry.available());
|
||||
assertThrows(ExecutionException.class, () -> await(registry.create(definition(1))));
|
||||
assertEquals("{invalid", Files.readString(file));
|
||||
} finally { close(registry); }
|
||||
}
|
||||
|
||||
@Test void closeReturnsWhileAcceptedWriteDrainsAndKeepsExclusiveFileOwnership() throws Exception {
|
||||
Path file = directory.resolve("npcs.json");
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
var store = new VotingNPCStore(file) {
|
||||
@Override synchronized void save(List<VotingNPCDefinition> values) throws IOException {
|
||||
entered.countDown(); waitOn(release); super.save(values);
|
||||
}
|
||||
};
|
||||
var first = new VotingNPCRegistry(file, store);
|
||||
VotingNPCRegistry replacement = null;
|
||||
try {
|
||||
await(first.initialLoad());
|
||||
var writing = first.create(definition(1));
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
first.close(); first.close();
|
||||
assertFalse(first.awaitClosed(0, TimeUnit.SECONDS));
|
||||
assertThrows(ExecutionException.class, () -> await(first.remove("npc-1")));
|
||||
replacement = new VotingNPCRegistry(file);
|
||||
var next = replacement;
|
||||
assertThrows(ExecutionException.class, () -> await(next.initialLoad()));
|
||||
assertFalse(replacement.available());
|
||||
release.countDown(); await(writing);
|
||||
assertTrue(first.awaitClosed(5, TimeUnit.SECONDS));
|
||||
assertEquals(List.of(definition(1)), await(replacement.reload()));
|
||||
} finally {
|
||||
release.countDown(); close(first);
|
||||
if (replacement != null) close(replacement);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void saturatedReloadIsRejectedWithoutDisablingTheHealthyOwner() throws Exception {
|
||||
Path file = directory.resolve("npcs.json");
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
var store = new VotingNPCStore(file) {
|
||||
@Override synchronized void save(List<VotingNPCDefinition> values) throws IOException {
|
||||
entered.countDown(); waitOn(release); super.save(values);
|
||||
}
|
||||
};
|
||||
var registry = new VotingNPCRegistry(file, store);
|
||||
try {
|
||||
await(registry.initialLoad());
|
||||
var writing = registry.create(definition(1));
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
var queued = new java.util.ArrayList<CompletionStage<Boolean>>();
|
||||
for (int i = 0; i < 128; i++) queued.add(registry.remove("missing"));
|
||||
assertThrows(ExecutionException.class, () -> await(registry.reload()));
|
||||
assertTrue(registry.available(), "rejected reload must leave existing readiness intact");
|
||||
release.countDown(); await(writing);
|
||||
for (var entry : queued) assertFalse(await(entry));
|
||||
await(registry.create(definition(2)));
|
||||
} finally { release.countDown(); close(registry); }
|
||||
}
|
||||
|
||||
@Test void failedWriteFencesAlreadyQueuedMutationsUntilCheckedReload() throws Exception {
|
||||
Path file = directory.resolve("npcs.json");
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
AtomicBoolean fail = new AtomicBoolean(true);
|
||||
var store = new VotingNPCStore(file) {
|
||||
@Override synchronized void save(List<VotingNPCDefinition> values) throws IOException {
|
||||
if (fail.getAndSet(false)) {
|
||||
entered.countDown(); waitOn(release);
|
||||
super.save(values);
|
||||
throw new IOException("Synthetic failure after visible write");
|
||||
}
|
||||
super.save(values);
|
||||
}
|
||||
};
|
||||
var registry = new VotingNPCRegistry(file, store);
|
||||
try {
|
||||
await(registry.initialLoad());
|
||||
var first = registry.create(definition(1));
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
var second = registry.create(definition(2));
|
||||
release.countDown();
|
||||
assertThrows(ExecutionException.class, () -> await(first));
|
||||
assertThrows(ExecutionException.class, () -> await(second));
|
||||
assertFalse(registry.available());
|
||||
assertEquals(List.of(), registry.snapshot());
|
||||
assertEquals(List.of(definition(1)), new VotingNPCStore(file).load());
|
||||
assertEquals(List.of(definition(1)), await(registry.reload()));
|
||||
await(registry.create(definition(2)));
|
||||
assertEquals(List.of(definition(1), definition(2)), registry.snapshot());
|
||||
} finally { release.countDown(); close(registry); }
|
||||
}
|
||||
|
||||
@Test void completionCallbackMayCloseWithoutDeadlockingOrInterruptingCommittedWork() throws Exception {
|
||||
var registry = new VotingNPCRegistry(directory.resolve("npcs.json"));
|
||||
try {
|
||||
await(registry.initialLoad());
|
||||
CompletableFuture<Void> callback = registry.create(definition(1)).thenAccept(value -> registry.close())
|
||||
.toCompletableFuture();
|
||||
await(callback);
|
||||
assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
|
||||
assertEquals(List.of(definition(1)), new VotingNPCStore(directory.resolve("npcs.json")).load());
|
||||
} finally { close(registry); }
|
||||
}
|
||||
|
||||
@Test void capacityAndIdentityRejectionsLeaveHealthyStorageAvailable() throws Exception {
|
||||
Path file = directory.resolve("npcs.json");
|
||||
var seed = new VotingNPCStore(file); seed.load();
|
||||
seed.save(java.util.stream.IntStream.rangeClosed(1, 63).mapToObj(this::definition).toList());
|
||||
var registry = new VotingNPCRegistry(file);
|
||||
try {
|
||||
await(registry.initialLoad());
|
||||
byte[] original = Files.readAllBytes(file);
|
||||
var duplicateIdentity = new VotingNPCDefinition("another", definition(1).identity(), definition(1).world(),
|
||||
0, 64, 0, 0, 0, "Vote");
|
||||
assertThrows(ExecutionException.class, () -> await(registry.create(duplicateIdentity)));
|
||||
assertTrue(registry.available(), "invalid new identity must not disable existing NPC storage");
|
||||
assertArrayEquals(original, Files.readAllBytes(file));
|
||||
await(registry.create(definition(64)));
|
||||
byte[] atLimit = Files.readAllBytes(file);
|
||||
assertThrows(ExecutionException.class, () -> await(registry.create(definition(65))));
|
||||
assertTrue(registry.available(), "one extra creation must not disable 64 existing NPCs");
|
||||
assertEquals(64, registry.snapshot().size());
|
||||
assertArrayEquals(atLimit, Files.readAllBytes(file));
|
||||
} finally { close(registry); }
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package com.bencodez.votingplugin.npc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
|
||||
class VotingNPCSchedulerTest {
|
||||
@Test void alreadyOwnedCleanupRunsInlineEvenDuringDisable() {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
bukkit.when(Bukkit::isPrimaryThread).thenReturn(true);
|
||||
var scheduler = new VotingNPCScheduler(plugin); var ran = new AtomicInteger();
|
||||
scheduler.entity(mock(Entity.class), ran::incrementAndGet, () -> fail("retired"), error -> fail(error));
|
||||
assertEquals(1, ran.get()); verify(plugin, never()).getBukkitScheduler();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void deferredRegionActionUsesTheLocationSchedulerAndReportsFailure() {
|
||||
var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
var scheduler = new VotingNPCScheduler(plugin); Location anchor = mock(Location.class);
|
||||
var completion = new CompletableFuture<Void>();
|
||||
var callback = new AtomicReference<Runnable>();
|
||||
when(plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtLocation(eq(anchor), any()))
|
||||
.thenAnswer(inv -> { Consumer<?> action = inv.getArgument(1); callback.set(() -> action.accept(null)); return completion; });
|
||||
var ran = new AtomicInteger(); List<Throwable> failures = new ArrayList<>();
|
||||
scheduler.region(anchor, ran::incrementAndGet, failures::add);
|
||||
assertEquals(0, ran.get()); callback.get().run(); assertEquals(1, ran.get());
|
||||
completion.completeExceptionally(new IllegalStateException("Synthetic scheduler failure"));
|
||||
assertEquals(1, failures.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test @SuppressWarnings({"unchecked", "rawtypes"})
|
||||
void schedulerRetirementDoesNotPretendThatAnEntityWasRemoved() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
var scheduler = new VotingNPCScheduler(plugin); Entity entity = mock(Entity.class);
|
||||
CompletableFuture completion = new CompletableFuture();
|
||||
when(plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtEntityWithFallback(eq(entity), any(), any()))
|
||||
.thenReturn(completion);
|
||||
var ran = new AtomicInteger(); var retired = new AtomicInteger(); List<Throwable> failures = new ArrayList<>();
|
||||
scheduler.entity(entity, ran::incrementAndGet, retired::incrementAndGet, failures::add);
|
||||
completion.complete(Enum.valueOf((Class) Class.forName("com.bencodez.simpleapi.folialib.enums.EntityTaskResult"), "SCHEDULER_RETIRED"));
|
||||
assertEquals(0, ran.get()); assertEquals(0, retired.get()); assertEquals(1, failures.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test @SuppressWarnings({"unchecked", "rawtypes"})
|
||||
void entityRetirementFallbackAndFutureCompletionAreHandledOnce() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
var scheduler = new VotingNPCScheduler(plugin); Entity entity = mock(Entity.class);
|
||||
CompletableFuture completion = new CompletableFuture(); var fallback = new AtomicReference<Runnable>();
|
||||
when(plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtEntityWithFallback(eq(entity), any(), any()))
|
||||
.thenAnswer(inv -> { fallback.set(inv.getArgument(2)); return completion; });
|
||||
var retired = new AtomicInteger();
|
||||
scheduler.entity(entity, () -> fail("Entity already retired"), retired::incrementAndGet, error -> fail(error));
|
||||
fallback.get().run();
|
||||
completion.complete(Enum.valueOf((Class) Class.forName("com.bencodez.simpleapi.folialib.enums.EntityTaskResult"), "ENTITY_RETIRED"));
|
||||
assertEquals(1, retired.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
package com.bencodez.votingplugin.npc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class VotingNPCStoreTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
private VotingNPCDefinition definition(int id) {
|
||||
return new VotingNPCDefinition("npc-" + id, new UUID(0, id), new UUID(1, 0),
|
||||
-0.5, 64, 16.5, 90, 0, "Vote");
|
||||
}
|
||||
|
||||
@Test void roundTripPreservesStableIdentitiesAndPositionAcrossRestart() throws Exception {
|
||||
Path file = directory.resolve("npcs.json");
|
||||
var store = new VotingNPCStore(file);
|
||||
assertEquals(List.of(), store.load());
|
||||
store.save(List.of(definition(1), definition(2)));
|
||||
assertEquals(List.of(definition(1), definition(2)), new VotingNPCStore(file).load());
|
||||
assertEquals(-1, definition(1).chunkX());
|
||||
assertEquals(1, definition(1).chunkZ());
|
||||
}
|
||||
|
||||
@Test void missingInitialReadDoesNotCreateFilesAndUninitializedStoreCannotOverwrite() throws Exception {
|
||||
Path file = directory.resolve("npcs.json");
|
||||
var store = new VotingNPCStore(file);
|
||||
assertThrows(IOException.class, () -> store.save(List.of(definition(1))));
|
||||
assertFalse(Files.exists(file));
|
||||
assertTrue(store.load().isEmpty());
|
||||
assertFalse(Files.exists(file));
|
||||
}
|
||||
|
||||
@Test void invalidLoadKeepsOriginalBytesAndBlocksMutations() throws Exception {
|
||||
Path file = directory.resolve("npcs.json");
|
||||
byte[] original = "{ damaged definitions".getBytes(StandardCharsets.UTF_8);
|
||||
Files.write(file, original);
|
||||
var store = new VotingNPCStore(file);
|
||||
assertThrows(IOException.class, store::load);
|
||||
assertThrows(IOException.class, () -> store.save(List.of()));
|
||||
assertArrayEquals(original, Files.readAllBytes(file));
|
||||
}
|
||||
|
||||
@Test void duplicateIdentitiesAndCapacityRejectionDoNotChangeDurableDefinitions() throws Exception {
|
||||
Path file = directory.resolve("npcs.json");
|
||||
var store = new VotingNPCStore(file); store.load(); store.save(List.of(definition(1)));
|
||||
byte[] before = Files.readAllBytes(file);
|
||||
assertThrows(IOException.class, () -> store.save(List.of(definition(1), definition(1))));
|
||||
var sameIdentity = new VotingNPCDefinition("another", definition(1).identity(), definition(1).world(),
|
||||
0, 64, 0, 0, 0, "Vote");
|
||||
assertThrows(IOException.class, () -> store.save(List.of(definition(1), sameIdentity)));
|
||||
assertThrows(IOException.class, () -> store.save(IntStream.rangeClosed(1, 65).mapToObj(this::definition).toList()));
|
||||
assertArrayEquals(before, Files.readAllBytes(file));
|
||||
}
|
||||
|
||||
@Test void publicationFailureFencesOldStateUntilCheckedReload() throws Exception {
|
||||
Path file = directory.resolve("npcs.json");
|
||||
AtomicBoolean fail = new AtomicBoolean();
|
||||
var store = new VotingNPCStore(file) {
|
||||
@Override void forceDirectory(Path parent) throws IOException {
|
||||
if (fail.get()) throw new IOException("Synthetic directory sync failure");
|
||||
super.forceDirectory(parent);
|
||||
}
|
||||
};
|
||||
store.load(); store.save(List.of(definition(1)));
|
||||
fail.set(true);
|
||||
assertThrows(IOException.class, () -> store.save(List.of(definition(1), definition(2))));
|
||||
byte[] uncertain = Files.readAllBytes(file);
|
||||
fail.set(false);
|
||||
assertThrows(IOException.class, () -> store.save(List.of(definition(1))));
|
||||
assertArrayEquals(uncertain, Files.readAllBytes(file));
|
||||
assertEquals(List.of(definition(1), definition(2)), store.load());
|
||||
store.save(List.of(definition(2)));
|
||||
assertEquals(List.of(definition(2)), new VotingNPCStore(file).load());
|
||||
}
|
||||
|
||||
@Test void missingUncertainFileDoesNotBecomeAnEmptySuccessfulRecovery() throws Exception {
|
||||
Path file = directory.resolve("npcs.json");
|
||||
var store = new VotingNPCStore(file) {
|
||||
@Override void forceDirectory(Path ignored) throws IOException { throw new IOException("Synthetic failure"); }
|
||||
};
|
||||
store.load(); assertThrows(IOException.class, () -> store.save(List.of(definition(1))));
|
||||
Files.delete(file);
|
||||
assertThrows(IOException.class, store::load);
|
||||
assertThrows(IOException.class, () -> store.save(List.of()));
|
||||
assertFalse(Files.exists(file));
|
||||
}
|
||||
|
||||
@Test void strictFieldsAndTypesRejectMalformedDocumentsIndividually() throws Exception {
|
||||
Path file = directory.resolve("npcs.json");
|
||||
var store = new VotingNPCStore(file); store.load(); store.save(List.of(definition(1)));
|
||||
String healthy = Files.readString(file);
|
||||
for (String damaged : List.of(
|
||||
healthy.replace("\"version\": 1", "\"version\": 1, \"version\": 1"),
|
||||
healthy.replace("\"version\": 1", "\"version\": \"1\""),
|
||||
healthy.replace("\"version\": 1", "\"version\": 1.0"),
|
||||
healthy.replace("\"id\": \"npc-1\"", "\"id\": \"npc-1\", \"id\": \"other\""),
|
||||
healthy.replace("\"x\": -0.5", "\"x\": \"-0.5\""),
|
||||
healthy.replace("\"name\": \"Vote\"", "\"unknown\": \"Vote\""),
|
||||
healthy.replace("\"name\": \"Vote\"", "\"name\": null"),
|
||||
healthy.replace("\"yaw\": 90.0", "\"yaw\": 1e100"),
|
||||
healthy + "{}")) {
|
||||
Files.writeString(file, damaged);
|
||||
assertThrows(IOException.class, () -> new VotingNPCStore(file).load(), damaged);
|
||||
assertEquals(damaged, Files.readString(file));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void invalidUtf8OversizeAndSymlinkCannotBeLoadedOrOverwritten() throws Exception {
|
||||
Path file = directory.resolve("npcs.json");
|
||||
Files.write(file, new byte[] {(byte) 0xc3, (byte) 0x28});
|
||||
assertThrows(IOException.class, () -> new VotingNPCStore(file).load());
|
||||
Files.write(file, new byte[128 * 1024 + 1]);
|
||||
assertThrows(IOException.class, () -> new VotingNPCStore(file).load());
|
||||
Files.delete(file);
|
||||
Path target = directory.resolve("unrelated.json");
|
||||
Files.writeString(target, "unrelated");
|
||||
Files.createSymbolicLink(file, target);
|
||||
assertThrows(IOException.class, () -> new VotingNPCStore(file).load());
|
||||
assertEquals("unrelated", Files.readString(target));
|
||||
}
|
||||
|
||||
@Test void linkedDirectoriesAllowDurableDefinitionsButLeafLinksStayRejected() throws Exception {
|
||||
Path real = Files.createDirectory(directory.resolve("real"));
|
||||
Path linked = Files.createSymbolicLink(directory.resolve("linked"), real);
|
||||
Path nested = Files.createDirectory(real.resolve("data"));
|
||||
for (Path parent : List.of(linked, linked.resolve("data"))) {
|
||||
Path file = parent.resolve("npcs.json");
|
||||
var store = new VotingNPCStore(file);
|
||||
assertTrue(store.load().isEmpty());
|
||||
store.save(List.of(definition(1)));
|
||||
assertEquals(List.of(definition(1)), new VotingNPCStore(file).load());
|
||||
}
|
||||
Path leaf = linked.resolve("alias.json");
|
||||
Files.createSymbolicLink(leaf, nested.resolve("npcs.json"));
|
||||
assertThrows(IOException.class, () -> new VotingNPCStore(leaf).load());
|
||||
assertEquals(List.of(definition(1)), new VotingNPCStore(nested.resolve("npcs.json")).load());
|
||||
}
|
||||
|
||||
@Test void invalidDefinitionsAreRejectedBeforePublication() {
|
||||
assertThrows(IllegalArgumentException.class, () -> new VotingNPCDefinition("bad id", new UUID(0, 1),
|
||||
new UUID(1, 0), 0, 64, 0, 0, 0, "Vote"));
|
||||
assertThrows(IllegalArgumentException.class, () -> new VotingNPCDefinition("valid", new UUID(0, 1),
|
||||
new UUID(1, 0), Double.NaN, 64, 0, 0, 0, "Vote"));
|
||||
assertThrows(IllegalArgumentException.class, () -> new VotingNPCDefinition("valid", new UUID(0, 1),
|
||||
new UUID(1, 0), 0, 64, 0, 0, 0, "Vote\nNew line"));
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.io.InputStreamReader;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.config.Config;
|
||||
import com.bencodez.votingplugin.maintenance.AutoCreateVoteSitesReminderService.Settings;
|
||||
|
||||
class AutoCreateVoteSitesReminderConfigTest {
|
||||
|
||||
@Test
|
||||
void bundledConfigHasOnlyExistingAutoCreateSetting() throws Exception {
|
||||
var resource = VotingPluginMain.class.getClassLoader().getResourceAsStream("Config.yml");
|
||||
assertNotNull(resource);
|
||||
try (var reader = new InputStreamReader(resource, StandardCharsets.UTF_8)) {
|
||||
YamlConfiguration config = YamlConfiguration.loadConfiguration(reader);
|
||||
assertTrue(config.getBoolean("AutoCreateVoteSites"));
|
||||
assertFalse(config.contains("AutoCreateVoteSitesReminder"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void enabledAutoCreationGetsStartupAndTwoDayNagWithoutExtraSettings() throws Exception {
|
||||
Config config = mock(Config.class);
|
||||
when(config.isAutoCreateVoteSites()).thenReturn(true);
|
||||
Settings settings = settings(config);
|
||||
assertTrue(settings.enabled());
|
||||
assertTrue(settings.autoCreateEnabled());
|
||||
assertEquals(0, settings.afterMillis());
|
||||
assertEquals(TimeUnit.HOURS.toMillis(48), settings.repeatMillis());
|
||||
verify(config, never()).getData();
|
||||
}
|
||||
|
||||
@Test
|
||||
void disablingExistingAutoCreateSettingStopsNag() throws Exception {
|
||||
Config config = mock(Config.class);
|
||||
when(config.isAutoCreateVoteSites()).thenReturn(false);
|
||||
assertFalse(settings(config).autoCreateEnabled());
|
||||
verify(config, never()).getData();
|
||||
}
|
||||
|
||||
private static Settings settings(Config config) throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
Field field = VotingPluginMain.class.getDeclaredField("configFile");
|
||||
field.setAccessible(true);
|
||||
ReflectiveAccess.set(field, plugin, config);
|
||||
Method method = VotingPluginMain.class.getDeclaredMethod("autoCreateVoteSitesReminderSettings");
|
||||
method.setAccessible(true);
|
||||
return (Settings) ReflectiveAccess.invoke(method, plugin, new Object[]{});
|
||||
}
|
||||
}
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.bencodez.votingplugin.core.maintenance.AutoCreateVoteSitesReminderState;
|
||||
import com.bencodez.votingplugin.core.maintenance.AutoCreateVoteSitesReminderState.Candidate;
|
||||
import com.bencodez.votingplugin.core.maintenance.AutoCreateVoteSitesReminderState.Snapshot;
|
||||
|
||||
class AutoCreateVoteSitesReminderStateTest {
|
||||
|
||||
@Test
|
||||
void firstReminderUsesStartupThresholdAndFortyEightHourRepeatDefault() {
|
||||
long threshold = AutoCreateVoteSitesReminderState.DEFAULT_THRESHOLD_MILLIS;
|
||||
assertEquals(0, threshold);
|
||||
assertEquals(TimeUnit.HOURS.toMillis(48), AutoCreateVoteSitesReminderState.DEFAULT_REPEAT_MILLIS);
|
||||
AutoCreateVoteSitesReminderState state = fresh(0);
|
||||
Candidate first = state.propose(0, true, true, threshold,
|
||||
AutoCreateVoteSitesReminderState.DEFAULT_REPEAT_MILLIS);
|
||||
assertTrue(first.reminderDue());
|
||||
assertEquals(new Snapshot(0, 0), first.snapshot());
|
||||
state.commit(first);
|
||||
Candidate boundary = state.propose(nanos(AutoCreateVoteSitesReminderState.DEFAULT_REPEAT_MILLIS), true, true,
|
||||
threshold, AutoCreateVoteSitesReminderState.DEFAULT_REPEAT_MILLIS);
|
||||
assertTrue(boundary.reminderDue());
|
||||
assertEquals(new Snapshot(AutoCreateVoteSitesReminderState.DEFAULT_REPEAT_MILLIS,
|
||||
AutoCreateVoteSitesReminderState.DEFAULT_REPEAT_MILLIS), boundary.snapshot());
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledFlagsSuppressOnlyReminderAndKeepAccruingRuntime() {
|
||||
boolean[][] disabled = { { false, false }, { false, true }, { true, false } };
|
||||
for (boolean[] flags : disabled) {
|
||||
AutoCreateVoteSitesReminderState state = fresh(0);
|
||||
Candidate candidate = state.propose(nanos(1500), flags[0], flags[1], 1000, 500);
|
||||
assertFalse(candidate.reminderDue());
|
||||
assertEquals(new Snapshot(1500, -1), candidate.snapshot());
|
||||
state.commit(candidate);
|
||||
Candidate enabled = state.propose(nanos(1500), true, true, 1000, 500);
|
||||
assertTrue(enabled.reminderDue());
|
||||
assertEquals(new Snapshot(1500, 1500), enabled.snapshot());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void restartLoadsCumulativeRuntimeAndExcludesOfflineTime() {
|
||||
AutoCreateVoteSitesReminderState previous = fresh(0);
|
||||
previous.commit(previous.propose(nanos(900), false, false, 1000, 500));
|
||||
long restartClock = nanos(TimeUnit.DAYS.toMillis(30));
|
||||
AutoCreateVoteSitesReminderState restarted = new AutoCreateVoteSitesReminderState(previous.snapshot(),
|
||||
restartClock);
|
||||
assertFalse(restarted.propose(restartClock, true, true, 1000, 500).reminderDue());
|
||||
Candidate before = restarted.propose(restartClock + nanos(99), true, true, 1000, 500);
|
||||
assertEquals(new Snapshot(999, -1), before.snapshot());
|
||||
assertFalse(before.reminderDue());
|
||||
restarted.commit(before);
|
||||
Candidate boundary = restarted.propose(restartClock + nanos(100), true, true, 1000, 500);
|
||||
assertEquals(new Snapshot(1000, 1000), boundary.snapshot());
|
||||
assertTrue(boundary.reminderDue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void restartPreservesReminderCooldownWithFreshClockAnchor() {
|
||||
AutoCreateVoteSitesReminderState state = new AutoCreateVoteSitesReminderState(new Snapshot(1000, 1000),
|
||||
-nanos(5000));
|
||||
assertFalse(state.propose(-nanos(4501), true, true, 1000, 500).reminderDue());
|
||||
assertTrue(state.propose(-nanos(4500), true, true, 1000, 500).reminderDue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatBoundaryIsInclusiveAndAdvancesOnlyAfterCommit() {
|
||||
AutoCreateVoteSitesReminderState state = new AutoCreateVoteSitesReminderState(new Snapshot(1000, 1000), 0);
|
||||
Candidate before = state.propose(nanos(499), true, true, 1000, 500);
|
||||
assertFalse(before.reminderDue());
|
||||
state.commit(before);
|
||||
Candidate boundary = state.propose(nanos(500), true, true, 1000, 500);
|
||||
assertTrue(boundary.reminderDue());
|
||||
assertEquals(new Snapshot(1499, 1000), state.snapshot());
|
||||
state.commit(boundary);
|
||||
assertEquals(new Snapshot(1500, 1500), state.snapshot());
|
||||
assertFalse(state.propose(nanos(999), true, true, 1000, 500).reminderDue());
|
||||
assertTrue(state.propose(nanos(1000), true, true, 1000, 500).reminderDue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedPersistenceLeavesUptimeAndReminderRetryableWithoutDoubleCounting() {
|
||||
AutoCreateVoteSitesReminderState state = fresh(0);
|
||||
Candidate failedWrite = state.propose(nanos(1000), true, true, 1000, 500);
|
||||
assertTrue(failedWrite.reminderDue());
|
||||
// The storage owner must omit commit when its write fails.
|
||||
assertEquals(new Snapshot(0, -1), state.snapshot());
|
||||
Candidate sameClockRetry = state.propose(nanos(1000), true, true, 1000, 500);
|
||||
assertEquals(failedWrite.snapshot(), sameClockRetry.snapshot());
|
||||
Candidate laterRetry = state.propose(nanos(1200), true, true, 1000, 500);
|
||||
assertTrue(laterRetry.reminderDue());
|
||||
assertEquals(new Snapshot(1200, 1200), laterRetry.snapshot());
|
||||
state.commit(laterRetry);
|
||||
assertThrows(IllegalArgumentException.class, () -> state.commit(failedWrite));
|
||||
assertThrows(IllegalArgumentException.class, () -> state.commit(sameClockRetry));
|
||||
Candidate next = state.propose(nanos(1300), true, true, 1000, 500);
|
||||
assertEquals(new Snapshot(1300, 1200), next.snapshot());
|
||||
assertFalse(next.reminderDue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedNonReminderCheckpointDoesNotMintRuntimeOnRetry() {
|
||||
AutoCreateVoteSitesReminderState state = fresh(0);
|
||||
state.propose(nanos(300), false, false, 1000, 500);
|
||||
Candidate retry = state.propose(nanos(400), false, false, 1000, 500);
|
||||
assertEquals(new Snapshot(400, -1), retry.snapshot());
|
||||
state.commit(retry);
|
||||
assertEquals(new Snapshot(500, -1), state.propose(nanos(500), false, false, 1000, 500).snapshot());
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleAndDuplicateCommitsAreRejectedWithoutPublishingProgress() {
|
||||
AutoCreateVoteSitesReminderState state = fresh(0);
|
||||
Candidate first = state.propose(nanos(100), false, false, 1000, 500);
|
||||
Candidate sibling = state.propose(nanos(200), false, false, 1000, 500);
|
||||
state.commit(first);
|
||||
assertThrows(IllegalArgumentException.class, () -> state.commit(first));
|
||||
assertThrows(IllegalArgumentException.class, () -> state.commit(sibling));
|
||||
assertEquals(new Snapshot(100, -1), state.snapshot());
|
||||
Candidate retry = state.propose(nanos(200), false, false, 1000, 500);
|
||||
state.commit(retry);
|
||||
assertEquals(new Snapshot(200, -1), state.snapshot());
|
||||
}
|
||||
|
||||
@Test
|
||||
void candidateFromAnotherStateIsRejectedEvenWithIdenticalSnapshots() {
|
||||
AutoCreateVoteSitesReminderState state = fresh(0);
|
||||
AutoCreateVoteSitesReminderState other = fresh(0);
|
||||
Candidate foreign = other.propose(nanos(1000), true, true, 1000, 500);
|
||||
assertThrows(IllegalArgumentException.class, () -> state.commit(foreign));
|
||||
assertEquals(new Snapshot(0, -1), state.snapshot());
|
||||
other.commit(foreign);
|
||||
assertEquals(new Snapshot(1000, 1000), other.snapshot());
|
||||
}
|
||||
|
||||
@Test
|
||||
void changingThresholdAndRepeatKeepsExistingRuntimeAndReminderMarker() {
|
||||
AutoCreateVoteSitesReminderState state = fresh(0);
|
||||
state.commit(state.propose(nanos(1500), true, true, 2000, 1000));
|
||||
Candidate loweredThreshold = state.propose(nanos(1500), true, true, 1000, 1000);
|
||||
assertTrue(loweredThreshold.reminderDue());
|
||||
state.commit(loweredThreshold);
|
||||
assertFalse(state.propose(nanos(2000), true, true, 3000, 100).reminderDue());
|
||||
assertFalse(state.propose(nanos(2000), true, true, 1000, 1000).reminderDue());
|
||||
Candidate loweredRepeat = state.propose(nanos(2000), true, true, 1000, 500);
|
||||
assertTrue(loweredRepeat.reminderDue());
|
||||
assertEquals(new Snapshot(2000, 2000), loweredRepeat.snapshot());
|
||||
}
|
||||
|
||||
@Test
|
||||
void disablingAfterReminderDoesNotClearItsMarker() {
|
||||
AutoCreateVoteSitesReminderState state = new AutoCreateVoteSitesReminderState(new Snapshot(1000, 1000), 0);
|
||||
Candidate disabled = state.propose(nanos(600), false, false, 1000, 500);
|
||||
state.commit(disabled);
|
||||
assertEquals(new Snapshot(1600, 1000), state.snapshot());
|
||||
assertFalse(disabled.reminderDue());
|
||||
assertTrue(state.propose(nanos(600), true, true, 1000, 500).reminderDue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void negativeNanoReadingsAccruePositiveElapsedRuntime() {
|
||||
AutoCreateVoteSitesReminderState state = fresh(-nanos(1000));
|
||||
Candidate candidate = state.propose(-nanos(900), false, false, 1000, 500);
|
||||
assertEquals(new Snapshot(100, -1), candidate.snapshot());
|
||||
state.commit(candidate);
|
||||
assertEquals(new Snapshot(200, -1), state.propose(-nanos(800), false, false, 1000, 500).snapshot());
|
||||
}
|
||||
|
||||
@Test
|
||||
void signedNanoClockWrapAccruesElapsedTime() {
|
||||
AutoCreateVoteSitesReminderState state = fresh(Long.MAX_VALUE - 500_000);
|
||||
Candidate wrapped = state.propose(Long.MIN_VALUE + 499_999, true, true, 1, 1);
|
||||
assertTrue(wrapped.reminderDue());
|
||||
assertEquals(new Snapshot(1, 1), wrapped.snapshot());
|
||||
state.commit(wrapped);
|
||||
assertEquals(new Snapshot(2, 2), state.propose(Long.MIN_VALUE + 1_499_999, true, true, 1, 1).snapshot());
|
||||
}
|
||||
|
||||
@Test
|
||||
void backwardClockDoesNotMoveAnchorOrMintElapsedRuntime() {
|
||||
AutoCreateVoteSitesReminderState state = fresh(nanos(1000));
|
||||
Candidate backward = state.propose(nanos(900), true, true, 1000, 500);
|
||||
assertEquals(new Snapshot(0, -1), backward.snapshot());
|
||||
state.commit(backward);
|
||||
assertEquals(new Snapshot(100, -1), state.propose(nanos(1100), false, false, 1000, 500).snapshot());
|
||||
}
|
||||
|
||||
@Test
|
||||
void subMillisecondCheckpointsRetainRemainderAndNeverDoubleCount() {
|
||||
AutoCreateVoteSitesReminderState state = fresh(0);
|
||||
for (long clock : new long[] { 400_000, 800_000, 1_200_000, 1_600_000, 2_000_000 }) {
|
||||
state.commit(state.propose(clock, false, false, 1000, 500));
|
||||
}
|
||||
assertEquals(new Snapshot(2, -1), state.snapshot());
|
||||
state.commit(state.propose(2_000_000, false, false, 1000, 500));
|
||||
assertEquals(new Snapshot(2, -1), state.snapshot());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runtimeSaturatesWithoutOverflowAndReminderMarkerRemainsValid() {
|
||||
AutoCreateVoteSitesReminderState state = new AutoCreateVoteSitesReminderState(new Snapshot(Long.MAX_VALUE - 2, -1), 0);
|
||||
Candidate candidate = state.propose(nanos(10), true, true, 1000, 500);
|
||||
assertTrue(candidate.reminderDue());
|
||||
assertEquals(new Snapshot(Long.MAX_VALUE, Long.MAX_VALUE), candidate.snapshot());
|
||||
state.commit(candidate);
|
||||
Candidate later = state.propose(nanos(20), true, true, 1000, 500);
|
||||
assertEquals(new Snapshot(Long.MAX_VALUE, Long.MAX_VALUE), later.snapshot());
|
||||
assertFalse(later.reminderDue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saturationDoesNotOverflowRepeatElapsedComparison() {
|
||||
AutoCreateVoteSitesReminderState state = new AutoCreateVoteSitesReminderState(new Snapshot(Long.MAX_VALUE - 1, 0), 0);
|
||||
Candidate candidate = state.propose(nanos(100), true, true,
|
||||
AutoCreateVoteSitesReminderState.MAX_INTERVAL_MILLIS, AutoCreateVoteSitesReminderState.MAX_INTERVAL_MILLIS);
|
||||
assertTrue(candidate.reminderDue());
|
||||
assertEquals(Long.MAX_VALUE, candidate.snapshot().totalRuntimeMillis());
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotsRejectNegativeRuntimeInvalidSentinelAndFutureReminderMarker() {
|
||||
assertThrows(IllegalArgumentException.class, () -> new Snapshot(-1, -1));
|
||||
assertThrows(IllegalArgumentException.class, () -> new Snapshot(0, -2));
|
||||
assertThrows(IllegalArgumentException.class, () -> new Snapshot(0, 1));
|
||||
assertThrows(IllegalArgumentException.class, () -> new Snapshot(Long.MAX_VALUE - 1, Long.MAX_VALUE));
|
||||
assertEquals(new Snapshot(0, -1), fresh(0).snapshot());
|
||||
assertEquals(0, new Snapshot(0, 0).lastReminderRuntimeMillis());
|
||||
assertEquals(Long.MAX_VALUE, new Snapshot(Long.MAX_VALUE, Long.MAX_VALUE).totalRuntimeMillis());
|
||||
}
|
||||
|
||||
@Test
|
||||
void intervalsAllowStartupThresholdButRequirePositiveBoundedRepeatsEvenWhileDisabled() {
|
||||
AutoCreateVoteSitesReminderState state = fresh(0);
|
||||
for (long invalid : new long[] { -1, AutoCreateVoteSitesReminderState.MAX_INTERVAL_MILLIS + 1, Long.MAX_VALUE }) {
|
||||
assertThrows(IllegalArgumentException.class, () -> state.propose(0, false, false, invalid, 1));
|
||||
assertThrows(IllegalArgumentException.class, () -> state.propose(0, false, false, 1, invalid));
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class, () -> state.propose(0, false, false, 0, 0));
|
||||
assertFalse(state.propose(0, false, false, 0, 1).reminderDue());
|
||||
assertEquals(new Snapshot(0, -1), state.snapshot());
|
||||
assertFalse(state.propose(0, true, true, AutoCreateVoteSitesReminderState.MAX_INTERVAL_MILLIS,
|
||||
AutoCreateVoteSitesReminderState.MAX_INTERVAL_MILLIS).reminderDue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullSnapshotAndNullCandidateAreRejected() {
|
||||
assertThrows(NullPointerException.class, () -> new AutoCreateVoteSitesReminderState(null, 0));
|
||||
AutoCreateVoteSitesReminderState state = fresh(0);
|
||||
assertThrows(NullPointerException.class, () -> state.commit(null));
|
||||
assertEquals(new Snapshot(0, -1), state.snapshot());
|
||||
}
|
||||
|
||||
private static AutoCreateVoteSitesReminderState fresh(long nowNanos) {
|
||||
return new AutoCreateVoteSitesReminderState(new Snapshot(0, AutoCreateVoteSitesReminderState.NEVER_REMINDED), nowNanos);
|
||||
}
|
||||
|
||||
private static long nanos(long millis) {
|
||||
return Math.multiplyExact(millis, 1_000_000);
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@@ -1418,7 +1420,7 @@ class CachedVoteBatchDurabilityTest {
|
||||
for (var pair : List.of(new Object[] {"useMySQL", true}, new Object[] {"voteCacheTable", serverTable},
|
||||
new Object[] {"onlineVoteCacheTable", onlineTable}, new Object[] {"timedVoteCacheTable", timedTable})) {
|
||||
Field field = VoteCacheHandler.class.getDeclaredField((String) pair[0]);
|
||||
field.setAccessible(true); field.set(handler, pair[1]);
|
||||
field.setAccessible(true); ReflectiveAccess.set(field, handler, pair[1]);
|
||||
}
|
||||
return handler;
|
||||
}
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.bukkit.configuration.file.FileConfiguration;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.data.GenerationFencedServerDataFile;
|
||||
import com.bencodez.votingplugin.util.ServerDataPublicationFence;
|
||||
|
||||
class DeferredServerDataOwnerPublicationTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test void deferredNativePublicationSerializesCheckpointsAndRetainsItsActualAcknowledgement() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(Logger.class));
|
||||
var ownerWorker = Executors.newSingleThreadExecutor();
|
||||
var checkpointWorker = Executors.newSingleThreadExecutor();
|
||||
var mutationEntered = new CountDownLatch(1);
|
||||
var releaseMutation = new CountDownLatch(1);
|
||||
var nativePublished = new CountDownLatch(1);
|
||||
var releaseAcknowledgement = new CountDownLatch(1);
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override public CompletionStage<Void> mutateFinalData(Consumer<FileConfiguration> mutation) {
|
||||
return CompletableFuture.supplyAsync(() -> super.mutateFinalData(mutation), ownerWorker)
|
||||
.thenCompose(stage -> stage.thenRun(() -> {
|
||||
assertFalse(Thread.holdsLock(this), "acknowledgement must run outside YAML ownership");
|
||||
nativePublished.countDown();
|
||||
await(releaseAcknowledgement);
|
||||
}));
|
||||
}
|
||||
};
|
||||
backing.setData(new YamlConfiguration());
|
||||
backing.getData().set("Adjacent.Value", "preserved");
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var inner = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
var facade = new GenerationFencedServerDataFile(plugin, inner, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
var data = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
data.getData();
|
||||
var checkpointThread = new AtomicReference<Thread>();
|
||||
var checkpointStarted = new CountDownLatch(1);
|
||||
try {
|
||||
// This returns a genuinely deferred owner stage, not a mocked future.
|
||||
var observation = facade.mutateFinalData(snapshot -> {
|
||||
assertTrue(Thread.holdsLock(backing));
|
||||
assertEquals("preserved", snapshot.getString("Adjacent.Value"));
|
||||
mutationEntered.countDown();
|
||||
await(releaseMutation);
|
||||
snapshot.set("Final.Value", "confirmed");
|
||||
}).toCompletableFuture();
|
||||
assertTrue(mutationEntered.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(observation.isDone());
|
||||
var checkpoint = checkpointWorker.submit(() -> {
|
||||
checkpointThread.set(Thread.currentThread());
|
||||
checkpointStarted.countDown();
|
||||
data.setInt("VoteParty.Total", 7);
|
||||
});
|
||||
assertTrue(checkpointStarted.await(5, TimeUnit.SECONDS));
|
||||
var threads = ManagementFactory.getThreadMXBean();
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||
boolean blockedByOwner = false;
|
||||
while (!checkpoint.isDone() && System.nanoTime() < deadline) {
|
||||
var info = threads.getThreadInfo(checkpointThread.get().threadId());
|
||||
if (info != null && info.getThreadState() == Thread.State.BLOCKED
|
||||
&& info.getLockInfo() != null
|
||||
&& info.getLockInfo().getIdentityHashCode() == System.identityHashCode(backing)) {
|
||||
blockedByOwner = true;
|
||||
break;
|
||||
}
|
||||
Thread.yield();
|
||||
}
|
||||
assertTrue(blockedByOwner, "ordinary checkpoints must wait for the deferred native YAML owner");
|
||||
releaseMutation.countDown();
|
||||
assertTrue(nativePublished.await(5, TimeUnit.SECONDS));
|
||||
checkpoint.get(5, TimeUnit.SECONDS);
|
||||
var disk = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
assertEquals("confirmed", disk.getString("Final.Value"));
|
||||
assertEquals("preserved", disk.getString("Adjacent.Value"));
|
||||
assertEquals(7, disk.getInt("VotingPlugin.VoteParty.Total"));
|
||||
fence.retire();
|
||||
assertFalse(observation.isDone(), "visible native bytes alone do not settle the owner's stage");
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
releaseAcknowledgement.countDown();
|
||||
observation.get(5, TimeUnit.SECONDS);
|
||||
var successor = ServerDataPublicationFence.beginGeneration(target);
|
||||
successor.retire();
|
||||
} finally {
|
||||
releaseMutation.countDown();
|
||||
releaseAcknowledgement.countDown();
|
||||
ownerWorker.shutdownNow();
|
||||
checkpointWorker.shutdownNow();
|
||||
assertTrue(ownerWorker.awaitTermination(5, TimeUnit.SECONDS));
|
||||
assertTrue(checkpointWorker.awaitTermination(5, TimeUnit.SECONDS));
|
||||
fence.retire();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void await(CountDownLatch latch) {
|
||||
try { assertTrue(latch.await(10, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import com.bencodez.advancedcore.api.user.presence.PlayerPresence;
|
||||
import com.bencodez.votingplugin.placeholders.PlaceholderPlayerPresence;
|
||||
import com.bencodez.votingplugin.signs.Signs;
|
||||
import com.bencodez.votingplugin.streaks.VoteStreaksMenu;
|
||||
import com.bencodez.votingplugin.topvoter.TopVoterDisplayFilter;
|
||||
import com.bencodez.votingplugin.topvoter.TopVoterPlayer;
|
||||
import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class DisplayUnloadIntegrationTest {
|
||||
private void field(Object target, String name, Object value) throws Exception {
|
||||
var field = VotingPluginMain.class.getDeclaredField(name); field.setAccessible(true); field.set(target, value);
|
||||
}
|
||||
private ScheduledExecutorService stoppedExecutor() throws Exception {
|
||||
var executor = mock(ScheduledExecutorService.class);
|
||||
when(executor.awaitTermination(anyLong(), any(TimeUnit.class))).thenReturn(true);
|
||||
return executor;
|
||||
}
|
||||
@Test void unloadSettlesPendingLeaderboardEvidenceAndRejectsLaterStreakMenuOpens() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
field(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
var discord = mock(com.bencodez.votingplugin.discord.DiscordHandler.class);
|
||||
field(plugin, "discordHandler", discord);
|
||||
doReturn(true).when(plugin).isEnabled();
|
||||
var generation = new AtomicLong();
|
||||
field(plugin, "topVoterDisplayGeneration", generation);
|
||||
field(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
field(plugin, "backendHostedControlLifecycle", stoppedExecutor());
|
||||
field(plugin, "backendHostedControlManagers", new HashSet<>());
|
||||
field(plugin, "backendControlConnectorLifecycle", stoppedExecutor());
|
||||
field(plugin, "voteTimer", stoppedExecutor());
|
||||
var signs = mock(Signs.class); doReturn(signs).when(plugin).getSigns();
|
||||
when(signs.storeSignsForShutdown()).thenReturn(CompletableFuture.completedFuture(null));
|
||||
field(plugin, "signs", signs);
|
||||
var pending = new CompletableFuture<PlayerPresence>();
|
||||
var filter = new TopVoterDisplayFilter(row -> pending);
|
||||
field(plugin, "topVoterDisplayFilter", filter);
|
||||
var menu = new VoteStreaksMenu(plugin); field(plugin, "voteStreaksMenu", menu);
|
||||
var rankings = new LinkedHashMap<TopVoterPlayer, Integer>();
|
||||
rankings.put(new TopVoterPlayer(UUID.randomUUID(), "Example", 1L), 10);
|
||||
var display = filter.projectAsync(rankings, true).toCompletableFuture();
|
||||
assertFalse(display.isDone());
|
||||
var player = mock(Player.class);
|
||||
try (var handlers = mockStatic(HandlerList.class); var scheduler = mockStatic(BukkitCompletionScheduler.class)) {
|
||||
scheduler.when(() -> BukkitCompletionScheduler.run(eq(plugin), eq(player), any(Runnable.class), any(Runnable.class), any(Runnable.class)))
|
||||
.thenAnswer(call -> { call.getArgument(2, Runnable.class).run(); return null; });
|
||||
plugin.onUnLoad();
|
||||
verify(discord).close();
|
||||
verify(discord).retireLeaderboardPublications();
|
||||
assertTrue(display.isDone(), "Unload must settle the retired filter's pending display wait");
|
||||
assertEquals(rankings, display.getNow(null));
|
||||
pending.complete(PlayerPresence.NEVER_JOINED);
|
||||
assertEquals(rankings, filter.project(rankings, true), "Late history results cannot hide rows after retirement");
|
||||
assertEquals(1, generation.get());
|
||||
menu.open(player);
|
||||
verifyNoInteractions(player);
|
||||
verify(signs).storeSignsForShutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
+382
@@ -0,0 +1,382 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import com.bencodez.votingplugin.data.GenerationFencedServerDataFile;
|
||||
import com.bencodez.votingplugin.util.ServerDataPublicationFence;
|
||||
|
||||
class GenerationFencedServerDataFinalPublicationTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test void ordinaryServiceProducerCannotRaceTheActualSDKListSnapshot() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(Logger.class));
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
backing.setData(new YamlConfiguration());
|
||||
var snapshotEntered = new java.util.concurrent.CountDownLatch(1);
|
||||
var releaseSnapshot = new java.util.concurrent.CountDownLatch(1);
|
||||
var producerStarted = new java.util.concurrent.CountDownLatch(1);
|
||||
var mutatedLiveList = new java.util.concurrent.CountDownLatch(1);
|
||||
var firstSnapshot = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
var sites = new java.util.ArrayList<String>() {
|
||||
@Override public boolean add(String value) {
|
||||
boolean added = super.add(value);
|
||||
if (value.equals("SyntheticLateSite")) mutatedLiveList.countDown();
|
||||
return added;
|
||||
}
|
||||
@Override public java.util.stream.Stream<String> stream() {
|
||||
var stream = super.stream();
|
||||
if (!firstSnapshot.compareAndSet(true, false)) return stream;
|
||||
return stream.peek(value -> {
|
||||
snapshotEntered.countDown();
|
||||
try { assertTrue(releaseSnapshot.await(5, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
});
|
||||
}
|
||||
};
|
||||
sites.add("SyntheticExistingSite");
|
||||
backing.getData().set("VotingPlugin.GottenServiceSites", sites);
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
var data = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
var workers = java.util.concurrent.Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var publication = workers.submit(() -> facade.mutateFinalData(snapshot ->
|
||||
snapshot.set("Final.Value", "confirmed")).toCompletableFuture().join());
|
||||
assertTrue(snapshotEntered.await(5, TimeUnit.SECONDS));
|
||||
var producer = workers.submit(() -> {
|
||||
producerStarted.countDown();
|
||||
data.addServiceSite("SyntheticLateSite");
|
||||
});
|
||||
assertTrue(producerStarted.await(5, TimeUnit.SECONDS));
|
||||
boolean overlapped = mutatedLiveList.await(200, TimeUnit.MILLISECONDS);
|
||||
releaseSnapshot.countDown();
|
||||
assertDoesNotThrow(() -> publication.get(5, TimeUnit.SECONDS),
|
||||
"ordinary producers must not invalidate the real SDK final snapshot");
|
||||
producer.get(5, TimeUnit.SECONDS);
|
||||
assertFalse(overlapped, "live service history changed while the SDK owned its snapshot");
|
||||
var disk = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
assertEquals(java.util.Set.of("SyntheticExistingSite", "SyntheticLateSite"),
|
||||
new java.util.HashSet<>(disk.getStringList("VotingPlugin.GottenServiceSites")));
|
||||
assertEquals("confirmed", disk.getString("Final.Value"));
|
||||
} finally {
|
||||
releaseSnapshot.countDown();
|
||||
workers.shutdownNow();
|
||||
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
fence.retire();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void ordinaryWriterFamiliesMutateOnlyUnderTheActualYamlOwner() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(Logger.class));
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
var yaml = new YamlConfiguration() {
|
||||
@Override public void set(String path, Object value) {
|
||||
assertTrue(Thread.holdsLock(backing), "YAML mutation escaped its actual owner: " + path);
|
||||
super.set(path, value);
|
||||
}
|
||||
};
|
||||
backing.setData(yaml);
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
var data = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
try {
|
||||
data.addServiceSite("SyntheticSite");
|
||||
data.addAutoCachedPlaceholder("SyntheticPlaceholder");
|
||||
data.addVoteShopPurchase("SyntheticItem");
|
||||
data.setBungeeVotePartyCurrent(3);
|
||||
data.setBungeeVotePartyRequired(9);
|
||||
data.setVotePartyExtraRequired(2);
|
||||
data.setShopConverted(true);
|
||||
data.saveDisabledReminders(new java.util.ArrayList<>());
|
||||
data.updatePlaceholders();
|
||||
var party = new com.bencodez.votingplugin.specialrewards.voteparty.VotePartyState(plugin);
|
||||
party.setTotalVotes(4);
|
||||
var users = new java.util.ArrayList<>(java.util.List.of("SyntheticParticipant"));
|
||||
party.setVotedUsers(users);
|
||||
users.add("UnpublishedParticipant");
|
||||
assertEquals(java.util.List.of("SyntheticParticipant"), party.getVotedUsers());
|
||||
// Suppress web refresh only; exercise the real handler and its actual YAML writer.
|
||||
var services = new com.bencodez.votingplugin.servicesites.ServiceSiteHandler(plugin) {
|
||||
@Override public void refreshFromWebOnce(boolean logMore) { }
|
||||
};
|
||||
services.saveToServerData();
|
||||
var world = mock(org.bukkit.World.class);
|
||||
when(world.getName()).thenAnswer(call -> {
|
||||
assertFalse(Thread.holdsLock(backing), "platform lookup ran under YAML ownership");
|
||||
return "SyntheticWorld";
|
||||
});
|
||||
var location = new org.bukkit.Location(world, -1.5, 64, 2.5);
|
||||
data.setSign("0", location, location, "SyntheticSign", 1);
|
||||
data.setSkullLocation("0", location);
|
||||
assertEquals(-1, data.getData().getInt("Signs.0.X"), "retain legacy truncation");
|
||||
data.removeSign("0");
|
||||
var disk = YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile());
|
||||
assertEquals(4, disk.getInt("VotingPlugin.VoteParty.Total"));
|
||||
assertEquals(java.util.List.of("SyntheticParticipant"), disk.getStringList("VotingPlugin.VoteParty.Voted"));
|
||||
assertEquals(1, disk.getInt("VotingPlugin.VoteShopPurchases.SyntheticItem"));
|
||||
assertFalse(disk.contains("VotingPlugin.Signs.0"));
|
||||
} finally { fence.retire(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void ordinaryMutationFamiliesWaitBeforeChangingAnOwnedSnapshot() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(Logger.class));
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
backing.setData(new YamlConfiguration());
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
var data = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
data.getData(); // Section already exists: test mutation, not lazy section creation.
|
||||
var party = new com.bencodez.votingplugin.specialrewards.voteparty.VotePartyState(plugin);
|
||||
var services = new com.bencodez.votingplugin.servicesites.ServiceSiteHandler(plugin) {
|
||||
@Override public void refreshFromWebOnce(boolean logMore) { }
|
||||
};
|
||||
var operations = java.util.List.<Runnable>of(
|
||||
() -> data.setBungeeVotePartyCurrent(3),
|
||||
() -> data.addServiceSite("SyntheticSite"),
|
||||
() -> data.setServiceSites(new java.util.ArrayList<>(java.util.List.of("SyntheticReplacement"))),
|
||||
() -> data.addVoteShopPurchase("SyntheticItem"),
|
||||
() -> data.addAutoCachedPlaceholder("SyntheticPlaceholder"),
|
||||
() -> data.updatePlaceholders(),
|
||||
() -> data.removeSign("0"),
|
||||
() -> party.setTotalVotes(4),
|
||||
() -> party.setVotedUsers(java.util.List.of("SyntheticParticipant")),
|
||||
services::saveToServerData);
|
||||
var worker = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
for (Runnable operation : operations) {
|
||||
var started = new java.util.concurrent.CountDownLatch(1);
|
||||
java.util.concurrent.Future<?> future;
|
||||
synchronized (backing) {
|
||||
String before = backing.getData().saveToString();
|
||||
future = worker.submit(() -> { started.countDown(); operation.run(); });
|
||||
assertTrue(started.await(5, TimeUnit.SECONDS));
|
||||
assertThrows(java.util.concurrent.TimeoutException.class,
|
||||
() -> future.get(100, TimeUnit.MILLISECONDS));
|
||||
assertEquals(before, backing.getData().saveToString(),
|
||||
"ordinary mutation changed YAML before acquiring the actual snapshot owner");
|
||||
}
|
||||
future.get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
} finally {
|
||||
worker.shutdownNow();
|
||||
assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS));
|
||||
fence.retire();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void serviceListInputsAndOutputsCannotMutatePublishedYamlByAlias() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(Logger.class));
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
backing.setData(new YamlConfiguration());
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
var data = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
try {
|
||||
var supplied = new java.util.ArrayList<>(java.util.List.of("SyntheticExistingSite"));
|
||||
data.setServiceSites(supplied);
|
||||
supplied.add("SyntheticInputAlias");
|
||||
data.getServiceSites().add("SyntheticOutputAlias");
|
||||
assertEquals(java.util.List.of("SyntheticExistingSite"), data.getServiceSitesReadOnly());
|
||||
data.saveData();
|
||||
var disk = YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile());
|
||||
assertEquals(java.util.List.of("SyntheticExistingSite"), disk.getStringList("VotingPlugin.GottenServiceSites"));
|
||||
data.setServiceSites(null);
|
||||
assertTrue(data.getServiceSites().isEmpty(), "retain legacy null-as-clear setter behavior");
|
||||
} finally { fence.retire(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void finalMutationConfirmsTheActualSDKOwnersPublication() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
checkFinalMutation();
|
||||
}
|
||||
}
|
||||
|
||||
private void checkFinalMutation() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(Logger.class));
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
backing.setData(new YamlConfiguration());
|
||||
backing.getData().set("Adjacent.Value", "preserved");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
try {
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
facade.mutateFinalData(data -> data.set("Final.Value", "confirmed"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertSame(backing.getData(), facade.getData());
|
||||
var disk = YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile());
|
||||
assertEquals("confirmed", disk.getString("Final.Value"));
|
||||
assertEquals("preserved", disk.getString("Adjacent.Value"));
|
||||
} finally {
|
||||
fence.retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void asynchronousOwnerRetainsAdmissionUntilItsActualCompletion() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
var backing = mock(com.bencodez.advancedcore.data.ServerData.class);
|
||||
var published = new CompletableFuture<Void>();
|
||||
when(backing.mutateFinalData(any())).thenReturn(published);
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
var observation = facade.mutateFinalData(data -> {}).toCompletableFuture();
|
||||
fence.retire();
|
||||
assertFalse(observation.isDone());
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
observation.cancel(false);
|
||||
assertFalse(published.isCancelled());
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
published.complete(null);
|
||||
var successor = ServerDataPublicationFence.beginGeneration(target);
|
||||
successor.retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedOrCancelledPublicationCannotReleaseAdmission() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
for (boolean cancelled : new boolean[] { false, true }) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
var backing = mock(com.bencodez.advancedcore.data.ServerData.class);
|
||||
var published = new CompletableFuture<Void>();
|
||||
when(backing.mutateFinalData(any())).thenReturn(published);
|
||||
var target = directory.resolve(cancelled ? "Cancelled.yml" : "Failed.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
var observation = facade.mutateFinalData(data -> {}).toCompletableFuture();
|
||||
fence.retire();
|
||||
if (cancelled) published.cancel(false);
|
||||
else published.completeExceptionally(new java.io.IOException("synthetic publication failure"));
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> observation.get(5, TimeUnit.SECONDS));
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void ordinaryMutatorsAndCheckedTransitionsUseTheSameDelegateOwner() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(Logger.class));
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
backing.setData(new YamlConfiguration());
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
try {
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
assertSame(backing, facade.publicationMonitor());
|
||||
facade.setPrevDay(12);
|
||||
facade.setPrevMonth("OCTOBER");
|
||||
facade.setPrevWeekDay(41);
|
||||
facade.setIgnoreTime(true);
|
||||
facade.setData("Adjacent.Value", "ordinary");
|
||||
var transition = facade.beginTimeChangeTransition(
|
||||
com.bencodez.advancedcore.api.time.TimeType.DAY, "synthetic-day", "13");
|
||||
assertEquals(transition, backing.getPendingTimeChangeTransition(
|
||||
com.bencodez.advancedcore.api.time.TimeType.DAY));
|
||||
facade.mutateFinalData(data -> data.set("Final.Value", "confirmed"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
facade.completeTimeChangeTransition(transition);
|
||||
assertFalse(backing.isPendingTimeChangeTransition(transition));
|
||||
assertEquals(13, backing.getPrevDay());
|
||||
var disk = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
assertEquals("ordinary", disk.getString("Adjacent.Value"));
|
||||
assertEquals("confirmed", disk.getString("Final.Value"));
|
||||
assertEquals(13, disk.getInt("PrevDay"));
|
||||
} finally { fence.retire(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void timedCheckpointCannotOvertakeTheNativeFinalSnapshot() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(Logger.class));
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
backing.setData(new YamlConfiguration());
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
var data = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var checkpointStarted = new java.util.concurrent.CountDownLatch(1);
|
||||
var checkpointFinished = new java.util.concurrent.CountDownLatch(1);
|
||||
var workers = java.util.concurrent.Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var publication = workers.submit(() -> facade.mutateFinalData(snapshot -> {
|
||||
snapshot.set("Final.Value", "confirmed");
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(5, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
}).toCompletableFuture().join());
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
var checkpoint = workers.submit(() -> {
|
||||
checkpointStarted.countDown();
|
||||
data.addTimeVoted(0, new com.bencodez.votingplugin.timequeue.VoteTimeQueue(
|
||||
"Synthetic", "site", 123L));
|
||||
checkpointFinished.countDown();
|
||||
});
|
||||
assertTrue(checkpointStarted.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(checkpointFinished.await(150, TimeUnit.MILLISECONDS));
|
||||
release.countDown();
|
||||
publication.get(5, TimeUnit.SECONDS);
|
||||
checkpoint.get(5, TimeUnit.SECONDS);
|
||||
var disk = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
assertEquals("confirmed", disk.getString("Final.Value"));
|
||||
assertEquals("Synthetic", disk.getString("VotingPlugin.TimedVoteCache.0.Name"));
|
||||
assertEquals(123L, disk.getLong("VotingPlugin.TimedVoteCache.0.Time"));
|
||||
} finally {
|
||||
release.countDown();
|
||||
workers.shutdownNow();
|
||||
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
fence.retire();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import com.bencodez.advancedcore.AdvancedCorePlugin;
|
||||
import com.bencodez.advancedcore.lifecycle.AdvancedCoreLifecycle;
|
||||
import com.bencodez.votingplugin.util.ServerDataPublicationFence;
|
||||
|
||||
class GenerationFencedShutdownStageTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
private VotingPluginMain plugin(ServerDataPublicationFence fence) throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
// Mockito bypasses constructors; initialize the real SDK lifecycle lock.
|
||||
var lock = AdvancedCorePlugin.class.getDeclaredField("lifecycleLock");
|
||||
lock.setAccessible(true);
|
||||
lock.set(plugin, new Object());
|
||||
var owner = VotingPluginMain.class.getDeclaredField("serverDataPublicationFence");
|
||||
owner.setAccessible(true);
|
||||
owner.set(plugin, fence);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
@Test void disableReservesBeforeTheSDKAdmitsItsFinalStageAndRepeatedDisableDoesNotReleaseIt() throws Exception {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var plugin = plugin(fence);
|
||||
var finalFiles = new CompletableFuture<Void>();
|
||||
// This fixture controls only SDK hook admission; it does not claim a
|
||||
// full runtime cleanup. Adapter tests exercise the real file publisher.
|
||||
try (var cleanup = mockConstruction(AdvancedCoreLifecycle.class)) {
|
||||
plugin.onDisable();
|
||||
fence.retainUntilTermination(plugin.getServerDataWriterExecutors())
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
plugin.onShutdownComplete(finalFiles);
|
||||
plugin.onDisable();
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
finalFiles.complete(null);
|
||||
var successor = ServerDataPublicationFence.beginGeneration(target);
|
||||
successor.retire();
|
||||
plugin.onDisable();
|
||||
assertThrows(IllegalStateException.class, fence::acquire);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void disableStartsTheProcessDrainBeforeSdkCleanupAndNeverRenewsIt() throws Exception {
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var plugin = plugin(fence);
|
||||
var startedField = ServerDataPublicationFence.class.getDeclaredField("processShutdownStarted");
|
||||
startedField.setAccessible(true);
|
||||
var started = (CompletableFuture<?>) startedField.get(fence);
|
||||
try (var cleanup = mockConstruction(AdvancedCoreLifecycle.class, (owner, context) -> {
|
||||
doAnswer(call -> {
|
||||
assertTrue(started.isDone(), "The consumer deadline must precede SDK cleanup");
|
||||
return null;
|
||||
}).when(owner).shutdown(anyLong());
|
||||
})) {
|
||||
plugin.onDisable();
|
||||
var original = started.get(5, TimeUnit.SECONDS);
|
||||
plugin.onDisable();
|
||||
assertEquals(original, started.get());
|
||||
plugin.onShutdownComplete(CompletableFuture.completedFuture(null));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedFinalStageRetainsTheGenerationAfterAllCapturedExecutorsTerminate() throws Exception {
|
||||
checkFailure(false);
|
||||
}
|
||||
|
||||
@Test void cancelledFinalStageRetainsTheGenerationAfterAllCapturedExecutorsTerminate() throws Exception {
|
||||
checkFailure(true);
|
||||
}
|
||||
|
||||
private void checkFailure(boolean cancelled) throws Exception {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var plugin = plugin(fence);
|
||||
var finalFiles = new CompletableFuture<Void>();
|
||||
try (var cleanup = mockConstruction(AdvancedCoreLifecycle.class)) {
|
||||
plugin.onDisable();
|
||||
fence.retainUntilTermination(plugin.getServerDataWriterExecutors())
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
plugin.onShutdownComplete(finalFiles);
|
||||
if (cancelled) finalFiles.cancel(false);
|
||||
else finalFiles.completeExceptionally(new java.io.IOException("synthetic final-stage failure"));
|
||||
plugin.onDisable();
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
// A later duplicate hook cannot replace the failed outcome.
|
||||
plugin.onShutdownComplete(CompletableFuture.completedFuture(null));
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@@ -63,7 +65,7 @@ class ProxyCacheCheckedReaderJdbcTest {
|
||||
}
|
||||
Field mysqlField = AbstractSqlTable.class.getDeclaredField("mysql");
|
||||
mysqlField.setAccessible(true);
|
||||
mysqlField.set(table, mysql);
|
||||
ReflectiveAccess.set(mysqlField, table, mysql);
|
||||
return new Fixture(checked, legacy, connection, statement, result);
|
||||
}
|
||||
|
||||
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import com.bencodez.advancedcore.api.item.FullInventoryHandler;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.data.GenerationFencedServerDataFile;
|
||||
import com.bencodez.votingplugin.util.ServerDataPublicationFence;
|
||||
|
||||
class SDKServerDataOwnerLockTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test void nativeInventoryOwnerExcludesOrdinaryPublication() throws Exception {
|
||||
assertInventoryReplacementIsAtomic(false, false);
|
||||
}
|
||||
|
||||
@Test void nativeInventoryOwnerExcludesFinalPublication() throws Exception {
|
||||
assertInventoryReplacementIsAtomic(false, true);
|
||||
}
|
||||
|
||||
@Test void wrappedInventoryOwnerExcludesOrdinaryPublication() throws Exception {
|
||||
assertInventoryReplacementIsAtomic(true, false);
|
||||
}
|
||||
|
||||
@Test void wrappedInventoryOwnerExcludesFinalPublication() throws Exception {
|
||||
assertInventoryReplacementIsAtomic(true, true);
|
||||
}
|
||||
|
||||
@Test void composedInventoryOwnerExcludesOrdinaryPublication() throws Exception {
|
||||
assertInventoryReplacementIsAtomic(2, false);
|
||||
}
|
||||
|
||||
@Test void composedInventoryOwnerExcludesFinalPublication() throws Exception {
|
||||
assertInventoryReplacementIsAtomic(2, true);
|
||||
}
|
||||
|
||||
@Test void composedFacadeAdvertisesTheInnermostYamlOwner() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
try {
|
||||
var inner = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
var outer = new GenerationFencedServerDataFile(plugin, inner, fence);
|
||||
assertSame(backing, outer.publicationMonitor(), "the delegate can itself be an adapter");
|
||||
} finally { fence.retire(); }
|
||||
}
|
||||
}
|
||||
|
||||
private void assertInventoryReplacementIsAtomic(boolean wrapped, boolean finalPublication) throws Exception {
|
||||
assertInventoryReplacementIsAtomic(wrapped ? 1 : 0, finalPublication);
|
||||
}
|
||||
|
||||
private void assertInventoryReplacementIsAtomic(int facadeDepth, boolean finalPublication) throws Exception {
|
||||
org.bukkit.configuration.serialization.ConfigurationSerialization.registerClass(PendingItem.class);
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(Logger.class));
|
||||
when(plugin.getBukkitScheduler()).thenReturn(mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class));
|
||||
// Exercise the public SDK writer. Startup has no optional file yet;
|
||||
// install the real owner before admitting either writer below.
|
||||
var inventory = new FullInventoryHandler(plugin);
|
||||
inventory.getTimer().shutdownNow();
|
||||
var cleared = new CountDownLatch(1);
|
||||
var release = new CountDownLatch(1);
|
||||
var armed = new AtomicBoolean();
|
||||
var observedIntermediate = new AtomicBoolean();
|
||||
var yaml = new YamlConfiguration() {
|
||||
@Override public void set(String path, Object value) {
|
||||
super.set(path, value);
|
||||
if (path.equals("FullInventory") && value == null && armed.compareAndSet(true, false)) {
|
||||
cleared.countDown();
|
||||
try { assertTrue(release.await(10, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
}
|
||||
}
|
||||
|
||||
@Override public String saveToString() {
|
||||
String text = super.saveToString();
|
||||
if (cleared.getCount() == 0 && release.getCount() != 0 && !contains("FullInventory")) {
|
||||
observedIntermediate.set(true);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
};
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override public void saveDataChecked() {
|
||||
// New SDK owners mutate an isolated candidate, so clearing
|
||||
// the live YAML is no longer a guaranteed observable seam.
|
||||
// Pause the actual native publication instead. The legacy
|
||||
// clear hook above still exercises its temporary-live gap.
|
||||
if (armed.compareAndSet(true, false)) {
|
||||
assertTrue(Thread.holdsLock(this),
|
||||
"native snapshot publication must own the backing monitor");
|
||||
cleared.countDown();
|
||||
try { assertTrue(release.await(10, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
}
|
||||
super.saveDataChecked();
|
||||
}
|
||||
};
|
||||
backing.setData(yaml);
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
com.bencodez.advancedcore.data.ServerData composed = backing;
|
||||
for (int depth = 0; depth < facadeDepth; depth++) {
|
||||
composed = new GenerationFencedServerDataFile(plugin, composed, fence);
|
||||
}
|
||||
var exposed = composed;
|
||||
when(plugin.getServerDataFile()).thenReturn(exposed);
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
var data = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
data.getData();
|
||||
UUID previous = new UUID(0, 2);
|
||||
inventory.getItems().put(previous, new ArrayList<>(java.util.List.of(new PendingItem(1))));
|
||||
assertTrue(inventory.saveDurably());
|
||||
var initialDisk = YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile());
|
||||
assertInstanceOf(PendingItem.class, initialDisk.get("FullInventory." + previous + ".Items.0"));
|
||||
assertEquals(1, initialDisk.getItemStack("FullInventory." + previous + ".Items.0").getAmount());
|
||||
inventory.getItems().clear();
|
||||
UUID replacement = new UUID(0, 1);
|
||||
inventory.getItems().put(replacement, new ArrayList<>(java.util.List.of(new PendingItem(2))));
|
||||
var workers = Executors.newFixedThreadPool(2);
|
||||
var inventoryThread = new AtomicReference<Thread>();
|
||||
var competingThread = new AtomicReference<Thread>();
|
||||
var competingStarted = new CountDownLatch(1);
|
||||
try {
|
||||
armed.set(true);
|
||||
var saving = workers.submit(() -> {
|
||||
inventoryThread.set(Thread.currentThread());
|
||||
return inventory.saveDurably();
|
||||
});
|
||||
assertTrue(cleared.await(5, TimeUnit.SECONDS));
|
||||
var competing = workers.submit(() -> {
|
||||
competingThread.set(Thread.currentThread());
|
||||
competingStarted.countDown();
|
||||
if (finalPublication) {
|
||||
exposed.mutateFinalData(snapshot -> {
|
||||
if (release.getCount() != 0 && !snapshot.contains("FullInventory")) {
|
||||
observedIntermediate.set(true);
|
||||
}
|
||||
snapshot.set("SyntheticFinal.Value", "confirmed");
|
||||
}).toCompletableFuture().join();
|
||||
} else {
|
||||
data.setInt("VoteParty.Total", 7);
|
||||
}
|
||||
});
|
||||
assertTrue(competingStarted.await(5, TimeUnit.SECONDS));
|
||||
var threads = ManagementFactory.getThreadMXBean();
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||
boolean blockedByOwner = false;
|
||||
while (!observedIntermediate.get() && !competing.isDone() && System.nanoTime() < deadline) {
|
||||
var info = threads.getThreadInfo(competingThread.get().threadId());
|
||||
if (info != null && info.getThreadState() == Thread.State.BLOCKED
|
||||
&& info.getLockOwnerId() == inventoryThread.get().threadId()
|
||||
&& info.getLockInfo() != null
|
||||
&& info.getLockInfo().getIdentityHashCode() == System.identityHashCode(backing)) {
|
||||
blockedByOwner = true;
|
||||
break;
|
||||
}
|
||||
Thread.yield();
|
||||
}
|
||||
assertTrue(blockedByOwner || observedIntermediate.get() || competing.isDone(),
|
||||
"competing writer never reached the ownership or serialization boundary");
|
||||
// Confirm the defective publication before releasing the SDK
|
||||
// replacement, rather than racing two unsynchronized YAML edits.
|
||||
if (observedIntermediate.get()) competing.get(5, TimeUnit.SECONDS);
|
||||
boolean publishedIntermediate = observedIntermediate.get();
|
||||
release.countDown();
|
||||
assertTrue(saving.get(5, TimeUnit.SECONDS));
|
||||
competing.get(5, TimeUnit.SECONDS);
|
||||
var disk = YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile());
|
||||
assertTrue(disk.contains("FullInventory." + replacement + ".Time"));
|
||||
assertEquals(2, disk.getItemStack("FullInventory." + replacement + ".Items.0").getAmount());
|
||||
assertFalse(disk.contains("FullInventory." + previous));
|
||||
if (finalPublication) assertEquals("confirmed", disk.getString("SyntheticFinal.Value"));
|
||||
else assertEquals(7, disk.getInt("VotingPlugin.VoteParty.Total"));
|
||||
assertFalse(publishedIntermediate,
|
||||
"a successful publication serialized the SDK's temporarily cleared recovery section");
|
||||
assertTrue(blockedByOwner, "the competing writer must share the actual SDK transaction monitor");
|
||||
} finally {
|
||||
release.countDown();
|
||||
workers.shutdownNow();
|
||||
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
inventory.getTimer().shutdownNow();
|
||||
assertTrue(inventory.getTimer().awaitTermination(5, TimeUnit.SECONDS));
|
||||
fence.retire();
|
||||
}
|
||||
} finally {
|
||||
org.bukkit.configuration.serialization.ConfigurationSerialization.unregisterClass(PendingItem.class);
|
||||
}
|
||||
}
|
||||
|
||||
/** Headless item serialization keeps the actual SDK writer and recovery YAML in the test. */
|
||||
@org.bukkit.configuration.serialization.SerializableAs("VPServerDataMonitorTestItem")
|
||||
public static final class PendingItem extends org.bukkit.inventory.ItemStack {
|
||||
public PendingItem(int amount) { super(org.bukkit.Material.STONE, amount); }
|
||||
@Override public java.util.Map<String, Object> serialize() { return java.util.Map.of("amount", getAmount()); }
|
||||
public static PendingItem deserialize(java.util.Map<String, Object> values) {
|
||||
return new PendingItem(((Number) values.get("amount")).intValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
+427
@@ -0,0 +1,427 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.plugin.PluginDescriptionFile;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import com.bencodez.advancedcore.AdvancedCorePlugin;
|
||||
import com.bencodez.advancedcore.api.time.TimeType;
|
||||
import com.bencodez.advancedcore.data.ServerData;
|
||||
import com.bencodez.advancedcore.lifecycle.AdvancedCoreLifecycle;
|
||||
import com.bencodez.simpleapi.scheduler.BukkitScheduler;
|
||||
|
||||
class SDKServerDataPublicationTest {
|
||||
@TempDir Path directory;
|
||||
private static final class StartupReached extends IllegalStateException { }
|
||||
|
||||
private VotingPluginMain plugin() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
// Mockito bypasses constructors; use the SDK's actual lifecycle lock.
|
||||
var lock = AdvancedCorePlugin.class.getDeclaredField("lifecycleLock");
|
||||
lock.setAccessible(true);
|
||||
lock.set(plugin, new Object());
|
||||
doNothing().when(plugin).registerEvents(any());
|
||||
doReturn(directory.toFile()).when(plugin).getDataFolder();
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
doReturn("VotingPlugin").when(plugin).getName();
|
||||
doReturn(new PluginDescriptionFile("VotingPlugin", "1", "synthetic.Main")).when(plugin).getDescription();
|
||||
doThrow(new StartupReached()).when(plugin).onPreLoad();
|
||||
return plugin;
|
||||
}
|
||||
|
||||
private static org.mockito.MockedConstruction<AdvancedCoreLifecycle> completedCleanup() {
|
||||
// Control only unrelated platform cleanup. Real SDK startup admission,
|
||||
// file publishers and VotingPlugin generation ownership stay in use.
|
||||
// These fixtures do not claim full platform shutdown validation.
|
||||
return mockConstruction(AdvancedCoreLifecycle.class, (cleanup, context) -> {
|
||||
var plugin = (VotingPluginMain) context.arguments().get(0);
|
||||
doAnswer(call -> {
|
||||
var completed = CompletableFuture.<Void>completedFuture(null);
|
||||
plugin.onShutdownComplete(completed);
|
||||
plugin.releaseServerDataOwnershipAfter(completed);
|
||||
return null;
|
||||
}).when(cleanup).shutdown(anyLong());
|
||||
});
|
||||
}
|
||||
|
||||
private static void awaitCapturedRetirement(VotingPluginMain plugin) throws Exception {
|
||||
plugin.getServerDataPublicationFence().retainUntilTermination(plugin.getServerDataWriterExecutors())
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static void installSDKFile(VotingPluginMain plugin, ServerData file) throws Exception {
|
||||
var field = AdvancedCorePlugin.class.getDeclaredField("serverDataFile");
|
||||
field.setAccessible(true);
|
||||
field.set(plugin, file);
|
||||
}
|
||||
|
||||
private static ScheduledExecutorService terminatedExecutor() {
|
||||
var executor = mock(ScheduledExecutorService.class);
|
||||
when(executor.isTerminated()).thenReturn(true);
|
||||
return executor;
|
||||
}
|
||||
|
||||
@Test void rejectedSDKAdmissionRetiresOnlyItsUnusedPluginGenerationAndCanRetryAfterRelease() throws Exception {
|
||||
var plugin = plugin();
|
||||
var delayedRelease = new CompletableFuture<Void>();
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
var constructors = mockConstruction(BukkitScheduler.class);
|
||||
var cleanup = mockConstruction(AdvancedCoreLifecycle.class, (owner, context) -> {
|
||||
doAnswer(call -> {
|
||||
plugin.onShutdownComplete(CompletableFuture.completedFuture(null));
|
||||
plugin.releaseServerDataOwnershipAfter(delayedRelease);
|
||||
return null;
|
||||
}).when(owner).shutdown(anyLong());
|
||||
})) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
awaitCapturedRetirement(plugin);
|
||||
for (int attempt = 0; attempt < 2; attempt++) {
|
||||
var failure = assertThrows(IllegalStateException.class, plugin::onEnable);
|
||||
assertEquals("Previous ServerData shutdown has not finished", failure.getMessage());
|
||||
plugin.onDisable();
|
||||
}
|
||||
delayedRelease.complete(null);
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
awaitCapturedRetirement(plugin);
|
||||
} finally {
|
||||
delayedRelease.complete(null);
|
||||
plugin.onShutdownComplete(CompletableFuture.completedFuture(null));
|
||||
if (plugin.getServerDataPublicationFence() != null) plugin.getServerDataPublicationFence().retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void admittedSDKInitializationFailureStillRetainsItsPendingFinalPublication() throws Exception {
|
||||
var plugin = plugin();
|
||||
var pendingPublication = new CompletableFuture<Void>();
|
||||
var initializationFailure = new IllegalStateException("synthetic executor initialization failure");
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
var constructors = mockConstruction(BukkitScheduler.class);
|
||||
var cleanup = mockConstruction(AdvancedCoreLifecycle.class, (owner, context) -> {
|
||||
doAnswer(call -> {
|
||||
plugin.onShutdownComplete(pendingPublication);
|
||||
plugin.releaseServerDataOwnershipAfter(pendingPublication);
|
||||
return null;
|
||||
}).when(owner).shutdown(anyLong());
|
||||
})) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any()))
|
||||
.thenThrow(initializationFailure).thenReturn(runtime);
|
||||
assertSame(initializationFailure, assertThrows(IllegalStateException.class, plugin::onEnable));
|
||||
verify(plugin, never()).onPreLoad();
|
||||
assertFalse(pendingPublication.isDone());
|
||||
assertThrows(IllegalStateException.class, plugin::onEnable);
|
||||
pendingPublication.complete(null);
|
||||
awaitCapturedRetirement(plugin);
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
awaitCapturedRetirement(plugin);
|
||||
} finally {
|
||||
pendingPublication.complete(null);
|
||||
if (plugin.getServerDataPublicationFence() != null) plugin.getServerDataPublicationFence().retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void actualSDKVersionCallbackRetainsOwnershipOutsideCapturedExecutors() throws Exception {
|
||||
var old = plugin(); var next = plugin();
|
||||
Path target = directory.resolve("ServerData.yml");
|
||||
var yaml = spy(new YamlConfiguration());
|
||||
var scheduler = mock(BukkitScheduler.class);
|
||||
var callback = new AtomicReference<Runnable>();
|
||||
doAnswer(call -> { callback.set(call.getArgument(1)); return null; })
|
||||
.when(scheduler).runTaskAsynchronously(any(), any());
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(scheduler,
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
ExecutorService externalWriter = Executors.newSingleThreadExecutor();
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
var cleanup = completedCleanup();
|
||||
var constructors = mockConstruction(BukkitScheduler.class)) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
var writingReference = new AtomicReference<Future<?>>();
|
||||
doAnswer(startup -> {
|
||||
var backing = new ServerData(old);
|
||||
backing.setData(yaml); backing.setPluginVersion(old);
|
||||
installSDKFile(old, backing);
|
||||
assertSame(yaml, old.getServerDataFile().getData());
|
||||
old.setLoadServerData(true);
|
||||
old.checkPluginUpdate();
|
||||
assertNotNull(callback.get());
|
||||
doAnswer(call -> {
|
||||
entered.countDown(); assertTrue(release.await(10, TimeUnit.SECONDS));
|
||||
return call.callRealMethod();
|
||||
}).when(yaml).save(target.toFile());
|
||||
// The SDK now cleans up a failed startup itself. Start the
|
||||
// real captured writer before that cleanup retires startup.
|
||||
writingReference.set(externalWriter.submit(callback.get()));
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
throw new StartupReached();
|
||||
}).when(old).onPreLoad();
|
||||
assertThrows(StartupReached.class, old::onEnable);
|
||||
Future<?> writing = writingReference.get();
|
||||
assertNotNull(writing);
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
old.onDisable();
|
||||
old.getServerDataPublicationFence().retainUntilTermination(old.getServerDataWriterExecutors())
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertFalse(assertThrows(IllegalStateException.class, next::onEnable) instanceof StartupReached,
|
||||
"SDK publication must retain ownership even when every captured executor is terminated");
|
||||
verify(next, never()).onPreLoad();
|
||||
release.countDown(); writing.get(5, TimeUnit.SECONDS);
|
||||
var successor = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
successor.set("Adjacent.Value", "successor"); successor.save(target.toFile());
|
||||
doAnswer(startup -> {
|
||||
// Run while the SDK's replacement plugin identity is live,
|
||||
// before its sentinel failure triggers automatic cleanup.
|
||||
assertThrows(IllegalStateException.class, callback.get()::run,
|
||||
"an old callback that starts after replacement cannot publish");
|
||||
assertThrows(IllegalStateException.class, () -> old.getServerDataFile().setPrevDay(12));
|
||||
assertEquals("successor", YamlConfiguration.loadConfiguration(target.toFile())
|
||||
.getString("Adjacent.Value"));
|
||||
throw new StartupReached();
|
||||
}).when(next).onPreLoad();
|
||||
assertThrows(StartupReached.class, next::onEnable);
|
||||
awaitCapturedRetirement(next);
|
||||
next.getServerDataPublicationFence().retire();
|
||||
} finally {
|
||||
release.countDown(); externalWriter.shutdownNow();
|
||||
assertTrue(externalWriter.awaitTermination(5, TimeUnit.SECONDS));
|
||||
if (old.getServerDataPublicationFence() != null) old.getServerDataPublicationFence().retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void inheritedFileOperationsKeepTheSDKConfigurationAuthoritative() throws Exception {
|
||||
var plugin = plugin();
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var constructors = mockConstruction(BukkitScheduler.class);
|
||||
var completedCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
var backing = new ServerData(plugin); backing.setData(new YamlConfiguration());
|
||||
installSDKFile(plugin, backing);
|
||||
var facade = plugin.getServerDataFile();
|
||||
assertSame(backing.getData(), facade.getData());
|
||||
facade.setIgnoreCase(true);
|
||||
assertTrue(backing.isIgnoreCase()); assertTrue(facade.isIgnoreCase());
|
||||
assertSame(backing.getData(), facade.getData());
|
||||
facade.setValue("Sample.Value", "kept");
|
||||
facade.createSection("Sample.Section");
|
||||
var merged = new YamlConfiguration(); merged.set("Adjacent.Value", "merged");
|
||||
facade.setData(merged); facade.saveData();
|
||||
facade.reloadData();
|
||||
assertSame(backing.getData(), facade.getData());
|
||||
assertEquals("kept", facade.getData().getString("Sample.Value"));
|
||||
assertEquals("merged", facade.getData().getString("Adjacent.Value"));
|
||||
assertNotNull(facade.getData().getConfigurationSection("Sample.Section"));
|
||||
assertEquals(backing.isJustCreated(), facade.isJustCreated());
|
||||
assertEquals(backing.isFailedToRead(), facade.isFailedToRead());
|
||||
assertSame(backing.getPlugin(), facade.getPlugin());
|
||||
assertEquals(backing.getdFile(), facade.getdFile());
|
||||
awaitCapturedRetirement(plugin);
|
||||
plugin.getServerDataPublicationFence().retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void oldVotingPluginWrapperDoesNotBorrowTheSamePluginObjectsReplacementFileOrFence() throws Exception {
|
||||
var plugin = plugin();
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var constructors = mockConstruction(BukkitScheduler.class);
|
||||
var completedCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
var first = new ServerData(plugin); first.setData(new YamlConfiguration());
|
||||
installSDKFile(plugin, first);
|
||||
var stale = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
stale.getData().set("Adjacent.Value", "first"); stale.saveData();
|
||||
awaitCapturedRetirement(plugin);
|
||||
plugin.getServerDataPublicationFence().retire();
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
var replacement = new ServerData(plugin); replacement.setData(new YamlConfiguration());
|
||||
installSDKFile(plugin, replacement);
|
||||
var current = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
current.getData().set("Adjacent.Value", "successor"); current.saveData();
|
||||
assertThrows(IllegalStateException.class, () -> stale.addTimeVoted(0,
|
||||
new com.bencodez.votingplugin.timequeue.VoteTimeQueue("Stale", "site", 123L)));
|
||||
assertEquals("successor", current.getData().getString("Adjacent.Value"));
|
||||
assertNull(current.getData().get("TimedVoteCache"));
|
||||
assertEquals("successor", YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile())
|
||||
.getString("VotingPlugin.Adjacent.Value"));
|
||||
awaitCapturedRetirement(plugin);
|
||||
plugin.getServerDataPublicationFence().retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void firstUseOfAnOldWrapperAfterReenableDoesNotMutateTheReplacementFile() throws Exception {
|
||||
var plugin = plugin();
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var constructors = mockConstruction(BukkitScheduler.class);
|
||||
var completedCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
var first = new ServerData(plugin); first.setData(new YamlConfiguration());
|
||||
installSDKFile(plugin, first);
|
||||
var stale = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
awaitCapturedRetirement(plugin);
|
||||
plugin.getServerDataPublicationFence().retire();
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
var replacement = new ServerData(plugin); replacement.setData(new YamlConfiguration());
|
||||
installSDKFile(plugin, replacement);
|
||||
var current = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
current.getData().set("Adjacent.Value", "successor"); current.saveData();
|
||||
assertThrows(IllegalStateException.class, () -> stale.addTimeVoted(0,
|
||||
new com.bencodez.votingplugin.timequeue.VoteTimeQueue("Stale", "site", 123L)));
|
||||
assertEquals("successor", current.getData().getString("Adjacent.Value"));
|
||||
assertNull(current.getData().get("TimedVoteCache"));
|
||||
assertEquals("successor", YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile())
|
||||
.getString("VotingPlugin.Adjacent.Value"));
|
||||
awaitCapturedRetirement(plugin);
|
||||
plugin.getServerDataPublicationFence().retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedReenableBeforeFileReplacementCanRecoverAfterNativeTimestampCleanup() throws Exception {
|
||||
var plugin = plugin();
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
var observed = new AtomicReference<CompletionStage<Void>>();
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var constructors = mockConstruction(BukkitScheduler.class);
|
||||
var cleanup = mockConstruction(AdvancedCoreLifecycle.class, (owner, context) -> {
|
||||
doAnswer(call -> {
|
||||
// Exercise the actual SDK timestamp submission, while
|
||||
// unrelated platform teardown remains controlled.
|
||||
var platform = new com.bencodez.advancedcore.bukkit.runtime.BukkitRuntimePlatform(plugin);
|
||||
var method = platform.getClass().getDeclaredMethod("submitTimestampPersistence", Executor.class);
|
||||
method.setAccessible(true);
|
||||
var workerField = platform.getClass().getDeclaredField("persistenceWorker");
|
||||
workerField.setAccessible(true);
|
||||
var worker = (ExecutorService) workerField.get(platform);
|
||||
CompletionStage<Void> stage;
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
var submitted = (CompletionStage<Void>) method.invoke(platform, worker);
|
||||
stage = submitted;
|
||||
} finally {
|
||||
worker.shutdown();
|
||||
}
|
||||
observed.set(stage);
|
||||
plugin.onShutdownComplete(stage);
|
||||
plugin.releaseServerDataOwnershipAfter(stage);
|
||||
return null;
|
||||
}).when(owner).shutdown(anyLong());
|
||||
})) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
var initialFailure = assertThrows(StartupReached.class, plugin::onEnable);
|
||||
assertEquals(0, initialFailure.getSuppressed().length,
|
||||
"native timestamp cleanup must actually execute without hidden fixture failures");
|
||||
assertNotNull(observed.get(), "capture the actual SDK final-publication stage");
|
||||
observed.get().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
awaitCapturedRetirement(plugin);
|
||||
var backing = new ServerData(plugin); backing.setData(new YamlConfiguration());
|
||||
installSDKFile(plugin, backing);
|
||||
var retiredFacade = plugin.getServerDataFile();
|
||||
assertNotNull(retiredFacade);
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
observed.get().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
awaitCapturedRetirement(plugin);
|
||||
assertThrows(IllegalStateException.class, () -> retiredFacade.setPrevDay(12));
|
||||
assertThrows(StartupReached.class, plugin::onEnable,
|
||||
"a failed pre-load must not permanently reserve either generation owner");
|
||||
observed.get().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
awaitCapturedRetirement(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void fileReadBetweenReenableAndSDKReplacementWithholdsPredecessorAndKeepsCapturedFence() throws Exception {
|
||||
var plugin = plugin();
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var constructors = mockConstruction(BukkitScheduler.class);
|
||||
var completedCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
var backing = new ServerData(plugin); backing.setData(new YamlConfiguration());
|
||||
installSDKFile(plugin, backing);
|
||||
backing.setPrevDay(1);
|
||||
var predecessor = plugin.getServerDataFile();
|
||||
awaitCapturedRetirement(plugin);
|
||||
plugin.getServerDataPublicationFence().retire();
|
||||
var intervening = new AtomicReference<ServerData>();
|
||||
doAnswer(call -> {
|
||||
intervening.set(plugin.getServerDataFile());
|
||||
throw new StartupReached();
|
||||
}).when(plugin).onPreLoad();
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
var replacement = new ServerData(plugin); replacement.setData(new YamlConfiguration());
|
||||
installSDKFile(plugin, replacement);
|
||||
plugin.getServerDataFile().setValue("Adjacent.Value", "successor");
|
||||
assertNull(intervening.get(), "pre-load must not expose a predecessor file to native final publication");
|
||||
assertThrows(IllegalStateException.class, () -> predecessor.setPrevDay(12));
|
||||
assertEquals("successor", YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile())
|
||||
.getString("Adjacent.Value"));
|
||||
awaitCapturedRetirement(plugin);
|
||||
plugin.getServerDataPublicationFence().retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void sdkDurableTransitionPublicationAlsoOwnsTheGenerationUntilItReturns() throws Exception {
|
||||
var old = plugin(); var next = plugin();
|
||||
Path target = directory.resolve("ServerData.yml");
|
||||
var yaml = spy(new YamlConfiguration());
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
ExecutorService externalWriter = Executors.newSingleThreadExecutor();
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
var constructors = mockConstruction(BukkitScheduler.class);
|
||||
var completedCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, old::onEnable);
|
||||
var backing = new ServerData(old); backing.setData(yaml); installSDKFile(old, backing);
|
||||
doAnswer(call -> {
|
||||
entered.countDown(); assertTrue(release.await(10, TimeUnit.SECONDS));
|
||||
return call.callRealMethod();
|
||||
}).when(yaml).saveToString();
|
||||
var file = old.getServerDataFile();
|
||||
Future<ServerData.TimeChangeTransitionState> writing = externalWriter.submit(() -> file.beginTimeChangeTransition(TimeType.DAY, "synthetic-period", "12"));
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
old.getServerDataPublicationFence().retire();
|
||||
assertFalse(assertThrows(IllegalStateException.class, next::onEnable) instanceof StartupReached);
|
||||
release.countDown(); var transition = writing.get(5, TimeUnit.SECONDS);
|
||||
var restored = new ServerData(old); restored.setData(YamlConfiguration.loadConfiguration(target.toFile()));
|
||||
assertEquals(transition, restored.getPendingTimeChangeTransition(TimeType.DAY));
|
||||
assertThrows(StartupReached.class, next::onEnable);
|
||||
assertThrows(IllegalStateException.class, () -> file.completeTimeChangeTransition(transition));
|
||||
next.getServerDataPublicationFence().retire();
|
||||
} finally {
|
||||
release.countDown(); externalWriter.shutdownNow();
|
||||
assertTrue(externalWriter.awaitTermination(5, TimeUnit.SECONDS));
|
||||
if (old.getServerDataPublicationFence() != null) old.getServerDataPublicationFence().retire();
|
||||
}
|
||||
}
|
||||
}
|
||||
+492
@@ -0,0 +1,492 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import com.bencodez.advancedcore.lifecycle.AdvancedCoreLifecycle;
|
||||
import com.bencodez.simpleapi.scheduler.BukkitScheduler;
|
||||
import com.bencodez.votingplugin.data.ServerData;
|
||||
import com.bencodez.votingplugin.timequeue.TimeQueueHandler;
|
||||
|
||||
class ServerDataGenerationShutdownTest {
|
||||
@TempDir Path directory;
|
||||
private static final class StartupReached extends IllegalStateException { }
|
||||
|
||||
private static void initializePlugin(VotingPluginMain plugin) throws Exception {
|
||||
// Constructor-bypassing Mockito objects need the real SDK lifecycle lock.
|
||||
var lock = com.bencodez.advancedcore.AdvancedCorePlugin.class.getDeclaredField("lifecycleLock");
|
||||
lock.setAccessible(true);
|
||||
lock.set(plugin, new Object());
|
||||
doNothing().when(plugin).registerEvents(any());
|
||||
}
|
||||
|
||||
private static org.mockito.MockedConstruction<AdvancedCoreLifecycle> completedCleanup() {
|
||||
// Only platform cleanup is controlled. Actual plugin admission, timed
|
||||
// publication and generation/writer reservations remain under test.
|
||||
return mockConstruction(AdvancedCoreLifecycle.class, (cleanup, context) -> {
|
||||
var plugin = (VotingPluginMain) context.arguments().get(0);
|
||||
doAnswer(call -> {
|
||||
for (var writer : plugin.getServerDataWriterExecutors())
|
||||
if (writer != null) writer.shutdown();
|
||||
var completed = CompletableFuture.<Void>completedFuture(null);
|
||||
plugin.onShutdownComplete(completed);
|
||||
plugin.releaseServerDataOwnershipAfter(completed);
|
||||
return null;
|
||||
}).when(cleanup).shutdown(anyLong());
|
||||
});
|
||||
}
|
||||
|
||||
private static ScheduledExecutorService terminatedExecutor() {
|
||||
var executor = mock(ScheduledExecutorService.class);
|
||||
when(executor.isTerminated()).thenReturn(true);
|
||||
return executor;
|
||||
}
|
||||
|
||||
@Test void failedShutdownSuffixKeepsItsGenerationUntilExplicitRetryPersistsEveryVote() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
initializePlugin(plugin);
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
Path target = directory.resolve("ServerData.yml");
|
||||
var fence = com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target);
|
||||
var field = VotingPluginMain.class.getDeclaredField("serverDataPublicationFence");
|
||||
field.setAccessible(true); field.set(plugin, fence);
|
||||
var yaml = spy(new YamlConfiguration());
|
||||
var file = mock(com.bencodez.advancedcore.data.ServerData.class);
|
||||
when(file.publicationMonitor()).thenCallRealMethod();
|
||||
when(file.getData()).thenReturn(yaml); when(file.getdFile()).thenReturn(target.toFile());
|
||||
doReturn(file).when(plugin).getServerDataFile();
|
||||
doReturn(new ServerData(plugin)).when(plugin).getServerData();
|
||||
doReturn(new ScheduledExecutorService[0]).when(plugin).getServerDataWriterExecutors();
|
||||
var producer = new ScheduledThreadPoolExecutor(1);
|
||||
producer.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
|
||||
doReturn(producer).when(plugin).getVoteTimer();
|
||||
try {
|
||||
var handler = new TimeQueueHandler(plugin);
|
||||
handler.addVote("First", "site");
|
||||
handler.addVote("Second", "site");
|
||||
handler.addVote("Third", "site");
|
||||
producer.shutdown(); assertTrue(producer.awaitTermination(5, TimeUnit.SECONDS));
|
||||
var writes = new java.util.concurrent.atomic.AtomicInteger();
|
||||
doAnswer(call -> {
|
||||
int attempt = writes.incrementAndGet();
|
||||
if (attempt == 2 || attempt == 3) throw new java.io.IOException("synthetic suffix failure");
|
||||
return call.callRealMethod();
|
||||
}).when(yaml).save(target.toFile());
|
||||
assertThrows(java.io.UncheckedIOException.class, handler::saveForShutdown);
|
||||
fence.retire();
|
||||
assertEquals(2, handler.getTimeChangeQueue().size());
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target));
|
||||
assertThrows(java.io.UncheckedIOException.class, handler::saveForShutdown);
|
||||
assertEquals(2, handler.getTimeChangeQueue().size());
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target));
|
||||
handler.saveForShutdown();
|
||||
assertTrue(handler.getTimeChangeQueue().isEmpty());
|
||||
var recovered = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
assertEquals("First", recovered.getString("VotingPlugin.TimedVoteCache.0.Name"));
|
||||
assertEquals("Second", recovered.getString("VotingPlugin.TimedVoteCache.1.Name"));
|
||||
assertEquals("Third", recovered.getString("VotingPlugin.TimedVoteCache.2.Name"));
|
||||
com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target).retire();
|
||||
} finally {
|
||||
producer.shutdownNow(); assertTrue(producer.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectedShutdownWorkerRetryRetainsTheOriginalProducerAfterUnloadClearsItsField() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
initializePlugin(plugin);
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
Path target = directory.resolve("ServerData.yml");
|
||||
var fence = com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target);
|
||||
var field = VotingPluginMain.class.getDeclaredField("serverDataPublicationFence");
|
||||
field.setAccessible(true); field.set(plugin, fence);
|
||||
var yaml = spy(new YamlConfiguration());
|
||||
var file = mock(com.bencodez.advancedcore.data.ServerData.class);
|
||||
when(file.publicationMonitor()).thenCallRealMethod();
|
||||
when(file.getData()).thenReturn(yaml); when(file.getdFile()).thenReturn(target.toFile());
|
||||
doReturn(file).when(plugin).getServerDataFile();
|
||||
doReturn(new ServerData(plugin)).when(plugin).getServerData();
|
||||
var releaseCore = new CountDownLatch(1);
|
||||
var coreWriter = mock(ScheduledExecutorService.class);
|
||||
when(coreWriter.isTerminated()).thenAnswer(call -> releaseCore.getCount() == 0);
|
||||
when(coreWriter.awaitTermination(anyLong(), any())).thenAnswer(call ->
|
||||
releaseCore.await(call.getArgument(0), call.getArgument(1)));
|
||||
var capturedWriters = new ScheduledExecutorService[] { coreWriter };
|
||||
doReturn(capturedWriters).when(plugin).getServerDataWriterExecutors();
|
||||
var producer = new ScheduledThreadPoolExecutor(1);
|
||||
producer.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
|
||||
doReturn(producer).when(plugin).getVoteTimer();
|
||||
var failWrites = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
doAnswer(call -> {
|
||||
if (failWrites.get()) throw new java.io.IOException("synthetic late suffix failure");
|
||||
return call.callRealMethod();
|
||||
}).when(yaml).save(target.toFile());
|
||||
var handler = new TimeQueueHandler(plugin) {
|
||||
private boolean reject = true;
|
||||
@Override protected void startShutdownWorker(Runnable task) {
|
||||
if (reject) { reject = false; throw new RejectedExecutionException("synthetic admission failure"); }
|
||||
super.startShutdownWorker(task);
|
||||
}
|
||||
};
|
||||
var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
|
||||
var lateFailure = new java.util.concurrent.atomic.AtomicReference<Throwable>();
|
||||
try {
|
||||
producer.submit(() -> {
|
||||
entered.countDown();
|
||||
boolean interrupted = false;
|
||||
while (true) {
|
||||
try { if (release.await(10, TimeUnit.SECONDS)) break; throw new AssertionError("release timeout"); }
|
||||
catch (InterruptedException ignored) { interrupted = true; }
|
||||
}
|
||||
try { handler.addVote("Late", "site"); }
|
||||
catch (Throwable failure) { lateFailure.set(failure); }
|
||||
finally { if (interrupted) Thread.currentThread().interrupt(); }
|
||||
});
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
producer.shutdownNow();
|
||||
assertThrows(RejectedExecutionException.class, handler::saveForShutdown);
|
||||
fence.retire();
|
||||
// The actual unload path clears this accessor even if shutdownNow did not terminate work.
|
||||
doReturn(null).when(plugin).getVoteTimer();
|
||||
capturedWriters[0] = null;
|
||||
handler.saveForShutdown();
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target).retire());
|
||||
release.countDown(); assertTrue(producer.awaitTermination(5, TimeUnit.SECONDS));
|
||||
assertInstanceOf(java.io.UncheckedIOException.class, lateFailure.get());
|
||||
// The original core writer is also retained, independent of the caller's mutable array.
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target).retire());
|
||||
releaseCore.countDown();
|
||||
assertThrows(java.io.UncheckedIOException.class, handler::saveForShutdown);
|
||||
assertEquals(1, handler.getTimeChangeQueue().size());
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target));
|
||||
failWrites.set(false); handler.saveForShutdown();
|
||||
assertTrue(handler.getTimeChangeQueue().isEmpty());
|
||||
assertEquals("Late", YamlConfiguration.loadConfiguration(target.toFile())
|
||||
.getString("VotingPlugin.TimedVoteCache.0.Name"));
|
||||
com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target).retire();
|
||||
} finally {
|
||||
release.countDown(); releaseCore.countDown(); producer.shutdownNow();
|
||||
assertTrue(producer.awaitTermination(5, TimeUnit.SECONDS));
|
||||
failWrites.set(false);
|
||||
// Do not start a new retry from cleanup or hide the failed ownership assertion.
|
||||
fence.retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedStartupWithoutATimedQueueKeepsCoreWritesFencedThroughActualDisable() throws Exception {
|
||||
VotingPluginMain old = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
VotingPluginMain next = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
for (var plugin : java.util.List.of(old, next)) {
|
||||
initializePlugin(plugin);
|
||||
doReturn(directory.toFile()).when(plugin).getDataFolder();
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
doThrow(new StartupReached()).when(plugin).onPreLoad();
|
||||
}
|
||||
ScheduledThreadPoolExecutor writer = new ScheduledThreadPoolExecutor(1);
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
writer, terminatedExecutor(), terminatedExecutor());
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
var controlledCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
var runningReference = new java.util.concurrent.atomic.AtomicReference<Future<?>>();
|
||||
doAnswer(startup -> {
|
||||
runningReference.set(writer.submit(() -> {
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(10, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
}));
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
throw new StartupReached();
|
||||
}).when(old).onPreLoad();
|
||||
assertThrows(StartupReached.class, old::onEnable);
|
||||
assertFalse(assertThrows(IllegalStateException.class, next::onEnable) instanceof StartupReached);
|
||||
Future<?> running = runningReference.get();
|
||||
assertNotNull(running);
|
||||
old.onDisable();
|
||||
assertFalse(assertThrows(IllegalStateException.class, next::onEnable) instanceof StartupReached);
|
||||
var completion = old.getServerDataPublicationFence().retainUntilTermination(old.getServerDataWriterExecutors());
|
||||
assertFalse(completion.toCompletableFuture().isDone());
|
||||
release.countDown(); running.get(5, TimeUnit.SECONDS);
|
||||
assertTrue(writer.awaitTermination(5, TimeUnit.SECONDS));
|
||||
completion.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertThrows(StartupReached.class, next::onEnable);
|
||||
next.getServerDataPublicationFence().retire();
|
||||
} finally { release.countDown(); writer.shutdownNow(); assertTrue(writer.awaitTermination(5, TimeUnit.SECONDS)); }
|
||||
}
|
||||
|
||||
@Test void heartbeatWriterRemainsFencedAfterEmptyTimedSnapshot() throws Exception { checkCoreWriter("heartbeat"); }
|
||||
@Test void loginWriterRemainsFencedAfterEmptyTimedSnapshot() throws Exception { checkCoreWriter("login"); }
|
||||
@Test void inventoryWriterRemainsFencedAfterEmptyTimedSnapshot() throws Exception { checkCoreWriter("inventory"); }
|
||||
@Test void timeCheckerWriterRemainsFencedAfterEmptyTimedSnapshot() throws Exception { checkCoreWriter("timechecker"); }
|
||||
|
||||
private void checkCoreWriter(String writerKind) throws Exception {
|
||||
Path target = directory.resolve("ServerData.yml");
|
||||
VotingPluginMain old = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
VotingPluginMain next = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
for (var plugin : java.util.List.of(old, next)) {
|
||||
initializePlugin(plugin);
|
||||
doReturn(directory.toFile()).when(plugin).getDataFolder();
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
doThrow(new StartupReached()).when(plugin).onPreLoad();
|
||||
}
|
||||
var yaml = spy(new YamlConfiguration());
|
||||
yaml.set("Adjacent.Value", "old");
|
||||
yaml.save(target.toFile());
|
||||
var file = mock(com.bencodez.advancedcore.data.ServerData.class);
|
||||
when(file.publicationMonitor()).thenCallRealMethod();
|
||||
when(file.getData()).thenReturn(yaml);
|
||||
when(file.getdFile()).thenReturn(target.toFile());
|
||||
doReturn(file).when(old).getServerDataFile();
|
||||
doReturn(new ServerData(old)).when(old).getServerData();
|
||||
doReturn(terminatedExecutor()).when(old).getVoteTimer();
|
||||
ScheduledThreadPoolExecutor writer = new ScheduledThreadPoolExecutor(1);
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
ExecutorService caller = Executors.newSingleThreadExecutor();
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
var controlledCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, old::onEnable);
|
||||
old.getServerDataPublicationFence().retainUntilTermination(old.getServerDataWriterExecutors())
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
old.getServerDataPublicationFence().retire();
|
||||
switch (writerKind) {
|
||||
case "heartbeat" -> doReturn(writer).when(old).getTimer();
|
||||
case "login" -> doReturn(writer).when(old).getLoginTimer();
|
||||
case "inventory" -> doReturn(writer).when(old).getInventoryTimer();
|
||||
case "timechecker" -> {
|
||||
var checker = mock(com.bencodez.advancedcore.api.time.TimeChecker.class);
|
||||
doReturn(checker).when(old).getTimeChecker();
|
||||
when(checker.getTimer()).thenReturn(writer);
|
||||
}
|
||||
default -> throw new AssertionError(writerKind);
|
||||
}
|
||||
TimeQueueHandler handler = new TimeQueueHandler(old);
|
||||
doAnswer(call -> {
|
||||
entered.countDown(); assertTrue(release.await(10, TimeUnit.SECONDS));
|
||||
return call.callRealMethod();
|
||||
}).when(yaml).save(target.toFile());
|
||||
Future<?> writing = writer.submit(() -> {
|
||||
try { file.getData().save(file.getdFile()); }
|
||||
catch (java.io.IOException failure) { throw new java.io.UncheckedIOException(failure); }
|
||||
});
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
caller.submit(handler::saveForShutdown).get(2, TimeUnit.SECONDS);
|
||||
IllegalStateException blocked = assertThrows(IllegalStateException.class, next::onEnable);
|
||||
assertFalse(blocked instanceof StartupReached);
|
||||
verify(next, never()).onPreLoad();
|
||||
writer.shutdown(); release.countDown(); writing.get(5, TimeUnit.SECONDS);
|
||||
assertTrue(writer.awaitTermination(5, TimeUnit.SECONDS));
|
||||
handler.saveForShutdown();
|
||||
assertThrows(StartupReached.class, next::onEnable);
|
||||
} finally {
|
||||
release.countDown(); writer.shutdownNow(); caller.shutdownNow();
|
||||
assertTrue(writer.awaitTermination(5, TimeUnit.SECONDS));
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void initialShutdownWriteFailureStillRetainsTheRunningProducerReservation() throws Exception {
|
||||
Path target = directory.resolve("ServerData.yml");
|
||||
VotingPluginMain old = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
VotingPluginMain next = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
for (var plugin : java.util.List.of(old, next)) {
|
||||
initializePlugin(plugin);
|
||||
doReturn(directory.toFile()).when(plugin).getDataFolder();
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
doThrow(new StartupReached()).when(plugin).onPreLoad();
|
||||
}
|
||||
var yaml = spy(new YamlConfiguration());
|
||||
var file = mock(com.bencodez.advancedcore.data.ServerData.class);
|
||||
when(file.publicationMonitor()).thenCallRealMethod();
|
||||
when(file.getData()).thenReturn(yaml); when(file.getdFile()).thenReturn(target.toFile());
|
||||
doReturn(file).when(old).getServerDataFile();
|
||||
doReturn(new ServerData(old)).when(old).getServerData();
|
||||
ScheduledThreadPoolExecutor producers = new ScheduledThreadPoolExecutor(1);
|
||||
producers.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
|
||||
doReturn(producers).when(old).getVoteTimer();
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
ExecutorService caller = Executors.newSingleThreadExecutor();
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
var controlledCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, old::onEnable);
|
||||
old.getServerDataPublicationFence().retainUntilTermination(old.getServerDataWriterExecutors())
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
old.getServerDataPublicationFence().retire();
|
||||
TimeQueueHandler handler = new TimeQueueHandler(old);
|
||||
handler.addVote("First", "site");
|
||||
var late = producers.submit(() -> {
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(10, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
handler.addVote("Late", "site");
|
||||
});
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS)); producers.shutdown();
|
||||
doThrow(new java.io.IOException("synthetic initial write failure")).doCallRealMethod()
|
||||
.when(yaml).save(target.toFile());
|
||||
caller.submit(handler::saveForShutdown).get(2, TimeUnit.SECONDS);
|
||||
IllegalStateException blocked = assertThrows(IllegalStateException.class, next::onEnable);
|
||||
assertFalse(blocked instanceof StartupReached);
|
||||
release.countDown(); late.get(5, TimeUnit.SECONDS);
|
||||
assertTrue(producers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
try { handler.saveForShutdown(); } catch (java.io.UncheckedIOException observedInitialFailure) { }
|
||||
handler.saveForShutdown();
|
||||
assertThrows(StartupReached.class, next::onEnable);
|
||||
assertEquals("First", YamlConfiguration.loadConfiguration(target.toFile()).getString("VotingPlugin.TimedVoteCache.0.Name"));
|
||||
assertEquals("Late", YamlConfiguration.loadConfiguration(target.toFile()).getString("VotingPlugin.TimedVoteCache.1.Name"));
|
||||
} finally {
|
||||
release.countDown(); producers.shutdownNow(); caller.shutdownNow();
|
||||
assertTrue(producers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void emptyShutdownSnapshotStillOwnsLateProducersUntilTheyFinish() throws Exception {
|
||||
Path target = directory.resolve("ServerData.yml");
|
||||
VotingPluginMain old = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
VotingPluginMain next = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
for (var plugin : java.util.List.of(old, next)) {
|
||||
initializePlugin(plugin);
|
||||
doReturn(directory.toFile()).when(plugin).getDataFolder();
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
doThrow(new StartupReached()).when(plugin).onPreLoad();
|
||||
}
|
||||
var yaml = new YamlConfiguration();
|
||||
var file = mock(com.bencodez.advancedcore.data.ServerData.class);
|
||||
when(file.publicationMonitor()).thenCallRealMethod();
|
||||
when(file.getData()).thenReturn(yaml);
|
||||
when(file.getdFile()).thenReturn(target.toFile());
|
||||
doReturn(file).when(old).getServerDataFile();
|
||||
var data = new ServerData(old);
|
||||
doReturn(data).when(old).getServerData();
|
||||
ScheduledThreadPoolExecutor producers = new ScheduledThreadPoolExecutor(1);
|
||||
producers.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
|
||||
doReturn(producers).when(old).getVoteTimer();
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
ExecutorService caller = Executors.newSingleThreadExecutor();
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
var controlledCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, old::onEnable);
|
||||
old.getServerDataPublicationFence().retainUntilTermination(old.getServerDataWriterExecutors())
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
old.getServerDataPublicationFence().retire();
|
||||
TimeQueueHandler handler = new TimeQueueHandler(old);
|
||||
Future<?> late = producers.submit(() -> {
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(10, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
handler.addVote("Late", "site");
|
||||
});
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
producers.shutdown();
|
||||
caller.submit(handler::saveForShutdown).get(2, TimeUnit.SECONDS);
|
||||
IllegalStateException blocked = assertThrows(IllegalStateException.class, next::onEnable);
|
||||
assertFalse(blocked instanceof StartupReached);
|
||||
verify(next, never()).onPreLoad();
|
||||
release.countDown();
|
||||
late.get(5, TimeUnit.SECONDS);
|
||||
assertTrue(producers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
handler.saveForShutdown();
|
||||
assertThrows(StartupReached.class, next::onEnable);
|
||||
assertEquals("Late", YamlConfiguration.loadConfiguration(target.toFile())
|
||||
.getString("VotingPlugin.TimedVoteCache.0.Name"));
|
||||
} finally {
|
||||
release.countDown(); producers.shutdownNow(); caller.shutdownNow();
|
||||
assertTrue(producers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void replacementCannotReadOrWriteServerDataWhilePreviousShutdownPublicationIsBlocked() throws Exception {
|
||||
Path target = directory.resolve("ServerData.yml");
|
||||
VotingPluginMain old = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
VotingPluginMain next = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
for (VotingPluginMain plugin : java.util.List.of(old, next)) {
|
||||
initializePlugin(plugin);
|
||||
doReturn(directory.toFile()).when(plugin).getDataFolder();
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
}
|
||||
YamlConfiguration yaml = spy(new YamlConfiguration());
|
||||
yaml.set("Adjacent.Value", "old");
|
||||
yaml.save(target.toFile());
|
||||
var file = mock(com.bencodez.advancedcore.data.ServerData.class);
|
||||
when(file.publicationMonitor()).thenCallRealMethod();
|
||||
when(file.getData()).thenReturn(yaml);
|
||||
when(file.getdFile()).thenReturn(target.toFile());
|
||||
doReturn(file).when(old).getServerDataFile();
|
||||
ServerData data = new ServerData(old);
|
||||
doReturn(data).when(old).getServerData();
|
||||
doReturn(terminatedExecutor()).when(old).getVoteTimer();
|
||||
doThrow(new StartupReached()).when(old).onPreLoad();
|
||||
doAnswer(call -> {
|
||||
YamlConfiguration replacement = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
replacement.set("Adjacent.Value", "replacement");
|
||||
replacement.save(target.toFile());
|
||||
throw new StartupReached();
|
||||
}).when(next).onPreLoad();
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(),
|
||||
terminatedExecutor());
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
ExecutorService caller = Executors.newSingleThreadExecutor();
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
var controlledCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, old::onEnable);
|
||||
old.getServerDataPublicationFence().retainUntilTermination(old.getServerDataWriterExecutors())
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
old.getServerDataPublicationFence().retire();
|
||||
TimeQueueHandler handler = new TimeQueueHandler(old);
|
||||
handler.addVote("First", "site");
|
||||
doAnswer(call -> {
|
||||
entered.countDown();
|
||||
assertTrue(release.await(10, TimeUnit.SECONDS));
|
||||
return call.callRealMethod();
|
||||
}).when(yaml).save(target.toFile());
|
||||
Future<?> shutdown = caller.submit(handler::saveForShutdown);
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
shutdown.get(2, TimeUnit.SECONDS);
|
||||
IllegalStateException blocked = assertThrows(IllegalStateException.class, next::onEnable);
|
||||
assertFalse(blocked instanceof StartupReached, "replacement must be fenced before any startup read/write");
|
||||
verify(next, never()).onPreLoad();
|
||||
release.countDown();
|
||||
handler.saveForShutdown();
|
||||
assertThrows(StartupReached.class, next::onEnable);
|
||||
assertThrows(IllegalStateException.class, () -> data.addTimeVoted(1,
|
||||
new com.bencodez.votingplugin.timequeue.VoteTimeQueue("Stale", "site", 123L)));
|
||||
assertEquals("replacement", YamlConfiguration.loadConfiguration(target.toFile()).getString("Adjacent.Value"));
|
||||
} finally {
|
||||
release.countDown();
|
||||
caller.shutdownNow();
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.data.GenerationFencedServerDataFile;
|
||||
import com.bencodez.votingplugin.data.ServerData;
|
||||
import com.bencodez.votingplugin.signs.SignHandler;
|
||||
import com.bencodez.votingplugin.signs.Signs;
|
||||
import com.bencodez.votingplugin.util.ServerDataPublicationFence;
|
||||
|
||||
class ServerDataProcessExitDrainTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
/** Observe entry to the real bounded wait without replacing its completion semantics. */
|
||||
private static final class ObservedCompletion extends CompletableFuture<Void> {
|
||||
final CountDownLatch entered = new CountDownLatch(1);
|
||||
volatile long nanos;
|
||||
@Override public Void get(long timeout, TimeUnit unit)
|
||||
throws InterruptedException, ExecutionException, TimeoutException {
|
||||
nanos = unit.toNanos(timeout);
|
||||
entered.countDown();
|
||||
return super.get(timeout, unit);
|
||||
}
|
||||
}
|
||||
|
||||
private static Object field(Object owner, String name) throws Exception {
|
||||
var f = owner.getClass().getDeclaredField(name);
|
||||
f.setAccessible(true);
|
||||
return f.get(owner);
|
||||
}
|
||||
|
||||
private static ObservedCompletion observe(ServerDataPublicationFence fence) throws Exception {
|
||||
var completion = new ObservedCompletion();
|
||||
var f = ServerDataPublicationFence.class.getDeclaredField("processExitCompletion");
|
||||
f.setAccessible(true);
|
||||
f.set(fence, completion);
|
||||
return completion;
|
||||
}
|
||||
|
||||
@Test void outstandingLeaseKeepsTheActualProcessHookWaitingUntilRetirementDrains() throws Exception {
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var completion = observe(fence);
|
||||
var lease = fence.acquire();
|
||||
fence.retainForProcessExit();
|
||||
var hook = (Thread) field(fence, "processHook");
|
||||
fence.retainForProcessExit();
|
||||
assertSame(hook, field(fence, "processHook"), "Repeated enrollment must not register another hook");
|
||||
fence.beginShutdown(System.nanoTime());
|
||||
fence.retire();
|
||||
var caller = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
var exit = caller.submit(hook::run);
|
||||
assertTrue(completion.entered.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(completion.isDone());
|
||||
assertFalse(exit.isDone(), "A daemon reservation must still be represented at process exit");
|
||||
lease.close();
|
||||
exit.get(5, TimeUnit.SECONDS);
|
||||
assertTrue(completion.isDone());
|
||||
ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml")).retire();
|
||||
} finally {
|
||||
lease.close();
|
||||
caller.shutdownNow();
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void repeatedDisableCannotRenewTheOriginalSharedDeadline() throws Exception {
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var completion = observe(fence);
|
||||
var lease = fence.acquire();
|
||||
fence.retainForProcessExit();
|
||||
long original = System.nanoTime() - TimeUnit.SECONDS.toNanos(1);
|
||||
fence.beginShutdown(original);
|
||||
fence.beginShutdown(System.nanoTime());
|
||||
var started = (CompletableFuture<?>) field(fence, "processShutdownStarted");
|
||||
assertEquals(original, started.get());
|
||||
fence.retire();
|
||||
var caller = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
var hook = (Thread) field(fence, "processHook");
|
||||
var exit = caller.submit(hook::run);
|
||||
assertTrue(completion.entered.await(5, TimeUnit.SECONDS));
|
||||
assertTrue(completion.nanos > 0 && completion.nanos <= TimeUnit.SECONDS.toNanos(14),
|
||||
"Process exit must use the remaining disable budget, not another fifteen seconds");
|
||||
lease.close();
|
||||
exit.get(5, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
lease.close();
|
||||
caller.shutdownNow();
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void expiredProcessBudgetDoesNotCertifyFailedPublicationOrLiftGenerationAdmission() throws Exception {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var completion = observe(fence);
|
||||
fence.retainForProcessExit();
|
||||
fence.retainUntilSuccessfulPublication(CompletableFuture.failedFuture(new IllegalStateException("Synthetic I/O")));
|
||||
var hook = (Thread) field(fence, "processHook");
|
||||
fence.beginShutdown(System.nanoTime() - TimeUnit.SECONDS.toNanos(16));
|
||||
fence.retire();
|
||||
var caller = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
caller.submit(hook::run).get(5, TimeUnit.SECONDS);
|
||||
assertFalse(completion.isDone(), "Deadline expiry cannot report persistence success");
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
} finally {
|
||||
caller.shutdownNow();
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void interruptedProcessWaitPreservesInterruptionWithoutAcknowledgingTheWriter() throws Exception {
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var completion = observe(fence);
|
||||
var lease = fence.acquire();
|
||||
fence.retainForProcessExit();
|
||||
fence.beginShutdown(System.nanoTime());
|
||||
fence.retire();
|
||||
var interrupted = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
var hook = (Thread) field(fence, "processHook");
|
||||
var observer = new Thread(() -> {
|
||||
hook.run();
|
||||
interrupted.set(Thread.currentThread().isInterrupted());
|
||||
});
|
||||
try {
|
||||
observer.start();
|
||||
assertTrue(completion.entered.await(5, TimeUnit.SECONDS));
|
||||
observer.interrupt();
|
||||
observer.join(5000);
|
||||
assertFalse(observer.isAlive());
|
||||
assertTrue(interrupted.get());
|
||||
assertFalse(completion.isDone());
|
||||
} finally {
|
||||
lease.close();
|
||||
observer.interrupt();
|
||||
observer.join(5000);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void retainedExecutorTerminationIsIncludedWithoutAPreStorageDependencyCycle() throws Exception {
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var completion = observe(fence);
|
||||
fence.retainForProcessExit();
|
||||
var writer = java.util.concurrent.Executors.newSingleThreadScheduledExecutor();
|
||||
var entered = new CountDownLatch(1);
|
||||
var release = new CountDownLatch(1);
|
||||
writer.execute(() -> {
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(5, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
});
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
writer.shutdown();
|
||||
var retirement = fence.retainUntilTermination(new java.util.concurrent.ScheduledExecutorService[] { writer });
|
||||
fence.beginShutdown(System.nanoTime());
|
||||
fence.retire();
|
||||
var caller = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
var exit = caller.submit(((Thread) field(fence, "processHook"))::run);
|
||||
assertTrue(completion.entered.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(completion.isDone());
|
||||
release.countDown();
|
||||
retirement.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
exit.get(5, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
release.countDown();
|
||||
writer.shutdownNow();
|
||||
caller.shutdownNow();
|
||||
assertTrue(writer.awaitTermination(5, TimeUnit.SECONDS));
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void queuedSignPublicationRemainsInTheProcessDrainAfterTheNativeSdkOwnerReleases() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var nativeOwner = com.bencodez.advancedcore.lifecycle.ServerDataOwnership.acquire(target);
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var completion = observe(fence);
|
||||
fence.retainForProcessExit();
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class));
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
backing.setData(new YamlConfiguration());
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
var data = new ServerData(plugin);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
var workers = new ArrayList<Runnable>();
|
||||
var signs = new Signs(plugin) {
|
||||
@Override protected void startShutdownWorker(Runnable worker) { workers.add(worker); }
|
||||
};
|
||||
var world = mock(World.class);
|
||||
when(world.getName()).thenReturn("SyntheticWorld");
|
||||
var sign = mock(SignHandler.class, CALLS_REAL_METHODS);
|
||||
for (var entry : java.util.Map.<String, Object>of("sign", "0", "position", 1,
|
||||
"data", "SyntheticSign").entrySet()) {
|
||||
var f = SignHandler.class.getDeclaredField(entry.getKey());
|
||||
f.setAccessible(true);
|
||||
f.set(sign, entry.getValue());
|
||||
}
|
||||
sign.setLocation(new Location(world, 1, 2, 3));
|
||||
signs.getSigns().add(sign);
|
||||
var published = signs.storeSignsForShutdown();
|
||||
long started = System.nanoTime();
|
||||
nativeOwner.beginShutdown(started);
|
||||
// Exercise the actual SDK ownership release once its own aggregate has settled.
|
||||
// This fixture does not simulate unrelated native cleanup actions.
|
||||
nativeOwner.releaseAfter(CompletableFuture.completedFuture(null));
|
||||
assertTrue(nativeOwner.isReleased());
|
||||
fence.beginShutdown(started);
|
||||
fence.retire();
|
||||
var caller = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
var exit = caller.submit(((Thread) field(fence, "processHook"))::run);
|
||||
assertTrue(completion.entered.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(completion.isDone(), "The native release cannot discard the consumer reservation");
|
||||
assertFalse(published.toCompletableFuture().isDone());
|
||||
workers.getFirst().run();
|
||||
published.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
exit.get(5, TimeUnit.SECONDS);
|
||||
assertEquals("SyntheticSign", YamlConfiguration.loadConfiguration(target.toFile())
|
||||
.getString("VotingPlugin.Signs.0.Data"));
|
||||
} finally {
|
||||
if (!published.toCompletableFuture().isDone()) workers.getFirst().run();
|
||||
caller.shutdownNow();
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.data.GenerationFencedServerDataFile;
|
||||
import com.bencodez.votingplugin.data.ServerData;
|
||||
import com.bencodez.votingplugin.signs.SignHandler;
|
||||
import com.bencodez.votingplugin.signs.Signs;
|
||||
import com.bencodez.votingplugin.util.ServerDataPublicationFence;
|
||||
|
||||
class SignShutdownPublicationTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
private static final class Admission extends Signs {
|
||||
final List<Runnable> workers = new ArrayList<>();
|
||||
boolean reject;
|
||||
Admission(VotingPluginMain plugin) { super(plugin); }
|
||||
@Override protected void startShutdownWorker(Runnable work) {
|
||||
if (reject) throw new RejectedExecutionException("Synthetic rejection");
|
||||
workers.add(work);
|
||||
}
|
||||
}
|
||||
|
||||
private VotingPluginMain plugin(ServerDataPublicationFence fence) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class));
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
private SignHandler sign(VotingPluginMain plugin, String id, Location location, Location skull) throws Exception {
|
||||
var sign = mock(SignHandler.class, CALLS_REAL_METHODS);
|
||||
for (var entry : java.util.Map.<String, Object>of("plugin", plugin, "sign", id,
|
||||
"data", "SyntheticSign" + id, "position", 2).entrySet()) {
|
||||
var field = SignHandler.class.getDeclaredField(entry.getKey());
|
||||
field.setAccessible(true);
|
||||
field.set(sign, entry.getValue());
|
||||
}
|
||||
sign.setLocation(location);
|
||||
sign.setSkullLocation(skull);
|
||||
return sign;
|
||||
}
|
||||
|
||||
@Test void rejectedAdmissionRetainsTheOriginalBatchAndPublishesItOnceOnRetry() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var plugin = plugin(fence);
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
backing.setData(new YamlConfiguration());
|
||||
backing.getData().set("VotingPlugin.Unrelated", "preserved");
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
var data = new ServerData(plugin);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
var signs = new Admission(plugin);
|
||||
var owner = Thread.currentThread();
|
||||
var world = mock(World.class);
|
||||
when(world.getName()).thenAnswer(call -> {
|
||||
assertSame(owner, Thread.currentThread(), "Platform state must be captured before handoff");
|
||||
return "SyntheticWorld";
|
||||
});
|
||||
var location = new Location(world, -1.5, 64, 2.5);
|
||||
var first = sign(plugin, "0", location, new Location(world, 3, 65, 4));
|
||||
signs.getSigns().add(first);
|
||||
signs.getSigns().add(sign(plugin, "1", new Location(world, 5, 66, 6), null));
|
||||
signs.reject = true;
|
||||
assertThrows(RejectedExecutionException.class, signs::storeSignsForShutdown);
|
||||
fence.retire();
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
// Retry must use the accepted detached snapshot, not newer platform state.
|
||||
location.setX(90);
|
||||
first.setData("LaterUnacceptedState");
|
||||
signs.getSigns().clear();
|
||||
signs.reject = false;
|
||||
var completion = signs.storeSignsForShutdown();
|
||||
signs.storeSignsForShutdown();
|
||||
assertEquals(1, signs.workers.size());
|
||||
var worker = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
worker.submit(signs.workers.getFirst()).get(5, TimeUnit.SECONDS);
|
||||
completion.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
worker.shutdownNow();
|
||||
assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
var disk = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
assertEquals(-1, disk.getInt("VotingPlugin.Signs.0.X"), "Preserve legacy coordinate truncation");
|
||||
assertEquals(3, disk.getInt("VotingPlugin.Signs.0.Skull.X"));
|
||||
assertEquals("SyntheticSign0", disk.getString("VotingPlugin.Signs.0.Data"));
|
||||
assertEquals(2, disk.getInt("VotingPlugin.Signs.1.Position"));
|
||||
assertEquals("preserved", disk.getString("VotingPlugin.Unrelated"));
|
||||
signs.storeSignsForShutdown();
|
||||
assertEquals(1, signs.workers.size(), "Successful publication is idempotent");
|
||||
ServerDataPublicationFence.beginGeneration(target).retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cancellationAndForgedObservationCannotReleaseTheRealPublicationOwner() throws Exception {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var plugin = plugin(fence);
|
||||
var data = mock(ServerData.class);
|
||||
var durable = new CompletableFuture<Void>();
|
||||
when(data.publishShutdownSigns(anyList())).thenReturn(durable);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
var signs = new Admission(plugin);
|
||||
var world = mock(World.class);
|
||||
when(world.getName()).thenReturn("SyntheticWorld");
|
||||
signs.getSigns().add(sign(plugin, "0", new Location(world, 1, 2, 3), null));
|
||||
var pending = signs.storeSignsForShutdown();
|
||||
pending.toCompletableFuture().cancel(true);
|
||||
pending.toCompletableFuture().complete(null);
|
||||
signs.storeSignsForShutdown();
|
||||
assertEquals(1, signs.workers.size(), "Accepted but unexecuted work retains one owner");
|
||||
fence.retire();
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
signs.workers.getFirst().run();
|
||||
assertFalse(pending.toCompletableFuture().isDone(), "Submission is not durability");
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
durable.complete(null);
|
||||
pending.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
ServerDataPublicationFence.beginGeneration(target).retire();
|
||||
}
|
||||
|
||||
@Test void failedPublicationRetainsPayloadAndRequiresAnExplicitSuccessfulRetry() throws Exception {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var plugin = plugin(fence);
|
||||
var data = mock(ServerData.class);
|
||||
var attempts = new ArrayList<List<ServerData.SignSnapshot>>();
|
||||
when(data.publishShutdownSigns(anyList())).thenAnswer(call -> {
|
||||
attempts.add(call.getArgument(0));
|
||||
return attempts.size() == 1 ? CompletableFuture.failedFuture(new IllegalStateException("Synthetic I/O"))
|
||||
: CompletableFuture.completedFuture(null);
|
||||
});
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
var signs = new Admission(plugin);
|
||||
var world = mock(World.class);
|
||||
when(world.getName()).thenReturn("SyntheticWorld");
|
||||
signs.getSigns().add(sign(plugin, "0", new Location(world, 1, 2, 3), null));
|
||||
var first = signs.storeSignsForShutdown();
|
||||
signs.workers.getFirst().run();
|
||||
assertThrows(java.util.concurrent.CompletionException.class, () -> first.toCompletableFuture().join());
|
||||
fence.retire();
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
signs.getSigns().clear();
|
||||
var retry = signs.storeSignsForShutdown();
|
||||
signs.workers.get(1).run();
|
||||
retry.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertSame(attempts.get(0), attempts.get(1), "Failure must not discard or recapture the accepted batch");
|
||||
ServerDataPublicationFence.beginGeneration(target).retire();
|
||||
}
|
||||
|
||||
@Test void emptyShutdownDoesNotAdmitAWorkerOrTouchServerData() {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
var signs = new Admission(plugin);
|
||||
assertDoesNotThrow(() -> signs.storeSignsForShutdown().toCompletableFuture().join());
|
||||
assertEquals(0, signs.workers.size());
|
||||
verifyNoInteractions(plugin);
|
||||
}
|
||||
|
||||
@Test void actualSdkFailedThenSuccessfulRetryReleasesTheOriginalPublicationOwner() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var plugin = plugin(fence);
|
||||
var attempts = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override protected void moveAtomically(Path source, Path destination) throws java.io.IOException {
|
||||
if (attempts.incrementAndGet() == 1)
|
||||
throw new java.io.IOException("Synthetic first publication failure");
|
||||
super.moveAtomically(source, destination);
|
||||
}
|
||||
};
|
||||
backing.setData(new YamlConfiguration());
|
||||
backing.getData().set("VotingPlugin.Unrelated", "preserved");
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
var data = new ServerData(plugin);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
var signs = new Admission(plugin);
|
||||
var world = mock(World.class);
|
||||
when(world.getName()).thenReturn("SyntheticWorld");
|
||||
signs.getSigns().add(sign(plugin, "0", new Location(world, 1, 2, 3), null));
|
||||
var failed = signs.storeSignsForShutdown();
|
||||
signs.workers.getFirst().run();
|
||||
assertThrows(java.util.concurrent.CompletionException.class, () -> failed.toCompletableFuture().join());
|
||||
fence.retire();
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
facade.mutateFinalData(candidate -> candidate.set("Independent.Value", "confirmed"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target),
|
||||
"A different successful operation cannot acknowledge the failed sign batch");
|
||||
signs.getSigns().clear();
|
||||
var retry = signs.storeSignsForShutdown();
|
||||
signs.workers.get(1).run();
|
||||
retry.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var disk = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
assertEquals("SyntheticSign0", disk.getString("VotingPlugin.Signs.0.Data"));
|
||||
assertEquals("preserved", disk.getString("VotingPlugin.Unrelated"));
|
||||
assertEquals("confirmed", disk.getString("Independent.Value"));
|
||||
assertEquals(3, attempts.get());
|
||||
signs.storeSignsForShutdown();
|
||||
assertEquals(2, signs.workers.size(), "Successful retry is not republished");
|
||||
assertDoesNotThrow(() -> ServerDataPublicationFence.beginGeneration(target).retire(),
|
||||
"Confirmed retry must release both facade and sign-batch ownership");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void actualPendingRetryCoalescesWithoutReplacingItsPayloadOrTrustingObservationCancellation() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var plugin = plugin(fence);
|
||||
var attempts = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var retryEntered = new java.util.concurrent.CountDownLatch(1);
|
||||
var releaseRetry = new java.util.concurrent.CountDownLatch(1);
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override protected void moveAtomically(Path source, Path destination) throws java.io.IOException {
|
||||
if (attempts.incrementAndGet() == 1)
|
||||
throw new java.io.IOException("Synthetic first publication failure");
|
||||
retryEntered.countDown();
|
||||
try {
|
||||
if (!releaseRetry.await(5, TimeUnit.SECONDS))
|
||||
throw new java.io.IOException("Synthetic retry barrier expired");
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new java.io.IOException(interrupted);
|
||||
}
|
||||
super.moveAtomically(source, destination);
|
||||
}
|
||||
};
|
||||
backing.setData(new YamlConfiguration());
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
var data = new ServerData(plugin);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
var world = mock(World.class);
|
||||
when(world.getName()).thenReturn("SyntheticWorld");
|
||||
var snapshot = List.of(ServerData.captureSignSnapshot("0", new Location(world, 1, 2, 3),
|
||||
null, "OriginalAcceptedSign", 2));
|
||||
var different = List.of(ServerData.captureSignSnapshot("0", new Location(world, 9, 8, 7),
|
||||
null, "DifferentUnacceptedSign", 4));
|
||||
assertThrows(java.util.concurrent.CompletionException.class,
|
||||
() -> data.publishShutdownSigns(snapshot).toCompletableFuture().join());
|
||||
fence.retire();
|
||||
var workers = java.util.concurrent.Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var retry = workers.submit(() -> data.publishShutdownSigns(snapshot));
|
||||
assertTrue(retryEntered.await(5, TimeUnit.SECONDS));
|
||||
var coalesced = workers.submit(() -> data.publishShutdownSigns(snapshot)).get(2, TimeUnit.SECONDS);
|
||||
coalesced.toCompletableFuture().cancel(true);
|
||||
coalesced.toCompletableFuture().complete(null);
|
||||
assertFalse(coalesced.toCompletableFuture().isDone(), "The real publication still owns its lease");
|
||||
var rejected = workers.submit(() -> data.publishShutdownSigns(different)).get(2, TimeUnit.SECONDS);
|
||||
assertThrows(java.util.concurrent.CompletionException.class, () -> rejected.toCompletableFuture().join());
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
releaseRetry.countDown();
|
||||
retry.get(5, TimeUnit.SECONDS).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
coalesced.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
data.publishShutdownSigns(snapshot).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertEquals(2, attempts.get(), "One failed attempt and one shared durable retry");
|
||||
var disk = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
assertEquals("OriginalAcceptedSign", disk.getString("VotingPlugin.Signs.0.Data"));
|
||||
assertEquals(1, disk.getInt("VotingPlugin.Signs.0.X"));
|
||||
ServerDataPublicationFence.beginGeneration(target).retire();
|
||||
} finally {
|
||||
releaseRetry.countDown();
|
||||
workers.shutdownNow();
|
||||
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void confirmedPublicationRetriesOnlyFailedDisplayObservationWithoutRepeatingTheWrite() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var plugin = plugin(fence);
|
||||
var writes = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override protected void moveAtomically(Path source, Path destination) throws java.io.IOException {
|
||||
writes.incrementAndGet();
|
||||
super.moveAtomically(source, destination);
|
||||
}
|
||||
};
|
||||
backing.setData(new YamlConfiguration());
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
var data = spy(new ServerData(plugin));
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
doThrow(new IllegalStateException("Synthetic observation failure")).doCallRealMethod()
|
||||
.when(data).refreshVotePartyDisplay();
|
||||
var world = mock(World.class);
|
||||
when(world.getName()).thenReturn("SyntheticWorld");
|
||||
var snapshot = List.of(ServerData.captureSignSnapshot("0", new Location(world, 1, 2, 3),
|
||||
null, "OriginalAcceptedSign", 2));
|
||||
assertThrows(java.util.concurrent.CompletionException.class,
|
||||
() -> data.publishShutdownSigns(snapshot).toCompletableFuture().join());
|
||||
assertEquals("OriginalAcceptedSign", YamlConfiguration.loadConfiguration(target.toFile())
|
||||
.getString("VotingPlugin.Signs.0.Data"), "The write committed before observation failed");
|
||||
fence.retire();
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
data.publishShutdownSigns(snapshot).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertEquals(1, writes.get(), "Retry must not replay an already confirmed mutation");
|
||||
ServerDataPublicationFence.beginGeneration(target).retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void actualSdkIoFailureKeepsTheGenerationBlocked() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var plugin = plugin(fence);
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override protected void moveAtomically(Path source, Path destination) throws java.io.IOException {
|
||||
throw new java.io.IOException("Synthetic failed atomic publication");
|
||||
}
|
||||
};
|
||||
backing.setData(new YamlConfiguration());
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
var data = new ServerData(plugin);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
var signs = new Admission(plugin);
|
||||
var world = mock(World.class);
|
||||
when(world.getName()).thenReturn("SyntheticWorld");
|
||||
signs.getSigns().add(sign(plugin, "0", new Location(world, 1, 2, 3), null));
|
||||
var result = signs.storeSignsForShutdown();
|
||||
signs.workers.getFirst().run();
|
||||
assertThrows(java.util.concurrent.CompletionException.class, () -> result.toCompletableFuture().join());
|
||||
assertFalse(java.nio.file.Files.exists(target));
|
||||
fence.retire();
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target),
|
||||
"Neither the SDK guard nor sign handoff may call failed I/O durable");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void runtimeSignSavingRemainsSynchronous() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
var data = mock(ServerData.class);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
var signs = new Admission(plugin);
|
||||
var location = new Location(mock(World.class), 1, 2, 3);
|
||||
signs.getSigns().add(sign(plugin, "0", location, null));
|
||||
var caller = Thread.currentThread();
|
||||
doAnswer(call -> { assertSame(caller, Thread.currentThread()); return null; })
|
||||
.when(data).setSign("0", location, null, "SyntheticSign0", 2);
|
||||
signs.storeSigns();
|
||||
verify(data).setSign("0", location, null, "SyntheticSign0", 2);
|
||||
assertEquals(0, signs.workers.size());
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@ public class VotePartyTest {
|
||||
public void addTotal_IncrementsTotalVotes() {
|
||||
when(plugin.getServerData().getData().getInt("VoteParty.Total")).thenReturn(5);
|
||||
voteParty.addTotal(user);
|
||||
verify(plugin.getServerData().getData()).set("VoteParty.Total", 6);
|
||||
verify(plugin.getServerData()).setInt("VoteParty.Total", 6);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -114,6 +114,8 @@ public class VotePartyTest {
|
||||
|
||||
@Test
|
||||
public void commandVoteParty_VotePartyEnabled_SendsMessage() {
|
||||
when(plugin.getServerData().getVotePartyDisplay()).thenReturn(
|
||||
new com.bencodez.votingplugin.data.ServerData.VotePartyDisplay(0, 0));
|
||||
CommandSender sender = Mockito.mock(CommandSender.class);
|
||||
when(plugin.getSpecialRewardsConfig().isVotePartyEnabled()).thenReturn(true);
|
||||
when(plugin.getConfigFile().getFormatCommandsVoteParty()).thenReturn(new ArrayList<>());
|
||||
|
||||
+4
-1
@@ -1,4 +1,7 @@
|
||||
package com.bencodez.votingplugin;
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.VotingPluginHooks;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
|
||||
import com.bencodez.votingplugin.*;
|
||||
import com.bencodez.votingplugin.VotingPluginMain.*;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
import com.bencodez.votingplugin.backendproxy.BackendProxyHandler;
|
||||
|
||||
/** Typed test-only access to package-private VotingPluginMain seams. */
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
public final class VotingPluginMainAccess {
|
||||
private VotingPluginMainAccess() { }
|
||||
public static void closePublishedPreviousBackendProxyHandler(VotingPluginMain target, BackendProxyHandler previous, String phase) { ReflectiveAccess.call("com.bencodez.votingplugin.VotingPluginMain", target, "closePublishedPreviousBackendProxyHandler", new String[]{"BackendProxyHandler", "String"}, new Object[]{previous, phase}); }
|
||||
public static void publishBackendProxyHandler(VotingPluginMain target, BackendProxyHandler previous, BackendProxyHandler replacement) { ReflectiveAccess.call("com.bencodez.votingplugin.VotingPluginMain", target, "publishBackendProxyHandler", new String[]{"BackendProxyHandler", "BackendProxyHandler"}, new Object[]{previous, replacement}); }
|
||||
public static UUID placeholderStorageUuid(Player player, boolean onlineMode) { return (UUID) ReflectiveAccess.call("com.bencodez.votingplugin.VotingPluginMain", null, "placeholderStorageUuid", new String[]{"Player", "boolean"}, new Object[]{player, onlineMode}); }
|
||||
public static void reloadBackendProxyRuntime(VotingPluginMain target, boolean updateActiveRuntime, boolean userStorage) { ReflectiveAccess.call("com.bencodez.votingplugin.VotingPluginMain", target, "reloadBackendProxyRuntime", new String[]{"boolean", "boolean"}, new Object[]{updateActiveRuntime, userStorage}); }
|
||||
}
|
||||
+16
-12
@@ -1,4 +1,8 @@
|
||||
package com.bencodez.votingplugin;
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
|
||||
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
@@ -44,7 +48,7 @@ class VotingPluginMainBackendProxyPublicationTest {
|
||||
setField(plugin, "bungeeSettings", settings);
|
||||
when(settings.isUseBungeecoord()).thenReturn(true);
|
||||
|
||||
plugin.reloadBackendProxyRuntime(true, false);
|
||||
VotingPluginMainAccess.reloadBackendProxyRuntime(plugin, true, false);
|
||||
|
||||
verify(active).reloadSharedTransportSecurity();
|
||||
verify(active).reloadPresenceReporting();
|
||||
@@ -63,7 +67,7 @@ class VotingPluginMainBackendProxyPublicationTest {
|
||||
|
||||
// Full-editor Control preparation reloads settings before this replacement
|
||||
// can be validated or published. It must not reconfigure the live handler.
|
||||
plugin.reloadBackendProxyRuntime(false, false);
|
||||
VotingPluginMainAccess.reloadBackendProxyRuntime(plugin, false, false);
|
||||
VotingPluginMain.BackendProxyRestart restart = restart(previous, replacement);
|
||||
assertThrows(IllegalStateException.class, () -> plugin.completeBackendProxyHandlerRestart(restart));
|
||||
|
||||
@@ -79,7 +83,7 @@ class VotingPluginMainBackendProxyPublicationTest {
|
||||
setField(plugin, "bungeeSettings", settings);
|
||||
when(settings.isUseBungeecoord()).thenReturn(true);
|
||||
|
||||
plugin.reloadBackendProxyRuntime(false, false);
|
||||
VotingPluginMainAccess.reloadBackendProxyRuntime(plugin, false, false);
|
||||
|
||||
assertNull(plugin.getBackendProxyHandler());
|
||||
}
|
||||
@@ -96,7 +100,7 @@ class VotingPluginMainBackendProxyPublicationTest {
|
||||
Method load = VotingPluginMain.class.getDeclaredMethod("loadBungeeHandler");
|
||||
load.setAccessible(true);
|
||||
|
||||
assertDoesNotThrow(() -> load.invoke(plugin));
|
||||
assertDoesNotThrow(() -> ReflectiveAccess.invoke(load, plugin, new Object[]{}));
|
||||
assertNull(plugin.getBackendProxyHandler());
|
||||
}
|
||||
|
||||
@@ -157,7 +161,7 @@ class VotingPluginMainBackendProxyPublicationTest {
|
||||
return null;
|
||||
}).when(replacement).activatePresenceReporting();
|
||||
|
||||
plugin.publishBackendProxyHandler(null, replacement);
|
||||
VotingPluginMainAccess.publishBackendProxyHandler(plugin, null, replacement);
|
||||
|
||||
assertSame(replacement, plugin.getBackendProxyHandler());
|
||||
}
|
||||
@@ -171,7 +175,7 @@ class VotingPluginMainBackendProxyPublicationTest {
|
||||
.when(replacement).activatePresenceReporting();
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class,
|
||||
() -> plugin.publishBackendProxyHandler(previous, replacement));
|
||||
() -> VotingPluginMainAccess.publishBackendProxyHandler(plugin, previous, replacement));
|
||||
|
||||
assertSame(previous, plugin.getBackendProxyHandler());
|
||||
verify(replacement).abortStagedInboundTo(previous);
|
||||
@@ -268,7 +272,7 @@ class VotingPluginMainBackendProxyPublicationTest {
|
||||
org.mockito.ArgumentMatchers.anyLong());
|
||||
verify(previous, never()).close();
|
||||
|
||||
plugin.closePublishedPreviousBackendProxyHandler(previous, "after publication");
|
||||
VotingPluginMainAccess.closePublishedPreviousBackendProxyHandler(plugin, previous, "after publication");
|
||||
assertTrue(closed.await(1, TimeUnit.SECONDS));
|
||||
assertTrue(closeThread.get().startsWith("VotingPlugin-Retired-Backend"));
|
||||
}
|
||||
@@ -332,7 +336,7 @@ class VotingPluginMainBackendProxyPublicationTest {
|
||||
|
||||
try {
|
||||
org.junit.jupiter.api.Assertions.assertTimeoutPreemptively(java.time.Duration.ofMillis(250),
|
||||
() -> plugin.closePublishedPreviousBackendProxyHandler(previous, "after publication"));
|
||||
() -> VotingPluginMainAccess.closePublishedPreviousBackendProxyHandler(plugin, previous, "after publication"));
|
||||
assertTrue(closeStarted.await(1, TimeUnit.SECONDS));
|
||||
assertTrue(closeThread.get().startsWith("VotingPlugin-Retired-Backend"));
|
||||
} finally {
|
||||
@@ -705,7 +709,7 @@ class VotingPluginMainBackendProxyPublicationTest {
|
||||
Constructor<VotingPluginMain.BackendProxyRestart> constructor = VotingPluginMain.BackendProxyRestart.class
|
||||
.getDeclaredConstructor(BackendProxyHandler.class, BackendProxyHandler.class, boolean.class, boolean.class);
|
||||
constructor.setAccessible(true);
|
||||
return constructor.newInstance(previous, replacement, replacement == null, previousRequiresPreparation);
|
||||
return ReflectiveAccess.newInstance(constructor, new Object[]{previous, replacement, replacement == null, previousRequiresPreparation});
|
||||
}
|
||||
|
||||
private void setBackendProxyHandler(VotingPluginMain plugin, BackendProxyHandler handler) throws Exception {
|
||||
@@ -715,13 +719,13 @@ class VotingPluginMainBackendProxyPublicationTest {
|
||||
private void setField(VotingPluginMain plugin, String name, Object value) throws Exception {
|
||||
Field field = VotingPluginMain.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(plugin, value);
|
||||
ReflectiveAccess.set(field, plugin, value);
|
||||
}
|
||||
|
||||
private void setRestartField(VotingPluginMain.BackendProxyRestart restart, String name, Object value)
|
||||
throws Exception {
|
||||
Field field = VotingPluginMain.BackendProxyRestart.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(restart, value);
|
||||
ReflectiveAccess.set(field, restart, value);
|
||||
}
|
||||
}
|
||||
+9
-7
@@ -1,4 +1,6 @@
|
||||
package com.bencodez.votingplugin;
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.*;
|
||||
@@ -145,18 +147,18 @@ class VotingPluginMainPlaceholderPresenceTest {
|
||||
verify(plugin, times(2)).captureOnlineTopVoterIgnore(any(), any());
|
||||
}
|
||||
@Test
|
||||
void cachedStorageIdentityWinsDuringColdPresenceRefresh() {
|
||||
void offlineStorageIdentityWinsDuringColdPresenceRefresh() {
|
||||
Player player = mock(Player.class);
|
||||
UuidLookup lookup = mock(UuidLookup.class);
|
||||
UUID bukkitUuid = UUID.randomUUID();
|
||||
UUID storageUuid = UUID.randomUUID();
|
||||
when(player.getName()).thenReturn("Player");
|
||||
when(player.getUniqueId()).thenReturn(bukkitUuid);
|
||||
when(lookup.getCachedUUID("Player")).thenReturn(storageUuid.toString());
|
||||
when(lookup.getUUIDWithoutStorage("Player")).thenReturn(storageUuid.toString());
|
||||
|
||||
try (var uuidLookup = mockStatic(UuidLookup.class)) {
|
||||
uuidLookup.when(UuidLookup::getInstance).thenReturn(lookup);
|
||||
assertEquals(storageUuid, VotingPluginMain.placeholderStorageUuid(player, false));
|
||||
assertEquals(storageUuid, VotingPluginMainAccess.placeholderStorageUuid(player, false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,11 +169,11 @@ class VotingPluginMainPlaceholderPresenceTest {
|
||||
UUID bukkitUuid = UUID.randomUUID();
|
||||
when(player.getName()).thenReturn("Player");
|
||||
when(player.getUniqueId()).thenReturn(bukkitUuid);
|
||||
when(lookup.getCachedUUID("Player")).thenReturn("");
|
||||
when(lookup.getUUIDWithoutStorage("Player")).thenReturn("");
|
||||
|
||||
try (var uuidLookup = mockStatic(UuidLookup.class)) {
|
||||
uuidLookup.when(UuidLookup::getInstance).thenReturn(lookup);
|
||||
assertEquals(bukkitUuid, VotingPluginMain.placeholderStorageUuid(player, false));
|
||||
assertEquals(bukkitUuid, VotingPluginMainAccess.placeholderStorageUuid(player, false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +186,7 @@ class VotingPluginMainPlaceholderPresenceTest {
|
||||
|
||||
try (var uuidLookup = mockStatic(UuidLookup.class)) {
|
||||
uuidLookup.when(UuidLookup::getInstance).thenReturn(lookup);
|
||||
assertEquals(bukkitUuid, VotingPluginMain.placeholderStorageUuid(player, true));
|
||||
assertEquals(bukkitUuid, VotingPluginMainAccess.placeholderStorageUuid(player, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
+603
@@ -0,0 +1,603 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.bencodez.votingplugin.signs.Signs;
|
||||
import com.bencodez.votingplugin.timequeue.TimeQueueHandler;
|
||||
import com.bencodez.votingplugin.placeholders.PlaceholderPlayerPresence;
|
||||
import com.bencodez.votingplugin.webhook.VotingPluginWebhookManager;
|
||||
|
||||
class VotingPluginMainShutdownTest {
|
||||
@Test
|
||||
void lateVoteTimerIsOptionalDuringPartialStartup() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
invokeShutdownVoteTimer(plugin);
|
||||
assertNull(field(plugin, "voteTimer"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void voteTimerCleanupIsRepeatable() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
ScheduledExecutorService timer = java.util.concurrent.Executors.newSingleThreadScheduledExecutor();
|
||||
setField(plugin, "voteTimer", timer);
|
||||
invokeShutdownVoteTimer(plugin);
|
||||
assertDoesNotThrow(() -> invokeShutdownVoteTimer(plugin));
|
||||
org.junit.jupiter.api.Assertions.assertSame(timer, field(plugin, "voteTimer"));
|
||||
org.junit.jupiter.api.Assertions.assertTrue(timer.isShutdown());
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicUnloadSkipsLateOptionalResourcesAndIsIdempotent() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "plugin", plugin);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
assertNull(field(plugin, "signs"));
|
||||
assertNull(field(plugin, "timeQueueHandler"));
|
||||
verify((java.util.concurrent.ExecutorService) field(plugin, "backendHostedControlLifecycle")).shutdownNow();
|
||||
verify((java.util.concurrent.ExecutorService) field(plugin, "backendControlConnectorLifecycle")).shutdownNow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void stoppedTimerRemainsVisibleUntilOverflowAndProducerHandlersClose() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
var timer = java.util.concurrent.Executors.newSingleThreadScheduledExecutor();
|
||||
var queue = mock(com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue.class);
|
||||
setField(plugin, "voteTimer", timer);
|
||||
setField(plugin, "votifierVoteOverflowQueue", queue);
|
||||
var duringClose = new java.util.concurrent.atomic.AtomicReference<ScheduledExecutorService>();
|
||||
var rejected = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
var ran = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
org.mockito.Mockito.doAnswer(call -> {
|
||||
duringClose.set(plugin.getVoteTimer());
|
||||
rejected.set(!com.bencodez.votingplugin.util.VoteTaskAdmission.trySubmit(plugin.getVoteTimer(),
|
||||
() -> ran.set(true)));
|
||||
return null;
|
||||
}).when(queue).close();
|
||||
try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) {
|
||||
plugin.onUnLoad();
|
||||
org.junit.jupiter.api.Assertions.assertSame(timer, duringClose.get());
|
||||
org.junit.jupiter.api.Assertions.assertTrue(rejected.get());
|
||||
org.junit.jupiter.api.Assertions.assertFalse(ran.get());
|
||||
handlers.verify(() -> org.bukkit.event.HandlerList.unregisterAll(plugin));
|
||||
assertNull(plugin.getVoteTimer());
|
||||
} finally { timer.shutdownNow(); }
|
||||
}
|
||||
|
||||
@Test
|
||||
void integratedUnloadClosesTheCurrentStreakMenuOnce() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
var menu = mock(com.bencodez.votingplugin.streaks.VoteStreaksMenu.class);
|
||||
var signs = shutdownSignsMock();
|
||||
setField(plugin, "voteStreaksMenu", menu);
|
||||
setField(plugin, "signs", signs);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
verify(menu).close();
|
||||
verify(signs).storeSignsForShutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedStreakMenuCloseDoesNotSkipOtherCleanupAndRetriesOnRepeat() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
var menu = mock(com.bencodez.votingplugin.streaks.VoteStreaksMenu.class);
|
||||
var signs = shutdownSignsMock();
|
||||
setField(plugin, "voteStreaksMenu", menu);
|
||||
setField(plugin, "signs", signs);
|
||||
doThrow(new IllegalStateException("synthetic close failure")).doNothing().when(menu).close();
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
verify(signs).storeSignsForShutdown();
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
verify(menu, org.mockito.Mockito.times(2)).close();
|
||||
verify(signs).storeSignsForShutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedDiscordCloseDoesNotSkipDisplayRetirementAndRetriesOnlyTheFailedStep() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
var generation = new java.util.concurrent.atomic.AtomicLong();
|
||||
setField(plugin, "topVoterDisplayGeneration", generation);
|
||||
var discord = mock(com.bencodez.votingplugin.discord.DiscordHandler.class);
|
||||
setField(plugin, "discordHandler", discord);
|
||||
doThrow(new IllegalStateException("synthetic Discord close failure")).doNothing().when(discord).close();
|
||||
var pending = new java.util.concurrent.CompletableFuture<com.bencodez.advancedcore.api.user.presence.PlayerPresence>();
|
||||
var filter = new com.bencodez.votingplugin.topvoter.TopVoterDisplayFilter(row -> pending);
|
||||
setField(plugin, "topVoterDisplayFilter", filter);
|
||||
var rankings = new java.util.LinkedHashMap<com.bencodez.votingplugin.topvoter.TopVoterPlayer, Integer>();
|
||||
rankings.put(new com.bencodez.votingplugin.topvoter.TopVoterPlayer(java.util.UUID.randomUUID(), "Example", 1L), 10);
|
||||
var display = filter.projectAsync(rankings, true).toCompletableFuture();
|
||||
org.junit.jupiter.api.Assertions.assertFalse(display.isDone());
|
||||
var signs = shutdownSignsMock();
|
||||
setField(plugin, "signs", signs);
|
||||
try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) {
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
org.junit.jupiter.api.Assertions.assertTrue(display.isDone(), "A failed independent close must not strand pending display evidence");
|
||||
org.junit.jupiter.api.Assertions.assertEquals(rankings, display.getNow(null));
|
||||
verify(signs).storeSignsForShutdown();
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
verify(discord, org.mockito.Mockito.times(2)).close();
|
||||
verify(discord).retireLeaderboardPublications();
|
||||
verify(signs).storeSignsForShutdown();
|
||||
org.junit.jupiter.api.Assertions.assertEquals(1L, generation.get());
|
||||
pending.complete(com.bencodez.advancedcore.api.user.presence.PlayerPresence.NEVER_JOINED);
|
||||
org.junit.jupiter.api.Assertions.assertEquals(rankings, display.getNow(null));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicUnloadCleansInitializedQueueAndSignResources() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "plugin", plugin);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
Signs signs = shutdownSignsMock();
|
||||
TimeQueueHandler timeQueue = mock(TimeQueueHandler.class);
|
||||
setField(plugin, "signs", signs);
|
||||
setField(plugin, "timeQueueHandler", timeQueue);
|
||||
|
||||
plugin.onUnLoad();
|
||||
|
||||
verify(signs).storeSignsForShutdown();
|
||||
verify(timeQueue).saveForShutdown();
|
||||
plugin.onUnLoad();
|
||||
verify(signs).storeSignsForShutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedCleanupDoesNotSkipLaterResources() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "plugin", plugin);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
Signs signs = shutdownSignsMock();
|
||||
TimeQueueHandler timeQueue = mock(TimeQueueHandler.class);
|
||||
VotingPluginWebhookManager webhook = mock(VotingPluginWebhookManager.class);
|
||||
doThrow(new IllegalStateException("webhook close failed")).when(webhook).shutdown();
|
||||
doThrow(new IllegalStateException("queue save failed")).when(timeQueue).saveForShutdown();
|
||||
setField(plugin, "webhookManager", webhook);
|
||||
setField(plugin, "signs", signs);
|
||||
setField(plugin, "timeQueueHandler", timeQueue);
|
||||
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
|
||||
verify(webhook).shutdown();
|
||||
verify(timeQueue).saveForShutdown();
|
||||
verify(signs).storeSignsForShutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedUnloadRetriesFailedBackendAndWebhookWithoutRepeatingSuccessfulResources() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
var backend = mock(com.bencodez.votingplugin.backendproxy.BackendProxyHandler.class);
|
||||
var webhook = mock(VotingPluginWebhookManager.class);
|
||||
var signs = shutdownSignsMock();
|
||||
org.mockito.Mockito.doReturn(backend).when(plugin).getBackendProxyHandler();
|
||||
org.mockito.Mockito.doThrow(new IllegalStateException("synthetic backend close failure")).doNothing()
|
||||
.when(backend).closeForShutdown();
|
||||
org.mockito.Mockito.doThrow(new IllegalStateException("synthetic webhook close failure")).doNothing()
|
||||
.when(webhook).shutdown();
|
||||
setField(plugin, "webhookManager", webhook); setField(plugin, "signs", signs);
|
||||
try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) {
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
verify(backend, org.mockito.Mockito.times(2)).closeForShutdown();
|
||||
verify(webhook, org.mockito.Mockito.times(2)).shutdown();
|
||||
verify(signs).storeSignsForShutdown();
|
||||
handlers.verify(() -> org.bukkit.event.HandlerList.unregisterAll(plugin));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedTimerShutdownRetainsItsReferenceUntilARepeatCanStopIt() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
var timer = mock(ScheduledExecutorService.class);
|
||||
org.mockito.Mockito.doThrow(new IllegalStateException("synthetic forced stop failure"))
|
||||
.doThrow(new IllegalStateException("synthetic repeated forced stop failure"))
|
||||
.doAnswer(call -> java.util.List.of())
|
||||
.when(timer).shutdownNow();
|
||||
setField(plugin, "voteTimer", timer);
|
||||
try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) {
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
org.junit.jupiter.api.Assertions.assertSame(timer, plugin.getVoteTimer());
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
assertNull(plugin.getVoteTimer());
|
||||
verify(timer, org.mockito.Mockito.times(2)).shutdown();
|
||||
verify(timer, org.mockito.Mockito.times(3)).shutdownNow();
|
||||
handlers.verify(() -> org.bukkit.event.HandlerList.unregisterAll(plugin));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentUnloadDoesNotRepeatAnActiveResourceClose() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
var backend = mock(com.bencodez.votingplugin.backendproxy.BackendProxyHandler.class);
|
||||
org.mockito.Mockito.doReturn(backend).when(plugin).getBackendProxyHandler();
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var worker = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
org.mockito.Mockito.doAnswer(call -> {
|
||||
entered.countDown();
|
||||
org.junit.jupiter.api.Assertions.assertTrue(release.await(10, java.util.concurrent.TimeUnit.SECONDS));
|
||||
return null;
|
||||
}).when(backend).closeForShutdown();
|
||||
try {
|
||||
var first = worker.submit(plugin::onUnLoad);
|
||||
org.junit.jupiter.api.Assertions.assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
verify(backend).closeForShutdown();
|
||||
release.countDown(); first.get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
verify(backend).closeForShutdown();
|
||||
} finally {
|
||||
release.countDown(); worker.shutdownNow();
|
||||
org.junit.jupiter.api.Assertions.assertTrue(worker.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedUnloadRetriesOnlyFailedTimedVotePersistence() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
TimeQueueHandler timeQueue = mock(TimeQueueHandler.class);
|
||||
Signs signs = shutdownSignsMock();
|
||||
org.mockito.Mockito.doThrow(new IllegalStateException("disk unavailable")).doNothing()
|
||||
.when(timeQueue).saveForShutdown();
|
||||
setField(plugin, "timeQueueHandler", timeQueue);
|
||||
setField(plugin, "signs", signs);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
verify(timeQueue, org.mockito.Mockito.times(2)).saveForShutdown();
|
||||
verify(signs).storeSignsForShutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void gracefulTimerShutdownDoesNotForceStop() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
ScheduledExecutorService timer = mock(ScheduledExecutorService.class);
|
||||
org.mockito.Mockito.when(timer.awaitTermination(1, java.util.concurrent.TimeUnit.SECONDS)).thenReturn(true);
|
||||
setField(plugin, "voteTimer", timer);
|
||||
|
||||
invokeShutdownVoteTimer(plugin);
|
||||
|
||||
verify(timer).shutdown();
|
||||
verify(timer, never()).shutdownNow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void timerTimeoutForcesStop() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
ScheduledExecutorService timer = mock(ScheduledExecutorService.class);
|
||||
org.mockito.Mockito.when(timer.awaitTermination(1, java.util.concurrent.TimeUnit.SECONDS)).thenReturn(false);
|
||||
setField(plugin, "voteTimer", timer);
|
||||
|
||||
invokeShutdownVoteTimer(plugin);
|
||||
|
||||
verify(timer).shutdownNow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void interruptedTimerShutdownRestoresInterruptAndForcesStop() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
ScheduledExecutorService timer = mock(ScheduledExecutorService.class);
|
||||
doThrow(new InterruptedException()).when(timer).awaitTermination(1, java.util.concurrent.TimeUnit.SECONDS);
|
||||
setField(plugin, "voteTimer", timer);
|
||||
|
||||
invokeShutdownVoteTimer(plugin);
|
||||
|
||||
try {
|
||||
org.junit.jupiter.api.Assertions.assertTrue(Thread.currentThread().isInterrupted());
|
||||
} finally {
|
||||
Thread.interrupted();
|
||||
}
|
||||
verify(timer).shutdownNow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedForcedTimerStopStillClosesInitializedQueuesAndListenersOnce() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "plugin", plugin);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
ScheduledExecutorService timer = mock(ScheduledExecutorService.class);
|
||||
doThrow(new IllegalStateException("controlled forced-stop failure")).when(timer).shutdownNow();
|
||||
setField(plugin, "voteTimer", timer);
|
||||
var queue = mock(com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue.class);
|
||||
var cooldown = mock(com.bencodez.votingplugin.cooldown.CoolDownCheck.class);
|
||||
var reminders = mock(com.bencodez.votingplugin.votereminding.VoteRemindersManager.class);
|
||||
setField(plugin, "votifierVoteOverflowQueue", queue);
|
||||
setField(plugin, "coolDownCheck", cooldown);
|
||||
setField(plugin, "voteRemindersManager", reminders);
|
||||
try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) {
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
verify(queue).close();
|
||||
verify(cooldown).shutdown();
|
||||
verify(reminders).shutdown();
|
||||
handlers.verify(() -> org.bukkit.event.HandlerList.unregisterAll(plugin));
|
||||
}
|
||||
org.junit.jupiter.api.Assertions.assertSame(timer, field(plugin, "voteTimer"));
|
||||
verify(timer, org.mockito.Mockito.times(4)).shutdownNow();
|
||||
assertNull(field(plugin, "votifierVoteOverflowQueue"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectedMysqlStartupRetainsItsCauseAndFullDisableCleansEarlyResources() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
var options = mock(com.bencodez.advancedcore.AdvancedCoreConfigOptions.class,
|
||||
org.mockito.Mockito.RETURNS_DEEP_STUBS);
|
||||
org.mockito.Mockito.doReturn(options).when(plugin).getOptions();
|
||||
org.mockito.Mockito.doReturn(java.util.logging.Logger.getLogger("startup-fixture")).when(plugin).getLogger();
|
||||
org.mockito.Mockito.when(options.getDebug().isDebug()).thenReturn(true);
|
||||
setField(plugin, "plugin", plugin);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
var logger = mock(java.util.logging.Logger.class);
|
||||
org.mockito.Mockito.doReturn(logger).when(plugin).getLogger();
|
||||
ScheduledExecutorService coreTimer = java.util.concurrent.Executors.newSingleThreadScheduledExecutor();
|
||||
setField(plugin, "timer", coreTimer);
|
||||
setField(plugin, "loadUserData", true);
|
||||
var configuration = new org.bukkit.configuration.file.YamlConfiguration();
|
||||
configuration.set("Host", "127.0.0.1"); configuration.set("Port", 3306);
|
||||
configuration.set("Username", "fixture"); configuration.set("Password", "fixture");
|
||||
configuration.set("Database", "fixture"); configuration.set("MaxConnections", 1);
|
||||
configuration.set("ConnectionTimeout", 1000); configuration.set("MinimumIdle", 0);
|
||||
configuration.set("Driver", RejectingDriver.class.getName());
|
||||
configuration.set("Debug", true);
|
||||
try {
|
||||
IllegalStateException failure = org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class,
|
||||
() -> new com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL(plugin, "Users", configuration));
|
||||
Throwable original = failure;
|
||||
while (original.getCause() != null) original = original.getCause();
|
||||
org.junit.jupiter.api.Assertions.assertInstanceOf(java.sql.SQLException.class, original);
|
||||
org.junit.jupiter.api.Assertions.assertEquals("controlled authentication rejection", original.getMessage());
|
||||
assertNull(plugin.getMysql(), "rejected constructor must not publish storage");
|
||||
assertDoesNotThrow(plugin::onDisable);
|
||||
org.junit.jupiter.api.Assertions.assertTrue(coreTimer.isTerminated());
|
||||
assertNull(field(plugin, "voteTimer"));
|
||||
assertNull(field(plugin, "signs"));
|
||||
assertDoesNotThrow(plugin::onDisable);
|
||||
org.junit.jupiter.api.Assertions.assertSame(original, rootCause(failure));
|
||||
verify(logger, never()).warning(org.mockito.ArgumentMatchers.anyString());
|
||||
verify(logger, never()).log(org.mockito.ArgumentMatchers.eq(java.util.logging.Level.WARNING),
|
||||
org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any(Throwable.class));
|
||||
} finally { coreTimer.shutdownNow(); }
|
||||
}
|
||||
|
||||
@Test
|
||||
void signPersistenceDoesNotReblockUnloadAfterATimedVoteCheckpointTimesOut(
|
||||
@org.junit.jupiter.api.io.TempDir java.nio.file.Path directory) throws Exception {
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var firstMove = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
var caller = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
var producers = new java.util.concurrent.ScheduledThreadPoolExecutor(1);
|
||||
producers.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
|
||||
try (var schedulers = org.mockito.Mockito.mockConstruction(
|
||||
com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
org.mockito.Mockito.doReturn(directory.toFile()).when(plugin).getDataFolder();
|
||||
var fence = com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(
|
||||
directory.resolve("ServerData.yml"));
|
||||
setField(plugin, "serverDataPublicationFence", fence);
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override protected void moveAtomically(java.nio.file.Path source, java.nio.file.Path target)
|
||||
throws java.io.IOException {
|
||||
if (firstMove.compareAndSet(true, false)) {
|
||||
entered.countDown();
|
||||
try {
|
||||
if (!release.await(10, java.util.concurrent.TimeUnit.SECONDS))
|
||||
throw new java.io.IOException("Synthetic stalled writer was not released");
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new java.io.IOException(failure);
|
||||
}
|
||||
}
|
||||
super.moveAtomically(source, target);
|
||||
}
|
||||
};
|
||||
backing.setData(new org.bukkit.configuration.file.YamlConfiguration());
|
||||
var facade = new com.bencodez.votingplugin.data.GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
org.mockito.Mockito.doReturn(facade).when(plugin).getServerDataFile();
|
||||
var data = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
org.mockito.Mockito.doReturn(data).when(plugin).getServerData();
|
||||
org.mockito.Mockito.doReturn(producers).when(plugin).getVoteTimer();
|
||||
var queue = new TimeQueueHandler(plugin);
|
||||
queue.addVote("SyntheticVoter", "SyntheticSite");
|
||||
producers.shutdown();
|
||||
org.junit.jupiter.api.Assertions.assertTrue(producers.awaitTermination(2,
|
||||
java.util.concurrent.TimeUnit.SECONDS));
|
||||
setField(plugin, "timeQueueHandler", queue);
|
||||
var signs = new Signs(plugin);
|
||||
var sign = mock(com.bencodez.votingplugin.signs.SignHandler.class, CALLS_REAL_METHODS);
|
||||
for (var entry : java.util.Map.<String, Object>of("plugin", plugin, "sign", "0",
|
||||
"data", "SyntheticSign", "position", 1).entrySet()) {
|
||||
var f = com.bencodez.votingplugin.signs.SignHandler.class.getDeclaredField(entry.getKey());
|
||||
f.setAccessible(true);
|
||||
f.set(sign, entry.getValue());
|
||||
}
|
||||
var world = mock(org.bukkit.World.class);
|
||||
org.mockito.Mockito.when(world.getName()).thenReturn("SyntheticWorld");
|
||||
sign.setLocation(new org.bukkit.Location(world, -1.5, 64, 2.5));
|
||||
signs.getSigns().add(sign);
|
||||
setField(plugin, "signs", signs);
|
||||
try {
|
||||
var unload = caller.submit(() -> {
|
||||
try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) {
|
||||
plugin.onUnLoad();
|
||||
handlers.verify(() -> org.bukkit.event.HandlerList.unregisterAll(plugin));
|
||||
}
|
||||
});
|
||||
org.junit.jupiter.api.Assertions.assertTrue(entered.await(5,
|
||||
java.util.concurrent.TimeUnit.SECONDS), "Actual SDK file writer did not reach disk boundary");
|
||||
org.junit.jupiter.api.Assertions.assertDoesNotThrow(
|
||||
() -> unload.get(3, java.util.concurrent.TimeUnit.SECONDS),
|
||||
"Sign persistence must not reacquire the stalled YAML monitor on the unload caller");
|
||||
org.junit.jupiter.api.Assertions.assertEquals(1, release.getCount(),
|
||||
"Unload must complete before the stalled writer is released");
|
||||
} finally {
|
||||
release.countDown();
|
||||
var pendingSigns = (java.util.concurrent.CompletionStage<?>) field(plugin, "signShutdownPublication");
|
||||
if (pendingSigns != null) pendingSigns.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
queue.saveForShutdown();
|
||||
var disk = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(
|
||||
directory.resolve("ServerData.yml").toFile());
|
||||
org.junit.jupiter.api.Assertions.assertEquals("SyntheticSign", disk.getString("VotingPlugin.Signs.0.Data"));
|
||||
org.junit.jupiter.api.Assertions.assertEquals(1, data.getTimedVoteCacheKeys().size(),
|
||||
"Final sign publication must preserve the timed-vote checkpoint");
|
||||
caller.shutdown();
|
||||
org.junit.jupiter.api.Assertions.assertTrue(caller.awaitTermination(5,
|
||||
java.util.concurrent.TimeUnit.SECONDS));
|
||||
fence.retire();
|
||||
}
|
||||
} finally {
|
||||
release.countDown();
|
||||
caller.shutdownNow();
|
||||
producers.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedUnloadRetriesAFailedSignPublicationWithoutRepeatingListenerCleanup() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
var signs = mock(Signs.class);
|
||||
var pending = new java.util.concurrent.CompletableFuture<Void>();
|
||||
org.mockito.Mockito.when(signs.storeSignsForShutdown()).thenReturn(
|
||||
java.util.concurrent.CompletableFuture.failedFuture(new IllegalStateException("Synthetic I/O")),
|
||||
pending.minimalCompletionStage());
|
||||
setField(plugin, "signs", signs);
|
||||
try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) {
|
||||
plugin.onUnLoad();
|
||||
plugin.onUnLoad();
|
||||
plugin.onUnLoad();
|
||||
verify(signs, org.mockito.Mockito.times(2)).storeSignsForShutdown();
|
||||
verify(signs, never()).storeSigns();
|
||||
handlers.verify(() -> org.bukkit.event.HandlerList.unregisterAll(plugin));
|
||||
pending.complete(null);
|
||||
plugin.onUnLoad();
|
||||
verify(signs, org.mockito.Mockito.times(2)).storeSignsForShutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private static Signs shutdownSignsMock() {
|
||||
var signs = mock(Signs.class);
|
||||
org.mockito.Mockito.when(signs.storeSignsForShutdown())
|
||||
.thenReturn(java.util.concurrent.CompletableFuture.completedFuture(null));
|
||||
return signs;
|
||||
}
|
||||
|
||||
private static Throwable rootCause(Throwable failure) {
|
||||
while (failure.getCause() != null) failure = failure.getCause();
|
||||
return failure;
|
||||
}
|
||||
|
||||
public static final class RejectingDriver implements java.sql.Driver {
|
||||
public java.sql.Connection connect(String url, java.util.Properties properties) throws java.sql.SQLException {
|
||||
throw new java.sql.SQLException("controlled authentication rejection", "28000", 1045);
|
||||
}
|
||||
public boolean acceptsURL(String url) { return url != null && url.startsWith("jdbc:"); }
|
||||
public java.sql.DriverPropertyInfo[] getPropertyInfo(String url, java.util.Properties properties) {
|
||||
return new java.sql.DriverPropertyInfo[0];
|
||||
}
|
||||
public int getMajorVersion() { return 1; }
|
||||
public int getMinorVersion() { return 0; }
|
||||
public boolean jdbcCompliant() { return false; }
|
||||
public java.util.logging.Logger getParentLogger() { return java.util.logging.Logger.getGlobal(); }
|
||||
}
|
||||
|
||||
private static void initializeEarlyLifecycle(VotingPluginMain plugin) throws Exception {
|
||||
// The real plugin initializes this display retirement counter before startup.
|
||||
setField(plugin, "topVoterDisplayGeneration", new java.util.concurrent.atomic.AtomicLong());
|
||||
// Mockito bypasses the SDK constructor that creates this lock.
|
||||
setField(plugin, "lifecycleLock", new Object());
|
||||
var hosted = mock(java.util.concurrent.ExecutorService.class);
|
||||
var connector = mock(ScheduledExecutorService.class);
|
||||
org.mockito.Mockito.when(hosted.awaitTermination(10, java.util.concurrent.TimeUnit.SECONDS)).thenReturn(true);
|
||||
org.mockito.Mockito.when(connector.awaitTermination(10, java.util.concurrent.TimeUnit.SECONDS)).thenReturn(true);
|
||||
setField(plugin, "pendingNativeUserStorageCloseLock", new Object());
|
||||
setField(plugin, "pendingNativeUserStorageCloses", new java.util.ArrayList<>());
|
||||
setField(plugin, "backendHostedControlLifecycle", hosted);
|
||||
setField(plugin, "backendControlConnectorLifecycle", connector);
|
||||
setField(plugin, "backendHostedControlManagers", java.util.concurrent.ConcurrentHashMap.newKeySet());
|
||||
org.mockito.Mockito.doReturn(java.util.logging.Logger.getLogger("shutdown-fixture")).when(plugin).getLogger();
|
||||
}
|
||||
|
||||
private static void invokeShutdownVoteTimer(VotingPluginMain plugin) throws Exception {
|
||||
Method method = VotingPluginMain.class.getDeclaredMethod("shutdownVoteTimer");
|
||||
method.setAccessible(true);
|
||||
method.invoke(plugin);
|
||||
}
|
||||
|
||||
private static Object field(VotingPluginMain plugin, String name) throws Exception {
|
||||
Field field = VotingPluginMain.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
return field.get(plugin);
|
||||
}
|
||||
|
||||
private static void setField(VotingPluginMain plugin, String name, Object value) throws Exception {
|
||||
Field field = findField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(plugin, value);
|
||||
}
|
||||
|
||||
private static Field findField(String name) throws Exception {
|
||||
Class<?> type = VotingPluginMain.class;
|
||||
while (type != null) {
|
||||
try { return type.getDeclaredField(name); }
|
||||
catch (NoSuchFieldException ignored) { type = type.getSuperclass(); }
|
||||
}
|
||||
throw new NoSuchFieldException(name);
|
||||
}
|
||||
}
|
||||
+53
-73
@@ -1,6 +1,8 @@
|
||||
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
@@ -245,7 +247,7 @@ public class VotingPluginProxyTest {
|
||||
com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption.Domain.PROXY_BACKEND, true);
|
||||
var field = VotingPluginProxy.class.getDeclaredField("communicationEncryption");
|
||||
field.setAccessible(true);
|
||||
field.set(votingPluginProxy, encryption);
|
||||
ReflectiveAccess.set(field, votingPluginProxy, encryption);
|
||||
return encryption;
|
||||
}
|
||||
|
||||
@@ -384,7 +386,7 @@ public class VotingPluginProxyTest {
|
||||
Mockito.mock(com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler.class));
|
||||
java.lang.reflect.Field decider = VotingPluginProxy.class.getDeclaredField("proxyBroadcastDecider");
|
||||
decider.setAccessible(true);
|
||||
decider.set(votingPluginProxy, new ProxyBroadcastDecider(votingPluginProxy::getConfig,
|
||||
ReflectiveAccess.set(decider, votingPluginProxy, new ProxyBroadcastDecider(votingPluginProxy::getConfig,
|
||||
votingPluginProxy::getAllAvailableServers, server -> true, server -> false));
|
||||
java.util.UUID voteId = java.util.UUID.randomUUID();
|
||||
|
||||
@@ -711,7 +713,7 @@ public class VotingPluginProxyTest {
|
||||
Class<?> retryType = Class.forName("com.bencodez.votingplugin.proxy.VotingPluginProxy$LiveVoteRetryState");
|
||||
java.lang.reflect.Constructor<?> constructor = retryType.getDeclaredConstructor();
|
||||
constructor.setAccessible(true);
|
||||
Object retry = constructor.newInstance();
|
||||
Object retry = ReflectiveAccess.newInstance(constructor, new Object[]{});
|
||||
for (java.util.Map.Entry<String, Object> field : java.util.Map.<String, Object>of(
|
||||
"player", "Player", "service", "Service", "uuid",
|
||||
"00000000-0000-0000-0000-000000000001", "time", 100L, "realVote", true,
|
||||
@@ -730,11 +732,9 @@ public class VotingPluginProxyTest {
|
||||
retryType, VoteTimeQueue.class, String.class, String.class, String.class, long.class,
|
||||
boolean.class, com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class);
|
||||
begin.setAccessible(true);
|
||||
assertFalse((Boolean) begin.invoke(spyProxy, retry, null, "Player",
|
||||
"00000000-0000-0000-0000-000000000001", "Service", 100L, true, null));
|
||||
assertFalse((Boolean) ReflectiveAccess.invoke(begin, spyProxy, new Object[]{retry, null, "Player", "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null}));
|
||||
verify(multiProxyHandler, never()).sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.any());
|
||||
assertFalse((Boolean) begin.invoke(spyProxy, retry, null, "Player",
|
||||
"00000000-0000-0000-0000-000000000001", "Service", 100L, true, null));
|
||||
assertFalse((Boolean) ReflectiveAccess.invoke(begin, spyProxy, new Object[]{retry, null, "Player", "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null}));
|
||||
@SuppressWarnings({ "rawtypes", "unchecked" })
|
||||
org.mockito.ArgumentCaptor<java.util.Collection<String>> recipients =
|
||||
(org.mockito.ArgumentCaptor) org.mockito.ArgumentCaptor.forClass(java.util.Collection.class);
|
||||
@@ -744,7 +744,7 @@ public class VotingPluginProxyTest {
|
||||
java.lang.reflect.Method settle = VotingPluginProxy.class
|
||||
.getDeclaredMethod("settleLiveVoteRetriesForFinalShutdown");
|
||||
settle.setAccessible(true);
|
||||
assertFalse((Boolean) settle.invoke(spyProxy));
|
||||
assertFalse((Boolean) ReflectiveAccess.invoke(settle, spyProxy, new Object[]{}));
|
||||
|
||||
org.mockito.ArgumentCaptor<VoteTimeQueue> outboxes = org.mockito.ArgumentCaptor.forClass(VoteTimeQueue.class);
|
||||
verify(voteCache, Mockito.atLeast(2)).addTimeVoteToCache(outboxes.capture());
|
||||
@@ -767,7 +767,7 @@ public class VotingPluginProxyTest {
|
||||
Class<?> retryType = Class.forName("com.bencodez.votingplugin.proxy.VotingPluginProxy$LiveVoteRetryState");
|
||||
java.lang.reflect.Constructor<?> constructor = retryType.getDeclaredConstructor();
|
||||
constructor.setAccessible(true);
|
||||
Object retry = constructor.newInstance();
|
||||
Object retry = ReflectiveAccess.newInstance(constructor, new Object[]{});
|
||||
java.util.UUID voteId = java.util.UUID.randomUUID();
|
||||
java.lang.reflect.Field retriesField = VotingPluginProxy.class.getDeclaredField("liveVoteRetries");
|
||||
retriesField.setAccessible(true);
|
||||
@@ -780,8 +780,7 @@ public class VotingPluginProxyTest {
|
||||
boolean.class, com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class);
|
||||
begin.setAccessible(true);
|
||||
|
||||
assertFalse((Boolean) begin.invoke(votingPluginProxy, retry, null, "Player",
|
||||
"00000000-0000-0000-0000-000000000001", "Service", 100L, true, null));
|
||||
assertFalse((Boolean) ReflectiveAccess.invoke(begin, votingPluginProxy, new Object[]{retry, null, "Player", "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null}));
|
||||
org.mockito.ArgumentCaptor<JsonEnvelope> envelope = org.mockito.ArgumentCaptor.forClass(JsonEnvelope.class);
|
||||
verify(multiProxyHandler).sendMultiProxyEnvelopeAccepted(envelope.capture(),
|
||||
Mockito.eq(java.util.Set.of("ProxyLegacy")));
|
||||
@@ -822,7 +821,7 @@ public class VotingPluginProxyTest {
|
||||
Class<?> retryType = Class.forName("com.bencodez.votingplugin.proxy.VotingPluginProxy$LiveVoteRetryState");
|
||||
java.lang.reflect.Constructor<?> constructor = retryType.getDeclaredConstructor();
|
||||
constructor.setAccessible(true);
|
||||
Object retry = constructor.newInstance();
|
||||
Object retry = ReflectiveAccess.newInstance(constructor, new Object[]{});
|
||||
java.util.UUID voteId = java.util.UUID.randomUUID();
|
||||
java.lang.reflect.Field retriesField = VotingPluginProxy.class.getDeclaredField("liveVoteRetries");
|
||||
retriesField.setAccessible(true);
|
||||
@@ -835,8 +834,7 @@ public class VotingPluginProxyTest {
|
||||
boolean.class, com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class);
|
||||
begin.setAccessible(true);
|
||||
|
||||
assertFalse((Boolean) begin.invoke(proxy, retry, null, "Player",
|
||||
"00000000-0000-0000-0000-000000000001", "Service", 100L, true, null));
|
||||
assertFalse((Boolean) ReflectiveAccess.invoke(begin, proxy, new Object[]{retry, null, "Player", "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null}));
|
||||
verify(multiProxyHandler, never()).sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.any());
|
||||
assertEquals(1, queue.size());
|
||||
assertTrue(queue.element().isMultiProxyCapabilityDiscoveryPending());
|
||||
@@ -844,15 +842,13 @@ public class VotingPluginProxyTest {
|
||||
verify(scheduler).schedule(Mockito.any(Runnable.class), Mockito.eq(5L),
|
||||
Mockito.eq(java.util.concurrent.TimeUnit.SECONDS));
|
||||
|
||||
assertTrue((Boolean) begin.invoke(proxy, retry, queue.element(), "Player",
|
||||
"00000000-0000-0000-0000-000000000001", "Service", 100L, true, null));
|
||||
assertTrue((Boolean) ReflectiveAccess.invoke(begin, proxy, new Object[]{retry, queue.element(), "Player", "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null}));
|
||||
verify(multiProxyHandler).sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.eq(java.util.Set.of("Candidate")));
|
||||
assertFalse(queue.element().isMultiProxyCapabilityDiscoveryPending());
|
||||
assertTrue(queue.element().isMultiProxyForwardingHandled(),
|
||||
"the durable discovery fence must be retired before reporting the legacy fallback complete");
|
||||
assertTrue(queue.element().getMultiProxyLegacyPendingRecipients().isEmpty());
|
||||
assertTrue((Boolean) begin.invoke(proxy, retry, queue.element(), "Player",
|
||||
"00000000-0000-0000-0000-000000000001", "Service", 100L, true, null));
|
||||
assertTrue((Boolean) ReflectiveAccess.invoke(begin, proxy, new Object[]{retry, queue.element(), "Player", "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null}));
|
||||
verify(multiProxyHandler, Mockito.times(1)).sendMultiProxyEnvelopeAccepted(Mockito.any(),
|
||||
Mockito.eq(java.util.Set.of("Candidate")));
|
||||
verify(multiProxyHandler).renewMultiProxyVoteCapabilityDiscoveryIfDue();
|
||||
@@ -872,14 +868,13 @@ public class VotingPluginProxyTest {
|
||||
Class<?> retryType = Class.forName("com.bencodez.votingplugin.proxy.VotingPluginProxy$LiveVoteRetryState");
|
||||
java.lang.reflect.Constructor<?> constructor = retryType.getDeclaredConstructor();
|
||||
constructor.setAccessible(true);
|
||||
Object retry = constructor.newInstance();
|
||||
Object retry = ReflectiveAccess.newInstance(constructor, new Object[]{});
|
||||
java.lang.reflect.Method begin = VotingPluginProxy.class.getDeclaredMethod("beginMultiProxyForwarding",
|
||||
retryType, VoteTimeQueue.class, String.class, String.class, String.class, long.class,
|
||||
boolean.class, com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class);
|
||||
begin.setAccessible(true);
|
||||
|
||||
assertFalse((Boolean) begin.invoke(votingPluginProxy, retry, null, "Player",
|
||||
"00000000-0000-0000-0000-000000000001", "Service", 100L, true, null));
|
||||
assertFalse((Boolean) ReflectiveAccess.invoke(begin, votingPluginProxy, new Object[]{retry, null, "Player", "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null}));
|
||||
verify(multiProxyHandler, never()).sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.any());
|
||||
}
|
||||
|
||||
@@ -893,14 +888,13 @@ public class VotingPluginProxyTest {
|
||||
Class<?> retryType = Class.forName("com.bencodez.votingplugin.proxy.VotingPluginProxy$LiveVoteRetryState");
|
||||
java.lang.reflect.Constructor<?> constructor = retryType.getDeclaredConstructor();
|
||||
constructor.setAccessible(true);
|
||||
Object retry = constructor.newInstance();
|
||||
Object retry = ReflectiveAccess.newInstance(constructor, new Object[]{});
|
||||
java.lang.reflect.Method begin = VotingPluginProxy.class.getDeclaredMethod("beginMultiProxyForwarding",
|
||||
retryType, VoteTimeQueue.class, String.class, String.class, String.class, long.class,
|
||||
boolean.class, com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class);
|
||||
begin.setAccessible(true);
|
||||
|
||||
assertFalse((Boolean) begin.invoke(spyProxy, retry, null, "Player",
|
||||
"00000000-0000-0000-0000-000000000001", "Service", 100L, true, null));
|
||||
assertFalse((Boolean) ReflectiveAccess.invoke(begin, spyProxy, new Object[]{retry, null, "Player", "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null}));
|
||||
verify(multiProxyHandler, never()).announceMultiProxyVoteCapability();
|
||||
verify(multiProxyHandler, never()).sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.any());
|
||||
verify(voteCache, never()).addTimeVoteToCache(Mockito.any());
|
||||
@@ -928,7 +922,7 @@ public class VotingPluginProxyTest {
|
||||
Class<?> retryType = Class.forName("com.bencodez.votingplugin.proxy.VotingPluginProxy$LiveVoteRetryState");
|
||||
java.lang.reflect.Constructor<?> constructor = retryType.getDeclaredConstructor();
|
||||
constructor.setAccessible(true);
|
||||
Object retry = constructor.newInstance();
|
||||
Object retry = ReflectiveAccess.newInstance(constructor, new Object[]{});
|
||||
java.util.UUID voteId = java.util.UUID.randomUUID();
|
||||
java.lang.reflect.Field retriesField = VotingPluginProxy.class.getDeclaredField("liveVoteRetries");
|
||||
retriesField.setAccessible(true);
|
||||
@@ -941,8 +935,7 @@ public class VotingPluginProxyTest {
|
||||
boolean.class, com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class);
|
||||
begin.setAccessible(true);
|
||||
|
||||
assertFalse((Boolean) begin.invoke(spyProxy, retry, null, "Player",
|
||||
"00000000-0000-0000-0000-000000000001", "Service", 100L, true, null));
|
||||
assertFalse((Boolean) ReflectiveAccess.invoke(begin, spyProxy, new Object[]{retry, null, "Player", "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null}));
|
||||
assertEquals(1, queue.size());
|
||||
assertEquals(java.util.Set.of("capable"), queue.element().getMultiProxyRecipients());
|
||||
assertTrue(queue.element().getMultiProxyLegacyPendingRecipients().isEmpty());
|
||||
@@ -975,7 +968,7 @@ public class VotingPluginProxyTest {
|
||||
Class<?> retryType = Class.forName("com.bencodez.votingplugin.proxy.VotingPluginProxy$LiveVoteRetryState");
|
||||
java.lang.reflect.Constructor<?> constructor = retryType.getDeclaredConstructor();
|
||||
constructor.setAccessible(true);
|
||||
Object retry = constructor.newInstance();
|
||||
Object retry = ReflectiveAccess.newInstance(constructor, new Object[]{});
|
||||
java.util.UUID voteId = java.util.UUID.randomUUID();
|
||||
java.lang.reflect.Field retriesField = VotingPluginProxy.class.getDeclaredField("liveVoteRetries");
|
||||
retriesField.setAccessible(true);
|
||||
@@ -988,8 +981,7 @@ public class VotingPluginProxyTest {
|
||||
boolean.class, com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class);
|
||||
begin.setAccessible(true);
|
||||
|
||||
assertFalse((Boolean) begin.invoke(proxy, retry, null, "Player",
|
||||
"00000000-0000-0000-0000-000000000001", "Service", 100L, true, null));
|
||||
assertFalse((Boolean) ReflectiveAccess.invoke(begin, proxy, new Object[]{retry, null, "Player", "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null}));
|
||||
assertEquals(1, queue.size());
|
||||
verify(scheduler).schedule(Mockito.any(Runnable.class), Mockito.eq(5L),
|
||||
Mockito.eq(java.util.concurrent.TimeUnit.SECONDS));
|
||||
@@ -1096,7 +1088,7 @@ public class VotingPluginProxyTest {
|
||||
Class<?> retryType = Class.forName("com.bencodez.votingplugin.proxy.VotingPluginProxy$LiveVoteRetryState");
|
||||
java.lang.reflect.Constructor<?> constructor = retryType.getDeclaredConstructor();
|
||||
constructor.setAccessible(true);
|
||||
Object retry = constructor.newInstance();
|
||||
Object retry = ReflectiveAccess.newInstance(constructor, new Object[]{});
|
||||
java.lang.reflect.Field retriesField = VotingPluginProxy.class.getDeclaredField("liveVoteRetries");
|
||||
retriesField.setAccessible(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
@@ -1176,17 +1168,17 @@ public class VotingPluginProxyTest {
|
||||
Class<?> retryType = Class.forName("com.bencodez.votingplugin.proxy.VotingPluginProxy$LiveVoteRetryState");
|
||||
java.lang.reflect.Constructor<?> retryConstructor = retryType.getDeclaredConstructor();
|
||||
retryConstructor.setAccessible(true);
|
||||
Object retry = retryConstructor.newInstance();
|
||||
Object retry = ReflectiveAccess.newInstance(retryConstructor, new Object[]{});
|
||||
java.lang.reflect.Method finish = VotingPluginProxy.class.getDeclaredMethod("finishMultiProxyRetirement",
|
||||
retryType, VoteTimeQueue.class);
|
||||
finish.setAccessible(true);
|
||||
|
||||
assertFalse((Boolean) finish.invoke(spyProxy, retry, outbox));
|
||||
assertFalse((Boolean) ReflectiveAccess.invoke(finish, spyProxy, new Object[]{retry, outbox}));
|
||||
assertFalse(outbox.isMultiProxyForwardingHandled());
|
||||
verify(multiProxyHandler).requestMultiProxyVoteRetirement(voteId, "Proxy1", "proxy2");
|
||||
|
||||
assertTrue(outbox.acknowledgeMultiProxyRetirement("Proxy2"));
|
||||
assertTrue((Boolean) finish.invoke(spyProxy, retry, outbox));
|
||||
assertTrue((Boolean) ReflectiveAccess.invoke(finish, spyProxy, new Object[]{retry, outbox}));
|
||||
assertTrue(outbox.isMultiProxyForwardingHandled());
|
||||
verify(voteCache).updateTimeVote(outbox);
|
||||
}
|
||||
@@ -1234,11 +1226,11 @@ public class VotingPluginProxyTest {
|
||||
"handleMultiProxyVoteRetirementRequest", java.util.UUID.class, String.class);
|
||||
retire.setAccessible(true);
|
||||
|
||||
retire.invoke(spyProxy, voteId, "Primary");
|
||||
ReflectiveAccess.invoke(retire, spyProxy, new Object[]{voteId, "Primary"});
|
||||
verify(voteCache, never()).removeMultiProxyVoteCompletion(voteId);
|
||||
verify(multiProxyHandler, never()).acknowledgeMultiProxyVoteRetirement(voteId, "Primary");
|
||||
|
||||
retire.invoke(spyProxy, voteId, "Primary");
|
||||
ReflectiveAccess.invoke(retire, spyProxy, new Object[]{voteId, "Primary"});
|
||||
verify(voteCache).removeMultiProxyVoteCompletion(voteId);
|
||||
verify(multiProxyHandler).acknowledgeMultiProxyVoteRetirement(voteId, "Primary");
|
||||
}
|
||||
@@ -1271,7 +1263,7 @@ public class VotingPluginProxyTest {
|
||||
Mockito.doNothing().when(spyProxy).addVoteParty();
|
||||
java.lang.reflect.Field decider = VotingPluginProxy.class.getDeclaredField("proxyBroadcastDecider");
|
||||
decider.setAccessible(true);
|
||||
decider.set(spyProxy, new ProxyBroadcastDecider(spyProxy::getConfig, spyProxy::getAllAvailableServers,
|
||||
ReflectiveAccess.set(decider, spyProxy, new ProxyBroadcastDecider(spyProxy::getConfig, spyProxy::getAllAvailableServers,
|
||||
server -> true, server -> false));
|
||||
|
||||
spyProxy.processQueue();
|
||||
@@ -1320,7 +1312,7 @@ public class VotingPluginProxyTest {
|
||||
.thenReturn(new java.util.ArrayList<>());
|
||||
java.lang.reflect.Field decider = VotingPluginProxy.class.getDeclaredField("proxyBroadcastDecider");
|
||||
decider.setAccessible(true);
|
||||
decider.set(spyProxy, new ProxyBroadcastDecider(spyProxy::getConfig, spyProxy::getAllAvailableServers,
|
||||
ReflectiveAccess.set(decider, spyProxy, new ProxyBroadcastDecider(spyProxy::getConfig, spyProxy::getAllAvailableServers,
|
||||
server -> true, server -> false));
|
||||
|
||||
spyProxy.processQueue();
|
||||
@@ -1368,7 +1360,7 @@ public class VotingPluginProxyTest {
|
||||
Mockito.when(proxyMySQL.getExactQuery(Mockito.any())).thenReturn(new java.util.ArrayList<>());
|
||||
java.lang.reflect.Field decider = VotingPluginProxy.class.getDeclaredField("proxyBroadcastDecider");
|
||||
decider.setAccessible(true);
|
||||
decider.set(spyProxy, new ProxyBroadcastDecider(spyProxy::getConfig, spyProxy::getAllAvailableServers,
|
||||
ReflectiveAccess.set(decider, spyProxy, new ProxyBroadcastDecider(spyProxy::getConfig, spyProxy::getAllAvailableServers,
|
||||
server -> true, server -> false));
|
||||
|
||||
spyProxy.processQueue();
|
||||
@@ -1445,7 +1437,7 @@ public class VotingPluginProxyTest {
|
||||
Mockito.doNothing().when(spyProxy).addVoteParty();
|
||||
java.lang.reflect.Field decider = VotingPluginProxy.class.getDeclaredField("proxyBroadcastDecider");
|
||||
decider.setAccessible(true);
|
||||
decider.set(spyProxy, new ProxyBroadcastDecider(spyProxy::getConfig, spyProxy::getAllAvailableServers,
|
||||
ReflectiveAccess.set(decider, spyProxy, new ProxyBroadcastDecider(spyProxy::getConfig, spyProxy::getAllAvailableServers,
|
||||
server -> true, server -> false));
|
||||
|
||||
spyProxy.processQueue();
|
||||
@@ -1465,7 +1457,7 @@ public class VotingPluginProxyTest {
|
||||
OfflineBungeeVote.class, boolean.class, boolean.class, int.class, int.class);
|
||||
envelopeMethod.setAccessible(true);
|
||||
|
||||
JsonEnvelope envelope = (JsonEnvelope) envelopeMethod.invoke(votingPluginProxy, cached, true, false, 1, 1);
|
||||
JsonEnvelope envelope = (JsonEnvelope) ReflectiveAccess.invoke(envelopeMethod, votingPluginProxy, new Object[]{cached, true, false, 1, 1});
|
||||
VotingPluginWire.Vote vote = VotingPluginWire.readVote(envelope);
|
||||
|
||||
assertTrue(vote.queuedDeliveryKnown);
|
||||
@@ -1483,8 +1475,7 @@ public class VotingPluginProxyTest {
|
||||
OfflineBungeeVote.class, boolean.class, boolean.class, int.class, int.class);
|
||||
envelopeMethod.setAccessible(true);
|
||||
|
||||
VotingPluginWire.Vote vote = VotingPluginWire.readVote((JsonEnvelope) envelopeMethod.invoke(
|
||||
votingPluginProxy, cached, true, false, 1, 1));
|
||||
VotingPluginWire.Vote vote = VotingPluginWire.readVote((JsonEnvelope) ReflectiveAccess.invoke(envelopeMethod, votingPluginProxy, new Object[]{cached, true, false, 1, 1}));
|
||||
|
||||
assertFalse(vote.queuedDeliveryKnown);
|
||||
assertFalse(vote.delayValidationKnown);
|
||||
@@ -1514,8 +1505,7 @@ public class VotingPluginProxyTest {
|
||||
String.class, boolean.class, boolean.class);
|
||||
receive.setAccessible(true);
|
||||
|
||||
receive.invoke(spyProxy, "Player", "Service", true, false, 100L, null,
|
||||
"00000000-0000-0000-0000-000000000001", java.util.UUID.randomUUID(), "Primary", true, true);
|
||||
ReflectiveAccess.invoke(receive, spyProxy, new Object[]{"Player", "Service", true, false, 100L, null, "00000000-0000-0000-0000-000000000001", java.util.UUID.randomUUID(), "Primary", true, true});
|
||||
|
||||
VotingPluginWire.Vote forwarded = VotingPluginWire.readVote(spyProxy.getLastVoteEnvelope());
|
||||
assertTrue(forwarded.delayValidationKnown);
|
||||
@@ -1546,8 +1536,7 @@ public class VotingPluginProxyTest {
|
||||
String.class, boolean.class, boolean.class);
|
||||
receive.setAccessible(true);
|
||||
|
||||
receive.invoke(spyProxy, "Player", "Service", true, false, 100L, null,
|
||||
"00000000-0000-0000-0000-000000000001", java.util.UUID.randomUUID(), "Legacy", false, false);
|
||||
ReflectiveAccess.invoke(receive, spyProxy, new Object[]{"Player", "Service", true, false, 100L, null, "00000000-0000-0000-0000-000000000001", java.util.UUID.randomUUID(), "Legacy", false, false});
|
||||
|
||||
VotingPluginWire.Vote forwarded = VotingPluginWire.readVote(spyProxy.getLastVoteEnvelope());
|
||||
assertFalse(forwarded.delayValidationKnown);
|
||||
@@ -1580,8 +1569,7 @@ public class VotingPluginProxyTest {
|
||||
String.class, boolean.class, boolean.class);
|
||||
receive.setAccessible(true);
|
||||
|
||||
receive.invoke(spyProxy, "Player", "Service", true, false, 100L, null,
|
||||
"00000000-0000-0000-0000-000000000001", java.util.UUID.randomUUID(), "Legacy", false, false);
|
||||
ReflectiveAccess.invoke(receive, spyProxy, new Object[]{"Player", "Service", true, false, 100L, null, "00000000-0000-0000-0000-000000000001", java.util.UUID.randomUUID(), "Legacy", false, false});
|
||||
|
||||
org.mockito.ArgumentCaptor<OfflineBungeeVote> cached =
|
||||
org.mockito.ArgumentCaptor.forClass(OfflineBungeeVote.class);
|
||||
@@ -1612,8 +1600,7 @@ public class VotingPluginProxyTest {
|
||||
com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class, String.class, java.util.UUID.class);
|
||||
receive.setAccessible(true);
|
||||
|
||||
receive.invoke(spyProxy, "Player", "Service", true, false, 100L, null,
|
||||
"00000000-0000-0000-0000-000000000001", voteId);
|
||||
ReflectiveAccess.invoke(receive, spyProxy, new Object[]{"Player", "Service", true, false, 100L, null, "00000000-0000-0000-0000-000000000001", voteId});
|
||||
org.mockito.ArgumentCaptor<Runnable> scheduled = org.mockito.ArgumentCaptor.forClass(Runnable.class);
|
||||
verify(scheduler, Mockito.atLeastOnce()).schedule(scheduled.capture(), Mockito.anyLong(),
|
||||
Mockito.any(java.util.concurrent.TimeUnit.class));
|
||||
@@ -1623,8 +1610,7 @@ public class VotingPluginProxyTest {
|
||||
+ scheduled.getAllValues().stream().map(task -> task.getClass().getName()).toList()));
|
||||
voteRetry.run();
|
||||
// A duplicate socket/Redis delivery of the completed envelope is ignored.
|
||||
receive.invoke(spyProxy, "Player", "Service", true, false, 100L, null,
|
||||
"00000000-0000-0000-0000-000000000001", voteId);
|
||||
ReflectiveAccess.invoke(receive, spyProxy, new Object[]{"Player", "Service", true, false, 100L, null, "00000000-0000-0000-0000-000000000001", voteId});
|
||||
|
||||
verify(spyProxy).addVoteParty();
|
||||
java.lang.reflect.Field retries = VotingPluginProxy.class.getDeclaredField("liveVoteRetries");
|
||||
@@ -1654,8 +1640,7 @@ public class VotingPluginProxyTest {
|
||||
com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class, String.class, java.util.UUID.class);
|
||||
receive.setAccessible(true);
|
||||
|
||||
receive.invoke(spyProxy, "Player", "Service", true, false, 100L, null,
|
||||
"00000000-0000-0000-0000-000000000001", voteId);
|
||||
ReflectiveAccess.invoke(receive, spyProxy, new Object[]{"Player", "Service", true, false, 100L, null, "00000000-0000-0000-0000-000000000001", voteId});
|
||||
org.mockito.ArgumentCaptor<Runnable> scheduled = org.mockito.ArgumentCaptor.forClass(Runnable.class);
|
||||
verify(scheduler).schedule(scheduled.capture(), Mockito.anyLong(),
|
||||
Mockito.any(java.util.concurrent.TimeUnit.class));
|
||||
@@ -1681,8 +1666,7 @@ public class VotingPluginProxyTest {
|
||||
com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class, String.class, java.util.UUID.class);
|
||||
receive.setAccessible(true);
|
||||
|
||||
receive.invoke(spyProxy, "Player", "Service", true, false, 100L, null,
|
||||
"00000000-0000-0000-0000-000000000001", voteId);
|
||||
ReflectiveAccess.invoke(receive, spyProxy, new Object[]{"Player", "Service", true, false, 100L, null, "00000000-0000-0000-0000-000000000001", voteId});
|
||||
|
||||
verify(spyProxy, never()).addVoteParty();
|
||||
}
|
||||
@@ -1709,8 +1693,7 @@ public class VotingPluginProxyTest {
|
||||
com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class, String.class, java.util.UUID.class);
|
||||
receive.setAccessible(true);
|
||||
|
||||
receive.invoke(spyProxy, "Player", "Service", false, true, 100L, null,
|
||||
"00000000-0000-0000-0000-000000000001", voteId);
|
||||
ReflectiveAccess.invoke(receive, spyProxy, new Object[]{"Player", "Service", false, true, 100L, null, "00000000-0000-0000-0000-000000000001", voteId});
|
||||
|
||||
assertEquals(1, queue.size());
|
||||
assertEquals(voteId, queue.element().getVoteId());
|
||||
@@ -1790,8 +1773,7 @@ public class VotingPluginProxyTest {
|
||||
com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class, String.class, java.util.UUID.class);
|
||||
receive.setAccessible(true);
|
||||
|
||||
receive.invoke(spyProxy, "Player", "Service", true, false, 100L, null,
|
||||
"00000000-0000-0000-0000-000000000001", voteId);
|
||||
ReflectiveAccess.invoke(receive, spyProxy, new Object[]{"Player", "Service", true, false, 100L, null, "00000000-0000-0000-0000-000000000001", voteId});
|
||||
spyProxy.onDisable(false);
|
||||
|
||||
verify(spyProxy).addVoteParty();
|
||||
@@ -1825,8 +1807,7 @@ public class VotingPluginProxyTest {
|
||||
com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class, String.class, java.util.UUID.class);
|
||||
receive.setAccessible(true);
|
||||
|
||||
receive.invoke(spyProxy, "Player", "Service", true, false, 100L, null,
|
||||
"00000000-0000-0000-0000-000000000001", voteId);
|
||||
ReflectiveAccess.invoke(receive, spyProxy, new Object[]{"Player", "Service", true, false, 100L, null, "00000000-0000-0000-0000-000000000001", voteId});
|
||||
spyProxy.onDisable(false);
|
||||
|
||||
java.lang.reflect.Field retries = VotingPluginProxy.class.getDeclaredField("multiProxyVoteRetries");
|
||||
@@ -1895,7 +1876,7 @@ public class VotingPluginProxyTest {
|
||||
Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler();
|
||||
java.lang.reflect.Field decider = VotingPluginProxy.class.getDeclaredField("proxyBroadcastDecider");
|
||||
decider.setAccessible(true);
|
||||
decider.set(spyProxy, new ProxyBroadcastDecider(spyProxy::getConfig, spyProxy::getAllAvailableServers,
|
||||
ReflectiveAccess.set(decider, spyProxy, new ProxyBroadcastDecider(spyProxy::getConfig, spyProxy::getAllAvailableServers,
|
||||
server -> true, server -> false));
|
||||
Mockito.doNothing().when(spyProxy).addVoteParty();
|
||||
Mockito.doReturn(true).when(spyProxy).checkVoteDelay(Mockito.anyString(), Mockito.anyString(),
|
||||
@@ -1931,8 +1912,7 @@ public class VotingPluginProxyTest {
|
||||
com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class, String.class, java.util.UUID.class);
|
||||
receive.setAccessible(true);
|
||||
|
||||
receive.invoke(spyProxy, "Player", "Service", true, false, 100L, null,
|
||||
"00000000-0000-0000-0000-000000000001", java.util.UUID.randomUUID());
|
||||
ReflectiveAccess.invoke(receive, spyProxy, new Object[]{"Player", "Service", true, false, 100L, null, "00000000-0000-0000-0000-000000000001", java.util.UUID.randomUUID()});
|
||||
|
||||
java.lang.reflect.Field liveRetries = VotingPluginProxy.class.getDeclaredField("liveVoteRetries");
|
||||
liveRetries.setAccessible(true);
|
||||
@@ -2830,7 +2810,7 @@ public class VotingPluginProxyTest {
|
||||
|
||||
java.lang.reflect.Method start = VotingPluginProxy.class.getDeclaredMethod("startHttpTransportOrFallBack");
|
||||
start.setAccessible(true);
|
||||
start.invoke(spyProxy);
|
||||
ReflectiveAccess.invoke(start, spyProxy, new Object[]{});
|
||||
|
||||
assertEquals(BungeeMethod.PLUGINMESSAGING, spyProxy.getMethod());
|
||||
assertFalse(java.nio.file.Files.exists(retained));
|
||||
@@ -2849,7 +2829,7 @@ public class VotingPluginProxyTest {
|
||||
.deleteRetainedHttpListenerSettings(retained);
|
||||
java.lang.reflect.Method start = VotingPluginProxy.class.getDeclaredMethod("startHttpTransportOrFallBack");
|
||||
start.setAccessible(true);
|
||||
start.invoke(spyProxy);
|
||||
ReflectiveAccess.invoke(start, spyProxy, new Object[]{});
|
||||
assertEquals(BungeeMethod.PLUGINMESSAGING, spyProxy.getMethod());
|
||||
assertTrue(java.nio.file.Files.isRegularFile(retained));
|
||||
spyProxy.reloadFromControl();
|
||||
@@ -2867,12 +2847,12 @@ public class VotingPluginProxyTest {
|
||||
java.lang.reflect.Method start = VotingPluginProxy.class.getDeclaredMethod("startHttpTransportOrFallBack");
|
||||
start.setAccessible(true);
|
||||
|
||||
start.invoke(votingPluginProxy);
|
||||
ReflectiveAccess.invoke(start, votingPluginProxy, new Object[]{});
|
||||
|
||||
Object handler = getProxyField(votingPluginProxy, "encryptionHandler");
|
||||
assertTrue(handler != null);
|
||||
votingPluginProxy.setMethod(BungeeMethod.HTTP);
|
||||
start.invoke(votingPluginProxy);
|
||||
ReflectiveAccess.invoke(start, votingPluginProxy, new Object[]{});
|
||||
assertSame(handler, getProxyField(votingPluginProxy, "encryptionHandler"));
|
||||
}
|
||||
|
||||
@@ -2887,7 +2867,7 @@ public class VotingPluginProxyTest {
|
||||
Mockito.when(votingPluginProxy.getConfig().getHttpPublicEndpoint()).thenReturn("");
|
||||
|
||||
java.lang.reflect.InvocationTargetException failure = assertThrows(
|
||||
java.lang.reflect.InvocationTargetException.class, () -> start.invoke(votingPluginProxy), configured.name());
|
||||
java.lang.reflect.InvocationTargetException.class, () -> ReflectiveAccess.invoke(start, votingPluginProxy, new Object[]{}), configured.name());
|
||||
|
||||
assertTrue(failure.getCause() instanceof IllegalStateException, configured.name());
|
||||
assertEquals(BungeeMethod.HTTP, votingPluginProxy.getMethod(), configured.name());
|
||||
@@ -3457,7 +3437,7 @@ public class VotingPluginProxyTest {
|
||||
java.lang.reflect.Method probe = VotingPluginProxy.class
|
||||
.getDeclaredMethod("probeReliableVoteDeliveryCapabilities");
|
||||
probe.setAccessible(true);
|
||||
probe.invoke(spyProxy);
|
||||
ReflectiveAccess.invoke(probe, spyProxy, new Object[]{});
|
||||
|
||||
org.mockito.ArgumentCaptor<String> target = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||
org.mockito.ArgumentCaptor<JsonEnvelope> envelope = org.mockito.ArgumentCaptor.forClass(JsonEnvelope.class);
|
||||
@@ -4107,7 +4087,7 @@ public class VotingPluginProxyTest {
|
||||
private static void setProxyField(VotingPluginProxy target, String name, Object value) throws Exception {
|
||||
java.lang.reflect.Field field = VotingPluginProxy.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
ReflectiveAccess.set(field, target, value);
|
||||
}
|
||||
|
||||
private static Object getProxyField(VotingPluginProxy target, String name) throws Exception {
|
||||
|
||||
+5
-3
@@ -1,5 +1,7 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
@@ -496,7 +498,7 @@ public class VotingPluginProxyTestImpl extends VotingPluginProxy {
|
||||
HttpProxyTransportServer.DeliveryRetryException.class
|
||||
.getDeclaredConstructor(String.class, Throwable.class);
|
||||
constructor.setAccessible(true);
|
||||
throw constructor.newInstance(generatedHttpRetryId, new IllegalStateException("publication ambiguous"));
|
||||
throw ReflectiveAccess.newInstance(constructor, new Object[]{generatedHttpRetryId, new IllegalStateException("publication ambiguous")});
|
||||
} catch (ReflectiveOperationException failure) {
|
||||
throw new AssertionError(failure);
|
||||
}
|
||||
@@ -557,7 +559,7 @@ public class VotingPluginProxyTestImpl extends VotingPluginProxy {
|
||||
HttpProxyTransportServer.DeliveryRetryException.class
|
||||
.getDeclaredConstructor(String.class, Throwable.class);
|
||||
constructor.setAccessible(true);
|
||||
return constructor.newInstance(deliveryId, new IllegalStateException("publication ambiguous"));
|
||||
return ReflectiveAccess.newInstance(constructor, new Object[]{deliveryId, new IllegalStateException("publication ambiguous")});
|
||||
} catch (ReflectiveOperationException failure) {
|
||||
throw new AssertionError(failure);
|
||||
}
|
||||
@@ -628,7 +630,7 @@ public class VotingPluginProxyTestImpl extends VotingPluginProxy {
|
||||
try {
|
||||
java.lang.reflect.Field field = VotingPluginProxy.class.getDeclaredField("globalMessageProxyHandler");
|
||||
field.setAccessible(true);
|
||||
field.set(this, handler);
|
||||
ReflectiveAccess.set(field, this, handler);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.bencodez.votingplugin.tests.backendproxy;
|
||||
|
||||
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
|
||||
import com.bencodez.votingplugin.backendproxy.*;
|
||||
import com.bencodez.votingplugin.backendproxy.BackendOrderedVoteOverflowQueue.*;
|
||||
import java.util.function.Consumer;
|
||||
import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
|
||||
|
||||
/** Typed test-only access to package-private BackendOrderedVoteOverflowQueue seams. */
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
public final class BackendOrderedVoteOverflowQueueAccess {
|
||||
public static final int MAX_NORMAL_ENTRIES = (Integer) ReflectiveAccess.field("com.bencodez.votingplugin.backendproxy.BackendOrderedVoteOverflowQueue", "MAX_NORMAL_ENTRIES");
|
||||
public static final int MAX_FAILED_ENTRIES = (Integer) ReflectiveAccess.field("com.bencodez.votingplugin.backendproxy.BackendOrderedVoteOverflowQueue", "MAX_FAILED_ENTRIES");
|
||||
private BackendOrderedVoteOverflowQueueAccess() { }
|
||||
public static boolean enqueue(BackendOrderedVoteOverflowQueue target, JsonEnvelope envelope) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.BackendOrderedVoteOverflowQueue", target, "enqueue", new String[]{"JsonEnvelope"}, new Object[]{envelope}); }
|
||||
public static boolean enqueue(BackendOrderedVoteOverflowQueue target, JsonEnvelope envelope, int reservedPrefixEntries) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.BackendOrderedVoteOverflowQueue", target, "enqueue", new String[]{"JsonEnvelope", "int"}, new Object[]{envelope, reservedPrefixEntries}); }
|
||||
public static int failedSize(BackendOrderedVoteOverflowQueue target) { return (Integer) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.BackendOrderedVoteOverflowQueue", target, "failedSize", new String[]{}, new Object[]{}); }
|
||||
public static boolean isQuarantineCapacityExhausted(BackendOrderedVoteOverflowQueue target) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.BackendOrderedVoteOverflowQueue", target, "isQuarantineCapacityExhausted", new String[]{}, new Object[]{}); }
|
||||
public static boolean quarantineForShutdown(BackendOrderedVoteOverflowQueue target, BackendOrderedVoteOverflowQueueAccess.PendingEnvelope expected, JsonEnvelope envelope) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.BackendOrderedVoteOverflowQueue", target, "quarantineForShutdown", new String[]{"PendingEnvelope", "JsonEnvelope"}, new Object[]{expected, envelope}); }
|
||||
public static void quarantineAsync(BackendOrderedVoteOverflowQueue target, BackendOrderedVoteOverflowQueueAccess.PendingEnvelope expected, JsonEnvelope envelope, Consumer<Boolean> completion) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.BackendOrderedVoteOverflowQueue", target, "quarantineAsync", new String[]{"PendingEnvelope", "JsonEnvelope", "Consumer"}, new Object[]{expected, envelope, completion}); }
|
||||
public static BackendOrderedVoteOverflowQueueAccess.PendingEnvelope peekDurable(BackendOrderedVoteOverflowQueue target) { return BackendOrderedVoteOverflowQueueAccess.PendingEnvelope.view(ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.BackendOrderedVoteOverflowQueue", target, "peekDurable", new String[]{}, new Object[]{})); }
|
||||
public static boolean awaitClose(BackendOrderedVoteOverflowQueue target, long timeoutMillis) throws InterruptedException { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.BackendOrderedVoteOverflowQueue", target, "awaitClose", new String[]{"long"}, new Object[]{timeoutMillis}); }
|
||||
public static BackendOrderedVoteOverflowQueueAccess.CloseState closeState(BackendOrderedVoteOverflowQueue target) { return ReflectiveAccess.enumView(BackendOrderedVoteOverflowQueueAccess.CloseState.class, ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.BackendOrderedVoteOverflowQueue", target, "closeState", new String[]{}, new Object[]{})); }
|
||||
public enum CloseState { OPEN, CLOSING, WAITING_TO_PERSIST, SNAPSHOT_REPLACED, COMPLETE, FAILED }
|
||||
|
||||
public static final class PendingEnvelope implements ReflectiveAccess.View {
|
||||
private final Object delegate;
|
||||
private PendingEnvelope(Object delegate) { this.delegate = delegate; }
|
||||
public static PendingEnvelope view(Object delegate) { return delegate == null ? null : new PendingEnvelope(delegate); }
|
||||
@Override public Object delegate() { return delegate; }
|
||||
@Override public boolean equals(Object other) { return other instanceof PendingEnvelope value && delegate.equals(value.delegate); }
|
||||
@Override public int hashCode() { return delegate.hashCode(); }
|
||||
public JsonEnvelope envelope() { return (JsonEnvelope) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.BackendOrderedVoteOverflowQueue$PendingEnvelope", delegate, "envelope", new String[]{}, new Object[]{}); }
|
||||
}
|
||||
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.bencodez.votingplugin.tests.backendproxy;
|
||||
|
||||
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
|
||||
import com.bencodez.votingplugin.backendproxy.*;
|
||||
import com.bencodez.votingplugin.backendproxy.BackendProxyHandler.*;
|
||||
import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
|
||||
|
||||
/** Typed test-only access to package-private BackendProxyHandler seams. */
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
public final class BackendProxyHandlerAccess {
|
||||
public static void dispatchIncomingAfterPublication(BackendProxyHandler target, JsonEnvelope envelope, Runnable localDispatch) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.BackendProxyHandler", target, "dispatchIncomingAfterPublication", new String[]{"JsonEnvelope", "Runnable"}, new Object[]{envelope, localDispatch}); }
|
||||
public static final int MAX_ORDERED_VOTE_QUEUE = (Integer) ReflectiveAccess.field("com.bencodez.votingplugin.backendproxy.BackendProxyHandler", "MAX_ORDERED_VOTE_QUEUE");
|
||||
private BackendProxyHandlerAccess() { }
|
||||
|
||||
}
|
||||
+125
-119
@@ -1,4 +1,10 @@
|
||||
package com.bencodez.votingplugin.backendproxy;
|
||||
package com.bencodez.votingplugin.tests.backendproxy;
|
||||
|
||||
import com.bencodez.votingplugin.tests.backendproxy.cache.ProcessedVoteCacheAccess;
|
||||
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
|
||||
|
||||
import com.bencodez.votingplugin.backendproxy.BackendOrderedVoteOverflowQueue;
|
||||
import com.bencodez.votingplugin.backendproxy.BackendProxyHandler;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
@@ -197,7 +203,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
TransportEnvelopeEncryption.Domain.PROXY_BACKEND, false));
|
||||
BackendProxyTransportManager manager = (BackendProxyTransportManager) getField(handler, "transportManager");
|
||||
BackendProxyTransport transport = method == BungeeMethod.REDIS
|
||||
? new RedisBackendProxyTransport(plugin, new ProcessedVoteCache())
|
||||
? new RedisBackendProxyTransport(plugin, ProcessedVoteCacheAccess.create())
|
||||
: new MqttBackendProxyTransport(plugin);
|
||||
setField(transport, "authenticator", SharedTransportEnvelopeAuthenticator.load(keyFile,
|
||||
Mode.COMPATIBILITY));
|
||||
@@ -233,7 +239,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
setField(handler, "method", BungeeMethod.REDIS);
|
||||
setField(handler, "sharedTransportMode", Mode.COMPATIBILITY);
|
||||
setField(handler, "communicationEncryptionEnabled", false);
|
||||
RedisBackendProxyTransport transport = new RedisBackendProxyTransport(plugin, new ProcessedVoteCache());
|
||||
RedisBackendProxyTransport transport = new RedisBackendProxyTransport(plugin, ProcessedVoteCacheAccess.create());
|
||||
SharedTransportEnvelopeAuthenticator original = SharedTransportEnvelopeAuthenticator.load(
|
||||
dataDirectory.resolve("secretkey.key"), Mode.COMPATIBILITY);
|
||||
setField(transport, "authenticator", original);
|
||||
@@ -267,7 +273,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
setField(handler, "communicationEncryption", originalEncryption);
|
||||
BackendProxyTransportManager manager = (BackendProxyTransportManager) getField(handler, "transportManager");
|
||||
BackendProxyTransport transport = method == BungeeMethod.REDIS
|
||||
? new RedisBackendProxyTransport(plugin, new ProcessedVoteCache())
|
||||
? new RedisBackendProxyTransport(plugin, ProcessedVoteCacheAccess.create())
|
||||
: new MqttBackendProxyTransport(plugin);
|
||||
SharedTransportEnvelopeAuthenticator originalAuthenticator =
|
||||
SharedTransportEnvelopeAuthenticator.load(keyFile, Mode.REQUIRED);
|
||||
@@ -298,7 +304,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
handler.activateInboundMessages();
|
||||
Runnable localDispatch = mock(Runnable.class);
|
||||
|
||||
handler.dispatchIncomingAfterPublication(
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler,
|
||||
JsonEnvelope.builder(VotingPluginWire.SUB_BUNGEE_TIME_CHANGE).build(), localDispatch);
|
||||
|
||||
verify(scheduler).runTaskAsynchronously(plugin, localDispatch);
|
||||
@@ -338,9 +344,9 @@ class BackendProxyHandlerLifecycleTest {
|
||||
return null;
|
||||
}).when(router).handleOrderedVote(any(JsonEnvelope.class), any());
|
||||
|
||||
handler.dispatchIncomingAfterPublication(voteEnvelope, mock(Runnable.class));
|
||||
handler.dispatchIncomingAfterPublication(updateEnvelope, mock(Runnable.class));
|
||||
handler.dispatchIncomingAfterPublication(onlineEnvelope, mock(Runnable.class));
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, voteEnvelope, mock(Runnable.class));
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, updateEnvelope, mock(Runnable.class));
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, onlineEnvelope, mock(Runnable.class));
|
||||
|
||||
assertEquals(1, asyncTasks.size(), "only the head of the ordered vote lane should be scheduled");
|
||||
|
||||
@@ -391,9 +397,9 @@ class BackendProxyHandlerLifecycleTest {
|
||||
return null;
|
||||
}).when(router).handleOrderedVote(eq(release), any());
|
||||
|
||||
handler.dispatchIncomingAfterPublication(blockedVote, mock(Runnable.class));
|
||||
handler.dispatchIncomingAfterPublication(release, mock(Runnable.class));
|
||||
handler.dispatchIncomingAfterPublication(secondRelease, mock(Runnable.class));
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, blockedVote, mock(Runnable.class));
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, release, mock(Runnable.class));
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, secondRelease, mock(Runnable.class));
|
||||
|
||||
assertEquals(2, asyncTasks.size(), "only one durable release should use the bounded release worker");
|
||||
asyncTasks.removeLast().run();
|
||||
@@ -419,7 +425,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
doThrow(new java.util.concurrent.RejectedExecutionException("stopping"))
|
||||
.when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class));
|
||||
|
||||
handler.dispatchIncomingAfterPublication(release, mock(Runnable.class));
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, release, mock(Runnable.class));
|
||||
|
||||
verify(router, never()).handleOrderedVote(eq(release), any());
|
||||
AtomicBoolean active = (AtomicBoolean) getField(handler, "durableReceiptReleaseActive");
|
||||
@@ -482,8 +488,8 @@ class BackendProxyHandlerLifecycleTest {
|
||||
"survival", UUID.randomUUID(), VotingPluginWire.SUB_VOTE);
|
||||
when(router.isValidReceiptRelease(release)).thenReturn(true);
|
||||
|
||||
handler.dispatchIncomingAfterPublication(olderVote, mock(Runnable.class));
|
||||
handler.dispatchIncomingAfterPublication(release, mock(Runnable.class));
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, olderVote, mock(Runnable.class));
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, release, mock(Runnable.class));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArrayDeque<JsonEnvelope> queued = (ArrayDeque<JsonEnvelope>) getField(handler, "orderedVoteDispatchQueue");
|
||||
@@ -521,7 +527,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
VotingPluginWire.vote("Player", UUID.randomUUID().toString(), "site", 10L,
|
||||
true, true, "", voteId, false, false, 1, 1));
|
||||
|
||||
handler.dispatchIncomingAfterPublication(envelope, mock(Runnable.class));
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, envelope, mock(Runnable.class));
|
||||
dispatch.get().run();
|
||||
verifyNoInteractions(messages);
|
||||
completion.get().accept(OrderedVoteOutcome.COMPLETE);
|
||||
@@ -560,7 +566,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
VotingPluginWire.voteDelayRejected("Player", UUID.randomUUID().toString(),
|
||||
"site", true, voteId));
|
||||
|
||||
handler.dispatchIncomingAfterPublication(envelope, mock(Runnable.class));
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, envelope, mock(Runnable.class));
|
||||
dispatch.get().run();
|
||||
verifyNoInteractions(messages);
|
||||
completion.get().accept(OrderedVoteOutcome.COMPLETE);
|
||||
@@ -580,19 +586,19 @@ class BackendProxyHandlerLifecycleTest {
|
||||
when(plugin.getLogger()).thenReturn(Logger.getLogger("ordered-overflow-test"));
|
||||
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
|
||||
try {
|
||||
BackendProxyHandler handler = new BackendProxyHandler(plugin, new ProcessedVoteCache(), overflow);
|
||||
BackendProxyHandler handler = new BackendProxyHandler(plugin, ProcessedVoteCacheAccess.create(), overflow);
|
||||
setField(handler, "messageRouter", mock(BackendProxyMessageRouter.class));
|
||||
handler.activateInboundMessages();
|
||||
|
||||
for (int i = 0; i < BackendProxyHandler.MAX_ORDERED_VOTE_QUEUE + 1; i++) {
|
||||
handler.dispatchIncomingAfterPublication(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
for (int i = 0; i < BackendProxyHandlerAccess.MAX_ORDERED_VOTE_QUEUE + 1; i++) {
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
.put(VotingPluginWire.K_VOTE_ID, new java.util.UUID(0L, i + 1L).toString()).build(),
|
||||
mock(Runnable.class));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArrayDeque<JsonEnvelope> queued = (ArrayDeque<JsonEnvelope>) getField(handler, "orderedVoteDispatchQueue");
|
||||
assertEquals(BackendProxyHandler.MAX_ORDERED_VOTE_QUEUE, queued.size());
|
||||
assertEquals(BackendProxyHandlerAccess.MAX_ORDERED_VOTE_QUEUE, queued.size());
|
||||
assertEquals(1, overflow.size());
|
||||
} finally {
|
||||
overflow.close();
|
||||
@@ -650,14 +656,14 @@ class BackendProxyHandlerLifecycleTest {
|
||||
blocker.start();
|
||||
assertTrue(locked.await(3, TimeUnit.SECONDS));
|
||||
try {
|
||||
BackendProxyHandler handler = new BackendProxyHandler(plugin, new ProcessedVoteCache(), overflow);
|
||||
BackendProxyHandler handler = new BackendProxyHandler(plugin, ProcessedVoteCacheAccess.create(), overflow);
|
||||
setField(handler, "messageRouter", mock(BackendProxyMessageRouter.class));
|
||||
handler.activateInboundMessages();
|
||||
for (int index = 0; index < BackendProxyHandler.MAX_ORDERED_VOTE_QUEUE; index++) {
|
||||
handler.dispatchIncomingAfterPublication(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
for (int index = 0; index < BackendProxyHandlerAccess.MAX_ORDERED_VOTE_QUEUE; index++) {
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
.put("sequence", Integer.toString(index)).build(), mock(Runnable.class));
|
||||
}
|
||||
CompletableFuture<Void> admission = CompletableFuture.runAsync(() -> handler.dispatchIncomingAfterPublication(
|
||||
CompletableFuture<Void> admission = CompletableFuture.runAsync(() -> BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler,
|
||||
JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).put("sequence", "overflow").build(),
|
||||
mock(Runnable.class)));
|
||||
admission.get(500, TimeUnit.MILLISECONDS);
|
||||
@@ -684,7 +690,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
when(plugin.getLogger()).thenReturn(Logger.getLogger("ordered-unreadable-test"));
|
||||
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
|
||||
try {
|
||||
assertFalse(overflow.enqueue(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build()));
|
||||
assertFalse(BackendOrderedVoteOverflowQueueAccess.enqueue(overflow, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build()));
|
||||
} finally {
|
||||
overflow.close();
|
||||
}
|
||||
@@ -705,7 +711,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
Files.writeString(queueFile, malformed);
|
||||
BackendOrderedVoteOverflowQueue malformedQueue = new BackendOrderedVoteOverflowQueue(plugin);
|
||||
try {
|
||||
assertFalse(malformedQueue.enqueue(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build()));
|
||||
assertFalse(BackendOrderedVoteOverflowQueueAccess.enqueue(malformedQueue, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build()));
|
||||
} finally {
|
||||
malformedQueue.close();
|
||||
}
|
||||
@@ -719,7 +725,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
Files.writeString(queueFile, unsupported);
|
||||
BackendOrderedVoteOverflowQueue unsupportedQueue = new BackendOrderedVoteOverflowQueue(plugin);
|
||||
try {
|
||||
assertFalse(unsupportedQueue.enqueue(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build()));
|
||||
assertFalse(BackendOrderedVoteOverflowQueueAccess.enqueue(unsupportedQueue, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build()));
|
||||
} finally {
|
||||
unsupportedQueue.close();
|
||||
}
|
||||
@@ -734,7 +740,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
|
||||
try {
|
||||
Files.createDirectory(tempDir.resolve("BackendProxyVoteQueue.yml"));
|
||||
assertFalse(overflow.enqueue(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build()));
|
||||
assertFalse(BackendOrderedVoteOverflowQueueAccess.enqueue(overflow, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build()));
|
||||
assertEquals(0, overflow.size());
|
||||
} finally {
|
||||
overflow.close();
|
||||
@@ -744,7 +750,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
@Test
|
||||
void oversizedFailureHistoryStopsRecoveryWithoutDiscardingEvidence(@TempDir Path tempDir) throws Exception {
|
||||
StringBuilder yaml = new StringBuilder("Envelopes: []\nFailedEnvelopes:\n");
|
||||
for (int index = 0; index < BackendOrderedVoteOverflowQueue.MAX_FAILED_ENTRIES + 20; index++) {
|
||||
for (int index = 0; index < BackendOrderedVoteOverflowQueueAccess.MAX_FAILED_ENTRIES + 20; index++) {
|
||||
yaml.append("- failed-").append(index).append('\n');
|
||||
}
|
||||
Files.writeString(tempDir.resolve("BackendProxyVoteQueue.yml"), yaml);
|
||||
@@ -753,8 +759,8 @@ class BackendProxyHandlerLifecycleTest {
|
||||
when(plugin.getLogger()).thenReturn(Logger.getLogger("ordered-failure-bound-test"));
|
||||
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
|
||||
try {
|
||||
assertEquals(0, overflow.failedSize());
|
||||
assertFalse(overflow.enqueue(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build()));
|
||||
assertEquals(0, BackendOrderedVoteOverflowQueueAccess.failedSize(overflow));
|
||||
assertFalse(BackendOrderedVoteOverflowQueueAccess.enqueue(overflow, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build()));
|
||||
assertTrue(Files.readString(tempDir.resolve("BackendProxyVoteQueue.yml")).contains("failed-0"));
|
||||
} finally {
|
||||
overflow.close();
|
||||
@@ -764,7 +770,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
@Test
|
||||
void fullFailureHistoryRejectsNextQuarantineWithoutEviction(@TempDir Path tempDir) throws Exception {
|
||||
StringBuilder yaml = new StringBuilder("Envelopes: []\nFailedEnvelopes:\n");
|
||||
for (int index = 0; index < BackendOrderedVoteOverflowQueue.MAX_FAILED_ENTRIES; index++) {
|
||||
for (int index = 0; index < BackendOrderedVoteOverflowQueueAccess.MAX_FAILED_ENTRIES; index++) {
|
||||
yaml.append("- failed-").append(index).append('\n');
|
||||
}
|
||||
Files.writeString(tempDir.resolve("BackendProxyVoteQueue.yml"), yaml);
|
||||
@@ -773,10 +779,10 @@ class BackendProxyHandlerLifecycleTest {
|
||||
when(plugin.getLogger()).thenReturn(Logger.getLogger("ordered-failure-capacity-test"));
|
||||
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
|
||||
try {
|
||||
assertFalse(overflow.quarantineForShutdown(null, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
assertFalse(BackendOrderedVoteOverflowQueueAccess.quarantineForShutdown(overflow, null, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
.put("sequence", "rejected").build()));
|
||||
assertEquals(BackendOrderedVoteOverflowQueue.MAX_FAILED_ENTRIES, overflow.failedSize());
|
||||
assertTrue(overflow.isQuarantineCapacityExhausted());
|
||||
assertEquals(BackendOrderedVoteOverflowQueueAccess.MAX_FAILED_ENTRIES, BackendOrderedVoteOverflowQueueAccess.failedSize(overflow));
|
||||
assertTrue(BackendOrderedVoteOverflowQueueAccess.isQuarantineCapacityExhausted(overflow));
|
||||
@SuppressWarnings("unchecked")
|
||||
ArrayDeque<String> failures = (ArrayDeque<String>) getField(overflow, "failedEntries");
|
||||
assertEquals("failed-0", failures.peekFirst());
|
||||
@@ -784,9 +790,9 @@ class BackendProxyHandlerLifecycleTest {
|
||||
|
||||
BukkitScheduler scheduler = mock(BukkitScheduler.class);
|
||||
when(plugin.getBukkitScheduler()).thenReturn(scheduler);
|
||||
BackendProxyHandler handler = new BackendProxyHandler(plugin, new ProcessedVoteCache(), overflow);
|
||||
BackendProxyHandler handler = new BackendProxyHandler(plugin, ProcessedVoteCacheAccess.create(), overflow);
|
||||
handler.activateInboundMessages();
|
||||
handler.dispatchIncomingAfterPublication(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build(),
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build(),
|
||||
mock(Runnable.class));
|
||||
verify(scheduler, never()).runTaskAsynchronously(eq(plugin), any(Runnable.class));
|
||||
} finally {
|
||||
@@ -804,19 +810,19 @@ class BackendProxyHandlerLifecycleTest {
|
||||
UUID.randomUUID().toString(), "site", 10L, true, true, "", voteId, false, false, 1, 1));
|
||||
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
|
||||
try {
|
||||
assertTrue(overflow.quarantineForShutdown(null, vote));
|
||||
assertTrue(BackendOrderedVoteOverflowQueueAccess.quarantineForShutdown(overflow, null, vote));
|
||||
long firstDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
|
||||
while (overflow.failedSize() != 1 && System.nanoTime() < firstDeadline) Thread.sleep(10L);
|
||||
assertEquals(1, overflow.failedSize());
|
||||
while (BackendOrderedVoteOverflowQueueAccess.failedSize(overflow) != 1 && System.nanoTime() < firstDeadline) Thread.sleep(10L);
|
||||
assertEquals(1, BackendOrderedVoteOverflowQueueAccess.failedSize(overflow));
|
||||
|
||||
assertTrue(overflow.enqueue(vote));
|
||||
BackendOrderedVoteOverflowQueue.PendingEnvelope retry = overflow.peekDurable();
|
||||
assertTrue(BackendOrderedVoteOverflowQueueAccess.enqueue(overflow, vote));
|
||||
BackendOrderedVoteOverflowQueueAccess.PendingEnvelope retry = BackendOrderedVoteOverflowQueueAccess.peekDurable(overflow);
|
||||
assertNotNull(retry);
|
||||
CompletableFuture<Boolean> quarantined = new CompletableFuture<>();
|
||||
overflow.quarantineAsync(retry, vote, quarantined::complete);
|
||||
BackendOrderedVoteOverflowQueueAccess.quarantineAsync(overflow, retry, vote, quarantined::complete);
|
||||
|
||||
assertTrue(quarantined.get(3, TimeUnit.SECONDS));
|
||||
assertEquals(1, overflow.failedSize());
|
||||
assertEquals(1, BackendOrderedVoteOverflowQueueAccess.failedSize(overflow));
|
||||
assertEquals(0, overflow.size());
|
||||
} finally {
|
||||
overflow.close();
|
||||
@@ -824,7 +830,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
|
||||
BackendOrderedVoteOverflowQueue recovered = new BackendOrderedVoteOverflowQueue(plugin);
|
||||
try {
|
||||
assertEquals(1, recovered.failedSize());
|
||||
assertEquals(1, BackendOrderedVoteOverflowQueueAccess.failedSize(recovered));
|
||||
assertEquals(0, recovered.size());
|
||||
} finally {
|
||||
recovered.close();
|
||||
@@ -845,7 +851,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
}).when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class));
|
||||
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
|
||||
try {
|
||||
BackendProxyHandler handler = new BackendProxyHandler(plugin, new ProcessedVoteCache(), overflow);
|
||||
BackendProxyHandler handler = new BackendProxyHandler(plugin, ProcessedVoteCacheAccess.create(), overflow);
|
||||
BackendProxyMessageRouter router = mock(BackendProxyMessageRouter.class);
|
||||
setField(handler, "messageRouter", router);
|
||||
AtomicInteger attempts = new AtomicInteger();
|
||||
@@ -856,9 +862,9 @@ class BackendProxyHandlerLifecycleTest {
|
||||
return null;
|
||||
}).when(router).handleOrderedVote(any(JsonEnvelope.class), any());
|
||||
handler.activateInboundMessages();
|
||||
assertTrue(overflow.enqueue(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
assertTrue(BackendOrderedVoteOverflowQueueAccess.enqueue(overflow, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
.put("sequence", "failed").build()));
|
||||
assertTrue(overflow.enqueue(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
assertTrue(BackendOrderedVoteOverflowQueueAccess.enqueue(overflow, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
.put("sequence", "next").build()));
|
||||
Runnable first = asyncTasks.poll(3, TimeUnit.SECONDS);
|
||||
assertNotNull(first);
|
||||
@@ -870,7 +876,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
|
||||
while (overflow.size() != 0 && System.nanoTime() < deadline) Thread.sleep(10);
|
||||
assertEquals(0, overflow.size());
|
||||
assertEquals(1, overflow.failedSize());
|
||||
assertEquals(1, BackendOrderedVoteOverflowQueueAccess.failedSize(overflow));
|
||||
assertTrue(Files.exists(tempDir.resolve("BackendProxyVoteQueue.yml")));
|
||||
} finally {
|
||||
overflow.close();
|
||||
@@ -878,7 +884,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
BackendOrderedVoteOverflowQueue recovered = new BackendOrderedVoteOverflowQueue(plugin);
|
||||
try {
|
||||
assertEquals(0, recovered.size());
|
||||
assertEquals(1, recovered.failedSize());
|
||||
assertEquals(1, BackendOrderedVoteOverflowQueueAccess.failedSize(recovered));
|
||||
} finally {
|
||||
recovered.close();
|
||||
}
|
||||
@@ -898,22 +904,22 @@ class BackendProxyHandlerLifecycleTest {
|
||||
}).when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class));
|
||||
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
|
||||
try {
|
||||
BackendProxyHandler handler = new BackendProxyHandler(plugin, new ProcessedVoteCache(), overflow);
|
||||
BackendProxyHandler handler = new BackendProxyHandler(plugin, ProcessedVoteCacheAccess.create(), overflow);
|
||||
BackendProxyMessageRouter router = mock(BackendProxyMessageRouter.class);
|
||||
setField(handler, "messageRouter", router);
|
||||
doThrow(new IllegalStateException("partial reward")).when(router)
|
||||
.handleOrderedVote(any(JsonEnvelope.class), any());
|
||||
handler.activateInboundMessages();
|
||||
handler.dispatchIncomingAfterPublication(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
.put("sequence", "failed").build(), mock(Runnable.class));
|
||||
Runnable first = asyncTasks.poll(3, TimeUnit.SECONDS);
|
||||
assertNotNull(first);
|
||||
assertThrows(IllegalStateException.class, first::run);
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
|
||||
while (overflow.failedSize() == 0 && System.nanoTime() < deadline) {
|
||||
while (BackendOrderedVoteOverflowQueueAccess.failedSize(overflow) == 0 && System.nanoTime() < deadline) {
|
||||
Thread.sleep(10);
|
||||
}
|
||||
assertEquals(1, overflow.failedSize());
|
||||
assertEquals(1, BackendOrderedVoteOverflowQueueAccess.failedSize(overflow));
|
||||
assertEquals(0, overflow.size());
|
||||
assertTrue(asyncTasks.isEmpty(), "failed reward must not be retried");
|
||||
} finally {
|
||||
@@ -947,13 +953,13 @@ class BackendProxyHandlerLifecycleTest {
|
||||
});
|
||||
assertTrue(occupied.await(3, TimeUnit.SECONDS));
|
||||
try {
|
||||
BackendProxyHandler handler = new BackendProxyHandler(plugin, new ProcessedVoteCache(), overflow);
|
||||
BackendProxyHandler handler = new BackendProxyHandler(plugin, ProcessedVoteCacheAccess.create(), overflow);
|
||||
BackendProxyMessageRouter router = mock(BackendProxyMessageRouter.class);
|
||||
setField(handler, "messageRouter", router);
|
||||
doThrow(new IllegalStateException("partial reward")).when(router)
|
||||
.handleOrderedVote(any(JsonEnvelope.class), any());
|
||||
handler.activateInboundMessages();
|
||||
handler.dispatchIncomingAfterPublication(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
.put("sequence", "failed").build(), mock(Runnable.class));
|
||||
Runnable first = asyncTasks.poll(3, TimeUnit.SECONDS);
|
||||
assertNotNull(first);
|
||||
@@ -966,7 +972,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
BackendOrderedVoteOverflowQueue recovered = new BackendOrderedVoteOverflowQueue(plugin);
|
||||
try {
|
||||
assertEquals(0, recovered.size());
|
||||
assertEquals(1, recovered.failedSize());
|
||||
assertEquals(1, BackendOrderedVoteOverflowQueueAccess.failedSize(recovered));
|
||||
} finally {
|
||||
recovered.close();
|
||||
}
|
||||
@@ -980,29 +986,29 @@ class BackendProxyHandlerLifecycleTest {
|
||||
when(plugin.getLogger()).thenReturn(Logger.getLogger("ordered-capacity-test"));
|
||||
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
|
||||
try {
|
||||
BackendProxyHandler handler = new BackendProxyHandler(plugin, new ProcessedVoteCache(), overflow);
|
||||
BackendProxyHandler handler = new BackendProxyHandler(plugin, ProcessedVoteCacheAccess.create(), overflow);
|
||||
handler.activateInboundMessages();
|
||||
for (int index = 0; index < BackendProxyHandler.MAX_ORDERED_VOTE_QUEUE; index++) {
|
||||
handler.dispatchIncomingAfterPublication(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
for (int index = 0; index < BackendProxyHandlerAccess.MAX_ORDERED_VOTE_QUEUE; index++) {
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
.put("sequence", Integer.toString(index)).build(), mock(Runnable.class));
|
||||
}
|
||||
for (int index = 0; index < BackendOrderedVoteOverflowQueue.MAX_NORMAL_ENTRIES; index++) {
|
||||
assertTrue(overflow.enqueue(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
.put("sequence", Integer.toString(index + BackendProxyHandler.MAX_ORDERED_VOTE_QUEUE))
|
||||
.build(), BackendProxyHandler.MAX_ORDERED_VOTE_QUEUE));
|
||||
for (int index = 0; index < BackendOrderedVoteOverflowQueueAccess.MAX_NORMAL_ENTRIES; index++) {
|
||||
assertTrue(BackendOrderedVoteOverflowQueueAccess.enqueue(overflow, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
.put("sequence", Integer.toString(index + BackendProxyHandlerAccess.MAX_ORDERED_VOTE_QUEUE))
|
||||
.build(), BackendProxyHandlerAccess.MAX_ORDERED_VOTE_QUEUE));
|
||||
}
|
||||
assertFalse(overflow.enqueue(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build(),
|
||||
BackendProxyHandler.MAX_ORDERED_VOTE_QUEUE));
|
||||
assertFalse(BackendOrderedVoteOverflowQueueAccess.enqueue(overflow, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build(),
|
||||
BackendProxyHandlerAccess.MAX_ORDERED_VOTE_QUEUE));
|
||||
handler.close();
|
||||
assertEquals(BackendProxyHandler.MAX_ORDERED_VOTE_QUEUE
|
||||
+ BackendOrderedVoteOverflowQueue.MAX_NORMAL_ENTRIES, overflow.size());
|
||||
assertEquals(BackendProxyHandlerAccess.MAX_ORDERED_VOTE_QUEUE
|
||||
+ BackendOrderedVoteOverflowQueueAccess.MAX_NORMAL_ENTRIES, overflow.size());
|
||||
} finally {
|
||||
overflow.close();
|
||||
}
|
||||
BackendOrderedVoteOverflowQueue recovered = new BackendOrderedVoteOverflowQueue(plugin);
|
||||
try {
|
||||
assertEquals(BackendProxyHandler.MAX_ORDERED_VOTE_QUEUE
|
||||
+ BackendOrderedVoteOverflowQueue.MAX_NORMAL_ENTRIES, recovered.size());
|
||||
assertEquals(BackendProxyHandlerAccess.MAX_ORDERED_VOTE_QUEUE
|
||||
+ BackendOrderedVoteOverflowQueueAccess.MAX_NORMAL_ENTRIES, recovered.size());
|
||||
} finally {
|
||||
recovered.close();
|
||||
}
|
||||
@@ -1022,7 +1028,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
}).when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class));
|
||||
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
|
||||
try {
|
||||
BackendProxyHandler handler = new BackendProxyHandler(plugin, new ProcessedVoteCache(), overflow);
|
||||
BackendProxyHandler handler = new BackendProxyHandler(plugin, ProcessedVoteCacheAccess.create(), overflow);
|
||||
BackendProxyMessageRouter router = mock(BackendProxyMessageRouter.class);
|
||||
setField(handler, "messageRouter", router);
|
||||
AtomicInteger attempts = new AtomicInteger();
|
||||
@@ -1033,7 +1039,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
return null;
|
||||
}).when(router).handleOrderedVote(any(JsonEnvelope.class), any());
|
||||
handler.activateInboundMessages();
|
||||
assertTrue(overflow.enqueue(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE).build()));
|
||||
assertTrue(BackendOrderedVoteOverflowQueueAccess.enqueue(overflow, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE).build()));
|
||||
|
||||
Runnable first = asyncTasks.poll(3, TimeUnit.SECONDS);
|
||||
assertNotNull(first, "durable head must wake the ordered lane");
|
||||
@@ -1067,7 +1073,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
}).when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class));
|
||||
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
|
||||
try {
|
||||
BackendProxyHandler handler = new BackendProxyHandler(plugin, new ProcessedVoteCache(), overflow);
|
||||
BackendProxyHandler handler = new BackendProxyHandler(plugin, ProcessedVoteCacheAccess.create(), overflow);
|
||||
BackendProxyMessageRouter router = mock(BackendProxyMessageRouter.class);
|
||||
setField(handler, "messageRouter", router);
|
||||
doAnswer(invocation -> {
|
||||
@@ -1076,7 +1082,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
return null;
|
||||
}).when(router).handleOrderedVote(any(JsonEnvelope.class), any());
|
||||
handler.activateInboundMessages();
|
||||
assertTrue(overflow.enqueue(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build()));
|
||||
assertTrue(BackendOrderedVoteOverflowQueueAccess.enqueue(overflow, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build()));
|
||||
asyncTasks.poll(3, TimeUnit.SECONDS).run();
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
|
||||
while (overflow.size() != 0 && System.nanoTime() < deadline) Thread.sleep(10);
|
||||
@@ -1118,11 +1124,11 @@ class BackendProxyHandlerLifecycleTest {
|
||||
try {
|
||||
overflow.close();
|
||||
assertTrue(System.nanoTime() - started < TimeUnit.SECONDS.toNanos(4));
|
||||
assertEquals(BackendOrderedVoteOverflowQueue.CloseState.WAITING_TO_PERSIST, overflow.closeState());
|
||||
assertEquals(BackendOrderedVoteOverflowQueueAccess.CloseState.WAITING_TO_PERSIST, BackendOrderedVoteOverflowQueueAccess.closeState(overflow));
|
||||
} finally {
|
||||
release.countDown();
|
||||
blocker.join(3_000L);
|
||||
assertTrue(overflow.awaitClose(3_000L));
|
||||
assertTrue(BackendOrderedVoteOverflowQueueAccess.awaitClose(overflow, 3_000L));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1137,7 +1143,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
when(plugin.getDataFolder()).thenReturn(tempDir.toFile());
|
||||
when(plugin.getLogger()).thenReturn(logger);
|
||||
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
|
||||
assertTrue(overflow.enqueue(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).put("sequence", "slow").build()));
|
||||
assertTrue(BackendOrderedVoteOverflowQueueAccess.enqueue(overflow, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).put("sequence", "slow").build()));
|
||||
Object persistenceLock = getField(overflow, "persistenceWriteLock");
|
||||
CountDownLatch locked = new CountDownLatch(1);
|
||||
Thread blocker = new Thread(() -> {
|
||||
@@ -1156,7 +1162,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
overflow.close();
|
||||
blocker.join(3_000L);
|
||||
assertTrue(System.nanoTime() - started >= TimeUnit.SECONDS.toNanos(1));
|
||||
assertEquals(BackendOrderedVoteOverflowQueue.CloseState.COMPLETE, overflow.closeState());
|
||||
assertEquals(BackendOrderedVoteOverflowQueueAccess.CloseState.COMPLETE, BackendOrderedVoteOverflowQueueAccess.closeState(overflow));
|
||||
assertFalse(logs.hasLevel(Level.SEVERE));
|
||||
|
||||
BackendOrderedVoteOverflowQueue recovered = new BackendOrderedVoteOverflowQueue(plugin);
|
||||
@@ -1183,7 +1189,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
|
||||
overflow.close();
|
||||
|
||||
assertEquals(BackendOrderedVoteOverflowQueue.CloseState.FAILED, overflow.closeState());
|
||||
assertEquals(BackendOrderedVoteOverflowQueueAccess.CloseState.FAILED, BackendOrderedVoteOverflowQueueAccess.closeState(overflow));
|
||||
assertTrue(logs.hasLevel(Level.SEVERE));
|
||||
LogRecord failure = logs.records.stream()
|
||||
.filter(record -> record.getLevel() == Level.SEVERE)
|
||||
@@ -1223,9 +1229,9 @@ class BackendProxyHandlerLifecycleTest {
|
||||
return null;
|
||||
}).when(router).handleOrderedVote(any(JsonEnvelope.class), any());
|
||||
handler.activateInboundMessages();
|
||||
handler.dispatchIncomingAfterPublication(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE)
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE)
|
||||
.put("sequence", "first").build(), mock(Runnable.class));
|
||||
handler.dispatchIncomingAfterPublication(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
.put("sequence", "second").build(), mock(Runnable.class));
|
||||
|
||||
asyncTasks.removeFirst().run();
|
||||
@@ -1246,12 +1252,12 @@ class BackendProxyHandlerLifecycleTest {
|
||||
when(plugin.getLogger()).thenReturn(Logger.getLogger("ordered-shutdown-test"));
|
||||
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
|
||||
try {
|
||||
BackendProxyHandler handler = new BackendProxyHandler(plugin, new ProcessedVoteCache(), overflow);
|
||||
BackendProxyHandler handler = new BackendProxyHandler(plugin, ProcessedVoteCacheAccess.create(), overflow);
|
||||
BackendProxyTransportManager transport = mock(BackendProxyTransportManager.class);
|
||||
setField(handler, "transportManager", transport);
|
||||
handler.activateInboundMessages();
|
||||
doAnswer(invocation -> {
|
||||
handler.dispatchIncomingAfterPublication(
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler,
|
||||
JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build(), mock(Runnable.class));
|
||||
return null;
|
||||
}).when(transport).close();
|
||||
@@ -1283,7 +1289,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
}).when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class));
|
||||
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
|
||||
try {
|
||||
BackendProxyHandler handler = new BackendProxyHandler(plugin, new ProcessedVoteCache(), overflow);
|
||||
BackendProxyHandler handler = new BackendProxyHandler(plugin, ProcessedVoteCacheAccess.create(), overflow);
|
||||
BackendProxyMessageRouter router = mock(BackendProxyMessageRouter.class);
|
||||
setField(handler, "messageRouter", router);
|
||||
AtomicReference<java.util.function.Consumer<OrderedVoteOutcome>> completion = new AtomicReference<>();
|
||||
@@ -1292,9 +1298,9 @@ class BackendProxyHandlerLifecycleTest {
|
||||
return null;
|
||||
}).when(router).handleOrderedVote(any(JsonEnvelope.class), any());
|
||||
handler.activateInboundMessages();
|
||||
handler.dispatchIncomingAfterPublication(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
.put("sequence", "active").build(), mock(Runnable.class));
|
||||
handler.dispatchIncomingAfterPublication(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
.put("sequence", "later").build(), mock(Runnable.class));
|
||||
asyncTasks.removeFirst().run();
|
||||
CompletableFuture<Void> closing = CompletableFuture.runAsync(handler::close);
|
||||
@@ -1323,7 +1329,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
|
||||
AtomicReference<java.util.function.Consumer<OrderedVoteOutcome>> completion = new AtomicReference<>();
|
||||
try {
|
||||
BackendProxyHandler handler = new BackendProxyHandler(plugin, new ProcessedVoteCache(), overflow);
|
||||
BackendProxyHandler handler = new BackendProxyHandler(plugin, ProcessedVoteCacheAccess.create(), overflow);
|
||||
BackendProxyMessageRouter router = mock(BackendProxyMessageRouter.class);
|
||||
setField(handler, "messageRouter", router);
|
||||
doAnswer(invocation -> {
|
||||
@@ -1331,26 +1337,26 @@ class BackendProxyHandlerLifecycleTest {
|
||||
return null;
|
||||
}).when(router).handleOrderedVote(any(JsonEnvelope.class), any());
|
||||
handler.activateInboundMessages();
|
||||
handler.dispatchIncomingAfterPublication(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
.put("sequence", "active").build(), mock(Runnable.class));
|
||||
handler.dispatchIncomingAfterPublication(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
.put("sequence", "later").build(), mock(Runnable.class));
|
||||
asyncTasks.removeFirst().run();
|
||||
|
||||
CompletableFuture.runAsync(handler::close).get(3, TimeUnit.SECONDS);
|
||||
long quarantineDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
|
||||
while (overflow.failedSize() != 1 && System.nanoTime() < quarantineDeadline) Thread.sleep(10L);
|
||||
assertEquals(1, overflow.failedSize());
|
||||
while (BackendOrderedVoteOverflowQueueAccess.failedSize(overflow) != 1 && System.nanoTime() < quarantineDeadline) Thread.sleep(10L);
|
||||
assertEquals(1, BackendOrderedVoteOverflowQueueAccess.failedSize(overflow));
|
||||
assertEquals(1, overflow.size());
|
||||
completion.get().accept(OrderedVoteOutcome.COMPLETE);
|
||||
assertEquals(1, overflow.failedSize());
|
||||
assertEquals(1, BackendOrderedVoteOverflowQueueAccess.failedSize(overflow));
|
||||
assertEquals(1, overflow.size());
|
||||
} finally {
|
||||
overflow.close();
|
||||
}
|
||||
BackendOrderedVoteOverflowQueue recovered = new BackendOrderedVoteOverflowQueue(plugin);
|
||||
try {
|
||||
assertEquals(1, recovered.failedSize());
|
||||
assertEquals(1, BackendOrderedVoteOverflowQueueAccess.failedSize(recovered));
|
||||
assertEquals(1, recovered.size());
|
||||
} finally {
|
||||
recovered.close();
|
||||
@@ -1372,32 +1378,32 @@ class BackendProxyHandlerLifecycleTest {
|
||||
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
|
||||
AtomicReference<java.util.function.Consumer<OrderedVoteOutcome>> completion = new AtomicReference<>();
|
||||
try {
|
||||
BackendProxyHandler handler = new BackendProxyHandler(plugin, new ProcessedVoteCache(), overflow);
|
||||
BackendProxyHandler handler = new BackendProxyHandler(plugin, ProcessedVoteCacheAccess.create(), overflow);
|
||||
BackendProxyMessageRouter router = mock(BackendProxyMessageRouter.class);
|
||||
setField(handler, "messageRouter", router);
|
||||
doAnswer(invocation -> {
|
||||
completion.set(invocation.getArgument(1));
|
||||
return null;
|
||||
}).when(router).handleOrderedVote(any(JsonEnvelope.class), any());
|
||||
assertTrue(overflow.enqueue(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
assertTrue(BackendOrderedVoteOverflowQueueAccess.enqueue(overflow, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
|
||||
.put("sequence", "active").build()));
|
||||
handler.activateInboundMessages();
|
||||
asyncTasks.removeFirst().run();
|
||||
|
||||
CompletableFuture.runAsync(handler::close).get(3, TimeUnit.SECONDS);
|
||||
long quarantineDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
|
||||
while (overflow.failedSize() != 1 && System.nanoTime() < quarantineDeadline) Thread.sleep(10L);
|
||||
assertEquals(1, overflow.failedSize());
|
||||
while (BackendOrderedVoteOverflowQueueAccess.failedSize(overflow) != 1 && System.nanoTime() < quarantineDeadline) Thread.sleep(10L);
|
||||
assertEquals(1, BackendOrderedVoteOverflowQueueAccess.failedSize(overflow));
|
||||
assertEquals(0, overflow.size());
|
||||
completion.get().accept(OrderedVoteOutcome.COMPLETE);
|
||||
assertEquals(1, overflow.failedSize());
|
||||
assertEquals(1, BackendOrderedVoteOverflowQueueAccess.failedSize(overflow));
|
||||
assertEquals(0, overflow.size());
|
||||
} finally {
|
||||
overflow.close();
|
||||
}
|
||||
BackendOrderedVoteOverflowQueue recovered = new BackendOrderedVoteOverflowQueue(plugin);
|
||||
try {
|
||||
assertEquals(1, recovered.failedSize());
|
||||
assertEquals(1, BackendOrderedVoteOverflowQueueAccess.failedSize(recovered));
|
||||
assertEquals(0, recovered.size());
|
||||
} finally {
|
||||
recovered.close();
|
||||
@@ -1419,12 +1425,12 @@ class BackendProxyHandlerLifecycleTest {
|
||||
|
||||
JsonEnvelope first = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).put("sequence", "1").build();
|
||||
JsonEnvelope second = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE).put("sequence", "2").build();
|
||||
previous.dispatchIncomingAfterPublication(first, mock(Runnable.class));
|
||||
previous.dispatchIncomingAfterPublication(second, mock(Runnable.class));
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(previous, first, mock(Runnable.class));
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(previous, second, mock(Runnable.class));
|
||||
|
||||
previous.completeOrderedVoteHandoff(replacement);
|
||||
JsonEnvelope late = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_ONLINE).put("sequence", "3").build();
|
||||
previous.dispatchIncomingAfterPublication(late, mock(Runnable.class));
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(previous, late, mock(Runnable.class));
|
||||
|
||||
ArrayDeque<Runnable> asyncTasks = new ArrayDeque<>();
|
||||
doAnswer(invocation -> {
|
||||
@@ -1498,7 +1504,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
|
||||
CompletableFuture<Void> callback = CompletableFuture.runAsync(() -> {
|
||||
started.countDown();
|
||||
handler.dispatchIncomingAfterPublication(envelope, localDispatch);
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, envelope, localDispatch);
|
||||
});
|
||||
assertTrue(started.await(1, TimeUnit.SECONDS));
|
||||
assertFalse(callback.isDone());
|
||||
@@ -1524,7 +1530,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
|
||||
CompletableFuture<Void> callback = CompletableFuture.runAsync(() -> {
|
||||
started.countDown();
|
||||
replacement.dispatchIncomingAfterPublication(envelope, replacementDispatch);
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(replacement, envelope, replacementDispatch);
|
||||
});
|
||||
assertTrue(started.await(1, TimeUnit.SECONDS));
|
||||
assertFalse(callback.isDone());
|
||||
@@ -1556,7 +1562,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
|
||||
CompletableFuture<Void> callback = CompletableFuture.runAsync(() -> {
|
||||
started.countDown();
|
||||
replacement.dispatchIncomingAfterPublication(envelope, replacementDispatch);
|
||||
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(replacement, envelope, replacementDispatch);
|
||||
});
|
||||
assertTrue(started.await(1, TimeUnit.SECONDS));
|
||||
assertFalse(callback.isDone());
|
||||
@@ -1608,7 +1614,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
try {
|
||||
Field transportField = BackendProxyTransportManager.class.getDeclaredField("transport");
|
||||
transportField.setAccessible(true);
|
||||
transportField.set(manager, previous);
|
||||
ReflectiveAccess.set(transportField, manager, previous);
|
||||
manager.restoreAfterFailedReplacement();
|
||||
} catch (ReflectiveOperationException failure) {
|
||||
throw new AssertionError(failure);
|
||||
@@ -1677,7 +1683,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
Field targetField = com.bencodez.votingplugin.VotingPluginMain.class
|
||||
.getDeclaredField("backendPluginMessageTarget");
|
||||
targetField.setAccessible(true);
|
||||
targetField.set(plugin, new AtomicReference<GlobalMessageHandler>());
|
||||
ReflectiveAccess.set(targetField, plugin, new AtomicReference<GlobalMessageHandler>());
|
||||
|
||||
plugin.activateBackendPluginMessageHandler(firstTarget);
|
||||
current.set(second);
|
||||
@@ -1864,7 +1870,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
BackendPresenceManager presence = mock(BackendPresenceManager.class);
|
||||
Field field = BackendProxyHandler.class.getDeclaredField("presenceManager");
|
||||
field.setAccessible(true);
|
||||
field.set(handler, presence);
|
||||
ReflectiveAccess.set(field, handler, presence);
|
||||
|
||||
verifyNoInteractions(presence);
|
||||
handler.activatePresenceReporting();
|
||||
@@ -1880,7 +1886,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
org.mockito.Mockito.doThrow(new java.util.concurrent.RejectedExecutionException()).when(presence).start();
|
||||
Field presenceField = BackendProxyHandler.class.getDeclaredField("presenceManager");
|
||||
presenceField.setAccessible(true);
|
||||
presenceField.set(handler, presence);
|
||||
ReflectiveAccess.set(presenceField, handler, presence);
|
||||
|
||||
Field managerField = BackendProxyHandler.class.getDeclaredField("transportManager");
|
||||
managerField.setAccessible(true);
|
||||
@@ -1888,7 +1894,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
BackendProxyTransport transport = mock(BackendProxyTransport.class);
|
||||
Field transportField = BackendProxyTransportManager.class.getDeclaredField("transport");
|
||||
transportField.setAccessible(true);
|
||||
transportField.set(manager, transport);
|
||||
ReflectiveAccess.set(transportField, manager, transport);
|
||||
|
||||
assertThrows(java.util.concurrent.RejectedExecutionException.class, handler::activatePresenceReporting);
|
||||
|
||||
@@ -1898,7 +1904,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
|
||||
@Test
|
||||
void sharesVoteDeduplicationAcrossHandlerReplacement() {
|
||||
ProcessedVoteCache cache = new ProcessedVoteCache();
|
||||
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create();
|
||||
BackendProxyHandler previous = new BackendProxyHandler(null, cache);
|
||||
BackendProxyHandler replacement = new BackendProxyHandler(null, cache);
|
||||
|
||||
@@ -1917,7 +1923,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
BackendProxyTransport transport = mock(BackendProxyTransport.class);
|
||||
Field transportField = BackendProxyTransportManager.class.getDeclaredField("transport");
|
||||
transportField.setAccessible(true);
|
||||
transportField.set(manager, transport);
|
||||
ReflectiveAccess.set(transportField, manager, transport);
|
||||
|
||||
handler.prepareForReplacement(BungeeMethod.PLUGINMESSAGING);
|
||||
|
||||
@@ -1930,7 +1936,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
SocketHandler socket = mock(SocketHandler.class);
|
||||
Field field = SocketBackendProxyTransport.class.getDeclaredField("socketHandler");
|
||||
field.setAccessible(true);
|
||||
field.set(handler, socket);
|
||||
ReflectiveAccess.set(field, handler, socket);
|
||||
|
||||
handler.prepareForReplacement();
|
||||
|
||||
@@ -2017,10 +2023,10 @@ class BackendProxyHandlerLifecycleTest {
|
||||
RedisHandler redis = mock(RedisHandler.class);
|
||||
Field field = RedisBackendProxyTransport.class.getDeclaredField("redisHandler");
|
||||
field.setAccessible(true);
|
||||
field.set(transport, redis);
|
||||
ReflectiveAccess.set(field, transport, redis);
|
||||
Field transportField = BackendProxyTransportManager.class.getDeclaredField("transport");
|
||||
transportField.setAccessible(true);
|
||||
transportField.set(manager, transport);
|
||||
ReflectiveAccess.set(transportField, manager, transport);
|
||||
|
||||
handler.prepareForReplacement(BungeeMethod.REDIS);
|
||||
|
||||
@@ -2034,7 +2040,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
MqttHandler mqtt = mock(MqttHandler.class);
|
||||
Field field = MqttBackendProxyTransport.class.getDeclaredField("mqttHandler");
|
||||
field.setAccessible(true);
|
||||
field.set(handler, mqtt);
|
||||
ReflectiveAccess.set(field, handler, mqtt);
|
||||
|
||||
handler.prepareForReplacement();
|
||||
|
||||
@@ -2766,7 +2772,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
MySqlMessenger messenger = mock(MySqlMessenger.class);
|
||||
Field field = MysqlBackendProxyTransport.class.getDeclaredField("messenger");
|
||||
field.setAccessible(true);
|
||||
field.set(handler, messenger);
|
||||
ReflectiveAccess.set(field, handler, messenger);
|
||||
|
||||
handler.prepareForReplacement();
|
||||
|
||||
@@ -2911,7 +2917,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
ScheduledExecutorService timer = mock(ScheduledExecutorService.class);
|
||||
Field field = BackendGlobalDataSync.class.getDeclaredField("timer");
|
||||
field.setAccessible(true);
|
||||
field.set(handler, timer);
|
||||
ReflectiveAccess.set(field, handler, timer);
|
||||
|
||||
handler.close();
|
||||
|
||||
@@ -2944,7 +2950,7 @@ class BackendProxyHandlerLifecycleTest {
|
||||
try {
|
||||
Field field = type.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
ReflectiveAccess.set(field, target, value);
|
||||
return;
|
||||
} catch (NoSuchFieldException ignored) {
|
||||
type = type.getSuperclass();
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.bencodez.votingplugin.tests.backendproxy.cache;
|
||||
|
||||
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
|
||||
import com.bencodez.votingplugin.backendproxy.cache.*;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Test-only forwarding access to the non-public production DurableVoteReceiptStore. */
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
public final class DurableVoteReceiptStoreAccess implements ReflectiveAccess.View {
|
||||
public static final int MAX_ACTIVE_RECEIPTS = (Integer) ReflectiveAccess.field("com.bencodez.votingplugin.backendproxy.cache.DurableVoteReceiptStore", "MAX_ACTIVE_RECEIPTS");
|
||||
public static final int COMPLETION_HEADROOM = (Integer) ReflectiveAccess.field("com.bencodez.votingplugin.backendproxy.cache.DurableVoteReceiptStore", "COMPLETION_HEADROOM");
|
||||
public static final int MAX_RELEASE_TOMBSTONES = (Integer) ReflectiveAccess.field("com.bencodez.votingplugin.backendproxy.cache.DurableVoteReceiptStore", "MAX_RELEASE_TOMBSTONES");
|
||||
public static final long MAX_FILE_BYTES = (Long) ReflectiveAccess.field("com.bencodez.votingplugin.backendproxy.cache.DurableVoteReceiptStore", "MAX_FILE_BYTES");
|
||||
public static final int MAX_RECORD_BYTES = (Integer) ReflectiveAccess.field("com.bencodez.votingplugin.backendproxy.cache.DurableVoteReceiptStore", "MAX_RECORD_BYTES");
|
||||
public static final int MAX_TOTAL_RECEIPTS = (Integer) ReflectiveAccess.field("com.bencodez.votingplugin.backendproxy.cache.DurableVoteReceiptStore", "MAX_TOTAL_RECEIPTS");
|
||||
private final Object delegate;
|
||||
private DurableVoteReceiptStoreAccess(Object delegate) { this.delegate = delegate; }
|
||||
public static DurableVoteReceiptStoreAccess view(Object delegate) { return delegate == null ? null : new DurableVoteReceiptStoreAccess(delegate); }
|
||||
@Override public Object delegate() { return delegate; }
|
||||
@Override public boolean equals(Object other) { return other instanceof DurableVoteReceiptStoreAccess value && delegate.equals(value.delegate); }
|
||||
@Override public int hashCode() { return delegate.hashCode(); }
|
||||
public static DurableVoteReceiptStoreAccess mock() { return view(org.mockito.Mockito.mock(ReflectiveAccess.type("com.bencodez.votingplugin.backendproxy.cache.DurableVoteReceiptStore"))); }
|
||||
public DurableVoteReceiptStoreAccess verify() { return view(org.mockito.Mockito.verify(delegate)); }
|
||||
public DurableVoteReceiptStoreAccess verify(org.mockito.verification.VerificationMode mode) { return view(org.mockito.Mockito.verify(delegate, mode)); }
|
||||
public DurableVoteReceiptStoreAccess stub(org.mockito.stubbing.Stubber stubber) { return view(stubber.when(delegate)); }
|
||||
public DurableVoteReceiptStoreAccess(Path file) throws IOException { this(ReflectiveAccess.construct("com.bencodez.votingplugin.backendproxy.cache.DurableVoteReceiptStore", new String[]{"Path"}, new Object[]{file})); }
|
||||
public DurableVoteReceiptStoreAccess(Path file, int maxActiveReceipts, int completionHeadroom, int maxReleaseTombstones) throws IOException { this(ReflectiveAccess.construct("com.bencodez.votingplugin.backendproxy.cache.DurableVoteReceiptStore", new String[]{"Path", "int", "int", "int"}, new Object[]{file, maxActiveReceipts, completionHeadroom, maxReleaseTombstones})); }
|
||||
public DurableVoteReceiptStoreAccess(Path file, int maxActiveReceipts, int completionHeadroom, int maxReleaseTombstones, int maxTotalReceipts) throws IOException { this(ReflectiveAccess.construct("com.bencodez.votingplugin.backendproxy.cache.DurableVoteReceiptStore", new String[]{"Path", "int", "int", "int", "int"}, new Object[]{file, maxActiveReceipts, completionHeadroom, maxReleaseTombstones, maxTotalReceipts})); }
|
||||
public Map<UUID, Long> snapshot() { return (Map<UUID, Long>) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.cache.DurableVoteReceiptStore", delegate, "snapshot", new String[]{}, new Object[]{}); }
|
||||
public boolean contains(UUID voteId) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.cache.DurableVoteReceiptStore", delegate, "contains", new String[]{"UUID"}, new Object[]{voteId}); }
|
||||
public long complete(UUID voteId) { return (Long) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.cache.DurableVoteReceiptStore", delegate, "complete", new String[]{"UUID"}, new Object[]{voteId}); }
|
||||
public long release(UUID voteId) { return (Long) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.cache.DurableVoteReceiptStore", delegate, "release", new String[]{"UUID"}, new Object[]{voteId}); }
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.bencodez.votingplugin.tests.backendproxy.cache;
|
||||
|
||||
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
|
||||
import com.bencodez.votingplugin.backendproxy.cache.*;
|
||||
import com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache.*;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/** Typed test-only access to package-private ProcessedVoteCache seams. */
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
public final class ProcessedVoteCacheAccess {
|
||||
public static final int DEFAULT_MAX_TRACKED_VOTES = (Integer) ReflectiveAccess.field("com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache", "DEFAULT_MAX_TRACKED_VOTES");
|
||||
private ProcessedVoteCacheAccess() { }
|
||||
public static ProcessedVoteCache create() { return (ProcessedVoteCache) ReflectiveAccess.construct("com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache", new String[]{}, new Object[]{}); }
|
||||
public static ProcessedVoteCache create(long ttlMillis) { return (ProcessedVoteCache) ReflectiveAccess.construct("com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache", new String[]{"long"}, new Object[]{ttlMillis}); }
|
||||
public static ProcessedVoteCache create(Path receiptFile) { return (ProcessedVoteCache) ReflectiveAccess.construct("com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache", new String[]{"Path"}, new Object[]{receiptFile}); }
|
||||
public static ProcessedVoteCache create(long ttlMillis, Path receiptFile) { return (ProcessedVoteCache) ReflectiveAccess.construct("com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache", new String[]{"long", "Path"}, new Object[]{ttlMillis, receiptFile}); }
|
||||
public static ProcessedVoteCache create(long ttlMillis, int maxTrackedVotes) { return (ProcessedVoteCache) ReflectiveAccess.construct("com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache", new String[]{"long", "int"}, new Object[]{ttlMillis, maxTrackedVotes}); }
|
||||
public static ProcessedVoteCache create(long ttlMillis, int maxTrackedVotes, DurableVoteReceiptStoreAccess durableReceipts) { return (ProcessedVoteCache) ReflectiveAccess.construct("com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache", new String[]{"long", "int", "DurableVoteReceiptStore"}, new Object[]{ttlMillis, maxTrackedVotes, durableReceipts}); }
|
||||
}
|
||||
+40
-37
@@ -1,4 +1,7 @@
|
||||
package com.bencodez.votingplugin.backendproxy.cache;
|
||||
package com.bencodez.votingplugin.tests.backendproxy.cache;
|
||||
|
||||
import com.bencodez.votingplugin.tests.backendproxy.cache.DurableVoteReceiptStoreAccess;
|
||||
import com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
@@ -16,7 +19,7 @@ class ProcessedVoteCacheDurabilityTest {
|
||||
|
||||
@Test
|
||||
void duplicateRetryDoesNotScanAndPruneUnrelatedEntries() {
|
||||
ProcessedVoteCache cache = new ProcessedVoteCache(TimeUnit.MINUTES.toMillis(30), 2);
|
||||
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create(TimeUnit.MINUTES.toMillis(30), 2);
|
||||
UUID expired = UUID.randomUUID();
|
||||
UUID live = UUID.randomUUID();
|
||||
cache.getProcessedVotes().put(expired, 0L);
|
||||
@@ -29,7 +32,7 @@ class ProcessedVoteCacheDurabilityTest {
|
||||
|
||||
@Test
|
||||
void expiredHeadRetryReclaimsItsSlotBeforeSaturationCheck() {
|
||||
ProcessedVoteCache cache = new ProcessedVoteCache(TimeUnit.MINUTES.toMillis(30), 1);
|
||||
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create(TimeUnit.MINUTES.toMillis(30), 1);
|
||||
UUID expired = UUID.randomUUID();
|
||||
cache.getProcessedVotes().put(expired, 0L);
|
||||
|
||||
@@ -44,44 +47,44 @@ class ProcessedVoteCacheDurabilityTest {
|
||||
void releaseTombstoneCapacityCoversSustainedVoteThroughputForFullTtl() {
|
||||
long threeVotesPerSecondForOneDay = TimeUnit.DAYS.toSeconds(1) * 3;
|
||||
|
||||
assertTrue(DurableVoteReceiptStore.MAX_RELEASE_TOMBSTONES >= threeVotesPerSecondForOneDay);
|
||||
assertTrue(DurableVoteReceiptStoreAccess.MAX_RELEASE_TOMBSTONES >= threeVotesPerSecondForOneDay);
|
||||
}
|
||||
|
||||
@Test
|
||||
void completedVoteRemainsDeduplicatedAfterBackendRestart() {
|
||||
Path receipts = directory.resolve("receipts.dat");
|
||||
UUID voteId = UUID.randomUUID();
|
||||
ProcessedVoteCache first = new ProcessedVoteCache(receipts);
|
||||
ProcessedVoteCache first = ProcessedVoteCacheAccess.create(receipts);
|
||||
|
||||
assertTrue(first.reserve(voteId));
|
||||
assertTrue(first.complete(voteId));
|
||||
assertTrue(first.hasDurableReceipt(voteId));
|
||||
assertFalse(new ProcessedVoteCache(receipts).reserve(voteId));
|
||||
assertFalse(ProcessedVoteCacheAccess.create(receipts).reserve(voteId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reservationWithoutCompletionDoesNotSuppressRestartRetry() {
|
||||
Path receipts = directory.resolve("receipts.dat");
|
||||
UUID voteId = UUID.randomUUID();
|
||||
assertTrue(new ProcessedVoteCache(receipts).reserve(voteId));
|
||||
assertTrue(ProcessedVoteCacheAccess.create(receipts).reserve(voteId));
|
||||
|
||||
assertTrue(new ProcessedVoteCache(receipts).reserve(voteId));
|
||||
assertTrue(ProcessedVoteCacheAccess.create(receipts).reserve(voteId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void repairsTruncatedTailBeforeAppendingAnotherReceipt() throws Exception {
|
||||
Path receipts = directory.resolve("receipts.dat");
|
||||
UUID firstId = UUID.randomUUID();
|
||||
ProcessedVoteCache first = new ProcessedVoteCache(receipts);
|
||||
ProcessedVoteCache first = ProcessedVoteCacheAccess.create(receipts);
|
||||
assertTrue(first.reserve(firstId));
|
||||
assertTrue(first.complete(firstId));
|
||||
Files.writeString(receipts, "truncated", StandardOpenOption.APPEND);
|
||||
|
||||
ProcessedVoteCache repaired = new ProcessedVoteCache(receipts);
|
||||
ProcessedVoteCache repaired = ProcessedVoteCacheAccess.create(receipts);
|
||||
UUID secondId = UUID.randomUUID();
|
||||
assertTrue(repaired.reserve(secondId));
|
||||
assertTrue(repaired.complete(secondId));
|
||||
ProcessedVoteCache restarted = new ProcessedVoteCache(receipts);
|
||||
ProcessedVoteCache restarted = ProcessedVoteCacheAccess.create(receipts);
|
||||
assertFalse(restarted.reserve(firstId));
|
||||
assertFalse(restarted.reserve(secondId));
|
||||
}
|
||||
@@ -91,14 +94,14 @@ class ProcessedVoteCacheDurabilityTest {
|
||||
Path receipts = directory.resolve("receipts.dat");
|
||||
UUID firstId = UUID.randomUUID();
|
||||
UUID secondId = UUID.randomUUID();
|
||||
ProcessedVoteCache cache = new ProcessedVoteCache(receipts);
|
||||
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create(receipts);
|
||||
assertTrue(cache.reserve(firstId));
|
||||
assertTrue(cache.complete(firstId));
|
||||
Files.writeString(receipts, "partial", StandardOpenOption.APPEND);
|
||||
|
||||
assertTrue(cache.reserve(secondId));
|
||||
assertTrue(cache.complete(secondId));
|
||||
ProcessedVoteCache restarted = new ProcessedVoteCache(receipts);
|
||||
ProcessedVoteCache restarted = ProcessedVoteCacheAccess.create(receipts);
|
||||
assertFalse(restarted.reserve(firstId));
|
||||
assertFalse(restarted.reserve(secondId));
|
||||
}
|
||||
@@ -109,7 +112,7 @@ class ProcessedVoteCacheDurabilityTest {
|
||||
UUID incompleteId = UUID.randomUUID();
|
||||
Files.writeString(receipts, "VP-VOTE-RECEIPTS-1\n" + incompleteId + "\t9");
|
||||
|
||||
ProcessedVoteCache repaired = new ProcessedVoteCache(receipts);
|
||||
ProcessedVoteCache repaired = ProcessedVoteCacheAccess.create(receipts);
|
||||
|
||||
assertTrue(repaired.reserve(incompleteId));
|
||||
assertTrue(Files.readString(receipts).endsWith("\n"));
|
||||
@@ -120,7 +123,7 @@ class ProcessedVoteCacheDurabilityTest {
|
||||
Path unusableParent = directory.resolve("not-a-directory");
|
||||
Files.writeString(unusableParent, "file");
|
||||
UUID voteId = UUID.randomUUID();
|
||||
ProcessedVoteCache cache = new ProcessedVoteCache(1L, unusableParent.resolve("receipts.dat"));
|
||||
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create(1L, unusableParent.resolve("receipts.dat"));
|
||||
|
||||
assertTrue(cache.reserve(voteId));
|
||||
assertFalse(cache.complete(voteId));
|
||||
@@ -132,31 +135,31 @@ class ProcessedVoteCacheDurabilityTest {
|
||||
void proxyConfirmedReleaseLeavesRestartSafeTombstone() {
|
||||
Path receipts = directory.resolve("receipts.dat");
|
||||
UUID voteId = UUID.randomUUID();
|
||||
ProcessedVoteCache cache = new ProcessedVoteCache(receipts);
|
||||
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create(receipts);
|
||||
assertTrue(cache.reserve(voteId));
|
||||
assertTrue(cache.complete(voteId));
|
||||
|
||||
assertTrue(cache.releaseCompletedReceipt(voteId));
|
||||
|
||||
assertFalse(new ProcessedVoteCache(receipts).reserve(voteId));
|
||||
assertFalse(ProcessedVoteCacheAccess.create(receipts).reserve(voteId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownReceiptReleaseLeavesRestartSafeTombstone() {
|
||||
Path receipts = directory.resolve("receipts.dat");
|
||||
UUID voteId = UUID.randomUUID();
|
||||
ProcessedVoteCache cache = new ProcessedVoteCache(receipts);
|
||||
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create(receipts);
|
||||
|
||||
assertFalse(cache.hasDurableReceipt(voteId));
|
||||
assertTrue(cache.releaseCompletedReceipt(voteId));
|
||||
assertTrue(cache.hasDurableReceipt(voteId));
|
||||
|
||||
assertFalse(new ProcessedVoteCache(receipts).reserve(voteId));
|
||||
assertFalse(ProcessedVoteCacheAccess.create(receipts).reserve(voteId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void completionHeadroomAllowsPrecedingVotesBeforeRelease() throws Exception {
|
||||
DurableVoteReceiptStore store = new DurableVoteReceiptStore(directory.resolve("receipts.dat"), 1, 2, 1);
|
||||
DurableVoteReceiptStoreAccess store = new DurableVoteReceiptStoreAccess(directory.resolve("receipts.dat"), 1, 2, 1);
|
||||
UUID first = UUID.randomUUID();
|
||||
UUID second = UUID.randomUUID();
|
||||
UUID third = UUID.randomUUID();
|
||||
@@ -173,14 +176,14 @@ class ProcessedVoteCacheDurabilityTest {
|
||||
@Test
|
||||
void activeReceiptWaitsWhenTombstoneCapacityIsFull() throws Exception {
|
||||
Path receipts = directory.resolve("receipts.dat");
|
||||
DurableVoteReceiptStore store = new DurableVoteReceiptStore(receipts, 1, 0, 1);
|
||||
DurableVoteReceiptStoreAccess store = new DurableVoteReceiptStoreAccess(receipts, 1, 0, 1);
|
||||
UUID active = UUID.randomUUID();
|
||||
assertTrue(store.complete(active) > 0L);
|
||||
assertTrue(store.release(UUID.randomUUID()) > 0L);
|
||||
|
||||
assertFalse(store.release(active) > 0L);
|
||||
|
||||
new DurableVoteReceiptStore(receipts, 1, 0, 1);
|
||||
new DurableVoteReceiptStoreAccess(receipts, 1, 0, 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -189,12 +192,12 @@ class ProcessedVoteCacheDurabilityTest {
|
||||
UUID voteId = UUID.randomUUID();
|
||||
Files.writeString(receipts, "VP-VOTE-RECEIPTS-1\nR\t" + voteId + "\t1\n");
|
||||
|
||||
assertTrue(new ProcessedVoteCache(receipts).reserve(voteId));
|
||||
assertTrue(ProcessedVoteCacheAccess.create(receipts).reserve(voteId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void saturationDoesNotEvictLiveReplayFences() {
|
||||
ProcessedVoteCache cache = new ProcessedVoteCache(TimeUnit.MINUTES.toMillis(30), 2);
|
||||
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create(TimeUnit.MINUTES.toMillis(30), 2);
|
||||
UUID first = UUID.randomUUID();
|
||||
UUID second = UUID.randomUUID();
|
||||
|
||||
@@ -208,19 +211,19 @@ class ProcessedVoteCacheDurabilityTest {
|
||||
|
||||
@Test
|
||||
void defaultCapacityIncludesDurableCompletionHeadroom() {
|
||||
assertTrue(ProcessedVoteCache.DEFAULT_MAX_TRACKED_VOTES
|
||||
== DurableVoteReceiptStore.MAX_ACTIVE_RECEIPTS + DurableVoteReceiptStore.COMPLETION_HEADROOM);
|
||||
assertTrue(ProcessedVoteCache.DEFAULT_MAX_TRACKED_VOTES <= DurableVoteReceiptStore.MAX_TOTAL_RECEIPTS);
|
||||
assertTrue((long) DurableVoteReceiptStore.MAX_TOTAL_RECEIPTS * DurableVoteReceiptStore.MAX_RECORD_BYTES
|
||||
+ "VP-VOTE-RECEIPTS-1\n".length() <= DurableVoteReceiptStore.MAX_FILE_BYTES);
|
||||
assertTrue((long) (DurableVoteReceiptStore.MAX_TOTAL_RECEIPTS + 1)
|
||||
* DurableVoteReceiptStore.MAX_RECORD_BYTES + "VP-VOTE-RECEIPTS-1\n".length()
|
||||
> DurableVoteReceiptStore.MAX_FILE_BYTES);
|
||||
assertTrue(ProcessedVoteCacheAccess.DEFAULT_MAX_TRACKED_VOTES
|
||||
== DurableVoteReceiptStoreAccess.MAX_ACTIVE_RECEIPTS + DurableVoteReceiptStoreAccess.COMPLETION_HEADROOM);
|
||||
assertTrue(ProcessedVoteCacheAccess.DEFAULT_MAX_TRACKED_VOTES <= DurableVoteReceiptStoreAccess.MAX_TOTAL_RECEIPTS);
|
||||
assertTrue((long) DurableVoteReceiptStoreAccess.MAX_TOTAL_RECEIPTS * DurableVoteReceiptStoreAccess.MAX_RECORD_BYTES
|
||||
+ "VP-VOTE-RECEIPTS-1\n".length() <= DurableVoteReceiptStoreAccess.MAX_FILE_BYTES);
|
||||
assertTrue((long) (DurableVoteReceiptStoreAccess.MAX_TOTAL_RECEIPTS + 1)
|
||||
* DurableVoteReceiptStoreAccess.MAX_RECORD_BYTES + "VP-VOTE-RECEIPTS-1\n".length()
|
||||
> DurableVoteReceiptStoreAccess.MAX_FILE_BYTES);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mixedActiveAndReleaseReceiptsRespectTheJournalByteAlignedLimit() throws Exception {
|
||||
DurableVoteReceiptStore store = new DurableVoteReceiptStore(directory.resolve("mixed-receipts.dat"), 2, 0, 2, 2);
|
||||
DurableVoteReceiptStoreAccess store = new DurableVoteReceiptStoreAccess(directory.resolve("mixed-receipts.dat"), 2, 0, 2, 2);
|
||||
UUID released = UUID.randomUUID();
|
||||
UUID active = UUID.randomUUID();
|
||||
|
||||
@@ -244,7 +247,7 @@ class ProcessedVoteCacheDurabilityTest {
|
||||
+ "R\t" + firstReleased + "\t" + expiresAt + "\n"
|
||||
+ "R\t" + secondReleased + "\t" + expiresAt + "\n");
|
||||
|
||||
DurableVoteReceiptStore store = new DurableVoteReceiptStore(receipts, 2, 0, 2, 2);
|
||||
DurableVoteReceiptStoreAccess store = new DurableVoteReceiptStoreAccess(receipts, 2, 0, 2, 2);
|
||||
|
||||
assertTrue(store.contains(firstActive));
|
||||
assertTrue(store.contains(secondActive));
|
||||
@@ -255,11 +258,11 @@ class ProcessedVoteCacheDurabilityTest {
|
||||
|
||||
@Test
|
||||
void mixedJournalUsesDurablePerKindCapacityForNewAdmission() throws Exception {
|
||||
DurableVoteReceiptStore store = new DurableVoteReceiptStore(directory.resolve("mixed-capacity.dat"), 2, 1, 2, 4);
|
||||
DurableVoteReceiptStoreAccess store = new DurableVoteReceiptStoreAccess(directory.resolve("mixed-capacity.dat"), 2, 1, 2, 4);
|
||||
assertTrue(store.release(UUID.randomUUID()) > 0L);
|
||||
assertTrue(store.release(UUID.randomUUID()) > 0L);
|
||||
assertTrue(store.complete(UUID.randomUUID()) > 0L);
|
||||
ProcessedVoteCache cache = new ProcessedVoteCache(TimeUnit.MINUTES.toMillis(30), 2, store);
|
||||
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create(TimeUnit.MINUTES.toMillis(30), 2, store);
|
||||
UUID admitted = UUID.randomUUID();
|
||||
UUID stale = UUID.randomUUID();
|
||||
cache.getProcessedVotes().put(stale, 0L);
|
||||
@@ -273,7 +276,7 @@ class ProcessedVoteCacheDurabilityTest {
|
||||
|
||||
@Test
|
||||
void cancelledValidationReservationFreesCapacity() {
|
||||
ProcessedVoteCache cache = new ProcessedVoteCache(TimeUnit.MINUTES.toMillis(30), 1);
|
||||
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create(TimeUnit.MINUTES.toMillis(30), 1);
|
||||
UUID invalid = UUID.randomUUID();
|
||||
|
||||
assertTrue(cache.reserve(invalid));
|
||||
+7
-7
@@ -16,7 +16,7 @@ public class ProcessedVoteCacheTest {
|
||||
|
||||
@Test
|
||||
public void duplicateVoteIdIsRejected() {
|
||||
ProcessedVoteCache cache = new ProcessedVoteCache();
|
||||
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create();
|
||||
UUID voteId = UUID.randomUUID();
|
||||
assertTrue(cache.reserve(voteId));
|
||||
assertFalse(cache.reserve(voteId));
|
||||
@@ -25,7 +25,7 @@ public class ProcessedVoteCacheTest {
|
||||
|
||||
@Test
|
||||
public void nullVoteIdsRemainCompatible() {
|
||||
ProcessedVoteCache cache = new ProcessedVoteCache();
|
||||
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create();
|
||||
assertTrue(cache.reserve(null));
|
||||
assertTrue(cache.reserve(null));
|
||||
assertTrue(cache.getProcessedVotes().isEmpty());
|
||||
@@ -33,7 +33,7 @@ public class ProcessedVoteCacheTest {
|
||||
|
||||
@Test
|
||||
public void expiredVoteIdCanBeReservedAgain() throws Exception {
|
||||
ProcessedVoteCache cache = new ProcessedVoteCache(1);
|
||||
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create(1);
|
||||
UUID voteId = UUID.randomUUID();
|
||||
assertTrue(cache.reserve(voteId));
|
||||
Thread.sleep(5);
|
||||
@@ -43,7 +43,7 @@ public class ProcessedVoteCacheTest {
|
||||
|
||||
@Test
|
||||
public void redisDeliveryIsAcceptedOnceAcrossSharedHandlers() {
|
||||
ProcessedVoteCache cache = new ProcessedVoteCache();
|
||||
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create();
|
||||
String deliveryId = UUID.randomUUID().toString();
|
||||
assertTrue(cache.reserveRedisDelivery(deliveryId));
|
||||
assertFalse(cache.reserveRedisDelivery(deliveryId));
|
||||
@@ -53,7 +53,7 @@ public class ProcessedVoteCacheTest {
|
||||
|
||||
@Test
|
||||
public void legacyRedisHandoffMatchesOldAndBufferedDeliveryCounts() {
|
||||
ProcessedVoteCache cache = new ProcessedVoteCache();
|
||||
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create();
|
||||
Object previous = new Object();
|
||||
Object replacement = new Object();
|
||||
cache.registerRedisSubscriber(previous);
|
||||
@@ -69,7 +69,7 @@ public class ProcessedVoteCacheTest {
|
||||
|
||||
@Test
|
||||
public void legacyRedisHandoffAccountingIsBounded() throws Exception {
|
||||
ProcessedVoteCache cache = new ProcessedVoteCache();
|
||||
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create();
|
||||
Object previous = new Object();
|
||||
Object replacement = new Object();
|
||||
cache.registerRedisSubscriber(previous);
|
||||
@@ -87,7 +87,7 @@ public class ProcessedVoteCacheTest {
|
||||
|
||||
@Test
|
||||
public void legacyRedisHandoffAccountingRejectsOversizedSignatures() throws Exception {
|
||||
ProcessedVoteCache cache = new ProcessedVoteCache();
|
||||
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create();
|
||||
Object previous = new Object();
|
||||
cache.registerRedisSubscriber(previous);
|
||||
cache.registerRedisSubscriber(new Object());
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.bencodez.votingplugin.tests.backendproxy.global;
|
||||
|
||||
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
|
||||
import com.bencodez.votingplugin.backendproxy.global.*;
|
||||
import com.bencodez.votingplugin.backendproxy.global.BackendGlobalDataSync.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/** Typed test-only access to package-private BackendGlobalDataSync seams. */
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
public final class BackendGlobalDataSyncAccess {
|
||||
public static void updateLastOnline(BackendGlobalDataSync target) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.global.BackendGlobalDataSync", target, "updateLastOnline", new String[]{}, new Object[]{}); }
|
||||
private BackendGlobalDataSyncAccess() { }
|
||||
public static void close(BackendGlobalDataSync target) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.global.BackendGlobalDataSync", target, "close", new String[]{}, new Object[]{}); }
|
||||
public static void close(BackendGlobalDataSync target, long timeout, TimeUnit unit) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.global.BackendGlobalDataSync", target, "close", new String[]{"long", "TimeUnit"}, new Object[]{timeout, unit}); }
|
||||
|
||||
}
|
||||
+15
-11
@@ -1,4 +1,8 @@
|
||||
package com.bencodez.votingplugin.backendproxy.global;
|
||||
package com.bencodez.votingplugin.tests.backendproxy.global;
|
||||
|
||||
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
|
||||
|
||||
import com.bencodez.votingplugin.backendproxy.global.BackendGlobalDataSync;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
@@ -287,7 +291,7 @@ class BackendGlobalDataSyncTest {
|
||||
oldSync.checkGlobalData();
|
||||
org.junit.jupiter.api.Assertions.assertEquals(1, schedulerRuns.get());
|
||||
oldSync.handoffCompletionSender(replacement);
|
||||
oldSync.close(1, TimeUnit.MILLISECONDS);
|
||||
BackendGlobalDataSyncAccess.close(oldSync, 1, TimeUnit.MILLISECONDS);
|
||||
|
||||
replacement.checkGlobalData();
|
||||
org.junit.jupiter.api.Assertions.assertEquals(1, schedulerRuns.get());
|
||||
@@ -400,7 +404,7 @@ class BackendGlobalDataSyncTest {
|
||||
CompletableFuture<Void> execution = CompletableFuture.runAsync(asyncWrite.get());
|
||||
assertTrue(writeStarted.await(1, TimeUnit.SECONDS));
|
||||
oldSync.handoffCompletionSender(replacement);
|
||||
oldSync.close(1, TimeUnit.MILLISECONDS);
|
||||
BackendGlobalDataSyncAccess.close(oldSync, 1, TimeUnit.MILLISECONDS);
|
||||
|
||||
replacement.checkGlobalData();
|
||||
verify(replacementHandler).setBoolean("lobby", "ForceUpdate", false);
|
||||
@@ -551,7 +555,7 @@ class BackendGlobalDataSyncTest {
|
||||
setField(sync, "globalDataHandler", handler);
|
||||
setField(sync, "ownsGlobalMysql", false);
|
||||
|
||||
CompletableFuture<Void> heartbeat = CompletableFuture.runAsync(sync::updateLastOnline);
|
||||
CompletableFuture<Void> heartbeat = CompletableFuture.runAsync(() -> BackendGlobalDataSyncAccess.updateLastOnline(sync));
|
||||
assertTrue(heartbeatStarted.await(1, TimeUnit.SECONDS));
|
||||
CompletableFuture<Void> close = CompletableFuture.runAsync(sync::close);
|
||||
Thread.sleep(50L);
|
||||
@@ -961,7 +965,7 @@ class BackendGlobalDataSyncTest {
|
||||
assertTrue(oldSync.checkGlobalDataTime(TimeType.DAY, data));
|
||||
scheduled.get().run();
|
||||
oldSync.handoffCompletionSender(replacement);
|
||||
oldSync.close(1, TimeUnit.MILLISECONDS);
|
||||
BackendGlobalDataSyncAccess.close(oldSync, 1, TimeUnit.MILLISECONDS);
|
||||
replacement.checkGlobalData();
|
||||
|
||||
verify(timeChecker).forceChanged(TimeType.DAY, false, true, true);
|
||||
@@ -1083,7 +1087,7 @@ class BackendGlobalDataSyncTest {
|
||||
week.put(TimeType.WEEK.toString(), new DataValueBoolean(true));
|
||||
|
||||
assertTrue(oldSync.checkGlobalDataTime(TimeType.DAY, day));
|
||||
oldSync.close(1, TimeUnit.MILLISECONDS);
|
||||
BackendGlobalDataSyncAccess.close(oldSync, 1, TimeUnit.MILLISECONDS);
|
||||
assertFalse(replacement.checkGlobalDataTime(TimeType.DAY, day));
|
||||
assertTrue(replacement.checkGlobalDataTime(TimeType.WEEK, week));
|
||||
org.junit.jupiter.api.Assertions.assertEquals(2, scheduled.size());
|
||||
@@ -1124,7 +1128,7 @@ class BackendGlobalDataSyncTest {
|
||||
week.put(TimeType.WEEK.toString(), new DataValueBoolean(true));
|
||||
|
||||
assertTrue(oldSync.checkGlobalDataTime(TimeType.DAY, day));
|
||||
oldSync.close(1, TimeUnit.MILLISECONDS);
|
||||
BackendGlobalDataSyncAccess.close(oldSync, 1, TimeUnit.MILLISECONDS);
|
||||
BackendGlobalDataSync replacement = new BackendGlobalDataSync(plugin, ignored -> { });
|
||||
setField(replacement, "globalDataHandler", replacementHandler);
|
||||
|
||||
@@ -1219,7 +1223,7 @@ class BackendGlobalDataSyncTest {
|
||||
() -> sync.checkGlobalDataTime(TimeType.DAY, data));
|
||||
assertTrue(writeStarted.await(1, TimeUnit.SECONDS));
|
||||
|
||||
sync.close(50L, TimeUnit.MILLISECONDS);
|
||||
BackendGlobalDataSyncAccess.close(sync, 50L, TimeUnit.MILLISECONDS);
|
||||
verify(mysql).close();
|
||||
|
||||
releaseWrite.countDown();
|
||||
@@ -1239,7 +1243,7 @@ class BackendGlobalDataSyncTest {
|
||||
setField(sync, "globalDataHandler", handler);
|
||||
setField(sync, "ownsGlobalMysql", false);
|
||||
|
||||
sync.close();
|
||||
BackendGlobalDataSyncAccess.close(sync);
|
||||
|
||||
verify(mysql, never()).close();
|
||||
}
|
||||
@@ -1254,7 +1258,7 @@ class BackendGlobalDataSyncTest {
|
||||
setField(sync, "globalDataHandler", handler);
|
||||
setField(sync, "ownsGlobalMysql", true);
|
||||
|
||||
sync.close();
|
||||
BackendGlobalDataSyncAccess.close(sync);
|
||||
|
||||
verify(mysql).close();
|
||||
}
|
||||
@@ -1263,7 +1267,7 @@ class BackendGlobalDataSyncTest {
|
||||
try {
|
||||
Field field = BackendGlobalDataSync.class.getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
ReflectiveAccess.set(field, target, value);
|
||||
} catch (ReflectiveOperationException failure) {
|
||||
throw new AssertionError(failure);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.bencodez.votingplugin.tests.backendproxy.messaging;
|
||||
|
||||
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
|
||||
import com.bencodez.votingplugin.backendproxy.messaging.*;
|
||||
import com.bencodez.votingplugin.backendproxy.messaging.BackendProxyMessageRouter.*;
|
||||
import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
|
||||
|
||||
/** Typed test-only access to package-private BackendProxyMessageRouter seams. */
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
public final class BackendProxyMessageRouterAccess {
|
||||
private BackendProxyMessageRouterAccess() { }
|
||||
public static void handleVoteUpdate(BackendProxyMessageRouter target, JsonEnvelope msg) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.messaging.BackendProxyMessageRouter", target, "handleVoteUpdate", new String[]{"JsonEnvelope"}, new Object[]{msg}); }
|
||||
public static void handleVoteUpdate(BackendProxyMessageRouter target, JsonEnvelope msg, Runnable completion) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.messaging.BackendProxyMessageRouter", target, "handleVoteUpdate", new String[]{"JsonEnvelope", "Runnable"}, new Object[]{msg, completion}); }
|
||||
}
|
||||
+6
-4
@@ -1,4 +1,6 @@
|
||||
package com.bencodez.votingplugin.backendproxy.messaging;
|
||||
package com.bencodez.votingplugin.tests.backendproxy.messaging;
|
||||
|
||||
import com.bencodez.votingplugin.backendproxy.messaging.BackendProxyMessageRouter;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
@@ -114,7 +116,7 @@ class BackendProxyMessageRouterTest {
|
||||
|
||||
@Test
|
||||
void ignoresLastVoteTimeForUnknownServiceSite() {
|
||||
router.handleVoteUpdate(VotingPluginWire.voteUpdate(PLAYER_UUID.toString(), 1, 10,
|
||||
BackendProxyMessageRouterAccess.handleVoteUpdate(router, VotingPluginWire.voteUpdate(PLAYER_UUID.toString(), 1, 10,
|
||||
"unknown.example\nforged-entry", LAST_VOTE_TIME, ""));
|
||||
|
||||
verify(user, times(2)).cache();
|
||||
@@ -130,7 +132,7 @@ class BackendProxyMessageRouterTest {
|
||||
VoteSite voteSite = mock(VoteSite.class);
|
||||
when(voteSiteManager.getVoteSite("known.example", true)).thenReturn(voteSite);
|
||||
|
||||
router.handleVoteUpdate(VotingPluginWire.voteUpdate(PLAYER_UUID.toString(), 1, 10,
|
||||
BackendProxyMessageRouterAccess.handleVoteUpdate(router, VotingPluginWire.voteUpdate(PLAYER_UUID.toString(), 1, 10,
|
||||
"known.example", LAST_VOTE_TIME, ""));
|
||||
|
||||
verify(user, times(2)).cache();
|
||||
@@ -787,7 +789,7 @@ class BackendProxyMessageRouterTest {
|
||||
}).when(coreUserManager).getUserAsync(eq(PLAYER_UUID), any(), any());
|
||||
|
||||
AtomicInteger completions = new AtomicInteger();
|
||||
router.handleVoteUpdate(VotingPluginWire.voteUpdate(PLAYER_UUID.toString(), 1, 10,
|
||||
BackendProxyMessageRouterAccess.handleVoteUpdate(router, VotingPluginWire.voteUpdate(PLAYER_UUID.toString(), 1, 10,
|
||||
"known.example", LAST_VOTE_TIME, ""), completions::incrementAndGet);
|
||||
|
||||
assertEquals(1, completions.get());
|
||||
+7
-3
@@ -1,4 +1,8 @@
|
||||
package com.bencodez.votingplugin.backendproxy.messaging;
|
||||
package com.bencodez.votingplugin.tests.backendproxy.messaging;
|
||||
|
||||
import com.bencodez.votingplugin.tests.backendproxy.cache.ProcessedVoteCacheAccess;
|
||||
|
||||
import com.bencodez.votingplugin.backendproxy.messaging.BackendProxyMessageRouter;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
@@ -92,7 +96,7 @@ class BackendProxyVoteDelayReplayTest {
|
||||
fixture.assertEffects(1);
|
||||
fixture.assertOccurrence(0, voteId, RECORDED_TIME - 60_000L);
|
||||
verify(fixture.site, never()).giveWaitUntilVoteDelayRewards(any(), anyBoolean(), anyBoolean());
|
||||
assertTrue(new ProcessedVoteCache(fixture.receipts).hasDurableReceipt(voteId));
|
||||
assertTrue(ProcessedVoteCacheAccess.create(fixture.receipts).hasDurableReceipt(voteId));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -277,7 +281,7 @@ class BackendProxyVoteDelayReplayTest {
|
||||
private void restartReceiver() {
|
||||
router = new BackendProxyMessageRouter(plugin, mock(BackendPresenceManager.class),
|
||||
mock(BackendGlobalDataSync.class), mock(BackendVotePartySync.class),
|
||||
new ProcessedVoteCache(receipts));
|
||||
ProcessedVoteCacheAccess.create(receipts));
|
||||
}
|
||||
|
||||
private void deliver(JsonEnvelope envelope) {
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.bencodez.votingplugin.tests.backendproxy.transport;
|
||||
|
||||
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
|
||||
import com.bencodez.votingplugin.backendproxy.transport.*;
|
||||
import com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport.*;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
|
||||
import com.bencodez.simpleapi.servercomm.global.GlobalMessageHandler;
|
||||
import com.bencodez.simpleapi.servercomm.http.HttpBackendTransportConnector;
|
||||
import com.bencodez.simpleapi.servercomm.http.HttpConnectionCode;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption;
|
||||
|
||||
/** Typed test-only access to package-private HttpBackendProxyTransport seams. */
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
public final class HttpBackendProxyTransportAccess {
|
||||
public static void validate(HttpBackendProxyTransport target) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "validate", new String[]{}, new Object[]{}); }
|
||||
public static void validate(HttpBackendProxyTransport target, long deadlineNanos) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "validate", new String[]{"long"}, new Object[]{deadlineNanos}); }
|
||||
public static void activateRestoredHandoffDrain(HttpBackendProxyTransport target, int managerMessages) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "activateRestoredHandoffDrain", new String[]{"int"}, new Object[]{managerMessages}); }
|
||||
public static void appendShutdownHandoffMessages(HttpBackendProxyTransport target, java.util.List<JsonEnvelope> messages) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "appendShutdownHandoffMessages", new String[]{"List"}, new Object[]{messages}); }
|
||||
public static void acceptHandoffMessages(HttpBackendProxyTransport target, java.util.List<JsonEnvelope> messages) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "acceptHandoffMessages", new String[]{"List"}, new Object[]{messages}); }
|
||||
public static void beginPreparedHandoff(HttpBackendProxyTransport target) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "beginPreparedHandoff", new String[]{}, new Object[]{}); }
|
||||
public static void flushForReplacement(HttpBackendProxyTransport target, HttpBackendTransportConnector connector, long deadlineNanos) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "flushForReplacement", new String[]{"HttpBackendTransportConnector", "long"}, new Object[]{connector, deadlineNanos}); }
|
||||
public static java.util.List<JsonEnvelope> drainPreparedMessages(HttpBackendProxyTransport target) { return (java.util.List<JsonEnvelope>) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "drainPreparedMessages", new String[]{}, new Object[]{}); }
|
||||
public static boolean enrollForStartup(HttpBackendProxyTransport target, HttpConnectionCode code, String serverId, Path directory, boolean retryEnrollment, java.util.function.LongPredicate waitForRetry) throws Exception { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "enrollForStartup", new String[]{"HttpConnectionCode", "String", "Path", "boolean", "LongPredicate"}, new Object[]{code, serverId, directory, retryEnrollment, waitForRetry}); }
|
||||
public static void dispatchAfterPublication(HttpBackendProxyTransport target, GlobalMessageHandler messageHandler, JsonEnvelope envelope) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "dispatchAfterPublication", new String[]{"GlobalMessageHandler", "JsonEnvelope"}, new Object[]{messageHandler, envelope}); }
|
||||
private HttpBackendProxyTransportAccess() { }
|
||||
public static HttpBackendProxyTransport create(VotingPluginMain plugin) { return (HttpBackendProxyTransport) ReflectiveAccess.construct("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", new String[]{"VotingPluginMain"}, new Object[]{plugin}); }
|
||||
public static HttpBackendProxyTransport create(VotingPluginMain plugin, TransportEnvelopeEncryption encryption) { return (HttpBackendProxyTransport) ReflectiveAccess.construct("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", new String[]{"VotingPluginMain", "TransportEnvelopeEncryption"}, new Object[]{plugin, encryption}); }
|
||||
public static HttpBackendProxyTransport recreatePrepared(HttpBackendProxyTransport target) { return (HttpBackendProxyTransport) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "recreatePrepared", new String[]{}, new Object[]{}); }
|
||||
public static boolean isClosedForReplacement(HttpBackendProxyTransport target) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "isClosedForReplacement", new String[]{}, new Object[]{}); }
|
||||
|
||||
public static int preparedMessageCount(HttpBackendProxyTransport target) { return (Integer) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "preparedMessageCount", new String[]{}, new Object[]{}); }
|
||||
public static java.util.List<JsonEnvelope> preparedMessagesSnapshot(HttpBackendProxyTransport target) { return (java.util.List<JsonEnvelope>) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "preparedMessagesSnapshot", new String[]{}, new Object[]{}); }
|
||||
|
||||
public static void finishInitialization(boolean installed, HttpBackendTransportConnector replacement, boolean acquired, Semaphore owner, CountDownLatch completion) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", null, "finishInitialization", new String[]{"boolean", "HttpBackendTransportConnector", "boolean", "Semaphore", "CountDownLatch"}, new Object[]{installed, replacement, acquired, owner, completion}); }
|
||||
public static void transferStartupQueue(ArrayDeque<JsonEnvelope> queue, HttpBackendTransportConnector connector) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", null, "transferStartupQueue", new String[]{"ArrayDeque", "HttpBackendTransportConnector"}, new Object[]{queue, connector}); }
|
||||
|
||||
public static void restoreUnenrolledCredentialState(Path directory, String serverId, String configuredCode) throws Exception { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", null, "restoreUnenrolledCredentialState", new String[]{"Path", "String", "String"}, new Object[]{directory, serverId, configuredCode}); }
|
||||
|
||||
public static void dispatchIncoming(HttpBackendProxyTransport target, GlobalMessageHandler messageHandler, JsonEnvelope envelope, long deadlineNanos) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "dispatchIncoming", new String[]{"GlobalMessageHandler", "JsonEnvelope", "long"}, new Object[]{messageHandler, envelope, deadlineNanos}); }
|
||||
|
||||
|
||||
|
||||
|
||||
public static java.util.List<JsonEnvelope> handoffMessagesSnapshot(HttpBackendProxyTransport target) { return (java.util.List<JsonEnvelope>) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "handoffMessagesSnapshot", new String[]{}, new Object[]{}); }
|
||||
|
||||
|
||||
public static HttpConnectionCode enrollmentCode(Path directory, String serverId, String configuredCode) { return (HttpConnectionCode) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", null, "enrollmentCode", new String[]{"Path", "String", "String"}, new Object[]{directory, serverId, configuredCode}); }
|
||||
public static boolean flushHandoffForShutdown(HttpBackendTransportConnector active, java.util.List<JsonEnvelope> finalHandoff, long deadlineNanos) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", null, "flushHandoffForShutdown", new String[]{"HttpBackendTransportConnector", "List", "long"}, new Object[]{active, finalHandoff, deadlineNanos}); }
|
||||
}
|
||||
+93
-88
@@ -1,4 +1,10 @@
|
||||
package com.bencodez.votingplugin.backendproxy.transport;
|
||||
package com.bencodez.votingplugin.tests.backendproxy.transport;
|
||||
|
||||
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
|
||||
|
||||
import com.bencodez.votingplugin.backendproxy.transport.BackendProxyTransport;
|
||||
import com.bencodez.votingplugin.backendproxy.transport.BackendProxyTransportManager;
|
||||
import com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
@@ -64,7 +70,7 @@ class HttpBackendProxyTransportTest {
|
||||
}).when(scheduler).runTask(org.mockito.ArgumentMatchers.eq(plugin), org.mockito.ArgumentMatchers.any(Runnable.class));
|
||||
JsonEnvelope envelope = JsonEnvelope.builder("test").put("message", "payload").build();
|
||||
|
||||
new HttpBackendProxyTransport(plugin).dispatchIncoming(handler, envelope,
|
||||
HttpBackendProxyTransportAccess.dispatchIncoming(new HttpBackendProxyTransport(plugin), handler, envelope,
|
||||
System.nanoTime() + TimeUnit.SECONDS.toNanos(1));
|
||||
|
||||
assertTrue(callbackFinished.await(1, TimeUnit.SECONDS));
|
||||
@@ -73,7 +79,7 @@ class HttpBackendProxyTransportTest {
|
||||
|
||||
@Test
|
||||
void publicationActivatesTheSimpleApiInboundBarrier() throws Exception {
|
||||
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class));
|
||||
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(mock(VotingPluginMain.class));
|
||||
HttpBackendTransportConnector connector = mock(HttpBackendTransportConnector.class);
|
||||
setField(transport, "connector", connector);
|
||||
|
||||
@@ -84,10 +90,10 @@ class HttpBackendProxyTransportTest {
|
||||
|
||||
@Test
|
||||
void closedPublicationGateRejectsDeliveryForReplay() {
|
||||
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class));
|
||||
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(mock(VotingPluginMain.class));
|
||||
transport.close();
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> transport.dispatchAfterPublication(
|
||||
assertThrows(IllegalStateException.class, () -> HttpBackendProxyTransportAccess.dispatchAfterPublication(transport,
|
||||
mock(GlobalMessageHandler.class), JsonEnvelope.builder("test").build()));
|
||||
}
|
||||
|
||||
@@ -98,7 +104,7 @@ class HttpBackendProxyTransportTest {
|
||||
when(connector.send(pending)).thenReturn(false, true);
|
||||
when(connector.flushOutgoing(org.mockito.ArgumentMatchers.anyLong())).thenReturn(true);
|
||||
|
||||
assertTrue(HttpBackendProxyTransport.flushHandoffForShutdown(connector, List.of(pending),
|
||||
assertTrue(HttpBackendProxyTransportAccess.flushHandoffForShutdown(connector, List.of(pending),
|
||||
System.nanoTime() + TimeUnit.SECONDS.toNanos(1)));
|
||||
|
||||
org.mockito.InOrder order = org.mockito.Mockito.inOrder(connector);
|
||||
@@ -109,7 +115,7 @@ class HttpBackendProxyTransportTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void unpublishedHandoffMessagesAreDiscardedOnRollback() throws Exception {
|
||||
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class));
|
||||
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(mock(VotingPluginMain.class));
|
||||
HttpBackendTransportConnector connector = mock(HttpBackendTransportConnector.class);
|
||||
setField(transport, "connector", connector);
|
||||
((java.util.ArrayDeque<JsonEnvelope>) field(transport, "handoffQueue"))
|
||||
@@ -130,7 +136,7 @@ class HttpBackendProxyTransportTest {
|
||||
when(connector.send(pending)).thenReturn(false);
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> HttpBackendProxyTransport.transferStartupQueue(queue, connector));
|
||||
() -> HttpBackendProxyTransportAccess.transferStartupQueue(queue, connector));
|
||||
assertEquals(java.util.List.of(pending), new java.util.ArrayList<>(queue));
|
||||
}
|
||||
|
||||
@@ -142,7 +148,7 @@ class HttpBackendProxyTransportTest {
|
||||
CountDownLatch completion = new CountDownLatch(1);
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> HttpBackendProxyTransport.finishInitialization(false, connector, true, owner, completion));
|
||||
() -> HttpBackendProxyTransportAccess.finishInitialization(false, connector, true, owner, completion));
|
||||
|
||||
verify(connector).close();
|
||||
assertEquals(1, owner.availablePermits());
|
||||
@@ -152,7 +158,7 @@ class HttpBackendProxyTransportTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void orderlyShutdownFlushesRecoveryQueueBeforeHandoffQueue() throws Exception {
|
||||
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class));
|
||||
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(mock(VotingPluginMain.class));
|
||||
HttpBackendTransportConnector connector = mock(HttpBackendTransportConnector.class);
|
||||
JsonEnvelope recovery = JsonEnvelope.builder("recovery").build();
|
||||
JsonEnvelope handoff = JsonEnvelope.builder("handoff").build();
|
||||
@@ -181,8 +187,8 @@ class HttpBackendProxyTransportTest {
|
||||
setField(manager, "transport", failed);
|
||||
org.mockito.Mockito.doThrow(new IllegalStateException("worker still stopping"))
|
||||
.when(failed).prepareForReplacement();
|
||||
when(failed.isClosedForReplacement()).thenReturn(true);
|
||||
when(failed.recreatePrepared()).thenReturn(restored);
|
||||
when(HttpBackendProxyTransportAccess.isClosedForReplacement(failed)).thenReturn(true);
|
||||
when(HttpBackendProxyTransportAccess.recreatePrepared(failed)).thenReturn(restored);
|
||||
|
||||
assertThrows(IllegalStateException.class, manager::prepareForReplacement);
|
||||
manager.send(queued);
|
||||
@@ -203,7 +209,7 @@ class HttpBackendProxyTransportTest {
|
||||
manager.send(queued);
|
||||
|
||||
verify(active).send(queued);
|
||||
verify(active, org.mockito.Mockito.never()).recreatePrepared();
|
||||
HttpBackendProxyTransportAccess.recreatePrepared(verify(active, org.mockito.Mockito.never()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -267,7 +273,7 @@ class HttpBackendProxyTransportTest {
|
||||
});
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> new HttpBackendProxyTransport(plugin).dispatchIncoming(handler,
|
||||
() -> HttpBackendProxyTransportAccess.dispatchIncoming(new HttpBackendProxyTransport(plugin), handler,
|
||||
JsonEnvelope.builder("test").build(), System.nanoTime()));
|
||||
assertTrue(scheduled.get() != null);
|
||||
scheduled.get().run();
|
||||
@@ -294,7 +300,7 @@ class HttpBackendProxyTransportTest {
|
||||
AtomicReference<Throwable> dispatchFailure = new AtomicReference<>();
|
||||
Thread dispatch = new Thread(() -> {
|
||||
try {
|
||||
new HttpBackendProxyTransport(plugin).dispatchIncoming(handler,
|
||||
HttpBackendProxyTransportAccess.dispatchIncoming(new HttpBackendProxyTransport(plugin), handler,
|
||||
JsonEnvelope.builder("test").build(), System.nanoTime() + TimeUnit.SECONDS.toNanos(5));
|
||||
} catch (Throwable thrown) {
|
||||
dispatchFailure.set(thrown);
|
||||
@@ -317,28 +323,28 @@ class HttpBackendProxyTransportTest {
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getBungeeSettings()).thenReturn(settings);
|
||||
when(settings.getServer()).thenReturn("lobby-1");
|
||||
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(plugin);
|
||||
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(plugin);
|
||||
|
||||
when(settings.getHttpConnectionCode()).thenReturn("malformed");
|
||||
assertThrows(IllegalStateException.class, transport::validate);
|
||||
assertThrows(IllegalStateException.class, () -> HttpBackendProxyTransportAccess.validate(transport));
|
||||
|
||||
when(settings.getHttpConnectionCode()).thenReturn(code("lobby-1", Instant.now().minusSeconds(1)).encode());
|
||||
assertThrows(IllegalStateException.class, transport::validate);
|
||||
assertThrows(IllegalStateException.class, () -> HttpBackendProxyTransportAccess.validate(transport));
|
||||
|
||||
when(settings.getHttpConnectionCode()).thenReturn(code("survival", Instant.now().plusSeconds(60)).encode());
|
||||
assertThrows(IllegalStateException.class, transport::validate);
|
||||
assertThrows(IllegalStateException.class, () -> HttpBackendProxyTransportAccess.validate(transport));
|
||||
assertThrows(IllegalStateException.class, () -> transport.start(mock(GlobalMessageHandler.class)),
|
||||
"invalid configuration must fail before the enrollment worker starts");
|
||||
|
||||
when(settings.getHttpConnectionCode()).thenReturn(code("lobby-1", Instant.now().plusSeconds(60)).encode());
|
||||
assertDoesNotThrow(() -> transport.validate());
|
||||
assertDoesNotThrow(() -> HttpBackendProxyTransportAccess.validate(transport));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ordinaryStartupRetriesTransientInitialEnrollmentWithBoundedBackoff() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
|
||||
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(plugin);
|
||||
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(plugin);
|
||||
HttpConnectionCode enrollment = code("lobby-1", Instant.now().plusSeconds(60));
|
||||
List<Long> delays = new ArrayList<>();
|
||||
java.util.concurrent.atomic.AtomicInteger attempts = new java.util.concurrent.atomic.AtomicInteger();
|
||||
@@ -349,7 +355,7 @@ class HttpBackendProxyTransportTest {
|
||||
if (attempts.getAndIncrement() == 0) throw new IOException("proxy unavailable");
|
||||
return null;
|
||||
});
|
||||
assertTrue(transport.enrollForStartup(enrollment, "lobby-1", directory, true,
|
||||
assertTrue(HttpBackendProxyTransportAccess.enrollForStartup(transport, enrollment, "lobby-1", directory, true,
|
||||
delay -> { delays.add(delay); return true; }));
|
||||
}
|
||||
|
||||
@@ -359,11 +365,11 @@ class HttpBackendProxyTransportTest {
|
||||
|
||||
@Test
|
||||
void replacementCancelsRetryingInitialEnrollmentWithoutCredential() throws Exception {
|
||||
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class));
|
||||
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(mock(VotingPluginMain.class));
|
||||
Path credentials = directory.resolve("http");
|
||||
java.lang.reflect.Field directoryField = HttpBackendProxyTransport.class.getDeclaredField("configuredDirectory");
|
||||
directoryField.setAccessible(true);
|
||||
directoryField.set(transport, credentials);
|
||||
ReflectiveAccess.set(directoryField, transport, credentials);
|
||||
java.lang.reflect.Field ownersField = HttpBackendProxyTransport.class.getDeclaredField("DIRECTORY_OWNERS");
|
||||
ownersField.setAccessible(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
@@ -380,7 +386,7 @@ class HttpBackendProxyTransportTest {
|
||||
});
|
||||
java.lang.reflect.Field workerField = HttpBackendProxyTransport.class.getDeclaredField("worker");
|
||||
workerField.setAccessible(true);
|
||||
workerField.set(transport, retryingEnrollment);
|
||||
ReflectiveAccess.set(workerField, transport, retryingEnrollment);
|
||||
retryingEnrollment.start();
|
||||
assertTrue(workerStarted.await(1, TimeUnit.SECONDS));
|
||||
JsonEnvelope queued = JsonEnvelope.builder("queued-before-replacement").build();
|
||||
@@ -392,7 +398,7 @@ class HttpBackendProxyTransportTest {
|
||||
HttpBackendProxyTransport.class.getDeclaredField("restoreUnenrolledState");
|
||||
restoreAbsent.setAccessible(true);
|
||||
assertTrue(restoreAbsent.getBoolean(transport), "rollback must remember that no credential existed");
|
||||
assertEquals(List.of(queued), transport.drainPreparedMessages(),
|
||||
assertEquals(List.of(queued), HttpBackendProxyTransportAccess.drainPreparedMessages(transport),
|
||||
"messages accepted before replacement must remain available for handoff");
|
||||
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2);
|
||||
@@ -404,7 +410,7 @@ class HttpBackendProxyTransportTest {
|
||||
|
||||
@Test
|
||||
void replacementSnapshotsInitialEnrollmentPublishedWhileCancellationCompletes() throws Exception {
|
||||
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class));
|
||||
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(mock(VotingPluginMain.class));
|
||||
Path credentials = directory.resolve("http");
|
||||
HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("original-proxy"),
|
||||
"proxy.example.test");
|
||||
@@ -451,7 +457,7 @@ class HttpBackendProxyTransportTest {
|
||||
assertTrue(HttpClientCredentialStore.hasEnrolledProfile(credentials));
|
||||
|
||||
HttpConnectionCode original = code("lobby-1", Instant.now().plusSeconds(60));
|
||||
HttpBackendProxyTransport.restoreUnenrolledCredentialState(credentials, "lobby-1", original.encode());
|
||||
HttpBackendProxyTransportAccess.restoreUnenrolledCredentialState(credentials, "lobby-1", original.encode());
|
||||
|
||||
assertFalse(HttpClientCredentialStore.hasEnrolledProfile(credentials),
|
||||
"a failed staged first enrollment must not remain active during rollback");
|
||||
@@ -466,7 +472,7 @@ class HttpBackendProxyTransportTest {
|
||||
identity.serverCertificatePin(), identity.caCertificatePin(), Instant.now().plusSeconds(60), "F".repeat(43));
|
||||
HttpClientCredentialStore.saveEnrolled(credentials, original, identity.issueClientCertificate("lobby-1"));
|
||||
|
||||
HttpBackendProxyTransport.restoreUnenrolledCredentialState(credentials, "lobby-1", original.encode());
|
||||
HttpBackendProxyTransportAccess.restoreUnenrolledCredentialState(credentials, "lobby-1", original.encode());
|
||||
|
||||
assertTrue(HttpClientCredentialStore.hasEnrolledProfile(credentials),
|
||||
"a credential issued for the consumed original code must remain recoverable");
|
||||
@@ -476,13 +482,13 @@ class HttpBackendProxyTransportTest {
|
||||
void stagedEnrollmentRemainsFailFast() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
|
||||
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(plugin);
|
||||
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(plugin);
|
||||
HttpConnectionCode enrollment = code("lobby-1", Instant.now().plusSeconds(60));
|
||||
try (MockedStatic<HttpBackendTransportConnector> connector = org.mockito.Mockito.mockStatic(HttpBackendTransportConnector.class)) {
|
||||
connector.when(() -> HttpBackendTransportConnector.enroll(enrollment, "lobby-1", directory))
|
||||
.thenThrow(new IOException("proxy unavailable"));
|
||||
assertThrows(IOException.class,
|
||||
() -> transport.enrollForStartup(enrollment, "lobby-1", directory, false,
|
||||
() -> HttpBackendProxyTransportAccess.enrollForStartup(transport, enrollment, "lobby-1", directory, false,
|
||||
delay -> { throw new AssertionError("staged enrollment must not retry"); }));
|
||||
}
|
||||
}
|
||||
@@ -494,32 +500,32 @@ class HttpBackendProxyTransportTest {
|
||||
HttpConnectionCode original = new HttpConnectionCode("lobby-1", URI.create("https://proxy.example.test:1297/"),
|
||||
identity.serverCertificatePin(), identity.caCertificatePin(), Instant.now().minusSeconds(1), "A".repeat(43));
|
||||
HttpClientCredentialStore.saveEnrolled(credentials, original, identity.issueClientCertificate("lobby-1"));
|
||||
assertNull(HttpBackendProxyTransport.enrollmentCode(credentials, "lobby-1", ""));
|
||||
assertNull(HttpBackendProxyTransport.enrollmentCode(credentials, "lobby-1", original.encode()),
|
||||
assertNull(HttpBackendProxyTransportAccess.enrollmentCode(credentials, "lobby-1", ""));
|
||||
assertNull(HttpBackendProxyTransportAccess.enrollmentCode(credentials, "lobby-1", original.encode()),
|
||||
"the already-consumed code must not be retried, even after it expires");
|
||||
|
||||
HttpConnectionCode replacement = new HttpConnectionCode("lobby-1", original.endpoint(), original.serverCertificatePin(),
|
||||
original.caCertificatePin(), Instant.now().plusSeconds(60), "B".repeat(43));
|
||||
assertEquals(replacement.encode(), HttpBackendProxyTransport.enrollmentCode(credentials, "lobby-1", replacement.encode()).encode());
|
||||
assertEquals(replacement.encode(), HttpBackendProxyTransportAccess.enrollmentCode(credentials, "lobby-1", replacement.encode()).encode());
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> HttpBackendProxyTransport.enrollmentCode(credentials, "lobby-1", "malformed"));
|
||||
() -> HttpBackendProxyTransportAccess.enrollmentCode(credentials, "lobby-1", "malformed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedReplacementFlushResumesTheExistingConnector() {
|
||||
HttpBackendTransportConnector connector = mock(HttpBackendTransportConnector.class);
|
||||
when(connector.flushOutgoing(org.mockito.ArgumentMatchers.anyLong())).thenReturn(false);
|
||||
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class));
|
||||
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(mock(VotingPluginMain.class));
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> transport.flushForReplacement(connector, System.nanoTime()));
|
||||
() -> HttpBackendProxyTransportAccess.flushForReplacement(transport, connector, System.nanoTime()));
|
||||
|
||||
verify(connector).start();
|
||||
}
|
||||
|
||||
@Test
|
||||
void closeNeverWaitsForSetupOnTheCallingThread() throws Exception {
|
||||
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class));
|
||||
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(mock(VotingPluginMain.class));
|
||||
CountDownLatch started = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
Thread blocked = new Thread(() -> {
|
||||
started.countDown();
|
||||
@@ -530,7 +536,7 @@ class HttpBackendProxyTransportTest {
|
||||
assertTrue(started.await(1, TimeUnit.SECONDS));
|
||||
java.lang.reflect.Field worker = HttpBackendProxyTransport.class.getDeclaredField("worker");
|
||||
worker.setAccessible(true);
|
||||
worker.set(transport, blocked);
|
||||
ReflectiveAccess.set(worker, transport, blocked);
|
||||
|
||||
long startedAt = System.nanoTime();
|
||||
transport.close();
|
||||
@@ -541,7 +547,7 @@ class HttpBackendProxyTransportTest {
|
||||
|
||||
@Test
|
||||
void prepareForReplacementSnapshotsThePersistedCredentialGeneration() throws Exception {
|
||||
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class));
|
||||
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(mock(VotingPluginMain.class));
|
||||
Path credentials = directory.resolve("http");
|
||||
HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("replacement-proxy"), "proxy.example.test");
|
||||
HttpConnectionCode code = new HttpConnectionCode("lobby-1", URI.create("https://proxy.example.test:1297/"),
|
||||
@@ -551,12 +557,12 @@ class HttpBackendProxyTransportTest {
|
||||
HttpClientCredentialStore.snapshotActiveGeneration(credentials);
|
||||
java.lang.reflect.Field directoryField = HttpBackendProxyTransport.class.getDeclaredField("configuredDirectory");
|
||||
directoryField.setAccessible(true);
|
||||
directoryField.set(transport, credentials);
|
||||
ReflectiveAccess.set(directoryField, transport, credentials);
|
||||
java.lang.reflect.Field connectorField = HttpBackendProxyTransport.class.getDeclaredField("connector");
|
||||
connectorField.setAccessible(true);
|
||||
HttpBackendTransportConnector connector = mock(HttpBackendTransportConnector.class);
|
||||
when(connector.flushOutgoing(org.mockito.ArgumentMatchers.anyLong())).thenReturn(true);
|
||||
connectorField.set(transport, connector);
|
||||
ReflectiveAccess.set(connectorField, transport, connector);
|
||||
|
||||
assertDoesNotThrow(transport::prepareForReplacement);
|
||||
java.lang.reflect.Field generationField =
|
||||
@@ -585,12 +591,12 @@ class HttpBackendProxyTransportTest {
|
||||
var owners = (java.util.concurrent.ConcurrentHashMap<Path, java.util.concurrent.Semaphore>) ownersField.get(null);
|
||||
java.util.concurrent.Semaphore predecessor = new java.util.concurrent.Semaphore(0);
|
||||
owners.put(credentials.toAbsolutePath().normalize(), predecessor);
|
||||
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(plugin);
|
||||
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(plugin);
|
||||
transport.start(mock(GlobalMessageHandler.class));
|
||||
java.util.concurrent.atomic.AtomicReference<Throwable> failure = new java.util.concurrent.atomic.AtomicReference<>();
|
||||
CountDownLatch finished = new CountDownLatch(1);
|
||||
Thread validation = new Thread(() -> {
|
||||
try { transport.validate(System.nanoTime() + TimeUnit.SECONDS.toNanos(1)); }
|
||||
try { HttpBackendProxyTransportAccess.validate(transport, System.nanoTime() + TimeUnit.SECONDS.toNanos(1)); }
|
||||
catch (Throwable thrown) { failure.set(thrown); }
|
||||
finally { finished.countDown(); }
|
||||
});
|
||||
@@ -611,32 +617,32 @@ class HttpBackendProxyTransportTest {
|
||||
void preparedHandoffMessagesStayAheadOfNewlyPublishedSendsWhenConnectorIsFull() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
|
||||
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(plugin);
|
||||
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(plugin);
|
||||
HttpBackendTransportConnector connector = mock(HttpBackendTransportConnector.class);
|
||||
when(connector.send(org.mockito.ArgumentMatchers.any(JsonEnvelope.class))).thenReturn(false);
|
||||
setField(transport, "connector", connector);
|
||||
JsonEnvelope acceptedBeforePublication = JsonEnvelope.builder("old").build();
|
||||
JsonEnvelope sentAfterPublication = JsonEnvelope.builder("new").build();
|
||||
|
||||
transport.beginPreparedHandoff();
|
||||
HttpBackendProxyTransportAccess.beginPreparedHandoff(transport);
|
||||
transport.send(sentAfterPublication);
|
||||
transport.acceptHandoffMessages(List.of(acceptedBeforePublication));
|
||||
HttpBackendProxyTransportAccess.acceptHandoffMessages(transport, List.of(acceptedBeforePublication));
|
||||
|
||||
assertEquals(List.of(acceptedBeforePublication, sentAfterPublication), transport.handoffMessagesSnapshot());
|
||||
assertEquals(List.of(acceptedBeforePublication, sentAfterPublication), HttpBackendProxyTransportAccess.handoffMessagesSnapshot(transport));
|
||||
transport.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void shutdownManagerRemainderStaysBehindAlreadyTransferredPrefix() throws Exception {
|
||||
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class));
|
||||
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(mock(VotingPluginMain.class));
|
||||
JsonEnvelope olderPrefix = JsonEnvelope.builder("older-prefix").build();
|
||||
JsonEnvelope newerRemainder = JsonEnvelope.builder("newer-remainder").build();
|
||||
((java.util.ArrayDeque<JsonEnvelope>) field(transport, "handoffQueue")).addLast(olderPrefix);
|
||||
|
||||
transport.appendShutdownHandoffMessages(List.of(newerRemainder));
|
||||
HttpBackendProxyTransportAccess.appendShutdownHandoffMessages(transport, List.of(newerRemainder));
|
||||
|
||||
assertEquals(List.of(olderPrefix, newerRemainder), transport.handoffMessagesSnapshot());
|
||||
assertEquals(List.of(olderPrefix, newerRemainder), HttpBackendProxyTransportAccess.handoffMessagesSnapshot(transport));
|
||||
transport.close();
|
||||
}
|
||||
|
||||
@@ -647,8 +653,8 @@ class HttpBackendProxyTransportTest {
|
||||
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
|
||||
BackendProxyTransportManager previous = new BackendProxyTransportManager(plugin);
|
||||
BackendProxyTransportManager replacement = new BackendProxyTransportManager(plugin);
|
||||
HttpBackendProxyTransport oldTransport = new HttpBackendProxyTransport(plugin);
|
||||
HttpBackendProxyTransport newTransport = new HttpBackendProxyTransport(plugin);
|
||||
HttpBackendProxyTransport oldTransport = HttpBackendProxyTransportAccess.create(plugin);
|
||||
HttpBackendProxyTransport newTransport = HttpBackendProxyTransportAccess.create(plugin);
|
||||
setField(previous, "preparedTransport", oldTransport);
|
||||
setField(replacement, "transport", newTransport);
|
||||
java.util.ArrayDeque<JsonEnvelope> oldQueue =
|
||||
@@ -677,12 +683,12 @@ class HttpBackendProxyTransportTest {
|
||||
|
||||
assertDoesNotThrow(() -> previous.completePreparedTransportHandoff(replacement));
|
||||
replacement.awaitAsyncHandoff(System.nanoTime() + TimeUnit.SECONDS.toNanos(2));
|
||||
List<JsonEnvelope> queued = newTransport.handoffMessagesSnapshot();
|
||||
List<JsonEnvelope> queued = HttpBackendProxyTransportAccess.handoffMessagesSnapshot(newTransport);
|
||||
assertEquals(4096, queued.size());
|
||||
assertEquals(oldMessages, queued.subList(0, 2048));
|
||||
assertEquals(preparedMessages, queued.subList(2048, 3072));
|
||||
assertEquals(replacementMessages.subList(0, 1024), queued.subList(3072, 4096));
|
||||
assertEquals(0, oldTransport.preparedMessageCount());
|
||||
assertEquals(0, HttpBackendProxyTransportAccess.preparedMessageCount(oldTransport));
|
||||
assertTrue(((java.util.ArrayDeque<JsonEnvelope>) field(previous, "preparedSends")).isEmpty());
|
||||
newTransport.close();
|
||||
}
|
||||
@@ -693,8 +699,8 @@ class HttpBackendProxyTransportTest {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class);
|
||||
BackendProxyTransportManager previous = new BackendProxyTransportManager(plugin);
|
||||
BackendProxyTransportManager replacement = new BackendProxyTransportManager(plugin);
|
||||
HttpBackendProxyTransport oldTransport = new HttpBackendProxyTransport(plugin);
|
||||
HttpBackendProxyTransport newTransport = new HttpBackendProxyTransport(plugin);
|
||||
HttpBackendProxyTransport oldTransport = HttpBackendProxyTransportAccess.create(plugin);
|
||||
HttpBackendProxyTransport newTransport = HttpBackendProxyTransportAccess.create(plugin);
|
||||
setField(previous, "preparedTransport", oldTransport);
|
||||
setField(replacement, "transport", newTransport);
|
||||
java.util.ArrayDeque<JsonEnvelope> oldQueue =
|
||||
@@ -718,8 +724,8 @@ class HttpBackendProxyTransportTest {
|
||||
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
|
||||
BackendProxyTransportManager previous = new BackendProxyTransportManager(plugin);
|
||||
BackendProxyTransportManager replacement = new BackendProxyTransportManager(plugin);
|
||||
HttpBackendProxyTransport oldTransport = new HttpBackendProxyTransport(plugin);
|
||||
HttpBackendProxyTransport newTransport = new HttpBackendProxyTransport(plugin);
|
||||
HttpBackendProxyTransport oldTransport = HttpBackendProxyTransportAccess.create(plugin);
|
||||
HttpBackendProxyTransport newTransport = HttpBackendProxyTransportAccess.create(plugin);
|
||||
setField(previous, "preparedTransport", oldTransport);
|
||||
setField(replacement, "transport", newTransport);
|
||||
java.util.ArrayDeque<JsonEnvelope> oldQueue =
|
||||
@@ -739,7 +745,7 @@ class HttpBackendProxyTransportTest {
|
||||
assertEquals(3072, oldQueue.size());
|
||||
assertEquals(1024, ((java.util.ArrayDeque<?>) field(previous, "preparedSends")).size());
|
||||
assertEquals(2, ((java.util.ArrayDeque<?>) field(replacement, "preparedSends")).size());
|
||||
assertTrue(newTransport.handoffMessagesSnapshot().isEmpty());
|
||||
assertTrue(HttpBackendProxyTransportAccess.handoffMessagesSnapshot(newTransport).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -748,7 +754,7 @@ class HttpBackendProxyTransportTest {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
|
||||
BackendProxyTransportManager manager = new BackendProxyTransportManager(plugin);
|
||||
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(plugin);
|
||||
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(plugin);
|
||||
setField(manager, "transport", transport);
|
||||
setField(transport, "managerHandoffActive", true);
|
||||
java.util.ArrayDeque<JsonEnvelope> httpQueue =
|
||||
@@ -772,7 +778,7 @@ class HttpBackendProxyTransportTest {
|
||||
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
|
||||
BackendProxyTransportManager previous = new BackendProxyTransportManager(plugin);
|
||||
BackendProxyTransportManager replacement = new BackendProxyTransportManager(plugin);
|
||||
HttpBackendProxyTransport oldTransport = new HttpBackendProxyTransport(plugin);
|
||||
HttpBackendProxyTransport oldTransport = HttpBackendProxyTransportAccess.create(plugin);
|
||||
HttpBackendProxyTransport newTransport = mock(HttpBackendProxyTransport.class);
|
||||
JsonEnvelope envelope = JsonEnvelope.builder("accepted-once").build();
|
||||
CountDownLatch sending = new CountDownLatch(1);
|
||||
@@ -801,8 +807,7 @@ class HttpBackendProxyTransportTest {
|
||||
closing.join(TimeUnit.SECONDS.toMillis(2));
|
||||
assertFalse(closing.isAlive());
|
||||
verify(newTransport).send(envelope);
|
||||
verify(newTransport, org.mockito.Mockito.never())
|
||||
.acceptHandoffMessages(org.mockito.ArgumentMatchers.anyList());
|
||||
HttpBackendProxyTransportAccess.acceptHandoffMessages(verify(newTransport, org.mockito.Mockito.never()), org.mockito.ArgumentMatchers.anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -812,8 +817,8 @@ class HttpBackendProxyTransportTest {
|
||||
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
|
||||
BackendProxyTransportManager previous = new BackendProxyTransportManager(plugin);
|
||||
BackendProxyTransportManager replacement = new BackendProxyTransportManager(plugin);
|
||||
HttpBackendProxyTransport oldTransport = new HttpBackendProxyTransport(plugin);
|
||||
HttpBackendProxyTransport newTransport = new HttpBackendProxyTransport(plugin);
|
||||
HttpBackendProxyTransport oldTransport = HttpBackendProxyTransportAccess.create(plugin);
|
||||
HttpBackendProxyTransport newTransport = HttpBackendProxyTransportAccess.create(plugin);
|
||||
HttpBackendTransportConnector connector = mock(HttpBackendTransportConnector.class);
|
||||
JsonEnvelope source = JsonEnvelope.builder("source").build();
|
||||
JsonEnvelope adapted = JsonEnvelope.builder("adapted").build();
|
||||
@@ -874,7 +879,7 @@ class HttpBackendProxyTransportTest {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
|
||||
BackendProxyTransportManager manager = new BackendProxyTransportManager(plugin);
|
||||
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(plugin);
|
||||
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(plugin);
|
||||
HttpBackendTransportConnector connector = mock(HttpBackendTransportConnector.class);
|
||||
JsonEnvelope envelope = JsonEnvelope.builder("shutdown").build();
|
||||
CountDownLatch sending = new CountDownLatch(1);
|
||||
@@ -941,8 +946,8 @@ class HttpBackendProxyTransportTest {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class);
|
||||
BackendProxyTransportManager previous = new BackendProxyTransportManager(plugin);
|
||||
BackendProxyTransportManager replacement = new BackendProxyTransportManager(plugin);
|
||||
HttpBackendProxyTransport oldTransport = new HttpBackendProxyTransport(plugin);
|
||||
HttpBackendProxyTransport newTransport = new HttpBackendProxyTransport(plugin);
|
||||
HttpBackendProxyTransport oldTransport = HttpBackendProxyTransportAccess.create(plugin);
|
||||
HttpBackendProxyTransport newTransport = HttpBackendProxyTransportAccess.create(plugin);
|
||||
JsonEnvelope old = JsonEnvelope.builder("old").build();
|
||||
JsonEnvelope prepared = JsonEnvelope.builder("prepared").build();
|
||||
setField(previous, "preparedTransport", oldTransport);
|
||||
@@ -956,7 +961,7 @@ class HttpBackendProxyTransportTest {
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> previous.completePreparedTransportHandoff(replacement));
|
||||
|
||||
assertEquals(List.of(old), oldTransport.preparedMessagesSnapshot());
|
||||
assertEquals(List.of(old), HttpBackendProxyTransportAccess.preparedMessagesSnapshot(oldTransport));
|
||||
java.util.ArrayDeque<?> retained = (java.util.ArrayDeque<?>) field(previous, "preparedSends");
|
||||
assertEquals(1, retained.size());
|
||||
assertSame(prepared, field(retained.peekFirst(), "source"));
|
||||
@@ -966,13 +971,13 @@ class HttpBackendProxyTransportTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void preparedDrainPreservesStartupAndExistingHandoffMessagesInFifoOrder() throws Exception {
|
||||
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class));
|
||||
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(mock(VotingPluginMain.class));
|
||||
JsonEnvelope startup = JsonEnvelope.builder("startup").build();
|
||||
JsonEnvelope handoff = JsonEnvelope.builder("handoff").build();
|
||||
((java.util.ArrayDeque<JsonEnvelope>) field(transport, "startupQueue")).add(startup);
|
||||
((java.util.ArrayDeque<JsonEnvelope>) field(transport, "handoffQueue")).add(handoff);
|
||||
|
||||
assertEquals(List.of(startup, handoff), transport.drainPreparedMessages());
|
||||
assertEquals(List.of(startup, handoff), HttpBackendProxyTransportAccess.drainPreparedMessages(transport));
|
||||
assertTrue(((java.util.ArrayDeque<JsonEnvelope>) field(transport, "startupQueue")).isEmpty());
|
||||
assertTrue(((java.util.ArrayDeque<JsonEnvelope>) field(transport, "handoffQueue")).isEmpty());
|
||||
}
|
||||
@@ -982,7 +987,7 @@ class HttpBackendProxyTransportTest {
|
||||
void rollbackRecreateKeepsAnOversizedCombinedQueueInTheDrainedHandoffLane() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
|
||||
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(plugin);
|
||||
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(plugin);
|
||||
Path credentials = directory.resolve("http");
|
||||
setField(transport, "configuredDirectory", credentials);
|
||||
setField(transport, "configuredServerId", "lobby-1");
|
||||
@@ -1009,7 +1014,7 @@ class HttpBackendProxyTransportTest {
|
||||
HttpBackendProxyTransport restored = null;
|
||||
Thread setup = null;
|
||||
try {
|
||||
restored = transport.recreatePrepared();
|
||||
restored = HttpBackendProxyTransportAccess.recreatePrepared(transport);
|
||||
assertTrue(((java.util.ArrayDeque<JsonEnvelope>) field(restored, "startupQueue")).isEmpty(),
|
||||
"rollback must not synchronously overfill the connector's 1024-entry queue");
|
||||
assertEquals(queued,
|
||||
@@ -1031,7 +1036,7 @@ class HttpBackendProxyTransportTest {
|
||||
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
|
||||
BackendProxyTransportManager manager = new BackendProxyTransportManager(plugin);
|
||||
HttpBackendProxyTransport previous = mock(HttpBackendProxyTransport.class);
|
||||
HttpBackendProxyTransport restored = new HttpBackendProxyTransport(plugin);
|
||||
HttpBackendProxyTransport restored = HttpBackendProxyTransportAccess.create(plugin);
|
||||
java.util.ArrayDeque<JsonEnvelope> restoredQueue =
|
||||
(java.util.ArrayDeque<JsonEnvelope>) field(restored, "handoffQueue");
|
||||
for (int index = 0; index < 4096; index++)
|
||||
@@ -1039,29 +1044,29 @@ class HttpBackendProxyTransportTest {
|
||||
JsonEnvelope first = JsonEnvelope.builder("fenced-1").build();
|
||||
JsonEnvelope second = JsonEnvelope.builder("fenced-2").build();
|
||||
setField(manager, "preparedTransport", previous);
|
||||
when(previous.recreatePrepared()).thenReturn(restored);
|
||||
when(HttpBackendProxyTransportAccess.recreatePrepared(previous)).thenReturn(restored);
|
||||
manager.send(first);
|
||||
manager.send(second);
|
||||
|
||||
manager.restorePreparedTransport();
|
||||
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2);
|
||||
while (restored.handoffMessagesSnapshot().size() != 4098 && System.nanoTime() < deadline)
|
||||
while (HttpBackendProxyTransportAccess.handoffMessagesSnapshot(restored).size() != 4098 && System.nanoTime() < deadline)
|
||||
Thread.onSpinWait();
|
||||
assertEquals(4098, restored.handoffMessagesSnapshot().size());
|
||||
assertSame(first, restored.handoffMessagesSnapshot().get(4096));
|
||||
assertSame(second, restored.handoffMessagesSnapshot().get(4097));
|
||||
assertEquals(4098, HttpBackendProxyTransportAccess.handoffMessagesSnapshot(restored).size());
|
||||
assertSame(first, HttpBackendProxyTransportAccess.handoffMessagesSnapshot(restored).get(4096));
|
||||
assertSame(second, HttpBackendProxyTransportAccess.handoffMessagesSnapshot(restored).get(4097));
|
||||
assertTrue(((java.util.ArrayDeque<?>) field(manager, "asyncHandoffSends")).isEmpty());
|
||||
|
||||
manager.send(JsonEnvelope.builder("newer-rejected").build());
|
||||
assertEquals(4098, restored.handoffMessagesSnapshot().size(),
|
||||
assertEquals(4098, HttpBackendProxyTransportAccess.handoffMessagesSnapshot(restored).size(),
|
||||
"normal sends must not expand the one-time rollback recovery allowance");
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void shutdownCanAppendTheReservedRollbackRemainder() throws Exception {
|
||||
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class));
|
||||
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(mock(VotingPluginMain.class));
|
||||
java.util.ArrayDeque<JsonEnvelope> handoff =
|
||||
(java.util.ArrayDeque<JsonEnvelope>) field(transport, "handoffQueue");
|
||||
for (int index = 0; index < 4096; index++)
|
||||
@@ -1069,12 +1074,12 @@ class HttpBackendProxyTransportTest {
|
||||
JsonEnvelope first = JsonEnvelope.builder("fenced-1").build();
|
||||
JsonEnvelope second = JsonEnvelope.builder("fenced-2").build();
|
||||
|
||||
transport.activateRestoredHandoffDrain(2);
|
||||
transport.appendShutdownHandoffMessages(List.of(first, second));
|
||||
HttpBackendProxyTransportAccess.activateRestoredHandoffDrain(transport, 2);
|
||||
HttpBackendProxyTransportAccess.appendShutdownHandoffMessages(transport, List.of(first, second));
|
||||
|
||||
assertEquals(4098, transport.handoffMessagesSnapshot().size());
|
||||
assertSame(first, transport.handoffMessagesSnapshot().get(4096));
|
||||
assertSame(second, transport.handoffMessagesSnapshot().get(4097));
|
||||
assertEquals(4098, HttpBackendProxyTransportAccess.handoffMessagesSnapshot(transport).size());
|
||||
assertSame(first, HttpBackendProxyTransportAccess.handoffMessagesSnapshot(transport).get(4096));
|
||||
assertSame(second, HttpBackendProxyTransportAccess.handoffMessagesSnapshot(transport).get(4097));
|
||||
}
|
||||
|
||||
private static HttpConnectionCode code(String serverId, Instant expiry) {
|
||||
@@ -1085,7 +1090,7 @@ class HttpBackendProxyTransportTest {
|
||||
private static void setField(Object target, String name, Object value) throws Exception {
|
||||
java.lang.reflect.Field field = target.getClass().getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
ReflectiveAccess.set(field, target, value);
|
||||
}
|
||||
|
||||
private static Object field(Object target, String name) throws Exception {
|
||||
Loaded 100 of 330 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user