Compare commits

..
Author SHA1 Message Date
Ben e8fa948ecd Merge pull request #1737 from BenCodez/vp-work/dependency-consolidation-20261029
Update compatible VotingPlugin dependencies
2026-11-03 17:10:46 -06:00
Ben 4b83cc08bd Update test runner and provided Gson 2026-11-02 05:39:31 -06:00
Ben b212f5cabb Verify packaged Redis dependency compatibility 2026-10-31 22:51:31 -06:00
Ben e09fa0f039 Merge pull request #1740 from BenCodez/fix/shared-mysql-getpoints-loading-fallback-20261031
fix(api): prevent shared MySQL getPoints() loading exceptions
2026-10-31 12:23:46 -06:00
Ben 79bbdb1395 docs(api): explain getPoints loading fallback and confirmed debit semantics 2026-10-31 12:16:30 -06:00
Ben 51f0b5e4a9 test(api): cover cold, invalidated and in-flight shared point reads 2026-10-31 12:16:16 -06:00
Ben eb9fc8618b fix(api): share publication fence with direct points reads 2026-10-31 12:15:29 -06:00
Ben 86735060bc fix(api): fail closed on unavailable shared MySQL point snapshots 2026-10-31 12:15:21 -06:00
Ben f2eb2152fc Merge pull request #1739 from BenCodez/fix/shared-mysql-point-cache-6923bd0c
fix(points): reconcile shared MySQL point placeholder publication
2026-10-30 21:25:47 -06:00
Ben d2bc2feb8e fix(points): avoid repeated reloads of published snapshots 2026-10-30 21:12:17 -06:00
Ben b34229ced0 fix(points): reconcile shared MySQL point placeholder publication 2026-10-30 20:05:22 -06:00
Ben 9f56166708 Merge pull request #1738 from BenCodez/fix/cooldown-reward-identity-20261029
Preserve player identity across asynchronous cooldown rewards
2026-10-29 18:58:23 -06:00
Ben 7746fc52dc Retry cooldown identity refresh before reward dispatch 2026-10-29 18:50:03 -06:00
Ben ee33a291a6 test(cooldown): restore globals after partial fixture setup 2026-10-29 18:08:53 -06:00
Ben 66d1c858c7 fix(cooldown): retain deferred reward identity and failure context 2026-10-29 17:21:59 -06:00
Ben 2e8cb4bd5d Update compatible VotingPlugin dependencies 2026-10-28 23:05:49 -06:00
Ben 2a1e4e19ce Merge pull request #1701 from BenCodez/fix/partial-startup-shutdown
Prevent shutdown exceptions after partial startup failures
2026-10-25 19:25:34 -06:00
Ben 73947ee4cc Retain shutdown sign publication ownership across retries
Reuse the accepted batch publication lease after failed I/O, coalesce pending retries, and retry display observation without repeating a confirmed write.
2026-10-25 18:08:27 -06:00
Ben dc127ce300 Keep shutdown sign publication within the bounded process drain
Capture sign state before asynchronous persistence, retain failed attempts for retry, and keep outstanding generation publication leases in the process-exit drain without blocking lifecycle cleanup.
2026-10-25 17:18:36 -06:00
Ben 1f27359df7 Merge current master into partial-startup cleanup
Retain independent Discord cleanup and display retirement, preserve shared server-data publication ownership, and cover cleanup failure and retry integration.
2026-10-25 15:39:29 -06:00
Ben c80e460640 Merge pull request #1717 from BenCodez/vp-work/top-voter-presence
Add optional checked-presence filtering for top-voter displays
2026-10-25 12:54:46 -06:00
Ben c3248a0a85 Merge pull request #1736 from BenCodez/fix/cold-offline-placeholder-cache
Populate cold offline placeholder entries in all-player modes
2026-10-25 11:17:20 -06:00
Ben 3db2ba2d2a Populate cold offline placeholder entries in all-player modes 2026-10-24 23:47:40 -06:00
Ben bfded64b30 Integrate startup shutdown ownership with current master 2026-10-24 22:33:22 -06:00
Ben 8c098ede6d Merge pull request #1733 from BenCodez/fix/folialib-official-repository
Resolve FoliaLib before the failing Velocity repository
2026-10-19 04:21:57 -06:00
Ben 6ff81d7919 fix(build): resolve FoliaLib from its official repository 2026-10-19 03:23:47 -06:00
Ben 0554c2b954 Merge pull request #1727 from BenCodez/fix/folialib-051-scheduler-fixtures
Fix FoliaLib 0.5.1 scheduler fixture compilation
2026-10-19 03:08:13 -06:00
Ben cfd4cf5cf0 test: support published and upgraded scheduler return types 2026-10-18 22:27:32 -06:00
Ben 84a8724ce2 test: align scheduler fixtures with FoliaLib 0.5.1 2026-10-18 22:10:31 -06:00
Ben 032c3834f3 Serialize Discord leaderboard edits and preserve queued refreshes 2026-10-17 23:27:52 -06:00
Ben bc33141f43 Complete top-voter presence display and recovery integration 2026-10-17 22:03:31 -06:00
Ben 0c83e75a16 Merge pull request #1724 from BenCodez/docs/deprecate-uuid-only-lookups
Deprecate UUID-only user lookups with identity-aware API guidance
2026-10-16 20:14:54 -06:00
Ben 5081f95e63 Deprecate UUID-only user lookups without changing identity behavior 2026-10-16 19:52:01 -06:00
Ben 36bf96559e Merge pull request #1723 from BenCodez/fix/offline-uuid-compatibility
Resolve online player UUIDs to existing offline voting data
2026-10-16 18:34:26 -06:00
Ben 63c3c9aecf Fence offline identity refresh and login ownership 2026-10-16 18:27:33 -06:00
Ben 8acf825eaa Resolve live UUID lookups to existing offline voting identities 2026-10-16 18:14:50 -06:00
Ben e40bf57093 Merge pull request #1719 from BenCodez/refactor/standardize-test-packages
Standardize test packages
2026-10-16 13:10:08 -06:00
Ben 04a83ad54c Integrate current command, streak and dependency updates with top-voter displays 2026-10-16 12:55:27 -06:00
Ben 0c512eb8c1 Standardize test packages 2026-10-16 12:44:16 -06:00
Ben de9852a974 Merge pull request #1718 from BenCodez/maintenance/consolidated-dependency-refresh
Consolidate dependency updates and refresh Spigot API
2026-10-16 11:53:51 -06:00
BenCodez ad0c58d945 Refresh Spigot API and consolidate dependency updates 2026-10-16 11:19:55 -06:00
BenCodez dea8c3a8ba Add support for opening vote streaks menu via votegui
- Implemented command handling to open the vote streaks menu when the
"streaks" command is issued.
2026-10-16 10:57:51 -06:00
Ben f79e4c5d36 Merge pull request #1716 from BenCodez/feature/vote-streaks-menu
Add a read-only vote streak progression menu
2026-10-16 10:49:52 -06:00
Ben ca591ed39c Merge pull request #1708 from BenCodez/feature/configurable-vote-command
Make the player vote command configurable
2026-10-16 10:25:20 -06:00
Ben 8d3b35fa4f Restrict empty leaderboard skull clearing to confirmed display targets 2026-10-16 00:36:08 -06:00
Ben d3f7c43550 Preserve leaderboard refresh recovery and presence evidence 2026-10-15 22:55:43 -06:00
Ben cd531e323b Filter top-voter displays with bounded checked presence evidence 2026-10-15 21:49:44 -06:00
BenCodez ccfe5f1cf1 Preserve custom vote streaks command precedence 2026-10-15 11:28:47 -06:00
BenCodez 2b59b83123 Merge master and preserve streak-menu cleanup 2026-10-15 10:56:21 -06:00
Ben e0c4a1c92f Fence server-data generations and retain timed-vote recovery across shutdown 2026-10-15 10:32:12 -06:00
Ben 1fbc1a1939 Merge pull request #1713 from BenCodez/fix/auto-create-site-setup-reminder
Warn when automatic vote-site creation remains enabled
2026-10-15 09:12:10 -06:00
BenCodez c6a099fdb1 Add a read-only vote streak progression menu 2026-10-15 09:02:42 -06:00
BenCodez 03bbdb4c93 Warn after startup while automatic vote-site creation remains enabled 2026-10-14 10:41:30 -06:00
BenCodez 4a2dae0e47 Use a brief automatic site setup reminder without extra settings 2026-10-14 08:44:22 -06:00
BenCodez 1ce14cc0ec Preserve reminder checkpoint ownership during recovery 2026-10-14 08:20:09 -06:00
BenCodez e2f326de2a Add owner reminder for automatic vote-site setup 2026-10-14 07:35:48 -06:00
Ben 3b4208c595 Bound timed-vote shutdown persistence and retain retryable recovery 2026-10-13 23:15:15 -06:00
Ben d7b5a89955 Preserve pending timed votes and late producers during cleanup 2026-10-13 22:03:46 -06:00
BenCodez 0518038d91 Make player vote command registration configurable 2026-10-12 00:00:18 -06:00
BenCodez cf8def4c8a Validate shutdown against latest AdvancedCore initialization fix 2026-10-11 11:06:37 -06:00
BenCodez a9b4af5fc5 Retain timed votes across shutdown races and failed persistence 2026-10-11 10:58:49 -06:00
BenCodez a89bb5594e Build startup regressions against corrected SQL dependencies 2026-10-11 10:25:34 -06:00
BenCodez 4409f8fdd9 Assert partial-startup shutdown emits no cleanup failure warnings 2026-10-10 23:25:48 -06:00
BenCodez 81993afd60 Prevent shutdown exceptions after partial startup failures 2026-10-10 23:09:46 -06:00
330 changed files with 27615 additions and 8187 deletions

No files matched your search

+6
View File
@@ -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
View File
@@ -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);
}
}
@@ -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;
}
@@ -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;
}
@@ -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)) {
@@ -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;
@@ -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<>();
@@ -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()));
}
@@ -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();
@@ -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");
@@ -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());
@@ -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);
@@ -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;
}
}
@@ -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 "
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
}
}
}
@@ -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. */
@@ -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();
}
}
@@ -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.
*/
@@ -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);
@@ -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;
@@ -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() {
@@ -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"; };
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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));
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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());
}
@@ -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.
*/
File diff suppressed because it is too large. Load diff
@@ -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;
@@ -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;
}
}
@@ -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. */
+17 -6
View File
@@ -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
+4
View File
@@ -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
+3 -5
View File
@@ -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"));
}
}
@@ -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[]{});
}
}
@@ -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);
}
}
@@ -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;
}
@@ -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);
}
}
}
@@ -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();
}
}
}
@@ -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();
}
}
}
}
@@ -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));
}
}
}
@@ -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);
}
@@ -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());
}
}
}
@@ -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();
}
}
}
@@ -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));
}
}
}
@@ -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));
}
}
}
}
@@ -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<>());
@@ -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;
@@ -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}); }
}
@@ -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);
}
}
@@ -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));
}
}
}
@@ -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);
}
}
@@ -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 {
@@ -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);
}
@@ -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[]{}); }
}
}
@@ -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() { }
}
@@ -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();
@@ -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}); }
}
@@ -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}); }
}
@@ -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));
@@ -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());
@@ -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}); }
}
@@ -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);
}
@@ -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}); }
}
@@ -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());
@@ -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) {
@@ -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}); }
}
@@ -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